chore: init
This commit is contained in:
@@ -0,0 +1,41 @@
|
||||
package com.example;
|
||||
|
||||
import net.runelite.client.config.Config;
|
||||
import net.runelite.client.config.ConfigGroup;
|
||||
import net.runelite.client.config.ConfigItem;
|
||||
import net.runelite.client.config.ConfigSection;
|
||||
|
||||
@ConfigGroup("example")
|
||||
public interface ExampleConfig extends Config
|
||||
{
|
||||
@ConfigSection(
|
||||
name = "General Settings",
|
||||
description = "Allgemeine Einstellungen für das Plugin",
|
||||
position = 0
|
||||
)
|
||||
String generalSection = "generalSection";
|
||||
|
||||
@ConfigItem(
|
||||
keyName = "greeting",
|
||||
name = "Welcome Greeting",
|
||||
description = "The message to show in game chat when logging in or enabling the plugin",
|
||||
section = generalSection,
|
||||
position = 1
|
||||
)
|
||||
default String greeting()
|
||||
{
|
||||
return "Hello World!";
|
||||
}
|
||||
|
||||
@ConfigItem(
|
||||
keyName = "showOnLogin",
|
||||
name = "Show on Login",
|
||||
description = "Whether to send the greeting message when logging in",
|
||||
section = generalSection,
|
||||
position = 2
|
||||
)
|
||||
default boolean showOnLogin()
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
package com.example;
|
||||
|
||||
import com.example.model.Profile;
|
||||
import com.example.overlay.BankHighlighterOverlay;
|
||||
import com.example.overlay.ScreenFlashOverlay;
|
||||
import com.example.overlay.TileHighlighterOverlay;
|
||||
import com.example.overlay.WorldObjectHighlighterOverlay;
|
||||
import com.example.ui.ExamplePluginPanel;
|
||||
import com.google.inject.Provides;
|
||||
import java.awt.Color;
|
||||
import java.awt.image.BufferedImage;
|
||||
import javax.annotation.Nullable;
|
||||
import javax.inject.Inject;
|
||||
import lombok.Getter;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import net.runelite.api.ChatMessageType;
|
||||
import net.runelite.api.Client;
|
||||
import net.runelite.api.GameState;
|
||||
import net.runelite.api.events.GameStateChanged;
|
||||
import net.runelite.api.events.GameTick;
|
||||
import net.runelite.api.events.ItemContainerChanged;
|
||||
import net.runelite.client.callback.ClientThread;
|
||||
import net.runelite.client.config.ConfigManager;
|
||||
import net.runelite.client.eventbus.Subscribe;
|
||||
import net.runelite.client.game.ItemManager;
|
||||
import net.runelite.client.plugins.Plugin;
|
||||
import net.runelite.client.plugins.PluginDescriptor;
|
||||
import net.runelite.client.plugins.banktags.BankTagsService;
|
||||
import net.runelite.client.ui.ClientToolbar;
|
||||
import net.runelite.client.ui.NavigationButton;
|
||||
import net.runelite.client.ui.overlay.OverlayManager;
|
||||
import net.runelite.client.util.ColorUtil;
|
||||
import net.runelite.client.util.ImageUtil;
|
||||
|
||||
@Slf4j
|
||||
@PluginDescriptor(
|
||||
name = "Bank Highlighter / Auto-Profile",
|
||||
description = "Aktiviert automatisch Profile, Item-Highlights, 3D-Objekte, Boden-Tiles und Pfade",
|
||||
tags = {"bank", "highlighter", "profile", "auto", "herblore", "tiles", "path", "agility"}
|
||||
)
|
||||
public class ExamplePlugin extends Plugin implements ProfileManager.ProfileChangeListener
|
||||
{
|
||||
@Getter
|
||||
@Inject
|
||||
private Client client;
|
||||
|
||||
@Getter
|
||||
@Inject
|
||||
private ClientThread clientThread;
|
||||
|
||||
@Getter
|
||||
@Inject
|
||||
private ItemManager itemManager;
|
||||
|
||||
@Getter
|
||||
@Nullable
|
||||
@com.google.inject.Inject(optional = true)
|
||||
private BankTagsService bankTagsService;
|
||||
|
||||
@Inject
|
||||
private ConfigManager configManager;
|
||||
|
||||
@Inject
|
||||
private ClientToolbar clientToolbar;
|
||||
|
||||
@Inject
|
||||
private OverlayManager overlayManager;
|
||||
|
||||
@Inject
|
||||
private ProfileManager profileManager;
|
||||
|
||||
@Inject
|
||||
private BankHighlighterOverlay bankHighlighterOverlay;
|
||||
|
||||
@Inject
|
||||
private WorldObjectHighlighterOverlay worldObjectHighlighterOverlay;
|
||||
|
||||
@Inject
|
||||
private TileHighlighterOverlay tileHighlighterOverlay;
|
||||
|
||||
@Inject
|
||||
private ScreenFlashOverlay screenFlashOverlay;
|
||||
|
||||
private NavigationButton navButton;
|
||||
private ExamplePluginPanel panel;
|
||||
|
||||
@Override
|
||||
protected void startUp() throws Exception
|
||||
{
|
||||
log.info("Bank Highlighter / Auto-Profile started!");
|
||||
|
||||
profileManager.addListener(this);
|
||||
panel = new ExamplePluginPanel(this, profileManager);
|
||||
|
||||
final BufferedImage icon = ImageUtil.loadImageResource(getClass(), "/com/example/icon.png");
|
||||
|
||||
navButton = NavigationButton.builder()
|
||||
.tooltip("Bank Highlighter & Profile")
|
||||
.icon(icon)
|
||||
.priority(5)
|
||||
.panel(panel)
|
||||
.build();
|
||||
|
||||
clientToolbar.addNavigation(navButton);
|
||||
overlayManager.add(bankHighlighterOverlay);
|
||||
overlayManager.add(worldObjectHighlighterOverlay);
|
||||
overlayManager.add(tileHighlighterOverlay);
|
||||
overlayManager.add(screenFlashOverlay);
|
||||
|
||||
clientThread.invokeLater(() ->
|
||||
{
|
||||
if (client.getGameState() == GameState.LOGGED_IN)
|
||||
{
|
||||
profileManager.evaluate(client, itemManager, bankTagsService);
|
||||
panel.updateLiveInspector(client);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void shutDown() throws Exception
|
||||
{
|
||||
log.info("Bank Highlighter / Auto-Profile stopped!");
|
||||
profileManager.removeListener(this);
|
||||
|
||||
overlayManager.remove(bankHighlighterOverlay);
|
||||
overlayManager.remove(worldObjectHighlighterOverlay);
|
||||
overlayManager.remove(tileHighlighterOverlay);
|
||||
overlayManager.remove(screenFlashOverlay);
|
||||
|
||||
if (navButton != null)
|
||||
{
|
||||
clientToolbar.removeNavigation(navButton);
|
||||
navButton = null;
|
||||
}
|
||||
panel = null;
|
||||
}
|
||||
|
||||
@Subscribe
|
||||
public void onGameTick(GameTick event)
|
||||
{
|
||||
if (client.getGameState() == GameState.LOGGED_IN)
|
||||
{
|
||||
screenFlashOverlay.decrementTick();
|
||||
profileManager.evaluate(client, itemManager, bankTagsService);
|
||||
if (panel != null)
|
||||
{
|
||||
panel.updateLiveInspector(client);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Subscribe
|
||||
public void onItemContainerChanged(ItemContainerChanged event)
|
||||
{
|
||||
if (client.getGameState() == GameState.LOGGED_IN)
|
||||
{
|
||||
profileManager.evaluate(client, itemManager, bankTagsService);
|
||||
if (panel != null)
|
||||
{
|
||||
panel.updateLiveInspector(client);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Subscribe
|
||||
public void onGameStateChanged(GameStateChanged gameStateChanged)
|
||||
{
|
||||
if (gameStateChanged.getGameState() == GameState.LOGGED_IN)
|
||||
{
|
||||
clientThread.invokeLater(() ->
|
||||
{
|
||||
profileManager.evaluate(client, itemManager, bankTagsService);
|
||||
if (panel != null)
|
||||
{
|
||||
panel.updateLiveInspector(client);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onActiveProfileChanged(Profile oldProfile, Profile newProfile)
|
||||
{
|
||||
if (newProfile != null)
|
||||
{
|
||||
// 1. Chat Message
|
||||
if (newProfile.isNotifyOnActivation())
|
||||
{
|
||||
String msg = newProfile.getActivationMessage().replace("{name}", newProfile.getName());
|
||||
sendChatMessage(msg);
|
||||
}
|
||||
|
||||
// 2. Screen Flash
|
||||
if (newProfile.isScreenFlash())
|
||||
{
|
||||
Color color = ColorUtil.fromString(newProfile.getFlashColor());
|
||||
screenFlashOverlay.flash(color != null ? color : new Color(255, 152, 0), 2);
|
||||
}
|
||||
|
||||
// 3. Sound Effect
|
||||
if (newProfile.isPlaySound())
|
||||
{
|
||||
clientThread.invokeLater(() ->
|
||||
{
|
||||
if (client.getGameState() == GameState.LOGGED_IN)
|
||||
{
|
||||
client.playSoundEffect(newProfile.getSoundId());
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onProfileListUpdated()
|
||||
{
|
||||
}
|
||||
|
||||
public void sendManualChatMessage(String message)
|
||||
{
|
||||
clientThread.invokeLater(() ->
|
||||
{
|
||||
if (client.getGameState() == GameState.LOGGED_IN)
|
||||
{
|
||||
sendChatMessage(message);
|
||||
}
|
||||
else
|
||||
{
|
||||
log.warn("Cannot send chat message - player is not logged in");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public void sendChatMessage(String message)
|
||||
{
|
||||
client.addChatMessage(ChatMessageType.GAMEMESSAGE, "", "<col=FF9800>[Profile]</col> " + message, null);
|
||||
}
|
||||
|
||||
@Provides
|
||||
ExampleConfig provideConfig(ConfigManager configManager)
|
||||
{
|
||||
return configManager.getConfig(ExampleConfig.class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,483 @@
|
||||
package com.example;
|
||||
|
||||
import com.example.model.Folder;
|
||||
import com.example.model.Profile;
|
||||
import com.google.gson.Gson;
|
||||
import com.google.gson.reflect.TypeToken;
|
||||
import java.lang.reflect.Type;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import javax.inject.Inject;
|
||||
import javax.inject.Singleton;
|
||||
import lombok.Getter;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import net.runelite.api.Client;
|
||||
import net.runelite.client.config.ConfigManager;
|
||||
import net.runelite.client.game.ItemManager;
|
||||
|
||||
@Slf4j
|
||||
@Singleton
|
||||
public class ProfileManager
|
||||
{
|
||||
private static final String CONFIG_GROUP = "bankhighlighter";
|
||||
private static final String PROFILES_KEY = "profiles";
|
||||
private static final String FOLDERS_KEY = "folders";
|
||||
|
||||
@Getter
|
||||
private final List<Folder> folders = new ArrayList<>();
|
||||
|
||||
@Getter
|
||||
private final List<Profile> profiles = new ArrayList<>();
|
||||
|
||||
@Getter
|
||||
private Profile activeProfile = null;
|
||||
|
||||
private final ConfigManager configManager;
|
||||
private final Gson gson;
|
||||
|
||||
public interface ProfileChangeListener
|
||||
{
|
||||
void onActiveProfileChanged(Profile oldProfile, Profile newProfile);
|
||||
void onProfileListUpdated();
|
||||
}
|
||||
|
||||
private final List<ProfileChangeListener> listeners = new ArrayList<>();
|
||||
|
||||
@Inject
|
||||
public ProfileManager(ConfigManager configManager, Gson gson)
|
||||
{
|
||||
this.configManager = configManager;
|
||||
this.gson = gson;
|
||||
loadProfiles();
|
||||
}
|
||||
|
||||
public void addListener(ProfileChangeListener listener)
|
||||
{
|
||||
if (!listeners.contains(listener))
|
||||
{
|
||||
listeners.add(listener);
|
||||
}
|
||||
}
|
||||
|
||||
public void removeListener(ProfileChangeListener listener)
|
||||
{
|
||||
listeners.remove(listener);
|
||||
}
|
||||
|
||||
public void loadProfiles()
|
||||
{
|
||||
profiles.clear();
|
||||
folders.clear();
|
||||
|
||||
// Load Folders
|
||||
String foldersJson = configManager.getConfiguration(CONFIG_GROUP, FOLDERS_KEY);
|
||||
if (foldersJson != null && !foldersJson.trim().isEmpty())
|
||||
{
|
||||
try
|
||||
{
|
||||
Type listType = new TypeToken<List<Folder>>() {}.getType();
|
||||
List<Folder> loaded = gson.fromJson(foldersJson, listType);
|
||||
if (loaded != null)
|
||||
{
|
||||
folders.addAll(loaded);
|
||||
}
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
log.error("Fehler beim Laden der Ordner aus Config", e);
|
||||
}
|
||||
}
|
||||
|
||||
// Load Profiles
|
||||
String json = configManager.getConfiguration(CONFIG_GROUP, PROFILES_KEY);
|
||||
if (json != null && !json.trim().isEmpty())
|
||||
{
|
||||
try
|
||||
{
|
||||
Type listType = new TypeToken<List<Profile>>() {}.getType();
|
||||
List<Profile> loaded = gson.fromJson(json, listType);
|
||||
if (loaded != null && !loaded.isEmpty())
|
||||
{
|
||||
profiles.addAll(loaded);
|
||||
}
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
log.error("Fehler beim Laden der Profile aus Config", e);
|
||||
}
|
||||
}
|
||||
|
||||
if (profiles.isEmpty())
|
||||
{
|
||||
loadHerblorePrayerPotionWorkflow();
|
||||
}
|
||||
|
||||
notifyListUpdated();
|
||||
}
|
||||
|
||||
public void saveProfiles()
|
||||
{
|
||||
try
|
||||
{
|
||||
String json = gson.toJson(profiles);
|
||||
configManager.setConfiguration(CONFIG_GROUP, PROFILES_KEY, json);
|
||||
|
||||
String foldersJson = gson.toJson(folders);
|
||||
configManager.setConfiguration(CONFIG_GROUP, FOLDERS_KEY, foldersJson);
|
||||
|
||||
notifyListUpdated();
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
log.error("Fehler beim Speichern der Profile / Ordner", e);
|
||||
}
|
||||
}
|
||||
|
||||
// Preset 1: Herblore Prayer Potions
|
||||
public void loadHerblorePrayerPotionWorkflow()
|
||||
{
|
||||
Folder herbFolder = new Folder("Prayer Potions", "Prayer potion(4)");
|
||||
folders.add(herbFolder);
|
||||
|
||||
Profile step2 = new Profile("2. Inv: Grimy säubern", "Grimy ranarr weed");
|
||||
step2.setFolderId(herbFolder.getId());
|
||||
step2.setRequiredInventory("Grimy ranarr weed");
|
||||
step2.setHighlightInBank(false);
|
||||
step2.setHighlightInInventory(true);
|
||||
step2.setHighlightItems("Grimy ranarr weed");
|
||||
step2.setHighlightColor("#4CAF50");
|
||||
step2.setShowTextLabel(true);
|
||||
step2.setCustomTextLabel("Clean");
|
||||
step2.setActivationMessage("Schritt 2: Grimy Ranarr im Inventar säubern!");
|
||||
|
||||
Profile step3 = new Profile("3. Inv: Unf Potion mixen", "Ranarr weed");
|
||||
step3.setFolderId(herbFolder.getId());
|
||||
step3.setRequiredInventory("Ranarr weed, Vial of water");
|
||||
step3.setHighlightInBank(false);
|
||||
step3.setHighlightInInventory(true);
|
||||
step3.setHighlightItems("Ranarr weed, Vial of water");
|
||||
step3.setHighlightColor("#FF9800");
|
||||
step3.setShowTextLabel(true);
|
||||
step3.setCustomTextLabel("Combine");
|
||||
step3.setActivationMessage("Schritt 3: Ranarr Weed & Vial of Water kombinieren!");
|
||||
|
||||
Profile step5 = new Profile("5. Inv: Potion fertigstellen", "Prayer potion(3)");
|
||||
step5.setFolderId(herbFolder.getId());
|
||||
step5.setRequiredInventory("Ranarr potion (unf), Snape grass");
|
||||
step5.setHighlightInBank(false);
|
||||
step5.setHighlightInInventory(true);
|
||||
step5.setHighlightItems("Ranarr potion (unf), Snape grass");
|
||||
step5.setHighlightColor("#9C27B0");
|
||||
step5.setShowTextLabel(true);
|
||||
step5.setCustomTextLabel("Combine");
|
||||
step5.setActivationMessage("Schritt 5: Snape Grass & Unf Potion zur Prayer Potion mixen!");
|
||||
|
||||
Profile step4a = new Profile("4a. Welt: Bank öffnen", "Ranarr potion (unf)");
|
||||
step4a.setFolderId(herbFolder.getId());
|
||||
step4a.setRequireBankClosed(true);
|
||||
step4a.setRequiredInventory("Ranarr potion (unf)");
|
||||
step4a.setEnableObjectHighlight(true);
|
||||
step4a.setHighlightObjects("Bank, Bank booth, Bank chest, Banker, Grand Exchange booth");
|
||||
step4a.setObjectHighlightColor("#00E5FF");
|
||||
step4a.setShowObjectText(true);
|
||||
step4a.setObjectCustomText("Bank öffnen");
|
||||
step4a.setActivationMessage("Schritt 4a: Klicke auf die Bank, um Snape Grass zu holen!");
|
||||
|
||||
Profile step6a = new Profile("6a. Welt: Bank öffnen", "Prayer potion(4)");
|
||||
step6a.setFolderId(herbFolder.getId());
|
||||
step6a.setRequireBankClosed(true);
|
||||
step6a.setRequiredInventory("Prayer potion");
|
||||
step6a.setEnableObjectHighlight(true);
|
||||
step6a.setHighlightObjects("Bank, Bank booth, Bank chest, Banker, Grand Exchange booth");
|
||||
step6a.setObjectHighlightColor("#00E5FF");
|
||||
step6a.setShowObjectText(true);
|
||||
step6a.setObjectCustomText("Bank öffnen");
|
||||
step6a.setActivationMessage("Schritt 6a: Klicke auf die Bank, um fertige Potions einzulagern!");
|
||||
|
||||
Profile step6b = new Profile("6b. Bank: Potions deponieren", "Prayer potion(4)");
|
||||
step6b.setFolderId(herbFolder.getId());
|
||||
step6b.setRequireBankOpen(true);
|
||||
step6b.setRequiredInventory("Prayer potion");
|
||||
step6b.setHighlightInBank(false);
|
||||
step6b.setHighlightInInventory(true);
|
||||
step6b.setHighlightItems("Prayer potion");
|
||||
step6b.setHighlightColor("#FF5722");
|
||||
step6b.setShowTextLabel(true);
|
||||
step6b.setCustomTextLabel("Depot");
|
||||
step6b.setActivationMessage("Schritt 6b: Fertige Prayer Potions in die Bank einlagern!");
|
||||
|
||||
Profile step4b = new Profile("4b. Bank: Snape Grass nehmen", "Snape grass");
|
||||
step4b.setFolderId(herbFolder.getId());
|
||||
step4b.setRequireBankOpen(true);
|
||||
step4b.setRequiredInventory("Ranarr potion (unf)");
|
||||
step4b.setHighlightInBank(true);
|
||||
step4b.setHighlightInInventory(false);
|
||||
step4b.setHighlightItems("Snape grass");
|
||||
step4b.setHighlightColor("#00E5FF");
|
||||
step4b.setShowTextLabel(true);
|
||||
step4b.setCustomTextLabel("Nehmen");
|
||||
step4b.setActivationMessage("Schritt 4b: Snape Grass aus der Bank entnehmen!");
|
||||
|
||||
Profile step1 = new Profile("1. Bank: Zutaten nehmen", "Vial of water");
|
||||
step1.setFolderId(herbFolder.getId());
|
||||
step1.setRequireBankOpen(true);
|
||||
step1.setBankTag("herblore");
|
||||
step1.setHighlightInBank(true);
|
||||
step1.setHighlightInInventory(false);
|
||||
step1.setHighlightItems("Vial of water, Grimy ranarr weed");
|
||||
step1.setHighlightColor("#00E5FF");
|
||||
step1.setShowTextLabel(true);
|
||||
step1.setCustomTextLabel("Nehmen");
|
||||
step1.setActivationMessage("Schritt 1: Vial of Water & Grimy Ranarr aus der Bank nehmen!");
|
||||
|
||||
profiles.add(step2);
|
||||
profiles.add(step3);
|
||||
profiles.add(step5);
|
||||
profiles.add(step4a);
|
||||
profiles.add(step6a);
|
||||
profiles.add(step6b);
|
||||
profiles.add(step4b);
|
||||
profiles.add(step1);
|
||||
|
||||
saveProfiles();
|
||||
}
|
||||
|
||||
// Preset 2: 🍳 Cooking (z. B. Raw Karambwan am Feuer/Range)
|
||||
public void loadCookingKarambwanWorkflow()
|
||||
{
|
||||
Folder cookFolder = new Folder("Cooking: Karambwan", "Cooked karambwan");
|
||||
folders.add(cookFolder);
|
||||
|
||||
Profile step1 = new Profile("1. Bank: Rohen Fisch nehmen", "Raw karambwan");
|
||||
step1.setFolderId(cookFolder.getId());
|
||||
step1.setRequireBankOpen(true);
|
||||
step1.setHighlightInBank(true);
|
||||
step1.setHighlightInInventory(false);
|
||||
step1.setHighlightItems("Raw karambwan, Raw shark, Raw monkfish");
|
||||
step1.setHighlightColor("#00E5FF");
|
||||
step1.setShowTextLabel(true);
|
||||
step1.setCustomTextLabel("Nehmen");
|
||||
step1.setActivationMessage("Schritt 1: Rohen Fisch aus der Bank entnehmen!");
|
||||
|
||||
Profile step2 = new Profile("2. Welt: Am Herd/Feuer braten", "Raw karambwan");
|
||||
step2.setFolderId(cookFolder.getId());
|
||||
step2.setRequireBankClosed(true);
|
||||
step2.setRequiredInventory("Raw karambwan");
|
||||
step2.setEnableObjectHighlight(true);
|
||||
step2.setHighlightObjects("Range, Stove, Fire, Cooking range, Clay oven");
|
||||
step2.setObjectHighlightColor("#FF5722");
|
||||
step2.setShowObjectText(true);
|
||||
step2.setObjectCustomText("Braten");
|
||||
step2.setActivationMessage("Schritt 2: Klicke auf den Herd/das Feuer zum Braten!");
|
||||
|
||||
Profile step3 = new Profile("3. Welt: Zurück zur Bank", "Cooked karambwan");
|
||||
step3.setFolderId(cookFolder.getId());
|
||||
step3.setRequireBankClosed(true);
|
||||
step3.setRequiredInventory("Cooked karambwan");
|
||||
step3.setEnableObjectHighlight(true);
|
||||
step3.setHighlightObjects("Bank, Bank booth, Bank chest, Banker");
|
||||
step3.setObjectHighlightColor("#00E5FF");
|
||||
step3.setShowObjectText(true);
|
||||
step3.setObjectCustomText("Bank öffnen");
|
||||
step3.setActivationMessage("Schritt 3: Gehe zur Bank, um den gebratenen Fisch einzulagern!");
|
||||
|
||||
Profile step4 = new Profile("4. Bank: Fisch deponieren", "Cooked karambwan");
|
||||
step4.setFolderId(cookFolder.getId());
|
||||
step4.setRequireBankOpen(true);
|
||||
step4.setRequiredInventory("Cooked karambwan");
|
||||
step4.setHighlightInBank(false);
|
||||
step4.setHighlightInInventory(true);
|
||||
step4.setHighlightItems("Cooked karambwan, Burnt karambwan");
|
||||
step4.setHighlightColor("#4CAF50");
|
||||
step4.setShowTextLabel(true);
|
||||
step4.setCustomTextLabel("Depot");
|
||||
step4.setActivationMessage("Schritt 4: Gebratenen Fisch in die Bank einlagern!");
|
||||
|
||||
profiles.add(step2);
|
||||
profiles.add(step3);
|
||||
profiles.add(step4);
|
||||
profiles.add(step1);
|
||||
|
||||
saveProfiles();
|
||||
}
|
||||
|
||||
// Preset 3: 🏃 Agility Rooftop Course Guide (mit 3D Hindernissen + Boden-Tiles + Pfadlinien)
|
||||
public void loadAgilitySeersWorkflow()
|
||||
{
|
||||
Folder agilFolder = new Folder("Agility: Seers Rooftop", "Graceful hood");
|
||||
folders.add(agilFolder);
|
||||
|
||||
Profile start = new Profile("1. Start: Bank Wall hochklettern", "Graceful top");
|
||||
start.setFolderId(agilFolder.getId());
|
||||
start.setRegionId("10806, 10550");
|
||||
start.setEnableObjectHighlight(true);
|
||||
start.setHighlightObjects("Wall, Climbing wall, Rough wall");
|
||||
start.setObjectHighlightColor("#2ECC71");
|
||||
start.setShowObjectText(true);
|
||||
start.setObjectCustomText("Klettern");
|
||||
start.setEnableTileHighlight(true);
|
||||
start.setHighlightTiles("2729, 3488, 0, Start-Kachel");
|
||||
start.setTileHighlightColor("#2ECC71");
|
||||
start.setRenderPathLine(true);
|
||||
start.setActivationMessage("Agility: Starte den Seers' Rooftop Kurs an der Wand!");
|
||||
|
||||
Profile gap1 = new Profile("2. Rooftop: Lücke überspringen", "Graceful legs");
|
||||
gap1.setFolderId(agilFolder.getId());
|
||||
gap1.setEnableObjectHighlight(true);
|
||||
gap1.setHighlightObjects("Gap, Tightrope, Edge");
|
||||
gap1.setObjectHighlightColor("#00E5FF");
|
||||
gap1.setShowObjectText(true);
|
||||
gap1.setObjectCustomText("Springen");
|
||||
gap1.setActivationMessage("Agility: Nächstes Dach-Hindernis überqueren!");
|
||||
|
||||
profiles.add(gap1);
|
||||
profiles.add(start);
|
||||
|
||||
saveProfiles();
|
||||
}
|
||||
|
||||
public Folder createFolder(String name, String iconItem)
|
||||
{
|
||||
Folder f = new Folder(name, iconItem);
|
||||
folders.add(f);
|
||||
saveProfiles();
|
||||
return f;
|
||||
}
|
||||
|
||||
public void deleteFolder(Folder folder)
|
||||
{
|
||||
if (folder == null) return;
|
||||
for (Profile p : profiles)
|
||||
{
|
||||
if (folder.getId().equals(p.getFolderId()))
|
||||
{
|
||||
p.setFolderId("");
|
||||
}
|
||||
}
|
||||
folders.removeIf(f -> f.getId().equals(folder.getId()));
|
||||
saveProfiles();
|
||||
}
|
||||
|
||||
public Folder getFolderById(String id)
|
||||
{
|
||||
if (id == null || id.isEmpty()) return null;
|
||||
for (Folder f : folders)
|
||||
{
|
||||
if (f.getId().equals(id)) return f;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public Profile createProfile(String name)
|
||||
{
|
||||
Profile p = new Profile(name);
|
||||
profiles.add(p);
|
||||
saveProfiles();
|
||||
return p;
|
||||
}
|
||||
|
||||
public void deleteProfile(Profile profile)
|
||||
{
|
||||
if (profile == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (activeProfile != null && activeProfile.getId().equals(profile.getId()))
|
||||
{
|
||||
setActiveProfile(null);
|
||||
}
|
||||
|
||||
profiles.removeIf(p -> p.getId().equals(profile.getId()));
|
||||
saveProfiles();
|
||||
}
|
||||
|
||||
public void updateProfile(Profile updated)
|
||||
{
|
||||
for (int i = 0; i < profiles.size(); i++)
|
||||
{
|
||||
if (profiles.get(i).getId().equals(updated.getId()))
|
||||
{
|
||||
profiles.set(i, updated);
|
||||
break;
|
||||
}
|
||||
}
|
||||
saveProfiles();
|
||||
}
|
||||
|
||||
public void moveProfileUp(Profile profile)
|
||||
{
|
||||
int idx = profiles.indexOf(profile);
|
||||
if (idx > 0)
|
||||
{
|
||||
Collections.swap(profiles, idx, idx - 1);
|
||||
saveProfiles();
|
||||
}
|
||||
}
|
||||
|
||||
public void moveProfileDown(Profile profile)
|
||||
{
|
||||
int idx = profiles.indexOf(profile);
|
||||
if (idx >= 0 && idx < profiles.size() - 1)
|
||||
{
|
||||
Collections.swap(profiles, idx, idx + 1);
|
||||
saveProfiles();
|
||||
}
|
||||
}
|
||||
|
||||
public void evaluate(Client client, ItemManager itemManager, net.runelite.client.plugins.banktags.BankTagsService bankTagsService)
|
||||
{
|
||||
if (client == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Profile matched = null;
|
||||
for (Profile p : profiles)
|
||||
{
|
||||
if (!p.isEnabled())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (p.getFolderId() != null && !p.getFolderId().isEmpty())
|
||||
{
|
||||
Folder f = getFolderById(p.getFolderId());
|
||||
if (f != null && !f.isEnabled())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (p.matches(client, itemManager, bankTagsService))
|
||||
{
|
||||
matched = p;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (matched != activeProfile)
|
||||
{
|
||||
setActiveProfile(matched);
|
||||
}
|
||||
}
|
||||
|
||||
private void setActiveProfile(Profile newActive)
|
||||
{
|
||||
Profile old = this.activeProfile;
|
||||
this.activeProfile = newActive;
|
||||
for (ProfileChangeListener listener : listeners)
|
||||
{
|
||||
listener.onActiveProfileChanged(old, newActive);
|
||||
}
|
||||
}
|
||||
|
||||
private void notifyListUpdated()
|
||||
{
|
||||
for (ProfileChangeListener listener : listeners)
|
||||
{
|
||||
listener.onProfileListUpdated();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,587 @@
|
||||
package com.example.essentialsplus;
|
||||
|
||||
import com.example.essentialsplus.helpers.TimerTextSize;
|
||||
import java.awt.Color;
|
||||
import net.runelite.client.config.Config;
|
||||
import net.runelite.client.config.ConfigGroup;
|
||||
import net.runelite.client.config.ConfigItem;
|
||||
import net.runelite.client.config.Range;
|
||||
import net.runelite.client.config.Units;
|
||||
|
||||
@ConfigGroup(EssentialsPlusConfig.GROUP)
|
||||
public interface EssentialsPlusConfig extends Config
|
||||
{
|
||||
String GROUP = "essentialsplus";
|
||||
|
||||
@ConfigItem(
|
||||
keyName = "snapshotSeconds",
|
||||
name = "Min. Abstand Datenpunkte",
|
||||
description = "Mindestabstand zwischen zwei gespeicherten Datenpunkten. Unveraenderte Werte werden nie doppelt gespeichert.",
|
||||
position = 1
|
||||
)
|
||||
@Units(Units.SECONDS)
|
||||
@Range(min = 10, max = 3600)
|
||||
default int snapshotSeconds()
|
||||
{
|
||||
return 60;
|
||||
}
|
||||
|
||||
@ConfigItem(
|
||||
keyName = "rawDays",
|
||||
name = "Jede Aenderung behalten (Tage)",
|
||||
description = "So lange wird jeder einzelne Datenpunkt gespeichert. Danach: stuendliche Werte (Open/Close/Min/Max/Durchschnitt).",
|
||||
position = 2
|
||||
)
|
||||
@Units(" d")
|
||||
@Range(min = 1, max = 90)
|
||||
default int rawDays()
|
||||
{
|
||||
return 7;
|
||||
}
|
||||
|
||||
@ConfigItem(
|
||||
keyName = "hourlyDays",
|
||||
name = "Stuendliche Werte (Tage)",
|
||||
description = "So lange bleiben stuendliche Werte erhalten. Aeltere Daten werden zu Tageswerten (Open/Close/Min/Max/Durchschnitt).",
|
||||
position = 3
|
||||
)
|
||||
@Units(" d")
|
||||
@Range(min = 1, max = 3650)
|
||||
default int hourlyDays()
|
||||
{
|
||||
return 90;
|
||||
}
|
||||
|
||||
@ConfigItem(
|
||||
keyName = "includeGe",
|
||||
name = "GE-Angebote zaehlen",
|
||||
description = "Gold und Items in laufenden Grand Exchange Angeboten mitzaehlen.",
|
||||
position = 4
|
||||
)
|
||||
default boolean includeGe()
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
@ConfigItem(
|
||||
keyName = "revalueOffline",
|
||||
name = "Offline-Charaktere neu bewerten",
|
||||
description = "Bewertet ausgeloggte Charaktere stuendlich mit aktuellen GE-Preisen neu (Marktschwankungen im Verlauf).",
|
||||
position = 5
|
||||
)
|
||||
default boolean revalueOffline()
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
@ConfigItem(
|
||||
keyName = "showGeBuyLimits",
|
||||
name = "GE-Kauflimit (4h) im Interface",
|
||||
description = "Zeigt im Grand Exchange direkt das 4-Stunden-Kauflimit, bereits gekaufte Menge und verbleibendes Limit an.",
|
||||
position = 7
|
||||
)
|
||||
default boolean showGeBuyLimits()
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
@ConfigItem(
|
||||
keyName = "showSkillsHud",
|
||||
name = "XP-HUD im Spiel",
|
||||
description = "Zeigt ingame die gefarmte XP pro Skill im gewaehlten Zeitraum (1H, 1D, ...) an.",
|
||||
position = 8
|
||||
)
|
||||
default boolean showSkillsHud()
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
@ConfigItem(
|
||||
keyName = "skillsHudMax",
|
||||
name = "XP-HUD max. Skills",
|
||||
description = "Maximale Anzahl Skills im XP-HUD (meiste XP zuerst).",
|
||||
position = 9
|
||||
)
|
||||
@Range(min = 1, max = 24)
|
||||
default int skillsHudMax()
|
||||
{
|
||||
return 8;
|
||||
}
|
||||
|
||||
@ConfigItem(
|
||||
keyName = "skillsHudLevel",
|
||||
name = "XP-HUD: Level",
|
||||
description = "Zeigt das aktuelle Level pro Skill im XP-HUD.",
|
||||
position = 10
|
||||
)
|
||||
default boolean skillsHudLevel()
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
@ConfigItem(
|
||||
keyName = "skillsHudPercent",
|
||||
name = "XP-HUD: Fortschritt %",
|
||||
description = "Zeigt den Fortschritt im aktuellen Level in Prozent.",
|
||||
position = 11
|
||||
)
|
||||
default boolean skillsHudPercent()
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
@ConfigItem(
|
||||
keyName = "skillsHudXpLeft",
|
||||
name = "XP-HUD: XP bis Level",
|
||||
description = "Zeigt die verbleibende XP bis zum naechsten Level.",
|
||||
position = 12
|
||||
)
|
||||
default boolean skillsHudXpLeft()
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
@ConfigItem(
|
||||
keyName = "skillsHudTimeToLevel",
|
||||
name = "XP-HUD: Zeit bis Level",
|
||||
description = "Geschaetzte Zeit bis zum naechsten Level (basierend auf XP/h im gewaehlten Zeitraum).",
|
||||
position = 13
|
||||
)
|
||||
default boolean skillsHudTimeToLevel()
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
@ConfigItem(
|
||||
keyName = "skillsHudXpHour",
|
||||
name = "XP-HUD: XP/h",
|
||||
description = "Zeigt XP pro Stunde aktiver Trainingszeit im gewaehlten Zeitraum.",
|
||||
position = 14
|
||||
)
|
||||
default boolean skillsHudXpHour()
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
@ConfigItem(
|
||||
keyName = "skillsHudProgressBar",
|
||||
name = "XP-HUD: Fortschrittsbalken",
|
||||
description = "Zeigt einen Balken mit dem Fortschritt zum naechsten Level.",
|
||||
position = 15
|
||||
)
|
||||
default boolean skillsHudProgressBar()
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
@ConfigItem(
|
||||
keyName = "wikiMenuOption",
|
||||
name = "Wiki im Rechtsklick-Menue",
|
||||
description = "Fuegt beim Rechtsklick auf NPCs, Objekte und Items eine 'Wiki'-Option hinzu, die die Seite im Spiel oeffnet.",
|
||||
position = 20
|
||||
)
|
||||
default boolean wikiMenuOption()
|
||||
{
|
||||
return true;
|
||||
}
|
||||
@ConfigItem(
|
||||
keyName = "attackTimer",
|
||||
name = "Attack Timer",
|
||||
description = "Zeigt ueber dem eigenen Kopf, wie viele Ticks bis zum naechsten Angriff verbleiben (0 = Angriff).",
|
||||
position = 30
|
||||
)
|
||||
default boolean attackTimer()
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
@ConfigItem(
|
||||
keyName = "enemyAttackTimer",
|
||||
name = "Enemy Attack Timer",
|
||||
description = "Zeigt ueber NPCs, die dich angreifen, die Ticks bis zu ihrem naechsten Angriff. Die Angriffsgeschwindigkeit wird aus ihren Angriffen gelernt (Standard 4).",
|
||||
position = 31
|
||||
)
|
||||
default boolean enemyAttackTimer()
|
||||
{
|
||||
return false;
|
||||
}
|
||||
@ConfigItem(
|
||||
keyName = "enemyAttackStyle",
|
||||
name = "Enemy Attack Style",
|
||||
description = "Zeigt ueber NPCs, die dich angreifen, womit sie zuletzt angegriffen haben (Nahkampf, Fernkampf, Magie).",
|
||||
position = 32
|
||||
)
|
||||
default boolean enemyAttackStyle()
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
@ConfigItem(
|
||||
keyName = "itemPrices",
|
||||
name = "Item Preise",
|
||||
description = "Zeigt beim Hovern ueber Inventar-Items eine Zeile mit GE- und/oder HA-Preis.",
|
||||
position = 33
|
||||
)
|
||||
default boolean itemPrices()
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
@ConfigItem(
|
||||
keyName = "itemPricesGe",
|
||||
name = "Item Preise: GE",
|
||||
description = "GE-Preis in der Hover-Zeile anzeigen.",
|
||||
position = 34
|
||||
)
|
||||
default boolean itemPricesGe()
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
@ConfigItem(
|
||||
keyName = "itemPricesHa",
|
||||
name = "Item Preise: HA",
|
||||
description = "High-Alch-Wert in der Hover-Zeile anzeigen.",
|
||||
position = 35
|
||||
)
|
||||
default boolean itemPricesHa()
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
@ConfigItem(
|
||||
keyName = "treeCooldowns",
|
||||
name = "Tree Cooldowns",
|
||||
description = "Zeigt Respawn-Countdown auf gefaellten Baeumen und verbleibende Hack-Zeit auf langlebigen Baeumen.",
|
||||
position = 36
|
||||
)
|
||||
default boolean treeCooldowns()
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
@ConfigItem(
|
||||
keyName = "itemCharges",
|
||||
name = "Item charges",
|
||||
description = "Zeigt Ladungen und Füllstände direkt auf Item-Icons an (Blowpipe, Trident, Tome, Quiver, Loot-Bag, Coal-Bag).",
|
||||
position = 37
|
||||
)
|
||||
default boolean itemCharges()
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
@ConfigItem(
|
||||
keyName = "enemyAttackStyleText",
|
||||
name = "Enemy Attack Style: Text statt Icon",
|
||||
description = "Zeigt 'Melee', 'Range' oder 'Mage' statt des Gebets-Icons.",
|
||||
position = 60
|
||||
)
|
||||
default boolean enemyAttackStyleText()
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
@ConfigItem(
|
||||
keyName = "enemyAttackStyleSize",
|
||||
name = "Enemy Attack Style: Groesse",
|
||||
description = "Groesse von Icon bzw. Text.",
|
||||
position = 61
|
||||
)
|
||||
default TimerTextSize enemyAttackStyleSize()
|
||||
{
|
||||
return TimerTextSize.NORMAL;
|
||||
}
|
||||
|
||||
@ConfigItem(
|
||||
keyName = "enemyAttackStyleOffset",
|
||||
name = "Enemy Attack Style: Hoehe",
|
||||
description = "Abstand ueber dem Kopf, unabhaengig vom Enemy Attack Timer.",
|
||||
position = 62
|
||||
)
|
||||
@Range(min = -100, max = 300)
|
||||
default int enemyAttackStyleOffset()
|
||||
{
|
||||
return 60;
|
||||
}
|
||||
|
||||
@ConfigItem(
|
||||
keyName = "attackTimerBar",
|
||||
name = "Attack Timer: Tick-Balken",
|
||||
description = "Zeigt unter der Zahl einen Balken mit einem Kaestchen pro Tick.",
|
||||
position = 40
|
||||
)
|
||||
default boolean attackTimerBar()
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
@ConfigItem(
|
||||
keyName = "attackTimerSize",
|
||||
name = "Attack Timer: Textgroesse",
|
||||
description = "Groesse der Zahl.",
|
||||
position = 41
|
||||
)
|
||||
default TimerTextSize attackTimerSize()
|
||||
{
|
||||
return TimerTextSize.NORMAL;
|
||||
}
|
||||
|
||||
@ConfigItem(
|
||||
keyName = "attackTimerColor",
|
||||
name = "Attack Timer: Farbe",
|
||||
description = "Farbe der Zahl und des Balkens.",
|
||||
position = 42
|
||||
)
|
||||
default Color attackTimerColor()
|
||||
{
|
||||
return Color.WHITE;
|
||||
}
|
||||
|
||||
@ConfigItem(
|
||||
keyName = "attackTimerReadyColor",
|
||||
name = "Attack Timer: Farbe bei 0",
|
||||
description = "Farbe, wenn der Angriff faellig ist (0).",
|
||||
position = 43
|
||||
)
|
||||
default Color attackTimerReadyColor()
|
||||
{
|
||||
return new Color(0x66FF66);
|
||||
}
|
||||
|
||||
@ConfigItem(
|
||||
keyName = "attackTimerOffset",
|
||||
name = "Attack Timer: Hoehe",
|
||||
description = "Abstand ueber dem Kopf.",
|
||||
position = 44
|
||||
)
|
||||
@Range(min = -100, max = 200)
|
||||
default int attackTimerOffset()
|
||||
{
|
||||
return 30;
|
||||
}
|
||||
|
||||
@ConfigItem(
|
||||
keyName = "enemyAttackTimerBar",
|
||||
name = "Enemy Attack Timer: Tick-Balken",
|
||||
description = "Zeigt unter der Zahl einen Balken mit einem Kaestchen pro Tick.",
|
||||
position = 50
|
||||
)
|
||||
default boolean enemyAttackTimerBar()
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
@ConfigItem(
|
||||
keyName = "enemyAttackTimerSize",
|
||||
name = "Enemy Attack Timer: Textgroesse",
|
||||
description = "Groesse der Zahl.",
|
||||
position = 51
|
||||
)
|
||||
default TimerTextSize enemyAttackTimerSize()
|
||||
{
|
||||
return TimerTextSize.NORMAL;
|
||||
}
|
||||
|
||||
@ConfigItem(
|
||||
keyName = "enemyAttackTimerColor",
|
||||
name = "Enemy Attack Timer: Farbe",
|
||||
description = "Farbe der Zahl und des Balkens.",
|
||||
position = 52
|
||||
)
|
||||
default Color enemyAttackTimerColor()
|
||||
{
|
||||
return new Color(0xFF7043);
|
||||
}
|
||||
|
||||
@ConfigItem(
|
||||
keyName = "enemyAttackTimerReadyColor",
|
||||
name = "Enemy Attack Timer: Farbe bei 0",
|
||||
description = "Farbe, wenn der Angriff faellig ist (0).",
|
||||
position = 53
|
||||
)
|
||||
default Color enemyAttackTimerReadyColor()
|
||||
{
|
||||
return new Color(0x66FF66);
|
||||
}
|
||||
|
||||
@ConfigItem(
|
||||
keyName = "enemyAttackTimerOffset",
|
||||
name = "Enemy Attack Timer: Hoehe",
|
||||
description = "Abstand ueber dem Kopf.",
|
||||
position = 54
|
||||
)
|
||||
@Range(min = -100, max = 200)
|
||||
default int enemyAttackTimerOffset()
|
||||
{
|
||||
return 30;
|
||||
}
|
||||
|
||||
@ConfigItem(
|
||||
keyName = "treeCooldownRespawn",
|
||||
name = "Tree Cooldowns: Respawn",
|
||||
description = "Respawn-Countdown ueber Baumstuempfen anzeigen.",
|
||||
position = 70
|
||||
)
|
||||
default boolean treeCooldownRespawn()
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
@ConfigItem(
|
||||
keyName = "treeCooldownChop",
|
||||
name = "Tree Cooldowns: Hackzeit",
|
||||
description = "Verbleibende Hack-Zeit ueber Baeumen waehrend des Faellens anzeigen.",
|
||||
position = 71
|
||||
)
|
||||
default boolean treeCooldownChop()
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
@ConfigItem(
|
||||
keyName = "treeCooldownBar",
|
||||
name = "Tree Cooldowns: Fortschrittsbalken",
|
||||
description = "Zeigt einen kleinen Fortschrittsbalken unter der Sekundenzahl.",
|
||||
position = 72
|
||||
)
|
||||
default boolean treeCooldownBar()
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
@ConfigItem(
|
||||
keyName = "treeCooldownSize",
|
||||
name = "Tree Cooldowns: Textgroesse",
|
||||
description = "Groesse der Zahl.",
|
||||
position = 73
|
||||
)
|
||||
default TimerTextSize treeCooldownSize()
|
||||
{
|
||||
return TimerTextSize.NORMAL;
|
||||
}
|
||||
|
||||
@ConfigItem(
|
||||
keyName = "treeCooldownColor",
|
||||
name = "Tree Cooldowns: Farbe",
|
||||
description = "Farbe der Zahl.",
|
||||
position = 74
|
||||
)
|
||||
default Color treeCooldownColor()
|
||||
{
|
||||
return new Color(0x8FBF3F);
|
||||
}
|
||||
|
||||
@ConfigItem(
|
||||
keyName = "treeCooldownReadyColor",
|
||||
name = "Tree Cooldowns: Farbe bei Bereit",
|
||||
description = "Farbe kurz vor bzw. beim Respawn.",
|
||||
position = 75
|
||||
)
|
||||
default Color treeCooldownReadyColor()
|
||||
{
|
||||
return new Color(0x66FF66);
|
||||
}
|
||||
|
||||
@ConfigItem(
|
||||
keyName = "treeCooldownOffset",
|
||||
name = "Tree Cooldowns: Hoehe",
|
||||
description = "Abstand ueber dem Stumpf bzw. der Baum-Basis.",
|
||||
position = 76
|
||||
)
|
||||
@Range(min = -100, max = 200)
|
||||
default int treeCooldownOffset()
|
||||
{
|
||||
return 50;
|
||||
}
|
||||
|
||||
@ConfigItem(
|
||||
keyName = "itemChargesWeapons",
|
||||
name = "Item Charges: Waffen & Rüstung",
|
||||
description = "Ladungen von Waffen und Rüstungsteilen anzeigen.",
|
||||
position = 80
|
||||
)
|
||||
default boolean itemChargesWeapons()
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
@ConfigItem(
|
||||
keyName = "itemChargesBags",
|
||||
name = "Item Charges: Beutel & Säcke",
|
||||
description = "Geldwert im Looting Bag und Füllstand des Coal Bags anzeigen.",
|
||||
position = 81
|
||||
)
|
||||
default boolean itemChargesBags()
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
@ConfigItem(
|
||||
keyName = "itemChargesBank",
|
||||
name = "Item Charges: Bank",
|
||||
description = "Ladungen auch auf Items im Bank-Interface anzeigen (Achtung: Varbits sind charakterweit).",
|
||||
position = 82
|
||||
)
|
||||
default boolean itemChargesBank()
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
@ConfigItem(
|
||||
keyName = "itemChargesSize",
|
||||
name = "Item Charges: Textgröße",
|
||||
description = "Schriftgröße der Ladungsanzeige.",
|
||||
position = 83
|
||||
)
|
||||
default TimerTextSize itemChargesSize()
|
||||
{
|
||||
return TimerTextSize.SMALL;
|
||||
}
|
||||
|
||||
@ConfigItem(
|
||||
keyName = "itemChargesColor",
|
||||
name = "Item Charges: Farbe",
|
||||
description = "Farbe der primären Ladungszahl.",
|
||||
position = 84
|
||||
)
|
||||
default Color itemChargesColor()
|
||||
{
|
||||
return Color.WHITE;
|
||||
}
|
||||
|
||||
@ConfigItem(
|
||||
keyName = "itemChargesSecondaryColor",
|
||||
name = "Item Charges: Sekundärfarbe",
|
||||
description = "Farbe für Sekundärwerte (z. B. Quiver-Blessing, Darts-Marker).",
|
||||
position = 85
|
||||
)
|
||||
default Color itemChargesSecondaryColor()
|
||||
{
|
||||
return new Color(0xC0C0C0);
|
||||
}
|
||||
|
||||
@ConfigItem(
|
||||
keyName = "itemChargesLowColor",
|
||||
name = "Item Charges: Warnfarbe",
|
||||
description = "Farbe bei niedrigem Ladungsstand.",
|
||||
position = 86
|
||||
)
|
||||
default Color itemChargesLowColor()
|
||||
{
|
||||
return new Color(0xFF3333);
|
||||
}
|
||||
|
||||
@ConfigItem(
|
||||
keyName = "itemChargesLowAt",
|
||||
name = "Item Charges: Niedrig ab",
|
||||
description = "Schwellenwert für niedrige Ladungen (0 = deaktiviert).",
|
||||
position = 87
|
||||
)
|
||||
@Range(min = 0, max = 10000)
|
||||
default int itemChargesLowAt()
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,318 @@
|
||||
package com.example.essentialsplus;
|
||||
|
||||
import com.example.essentialsplus.data.AccountData;
|
||||
import com.example.essentialsplus.data.GeSlot;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.function.IntUnaryOperator;
|
||||
import net.runelite.api.Client;
|
||||
import net.runelite.api.GrandExchangeOffer;
|
||||
import net.runelite.api.GrandExchangeOfferState;
|
||||
import net.runelite.api.Item;
|
||||
import net.runelite.api.ItemContainer;
|
||||
import net.runelite.api.gameval.InterfaceID;
|
||||
import net.runelite.api.gameval.InventoryID;
|
||||
import net.runelite.api.gameval.ItemID;
|
||||
import net.runelite.api.widgets.Widget;
|
||||
|
||||
/**
|
||||
* Values Grand Exchange offers without double counting.
|
||||
* <p>
|
||||
* A slot holds two things: the escrow of the part that has not traded yet and the collection box
|
||||
* (bought items / received coins). The client does not say how much of a partially completed offer was
|
||||
* already collected, so collections are detected: whenever the offer item or coins show up in inventory
|
||||
* or bank while a GE / bank interface is open, that amount is treated as collected. If a slot empties and
|
||||
* the items did not show up anywhere visible they went to the bank unseen and are booked there.
|
||||
* The game's collection box containers are used directly when the client has them.
|
||||
*/
|
||||
final class GrandExchangeTracker
|
||||
{
|
||||
private static final int SLOTS = 8;
|
||||
private static final int[] COLLECT_CONTAINERS = {
|
||||
InventoryID.TRADINGPOST_SELL_0, InventoryID.TRADINGPOST_SELL_1, InventoryID.TRADINGPOST_SELL_2,
|
||||
InventoryID.TRADINGPOST_SELL_3, InventoryID.TRADINGPOST_SELL_4, InventoryID.TRADINGPOST_SELL_5,
|
||||
InventoryID.GE_COLLECT_6, InventoryID.GE_COLLECT_7
|
||||
};
|
||||
|
||||
/** how long a visible coin loss may be matched to a new buy offer */
|
||||
private static final long COIN_LOSS_WINDOW_MS = 15_000L;
|
||||
|
||||
/** inventory + visible bank holdings (canonical ids) at the previous update */
|
||||
private Map<Integer, Long> previousHoldings;
|
||||
/** coins that recently left inventory / visible bank and were not matched to a buy offer yet */
|
||||
private long recentCoinLoss;
|
||||
private long recentCoinLossAt;
|
||||
|
||||
void reset()
|
||||
{
|
||||
previousHoldings = null;
|
||||
recentCoinLoss = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param live false while the offers of a fresh login have not arrived yet - persisted values are used then
|
||||
* @return canonical item id -> quantity held by the Grand Exchange
|
||||
*/
|
||||
Map<Integer, Long> update(AccountData acc, Client client, IntUnaryOperator canonicalize, boolean live)
|
||||
{
|
||||
boolean migrate = acc.geSlots == null || acc.geSlots.length != SLOTS;
|
||||
|
||||
Map<Integer, Long> holdings = new HashMap<>();
|
||||
addCanonical(holdings, acc.containers.get("inventory"), canonicalize);
|
||||
addCanonical(holdings, acc.containers.get("bank"), canonicalize);
|
||||
Map<Integer, Long> gains = new HashMap<>();
|
||||
long updateTime = System.currentTimeMillis();
|
||||
if (updateTime - recentCoinLossAt > COIN_LOSS_WINDOW_MS)
|
||||
{
|
||||
recentCoinLoss = 0;
|
||||
}
|
||||
if (previousHoldings != null)
|
||||
{
|
||||
long coinLoss = previousHoldings.getOrDefault(ItemID.COINS, 0L) - holdings.getOrDefault(ItemID.COINS, 0L);
|
||||
if (coinLoss > 0)
|
||||
{
|
||||
recentCoinLoss += coinLoss;
|
||||
recentCoinLossAt = updateTime;
|
||||
}
|
||||
for (Map.Entry<Integer, Long> e : holdings.entrySet())
|
||||
{
|
||||
long d = e.getValue() - previousHoldings.getOrDefault(e.getKey(), 0L);
|
||||
if (d > 0)
|
||||
{
|
||||
gains.put(e.getKey(), d);
|
||||
}
|
||||
}
|
||||
}
|
||||
boolean haveBaseline = previousHoldings != null;
|
||||
previousHoldings = holdings;
|
||||
|
||||
GrandExchangeOffer[] offers = client.getGrandExchangeOffers();
|
||||
Map<Integer, Long> out = new HashMap<>();
|
||||
if (!live || offers == null || offers.length < SLOTS || (!haveBaseline && !migrate))
|
||||
{
|
||||
// keep the last known state
|
||||
if (!migrate)
|
||||
{
|
||||
for (GeSlot s : acc.geSlots)
|
||||
{
|
||||
addSlot(out, s);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
if (migrate)
|
||||
{
|
||||
acc.geSlots = new GeSlot[SLOTS];
|
||||
}
|
||||
boolean seeded = migrate;
|
||||
|
||||
boolean collecting = isOpen(client, InterfaceID.GE_OFFERS) || isOpen(client, InterfaceID.GE_COLLECT) || isOpen(client, InterfaceID.BANKMAIN);
|
||||
|
||||
for (int i = 0; i < SLOTS; i++)
|
||||
{
|
||||
GrandExchangeOffer o = offers[i];
|
||||
GeSlot s = acc.geSlots[i];
|
||||
GrandExchangeOfferState state = o == null ? null : o.getState();
|
||||
|
||||
if (state == null || state == GrandExchangeOfferState.EMPTY || o.getItemId() <= 0)
|
||||
{
|
||||
if (s != null && !migrate)
|
||||
{
|
||||
// slot cleared: whatever was still collectable was collected now
|
||||
int item = canonicalize.applyAsInt(s.item);
|
||||
long items = s.collectItems - consume(gains, item, s.collectItems);
|
||||
long coins = s.collectCoins - consume(gains, ItemID.COINS, s.collectCoins);
|
||||
bookToBank(acc, item, items);
|
||||
bookToBank(acc, ItemID.COINS, coins);
|
||||
}
|
||||
acc.geSlots[i] = null;
|
||||
continue;
|
||||
}
|
||||
|
||||
boolean buy = state == GrandExchangeOfferState.BUYING || state == GrandExchangeOfferState.BOUGHT
|
||||
|| state == GrandExchangeOfferState.CANCELLED_BUY;
|
||||
boolean cancelled = state == GrandExchangeOfferState.CANCELLED_BUY || state == GrandExchangeOfferState.CANCELLED_SELL;
|
||||
long total = o.getTotalQuantity();
|
||||
long price = o.getPrice();
|
||||
long done = o.getQuantitySold();
|
||||
long spent = o.getSpent();
|
||||
|
||||
// what the slot has produced so far (collectable if nothing was taken out)
|
||||
long grossItems = buy ? done : (cancelled ? total - done : 0);
|
||||
long grossCoins = buy ? Math.max(0, done * price - spent) + (cancelled ? (total - done) * price : 0) : spent;
|
||||
long escrowItems = !buy && !cancelled ? total - done : 0;
|
||||
long escrowCoins = buy && !cancelled ? (total - done) * price : 0;
|
||||
|
||||
if (s == null || s.item != o.getItemId() || s.buy != buy || s.total != total || s.price != price)
|
||||
{
|
||||
GeSlot fresh = new GeSlot();
|
||||
fresh.item = o.getItemId();
|
||||
fresh.buy = buy;
|
||||
fresh.total = total;
|
||||
fresh.price = price;
|
||||
long now = System.currentTimeMillis();
|
||||
fresh.trackedSince = now;
|
||||
fresh.placedAt = migrate ? 0 : now;
|
||||
fresh.done = migrate ? done : 0;
|
||||
fresh.trackedDone = fresh.done;
|
||||
if (migrate && acc.geSeedCollected != null)
|
||||
{
|
||||
// first run with this tracker: take over what is known to be collected already
|
||||
long seed = acc.geSeedCollected.getOrDefault(fresh.item, 0L);
|
||||
fresh.collectedItems = Math.min(grossItems, seed);
|
||||
if (seed > 0)
|
||||
{
|
||||
acc.geSeedCollected.put(fresh.item, seed - fresh.collectedItems);
|
||||
}
|
||||
}
|
||||
if (!migrate && buy)
|
||||
{
|
||||
// A buy offer can be paid with coins from the bank while the bank is not visible. Whatever did not
|
||||
// leave inventory / visible bank came from there - otherwise the stale bank and the offer both count it.
|
||||
long cost = total * price;
|
||||
long seen = Math.min(cost, recentCoinLoss);
|
||||
recentCoinLoss -= seen;
|
||||
bookToBank(acc, ItemID.COINS, -(cost - seen));
|
||||
}
|
||||
s = fresh;
|
||||
acc.geSlots[i] = s;
|
||||
}
|
||||
|
||||
int item = canonicalize.applyAsInt(s.item);
|
||||
// items / coins of this slot showing up in inventory or bank were collected
|
||||
if (collecting)
|
||||
{
|
||||
s.collectedItems += consume(gains, item, Math.max(0, grossItems - s.collectedItems));
|
||||
s.collectedCoins += consume(gains, ItemID.COINS, Math.max(0, grossCoins - s.collectedCoins));
|
||||
}
|
||||
long collectItems = Math.max(0, grossItems - s.collectedItems);
|
||||
long collectCoins = Math.max(0, grossCoins - s.collectedCoins);
|
||||
ItemContainer box = client.getItemContainer(COLLECT_CONTAINERS[i]);
|
||||
if (box != null)
|
||||
{
|
||||
long boxItems = 0;
|
||||
long boxCoins = 0;
|
||||
for (Item it : box.getItems())
|
||||
{
|
||||
if (it == null || it.getId() <= 0 || it.getQuantity() <= 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if (it.getId() == ItemID.COINS)
|
||||
{
|
||||
boxCoins += it.getQuantity();
|
||||
}
|
||||
else if (canonicalize.applyAsInt(it.getId()) == item)
|
||||
{
|
||||
boxItems += it.getQuantity();
|
||||
}
|
||||
}
|
||||
// The client can keep an outdated box after collecting and may fill it a tick after the offer, so the box
|
||||
// only counts when it shrank since the last update: that part went to the bank without being seen.
|
||||
long goneItems = Math.min(collectItems - boxItems, s.collectItems - boxItems);
|
||||
if (goneItems > 0)
|
||||
{
|
||||
bookToBank(acc, item, goneItems);
|
||||
s.collectedItems += goneItems;
|
||||
collectItems -= goneItems;
|
||||
}
|
||||
long goneCoins = Math.min(collectCoins - boxCoins, s.collectCoins - boxCoins);
|
||||
if (goneCoins > 0)
|
||||
{
|
||||
bookToBank(acc, ItemID.COINS, goneCoins);
|
||||
s.collectedCoins += goneCoins;
|
||||
collectCoins -= goneCoins;
|
||||
}
|
||||
}
|
||||
|
||||
long now = System.currentTimeMillis();
|
||||
if (s.trackedSince == 0)
|
||||
{
|
||||
s.trackedSince = now;
|
||||
s.done = done;
|
||||
s.trackedDone = done;
|
||||
}
|
||||
if (done != s.done)
|
||||
{
|
||||
s.lastTradeAt = now;
|
||||
}
|
||||
s.done = done;
|
||||
s.spent = spent;
|
||||
s.state = state.name();
|
||||
s.escrowItems = escrowItems;
|
||||
s.escrowCoins = escrowCoins;
|
||||
s.collectItems = collectItems;
|
||||
s.collectCoins = collectCoins;
|
||||
addSlot(out, s);
|
||||
}
|
||||
if (seeded)
|
||||
{
|
||||
acc.geSeedCollected = null;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private static void addSlot(Map<Integer, Long> out, GeSlot s)
|
||||
{
|
||||
if (s == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
long items = s.escrowItems + s.collectItems;
|
||||
long coins = s.escrowCoins + s.collectCoins;
|
||||
if (items > 0)
|
||||
{
|
||||
out.merge(s.item, items, Long::sum);
|
||||
}
|
||||
if (coins > 0)
|
||||
{
|
||||
out.merge(ItemID.COINS, coins, Long::sum);
|
||||
}
|
||||
}
|
||||
|
||||
/** Takes up to {@code max} of a visible gain and returns how much was taken. */
|
||||
private static long consume(Map<Integer, Long> gains, int item, long max)
|
||||
{
|
||||
if (max <= 0)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
long g = gains.getOrDefault(item, 0L);
|
||||
long take = Math.min(g, max);
|
||||
if (take > 0)
|
||||
{
|
||||
gains.put(item, g - take);
|
||||
}
|
||||
return take;
|
||||
}
|
||||
|
||||
/** Books items that went to the bank while the bank was not visible. */
|
||||
private static void bookToBank(AccountData acc, int item, long qty)
|
||||
{
|
||||
if (qty == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
Map<Integer, Long> pending = new HashMap<>(acc.bankPending);
|
||||
pending.merge(item, qty, Long::sum);
|
||||
acc.bankPending = pending;
|
||||
}
|
||||
|
||||
private static void addCanonical(Map<Integer, Long> into, Map<Integer, Long> items, IntUnaryOperator canonicalize)
|
||||
{
|
||||
if (items == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
for (Map.Entry<Integer, Long> e : items.entrySet())
|
||||
{
|
||||
into.merge(canonicalize.applyAsInt(e.getKey()), e.getValue(), Long::sum);
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean isOpen(Client client, int group)
|
||||
{
|
||||
Widget w = client.getWidget(group, 0);
|
||||
return w != null && !w.isHidden();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.example.essentialsplus;
|
||||
|
||||
import java.awt.Color;
|
||||
import lombok.Getter;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
/**
|
||||
* Wealth buckets. Ordinal is used as index into persisted value arrays - only append new entries.
|
||||
*/
|
||||
@Getter
|
||||
@RequiredArgsConstructor
|
||||
public enum WealthCategory
|
||||
{
|
||||
BANK("Bank", new Color(232, 163, 61)),
|
||||
INVENTORY("Inventory", new Color(90, 176, 232)),
|
||||
EQUIPMENT("Equipment", new Color(165, 123, 224)),
|
||||
STORAGE("Storage", new Color(77, 201, 166)),
|
||||
COFFERS("Coffers", new Color(232, 112, 122)),
|
||||
GRAND_EXCHANGE("Grand Exchange", new Color(201, 214, 90)),
|
||||
/** Totals imported from other plugins without a breakdown. */
|
||||
IMPORTED("Imported (total only)", new Color(120, 120, 128));
|
||||
|
||||
public static final int COUNT = values().length;
|
||||
|
||||
private final String label;
|
||||
private final Color color;
|
||||
}
|
||||
@@ -0,0 +1,475 @@
|
||||
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.google.gson.Gson;
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.io.Reader;
|
||||
import java.io.Writer;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.time.DayOfWeek;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.time.ZoneId;
|
||||
import java.time.ZonedDateTime;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.time.temporal.TemporalAdjusters;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.CopyOnWriteArrayList;
|
||||
import javax.inject.Inject;
|
||||
import javax.inject.Singleton;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import net.runelite.client.RuneLite;
|
||||
|
||||
/**
|
||||
* Loads and saves the files in ~/.runelite/essentials-plus:
|
||||
* <ul>
|
||||
* <li>{@code <key>.json} - state + wealth timeline</li>
|
||||
* <li>{@code <key>.items.json} - item holdings over time</li>
|
||||
* </ul>
|
||||
* Several RuneLite clients may run at once, so files of characters that are not logged in
|
||||
* in this client are re-read when they change on disk.
|
||||
*/
|
||||
@Slf4j
|
||||
@Singleton
|
||||
public class WealthStore
|
||||
{
|
||||
public static final File DIR = new File(RuneLite.RUNELITE_DIR, "essentials-plus");
|
||||
private static final String LEDGER_SUFFIX = ".items.json";
|
||||
|
||||
private final Gson gson;
|
||||
private final Map<String, AccountData> accounts = new ConcurrentHashMap<>();
|
||||
private final Map<String, ItemLedger> ledgers = new ConcurrentHashMap<>();
|
||||
|
||||
@Inject
|
||||
WealthStore(Gson gson)
|
||||
{
|
||||
this.gson = gson.newBuilder().disableHtmlEscaping().create();
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ accounts
|
||||
|
||||
public List<AccountData> all()
|
||||
{
|
||||
List<AccountData> list = new ArrayList<>(accounts.values());
|
||||
list.sort(Comparator.comparingLong((AccountData a) -> -a.currentTotal())
|
||||
.thenComparing(a -> a.name == null ? "" : a.name, String.CASE_INSENSITIVE_ORDER));
|
||||
return list;
|
||||
}
|
||||
|
||||
public AccountData get(String key)
|
||||
{
|
||||
return key == null ? null : accounts.get(key);
|
||||
}
|
||||
|
||||
public AccountData getOrCreate(String key, String name)
|
||||
{
|
||||
return accounts.computeIfAbsent(key, k ->
|
||||
{
|
||||
AccountData a = new AccountData();
|
||||
a.key = k;
|
||||
a.name = name;
|
||||
return a;
|
||||
});
|
||||
}
|
||||
|
||||
public void put(AccountData account)
|
||||
{
|
||||
accounts.put(account.key, account);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Re-reads account files changed on disk, skipping the character logged in here (this client owns it).
|
||||
*
|
||||
* @return true if anything changed
|
||||
*/
|
||||
public boolean reloadExternal(String activeKey)
|
||||
{
|
||||
File[] files = DIR.listFiles((d, n) -> n.endsWith(".json") && !n.endsWith(LEDGER_SUFFIX));
|
||||
if (files == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
boolean changed = false;
|
||||
Set<String> seen = new HashSet<>();
|
||||
for (File f : files)
|
||||
{
|
||||
AccountData existing = findByFile(f);
|
||||
if (existing != null)
|
||||
{
|
||||
seen.add(existing.key);
|
||||
if (existing.key.equals(activeKey) || existing.fileModified >= f.lastModified())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
AccountData loaded = read(f, AccountData.class);
|
||||
if (loaded == null || loaded.key == null || loaded.key.equals(activeKey))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
loaded.normalize();
|
||||
loaded.fileModified = f.lastModified();
|
||||
accounts.put(loaded.key, loaded);
|
||||
seen.add(loaded.key);
|
||||
changed = true;
|
||||
}
|
||||
|
||||
// files deleted by another client
|
||||
for (String key : new ArrayList<>(accounts.keySet()))
|
||||
{
|
||||
AccountData a = accounts.get(key);
|
||||
if (a != null && !seen.contains(key) && !key.equals(activeKey) && a.fileModified > 0)
|
||||
{
|
||||
accounts.remove(key);
|
||||
ledgers.remove(key);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
/** Replaces the in-memory copy with the file if the file is newer (used when a character logs in). */
|
||||
public AccountData refreshFromDisk(String key)
|
||||
{
|
||||
File f = fileFor(key);
|
||||
AccountData mem = accounts.get(key);
|
||||
if (f.exists() && (mem == null || mem.fileModified < f.lastModified()))
|
||||
{
|
||||
AccountData loaded = read(f, AccountData.class);
|
||||
if (loaded != null)
|
||||
{
|
||||
loaded.key = key;
|
||||
loaded.normalize();
|
||||
loaded.fileModified = f.lastModified();
|
||||
accounts.put(key, loaded);
|
||||
ledgers.remove(key);
|
||||
return loaded;
|
||||
}
|
||||
}
|
||||
return mem;
|
||||
}
|
||||
|
||||
private AccountData findByFile(File f)
|
||||
{
|
||||
for (AccountData a : accounts.values())
|
||||
{
|
||||
if (fileFor(a.key).getName().equals(f.getName()))
|
||||
{
|
||||
return a;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public void save(AccountData account)
|
||||
{
|
||||
if (account == null || account.key == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
File target = fileFor(account.key);
|
||||
if (write(target, account))
|
||||
{
|
||||
account.fileModified = target.lastModified();
|
||||
}
|
||||
}
|
||||
|
||||
public void delete(String key)
|
||||
{
|
||||
accounts.remove(key);
|
||||
ledgers.remove(key);
|
||||
for (File f : new File[]{fileFor(key), ledgerFileFor(key)})
|
||||
{
|
||||
if (f.exists() && !f.delete())
|
||||
{
|
||||
log.warn("Wealth tracker: could not delete {}", f);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ item ledgers
|
||||
|
||||
/** Item history of a character, loaded lazily and re-read when another client changed it. */
|
||||
public ItemLedger ledger(String key, boolean owned)
|
||||
{
|
||||
ItemLedger mem = ledgers.get(key);
|
||||
File f = ledgerFileFor(key);
|
||||
if (mem != null && (owned || !f.exists() || mem.fileModified >= f.lastModified()))
|
||||
{
|
||||
return mem;
|
||||
}
|
||||
ItemLedger loaded = f.exists() ? read(f, ItemLedger.class) : null;
|
||||
if (loaded == null)
|
||||
{
|
||||
loaded = mem != null ? mem : new ItemLedger();
|
||||
}
|
||||
else
|
||||
{
|
||||
loaded.normalize();
|
||||
loaded.fileModified = f.lastModified();
|
||||
}
|
||||
ledgers.put(key, loaded);
|
||||
return loaded;
|
||||
}
|
||||
|
||||
public void saveLedger(String key, ItemLedger ledger)
|
||||
{
|
||||
File target = ledgerFileFor(key);
|
||||
if (write(target, ledger))
|
||||
{
|
||||
ledger.fileModified = target.lastModified();
|
||||
ledger.dirty = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ io
|
||||
|
||||
private <T> T read(File f, Class<T> type)
|
||||
{
|
||||
try (Reader r = Files.newBufferedReader(f.toPath(), StandardCharsets.UTF_8))
|
||||
{
|
||||
return gson.fromJson(r, type);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
log.warn("Wealth tracker: could not read {}", f, e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private synchronized boolean write(File target, Object data)
|
||||
{
|
||||
try
|
||||
{
|
||||
Files.createDirectories(DIR.toPath());
|
||||
File tmp = new File(DIR, target.getName() + ".tmp");
|
||||
try (Writer w = Files.newBufferedWriter(tmp.toPath(), StandardCharsets.UTF_8))
|
||||
{
|
||||
gson.toJson(data, w);
|
||||
}
|
||||
Files.move(tmp.toPath(), target.toPath(), StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE);
|
||||
return true;
|
||||
}
|
||||
catch (IOException e)
|
||||
{
|
||||
log.warn("Wealth tracker: could not save {}", target, e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
static String fileBase(String key)
|
||||
{
|
||||
return key.replaceAll("[^A-Za-z0-9 _()\\-]", "_");
|
||||
}
|
||||
|
||||
static File fileFor(String key)
|
||||
{
|
||||
return new File(DIR, fileBase(key) + ".json");
|
||||
}
|
||||
|
||||
static File ledgerFileFor(String key)
|
||||
{
|
||||
return new File(DIR, fileBase(key) + LEDGER_SUFFIX);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ compaction
|
||||
|
||||
/**
|
||||
* Keeps the timeline useful for analysis while bounding its size:
|
||||
* <ul>
|
||||
* <li>younger than {@code rawDays}: every change (identical consecutive points dropped)</li>
|
||||
* <li>younger than {@code hourlyDays}: one point per hour</li>
|
||||
* <li>older: one point per day</li>
|
||||
* </ul>
|
||||
* Buckets keep open, close, low, high and the time-weighted average breakdown.
|
||||
* Must be called on the thread that appends to the history (client thread).
|
||||
*/
|
||||
public static void compact(AccountData account, int rawDays, int hourlyDays, long now)
|
||||
{
|
||||
List<Snapshot> src = AccountData.sanitizeHistory(account.history);
|
||||
if (src.size() < 2)
|
||||
{
|
||||
if (src.size() != account.history.size())
|
||||
{
|
||||
account.history = new CopyOnWriteArrayList<>(src);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
ZoneId zone = ZoneId.systemDefault();
|
||||
ZonedDateTime nowZ = Instant.ofEpochMilli(now).atZone(zone);
|
||||
long rawCutoff = nowZ.minusDays(rawDays).truncatedTo(ChronoUnit.HOURS).toInstant().toEpochMilli();
|
||||
long hourlyCutoff = nowZ.minusDays(Math.max(rawDays, hourlyDays)).truncatedTo(ChronoUnit.DAYS).toInstant().toEpochMilli();
|
||||
|
||||
List<Snapshot> out = new ArrayList<>(src.size());
|
||||
List<Snapshot> bucket = new ArrayList<>();
|
||||
long bucketStart = Long.MIN_VALUE;
|
||||
String bucketG = null;
|
||||
Snapshot prevRaw = null;
|
||||
|
||||
for (Snapshot s : src)
|
||||
{
|
||||
if (s.t >= rawCutoff)
|
||||
{
|
||||
flush(bucket, bucketStart, bucketG, zone, out);
|
||||
bucketStart = Long.MIN_VALUE;
|
||||
if (!s.isAggregate() && prevRaw != null && prevRaw.sameValues(s.v))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
out.add(s);
|
||||
prevRaw = s.isAggregate() ? null : s;
|
||||
continue;
|
||||
}
|
||||
|
||||
String g = s.t >= hourlyCutoff ? Snapshot.HOUR : Snapshot.DAY;
|
||||
ZonedDateTime z = Instant.ofEpochMilli(s.t).atZone(zone);
|
||||
long bs = (g.equals(Snapshot.HOUR) ? z.truncatedTo(ChronoUnit.HOURS) : z.truncatedTo(ChronoUnit.DAYS)).toInstant().toEpochMilli();
|
||||
if (bs != bucketStart || !g.equals(bucketG))
|
||||
{
|
||||
flush(bucket, bucketStart, bucketG, zone, out);
|
||||
bucketStart = bs;
|
||||
bucketG = g;
|
||||
}
|
||||
bucket.add(s);
|
||||
}
|
||||
flush(bucket, bucketStart, bucketG, zone, out);
|
||||
|
||||
if (out.size() != src.size())
|
||||
{
|
||||
account.history = new CopyOnWriteArrayList<>(out);
|
||||
}
|
||||
}
|
||||
|
||||
private static void flush(List<Snapshot> bucket, long start, String g, ZoneId zone, List<Snapshot> out)
|
||||
{
|
||||
if (bucket.isEmpty())
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (bucket.size() == 1 && g.equals(bucket.get(0).g))
|
||||
{
|
||||
out.add(bucket.get(0));
|
||||
bucket.clear();
|
||||
return;
|
||||
}
|
||||
|
||||
long end = g.equals(Snapshot.HOUR)
|
||||
? start + Ui_HOUR
|
||||
: Instant.ofEpochMilli(start).atZone(zone).plusDays(1).toInstant().toEpochMilli();
|
||||
|
||||
double[] sum = new double[WealthCategory.COUNT];
|
||||
double weight = 0;
|
||||
long lo = Long.MAX_VALUE;
|
||||
long hi = Long.MIN_VALUE;
|
||||
for (int i = 0; i < bucket.size(); i++)
|
||||
{
|
||||
Snapshot s = bucket.get(i);
|
||||
long from = Math.max(s.t, start);
|
||||
long to = i + 1 < bucket.size() ? bucket.get(i + 1).t : end;
|
||||
double w = Math.max(1, Math.min(to, end) - from);
|
||||
for (WealthCategory c : WealthCategory.values())
|
||||
{
|
||||
sum[c.ordinal()] += s.get(c) * w;
|
||||
}
|
||||
weight += w;
|
||||
lo = Math.min(lo, s.low());
|
||||
hi = Math.max(hi, s.high());
|
||||
}
|
||||
|
||||
long[] avg = new long[WealthCategory.COUNT];
|
||||
for (int i = 0; i < avg.length; i++)
|
||||
{
|
||||
avg[i] = Math.round(sum[i] / weight);
|
||||
}
|
||||
|
||||
Snapshot agg = new Snapshot(start + (end - start) / 2, avg);
|
||||
agg.o = bucket.get(0).open();
|
||||
agg.c = bucket.get(bucket.size() - 1).close();
|
||||
agg.lo = lo;
|
||||
agg.hi = hi;
|
||||
agg.g = g;
|
||||
agg.m = bucket.get(bucket.size() - 1).m;
|
||||
out.add(agg);
|
||||
bucket.clear();
|
||||
}
|
||||
|
||||
private static final long Ui_HOUR = 3_600_000L;
|
||||
|
||||
/**
|
||||
* Item history resolution: hourly for 2 days, daily for 90 days, weekly afterwards.
|
||||
* Identical consecutive snapshots are dropped.
|
||||
*/
|
||||
public static void compact(ItemLedger ledger, long now)
|
||||
{
|
||||
List<ItemSnapshot> src = ledger.snapshots;
|
||||
if (src.size() < 2)
|
||||
{
|
||||
return;
|
||||
}
|
||||
ZoneId zone = ZoneId.systemDefault();
|
||||
List<ItemSnapshot> out = new ArrayList<>(src.size());
|
||||
long prevBucket = Long.MIN_VALUE;
|
||||
for (ItemSnapshot s : src)
|
||||
{
|
||||
long age = now - s.t;
|
||||
ZonedDateTime z = Instant.ofEpochMilli(s.t).atZone(zone);
|
||||
long bucket;
|
||||
if (age < 2 * 86_400_000L)
|
||||
{
|
||||
bucket = z.truncatedTo(ChronoUnit.HOURS).toInstant().toEpochMilli();
|
||||
}
|
||||
else if (age < 90 * 86_400_000L)
|
||||
{
|
||||
bucket = z.truncatedTo(ChronoUnit.DAYS).toInstant().toEpochMilli() + 1;
|
||||
}
|
||||
else
|
||||
{
|
||||
LocalDate monday = z.toLocalDate().with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY));
|
||||
bucket = monday.atStartOfDay(zone).toInstant().toEpochMilli() + 2;
|
||||
}
|
||||
|
||||
if (Boolean.TRUE.equals(s.k) && age < 2 * 86_400_000L)
|
||||
{
|
||||
out.add(s);
|
||||
prevBucket = Long.MIN_VALUE;
|
||||
continue;
|
||||
}
|
||||
if (!out.isEmpty() && bucket == prevBucket && !Boolean.TRUE.equals(out.get(out.size() - 1).k))
|
||||
{
|
||||
// keep the latest state of each bucket
|
||||
out.set(out.size() - 1, s);
|
||||
}
|
||||
else if (!out.isEmpty() && s.sameAs(out.get(out.size() - 1)))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
else
|
||||
{
|
||||
out.add(s);
|
||||
}
|
||||
prevBucket = bucket;
|
||||
}
|
||||
if (out.size() != src.size())
|
||||
{
|
||||
ledger.snapshots = new CopyOnWriteArrayList<>(out);
|
||||
ledger.dirty = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,515 @@
|
||||
package com.example.essentialsplus.collectionlog;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.CopyOnWriteArrayList;
|
||||
import java.util.stream.Collectors;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import net.runelite.api.ChatMessageType;
|
||||
import net.runelite.api.Client;
|
||||
import net.runelite.api.EnumComposition;
|
||||
import net.runelite.api.GameState;
|
||||
import net.runelite.api.Item;
|
||||
import net.runelite.api.ItemContainer;
|
||||
import net.runelite.api.StructComposition;
|
||||
import net.runelite.api.events.ChatMessage;
|
||||
import net.runelite.api.gameval.InterfaceID;
|
||||
import net.runelite.api.widgets.Widget;
|
||||
import net.runelite.client.config.ConfigManager;
|
||||
import net.runelite.client.util.Text;
|
||||
|
||||
/**
|
||||
* Collection log pages and items (read from the game cache) plus which items the logged-in account has
|
||||
* obtained. Obtained items are learned whenever a collection log page is open and from the
|
||||
* "New item added to your collection log" chat message, and stored per RuneScape profile.
|
||||
*/
|
||||
@Slf4j
|
||||
public class CollectionLogService
|
||||
{
|
||||
private static final String GROUP = com.example.essentialsplus.EssentialsPlusConfig.GROUP;
|
||||
private static final String KEY = "clogObtained";
|
||||
private static final String KEY_FULL_SYNC = "clogFullSync";
|
||||
/** runs once per log item (item id, count) when the log's search is opened */
|
||||
private static final int SCRIPT_SEARCH_ITEM = 4100;
|
||||
|
||||
/** tab structs (Bosses, Raids, Clues, Minigames, Other) */
|
||||
private static final int[] TAB_STRUCTS = {471, 472, 473, 474, 475};
|
||||
private static final String[] TAB_NAMES = {"Bosses", "Raids", "Clues", "Minigames", "Other"};
|
||||
private static final int PARAM_TAB_PAGES = 683;
|
||||
private static final int PARAM_PAGE_NAME = 689;
|
||||
private static final int PARAM_PAGE_ITEMS = 690;
|
||||
/** server-sent inventory holding the obtained count of every collection log item */
|
||||
private static final int CLOG_INVENTORY = 620;
|
||||
/** obj -> obj enum the log script uses to remap page items before looking them up in the inventory */
|
||||
private static final int ENUM_ITEM_REMAP = 3721;
|
||||
/** unobtained items are drawn with this transparency */
|
||||
private static final int UNOBTAINED_OPACITY = 175;
|
||||
private static final String NEW_ITEM = "New item added to your collection log:";
|
||||
|
||||
/** A collection log page, e.g. "Zulrah", with its item ids in log order. */
|
||||
public static final class Page
|
||||
{
|
||||
public final String name;
|
||||
public final List<Integer> itemIds;
|
||||
|
||||
Page(String name, List<Integer> itemIds)
|
||||
{
|
||||
this.name = name;
|
||||
this.itemIds = itemIds;
|
||||
}
|
||||
}
|
||||
|
||||
/** A collection log tab, e.g. "Bosses", with its pages in log order. */
|
||||
public static final class Tab
|
||||
{
|
||||
public final String name;
|
||||
public final List<Page> pages;
|
||||
|
||||
Tab(String name, List<Page> pages)
|
||||
{
|
||||
this.name = name;
|
||||
this.pages = pages;
|
||||
}
|
||||
}
|
||||
|
||||
private final Client client;
|
||||
private final ConfigManager configManager;
|
||||
private final List<Runnable> listeners = new CopyOnWriteArrayList<>();
|
||||
|
||||
private volatile List<Page> pages = Collections.emptyList();
|
||||
private volatile List<Tab> tabs = Collections.emptyList();
|
||||
private volatile Map<Integer, String> itemNames = Collections.emptyMap();
|
||||
private final Set<Integer> obtained = ConcurrentHashMap.newKeySet();
|
||||
private volatile String profileKey;
|
||||
private volatile boolean synced;
|
||||
/** when all items were last read via the log search, 0 = never */
|
||||
private volatile long fullSyncAt;
|
||||
/** items reported by the search script this tick: id -> count */
|
||||
private final Map<Integer, Integer> searchItems = new java.util.HashMap<>();
|
||||
private int searchTick = -1;
|
||||
private int lastWidgetHash;
|
||||
private int lastInventoryHash;
|
||||
|
||||
public CollectionLogService(Client client, ConfigManager configManager)
|
||||
{
|
||||
this.client = client;
|
||||
this.configManager = configManager;
|
||||
}
|
||||
|
||||
public void addListener(Runnable r)
|
||||
{
|
||||
listeners.add(r);
|
||||
}
|
||||
|
||||
public void removeListener(Runnable r)
|
||||
{
|
||||
listeners.remove(r);
|
||||
}
|
||||
|
||||
/** Client thread. Loads the page list once and the obtained items when the profile changes. */
|
||||
public void onGameTick()
|
||||
{
|
||||
if (client.getGameState() != GameState.LOGGED_IN)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (pages.isEmpty())
|
||||
{
|
||||
loadPages();
|
||||
}
|
||||
String key = configManager.getRSProfileKey();
|
||||
if (key != null && !key.equals(profileKey))
|
||||
{
|
||||
profileKey = key;
|
||||
loadObtained();
|
||||
log.info("collection log: profile {} has {} stored obtained items", key, obtained.size());
|
||||
notifyListeners();
|
||||
}
|
||||
finishSearchSync();
|
||||
scanInventory();
|
||||
scanOpenPage();
|
||||
}
|
||||
|
||||
/** Client thread. The log search reports every item with its count: a complete, exact sync. */
|
||||
public void onScriptPreFired(net.runelite.api.events.ScriptPreFired event)
|
||||
{
|
||||
if (event.getScriptId() != SCRIPT_SEARCH_ITEM || event.getScriptEvent() == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
Object[] args = event.getScriptEvent().getArguments();
|
||||
if (args == null || args.length < 3 || !(args[1] instanceof Integer) || !(args[2] instanceof Integer))
|
||||
{
|
||||
return;
|
||||
}
|
||||
searchItems.merge((Integer) args[1], (Integer) args[2], Math::max);
|
||||
searchTick = client.getTickCount();
|
||||
}
|
||||
|
||||
private void finishSearchSync()
|
||||
{
|
||||
if (searchItems.isEmpty() || client.getTickCount() == searchTick || itemNames.isEmpty())
|
||||
{
|
||||
return;
|
||||
}
|
||||
Map<Integer, Integer> seen = new java.util.HashMap<>(searchItems);
|
||||
searchItems.clear();
|
||||
EnumComposition remap = client.getEnum(ENUM_ITEM_REMAP);
|
||||
Set<Integer> have = new HashSet<>();
|
||||
int known = 0;
|
||||
for (int id : itemNames.keySet())
|
||||
{
|
||||
int mapped = remap.getIntValue(id);
|
||||
boolean inSearch = seen.containsKey(id) || mapped > 0 && seen.containsKey(mapped);
|
||||
known += inSearch ? 1 : 0;
|
||||
if (seen.getOrDefault(id, 0) > 0 || mapped > 0 && seen.getOrDefault(mapped, 0) > 0)
|
||||
{
|
||||
have.add(id);
|
||||
}
|
||||
}
|
||||
boolean complete = known >= itemNames.size() * 9 / 10;
|
||||
log.info("collection log search: {} items reported, {} log items matched, {} obtained, complete={}", seen.size(), known, have.size(), complete);
|
||||
if (complete)
|
||||
{
|
||||
// the search lists every item, so this replaces what was guessed from pages before
|
||||
obtained.clear();
|
||||
fullSyncAt = System.currentTimeMillis();
|
||||
if (profileKey != null)
|
||||
{
|
||||
configManager.setRSProfileConfiguration(GROUP, KEY_FULL_SYNC, fullSyncAt);
|
||||
}
|
||||
client.addChatMessage(ChatMessageType.GAMEMESSAGE, "", String.format(
|
||||
"<col=ff9040>Essentials Plus:</col> collection log synced - %d / %d items.", have.size(), itemNames.size()), null);
|
||||
}
|
||||
obtained.addAll(have);
|
||||
synced = true;
|
||||
saveObtained();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/** When the log was last fully synced through its search, 0 = never. */
|
||||
public long getFullSyncAt()
|
||||
{
|
||||
return fullSyncAt;
|
||||
}
|
||||
|
||||
/** Client thread. */
|
||||
public void onChatMessage(ChatMessage event)
|
||||
{
|
||||
if (event.getType() != ChatMessageType.GAMEMESSAGE && event.getType() != ChatMessageType.SPAM)
|
||||
{
|
||||
return;
|
||||
}
|
||||
String msg = Text.removeTags(event.getMessage());
|
||||
if (!msg.startsWith(NEW_ITEM))
|
||||
{
|
||||
return;
|
||||
}
|
||||
String name = msg.substring(NEW_ITEM.length()).trim();
|
||||
boolean changed = false;
|
||||
for (Map.Entry<Integer, String> e : itemNames.entrySet())
|
||||
{
|
||||
if (e.getValue().equalsIgnoreCase(name))
|
||||
{
|
||||
changed |= obtained.add(e.getKey());
|
||||
}
|
||||
}
|
||||
if (changed)
|
||||
{
|
||||
saveObtained();
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
public void reset()
|
||||
{
|
||||
profileKey = null;
|
||||
obtained.clear();
|
||||
synced = false;
|
||||
fullSyncAt = 0;
|
||||
searchItems.clear();
|
||||
}
|
||||
|
||||
/** True once any collection log data is known for the current profile. */
|
||||
public boolean isSynced()
|
||||
{
|
||||
return synced;
|
||||
}
|
||||
|
||||
public boolean isLoaded()
|
||||
{
|
||||
return !pages.isEmpty();
|
||||
}
|
||||
|
||||
public boolean isObtained(int itemId)
|
||||
{
|
||||
return obtained.contains(itemId);
|
||||
}
|
||||
|
||||
public List<Tab> getTabs()
|
||||
{
|
||||
return tabs;
|
||||
}
|
||||
|
||||
/** Number of distinct items in the log. */
|
||||
public int totalItems()
|
||||
{
|
||||
return itemNames.size();
|
||||
}
|
||||
|
||||
/** Number of distinct log items the account has obtained. */
|
||||
public int obtainedItems()
|
||||
{
|
||||
int n = 0;
|
||||
for (int id : itemNames.keySet())
|
||||
{
|
||||
if (obtained.contains(id))
|
||||
{
|
||||
n++;
|
||||
}
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
/** Obtained items of a page or tab. */
|
||||
public int obtainedCount(List<Integer> itemIds)
|
||||
{
|
||||
int n = 0;
|
||||
for (int id : itemIds)
|
||||
{
|
||||
if (obtained.contains(id))
|
||||
{
|
||||
n++;
|
||||
}
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
public String itemName(int itemId)
|
||||
{
|
||||
return itemNames.getOrDefault(itemId, "Item " + itemId);
|
||||
}
|
||||
|
||||
/** All item ids in the log with the given name (some items appear under several ids). */
|
||||
public Set<Integer> idsByName(String name)
|
||||
{
|
||||
Set<Integer> out = new HashSet<>();
|
||||
for (Map.Entry<Integer, String> e : itemNames.entrySet())
|
||||
{
|
||||
if (e.getValue().equalsIgnoreCase(name))
|
||||
{
|
||||
out.add(e.getKey());
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The log page of a loot source: exact name, else a page starting with it ("Barrows" -> "Barrows Chests"). */
|
||||
public Page findPage(String source)
|
||||
{
|
||||
if (source == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
String s = source.trim().toLowerCase(Locale.ROOT);
|
||||
for (Page p : pages)
|
||||
{
|
||||
if (p.name.toLowerCase(Locale.ROOT).equals(s))
|
||||
{
|
||||
return p;
|
||||
}
|
||||
}
|
||||
for (Page p : pages)
|
||||
{
|
||||
if (!p.name.equalsIgnoreCase("All Pets") && p.name.toLowerCase(Locale.ROOT).startsWith(s))
|
||||
{
|
||||
return p;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private void loadPages()
|
||||
{
|
||||
try
|
||||
{
|
||||
List<Page> out = new ArrayList<>();
|
||||
List<Tab> tabList = new ArrayList<>();
|
||||
Map<Integer, String> names = new LinkedHashMap<>();
|
||||
for (int t = 0; t < TAB_STRUCTS.length; t++)
|
||||
{
|
||||
int tabStart = out.size();
|
||||
StructComposition tab = client.getStructComposition(TAB_STRUCTS[t]);
|
||||
EnumComposition pageEnum = client.getEnum(tab.getIntValue(PARAM_TAB_PAGES));
|
||||
for (int pageStructId : pageEnum.getIntVals())
|
||||
{
|
||||
StructComposition page = client.getStructComposition(pageStructId);
|
||||
EnumComposition items = client.getEnum(page.getIntValue(PARAM_PAGE_ITEMS));
|
||||
List<Integer> ids = new ArrayList<>();
|
||||
for (int id : items.getIntVals())
|
||||
{
|
||||
ids.add(id);
|
||||
names.computeIfAbsent(id, i -> client.getItemDefinition(i).getName());
|
||||
}
|
||||
out.add(new Page(page.getStringValue(PARAM_PAGE_NAME), Collections.unmodifiableList(ids)));
|
||||
}
|
||||
tabList.add(new Tab(TAB_NAMES[t], Collections.unmodifiableList(new ArrayList<>(out.subList(tabStart, out.size())))));
|
||||
}
|
||||
itemNames = names;
|
||||
tabs = Collections.unmodifiableList(tabList);
|
||||
pages = Collections.unmodifiableList(out);
|
||||
log.debug("collection log: {} pages, {} items", out.size(), names.size());
|
||||
notifyListeners();
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
log.warn("could not read collection log structure", e);
|
||||
}
|
||||
}
|
||||
|
||||
/** Reads the collection log inventory, which the server sends while the log is open. */
|
||||
private void scanInventory()
|
||||
{
|
||||
ItemContainer inv = client.getItemContainer(CLOG_INVENTORY);
|
||||
if (inv == null || itemNames.isEmpty())
|
||||
{
|
||||
return;
|
||||
}
|
||||
Item[] items = inv.getItems();
|
||||
int hash = java.util.Arrays.hashCode(items);
|
||||
if (items.length == 0 || hash == lastInventoryHash)
|
||||
{
|
||||
return;
|
||||
}
|
||||
lastInventoryHash = hash;
|
||||
|
||||
Set<Integer> have = new HashSet<>();
|
||||
for (Item it : items)
|
||||
{
|
||||
if (it.getId() > 0 && it.getQuantity() > 0)
|
||||
{
|
||||
have.add(it.getId());
|
||||
}
|
||||
}
|
||||
log.info("collection log inventory: {} slots, {} obtained items", items.length, have.size());
|
||||
EnumComposition remap = client.getEnum(ENUM_ITEM_REMAP);
|
||||
boolean changed = !synced;
|
||||
for (int id : itemNames.keySet())
|
||||
{
|
||||
int mapped = remap.getIntValue(id);
|
||||
// add only: the inventory may hold just the open page, and log items are never lost
|
||||
if (have.contains(id) || (mapped > 0 && have.contains(mapped)))
|
||||
{
|
||||
changed |= obtained.add(id);
|
||||
}
|
||||
}
|
||||
synced = true;
|
||||
if (changed)
|
||||
{
|
||||
saveObtained();
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
/** Fallback: items drawn fully opaque on the open log page are obtained. */
|
||||
private void scanOpenPage()
|
||||
{
|
||||
List<Widget> children = new ArrayList<>();
|
||||
for (int component : new int[]{InterfaceID.Collection.ITEMS, InterfaceID.Collection.ITEMS_CONTENTS})
|
||||
{
|
||||
Widget w = client.getWidget(component);
|
||||
if (w == null || w.isHidden() || w.getDynamicChildren() == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
for (Widget c : w.getDynamicChildren())
|
||||
{
|
||||
if (c != null && c.getItemId() > 0)
|
||||
{
|
||||
children.add(c);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (children.isEmpty())
|
||||
{
|
||||
lastWidgetHash = 0;
|
||||
return;
|
||||
}
|
||||
int hash = 1;
|
||||
for (Widget c : children)
|
||||
{
|
||||
hash = 31 * hash + c.getItemId() * 7 + c.getOpacity();
|
||||
}
|
||||
if (hash == lastWidgetHash)
|
||||
{
|
||||
return;
|
||||
}
|
||||
lastWidgetHash = hash;
|
||||
log.info("collection log page widgets: {} items", children.size());
|
||||
|
||||
boolean changed = !synced;
|
||||
for (Widget c : children)
|
||||
{
|
||||
int id = c.getItemId();
|
||||
if (c.getOpacity() != UNOBTAINED_OPACITY)
|
||||
{
|
||||
changed |= obtained.add(id);
|
||||
}
|
||||
}
|
||||
synced = true;
|
||||
if (changed)
|
||||
{
|
||||
saveObtained();
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
private void loadObtained()
|
||||
{
|
||||
obtained.clear();
|
||||
Long full = configManager.getRSProfileConfiguration(GROUP, KEY_FULL_SYNC, Long.class);
|
||||
fullSyncAt = full == null ? 0 : full;
|
||||
String raw = configManager.getRSProfileConfiguration(GROUP, KEY);
|
||||
synced = raw != null;
|
||||
if (raw == null || raw.isEmpty())
|
||||
{
|
||||
return;
|
||||
}
|
||||
for (String s : raw.split(","))
|
||||
{
|
||||
try
|
||||
{
|
||||
obtained.add(Integer.parseInt(s.trim()));
|
||||
}
|
||||
catch (NumberFormatException ignored)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void saveObtained()
|
||||
{
|
||||
if (profileKey == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
synced = true;
|
||||
String raw = obtained.stream().sorted().map(String::valueOf).collect(Collectors.joining(","));
|
||||
configManager.setRSProfileConfiguration(GROUP, KEY, raw);
|
||||
}
|
||||
|
||||
private void notifyListeners()
|
||||
{
|
||||
for (Runnable r : listeners)
|
||||
{
|
||||
r.run();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package com.example.essentialsplus.currency;
|
||||
|
||||
import net.runelite.api.gameval.ItemID;
|
||||
import net.runelite.api.gameval.VarPlayerID;
|
||||
import net.runelite.api.gameval.VarbitID;
|
||||
|
||||
/** Points, marks and tokens earned from minigames and activities. */
|
||||
public enum Currency
|
||||
{
|
||||
PEST_CONTROL("Pest Control points", Source.WIDGET, -1, ItemID.PEST_VOID_KNIGHT_TOP),
|
||||
SLAYER("Slayer points", Source.VARBIT, VarbitID.SLAYER_POINTS, ItemID.SLAYER_HELM),
|
||||
NMZ("Nightmare Zone points", Source.VARP, VarPlayerID.NZONE_REWARDPOINTS, ItemID.NZONE4DOSEOVERLOADPOTION),
|
||||
TITHE_FARM("Tithe Farm points", Source.VARBIT, VarbitID.HOSIDIUS_TITHE_REWARDPOINTS, ItemID.HOSIDIUS_TITHE_FRUIT_A),
|
||||
TEMPOROSS("Reward permits", Source.VARBIT, VarbitID.TEMPOROSS_REWARDPERMITS, ItemID.SPIRIT_FLAKES),
|
||||
SOUL_WARS("Zeal", Source.VARP, VarPlayerID.SOUL_WARS_ZEAL, ItemID.SOUL_FRAGMENT),
|
||||
GIANTS_FOUNDRY("Foundry reputation", Source.VARP, VarPlayerID.GIANTS_FOUNDRY_REWARD_SHOP_POINTS, ItemID.DOUBLE_AMMO_MOULD),
|
||||
COLOSSEUM("Colosseum glory", Source.VARP, VarPlayerID.COLOSSEUM_GLORY, -1),
|
||||
MIXOLOGY_MOX("Mox resin", Source.VARP, VarPlayerID.MIXOLOGY_MOX_POINTS, ItemID.MM_MOX_PASTE),
|
||||
MIXOLOGY_AGA("Aga resin", Source.VARP, VarPlayerID.MIXOLOGY_AGA_POINTS, ItemID.MM_AGA_PASTE),
|
||||
MIXOLOGY_LYE("Lye resin", Source.VARP, VarPlayerID.MIXOLOGY_LYE_POINTS, ItemID.MM_LYE_PASTE),
|
||||
COMBAT_ACHIEVEMENTS("Combat achievement points", Source.VARBIT, VarbitID.CA_POINTS, -1),
|
||||
BOUNTY_HUNTER("Bounty Hunter points", Source.VARP, VarPlayerID.BH_2023_POINTS, ItemID.BH_EMBLEM),
|
||||
PVP_ARENA("PvP Arena points", Source.VARBIT, VarbitID.PVPA_POINTS_CURRENCY, -1),
|
||||
|
||||
MARKS_OF_GRACE("Marks of grace", Source.ITEM, ItemID.GRACE, ItemID.GRACE),
|
||||
TOKKUL("Tokkul", Source.ITEM, ItemID.TZHAAR_TOKEN, ItemID.TZHAAR_TOKEN),
|
||||
GOLDEN_NUGGETS("Golden nuggets", Source.ITEM, ItemID.MOTHERLODE_NUGGET, ItemID.MOTHERLODE_NUGGET),
|
||||
HALLOWED_MARKS("Hallowed marks", Source.ITEM, ItemID.HALLOWED_MARK, ItemID.HALLOWED_MARK),
|
||||
ABYSSAL_PEARLS("Abyssal pearls", Source.ITEM, ItemID.ABYSSAL_PEARL, ItemID.ABYSSAL_PEARL),
|
||||
STARDUST("Stardust", Source.ITEM, ItemID.STAR_DUST, ItemID.STAR_DUST),
|
||||
MOLCH_PEARLS("Molch pearls", Source.ITEM, ItemID.AERIAL_FISHING_PEARL, ItemID.AERIAL_FISHING_PEARL),
|
||||
NUMULITE("Numulite", Source.ITEM, ItemID.FOSSIL_NUMULITE, ItemID.FOSSIL_NUMULITE),
|
||||
SPIRIT_FLAKES("Spirit flakes", Source.ITEM, ItemID.SPIRIT_FLAKES, ItemID.SPIRIT_FLAKES),
|
||||
ANIMA_BARK("Anima-infused bark", Source.ITEM, ItemID.FORESTRY_CURRENCY, ItemID.FORESTRY_CURRENCY),
|
||||
CRYSTAL_SHARDS("Crystal shards", Source.ITEM, ItemID.PRIF_CRYSTAL_SHARD, ItemID.PRIF_CRYSTAL_SHARD),
|
||||
CASTLE_WARS("Castle wars tickets", Source.ITEM, ItemID.CASTLEWARS_TICKET, ItemID.CASTLEWARS_TICKET),
|
||||
AGILITY_ARENA("Agility arena tickets", Source.ITEM, ItemID.AGILITYARENA_TICKET, ItemID.AGILITYARENA_TICKET),
|
||||
ECTO_TOKENS("Ecto-tokens", Source.ITEM, ItemID.ECTOTOKEN, ItemID.ECTOTOKEN),
|
||||
WARRIOR_GUILD("Warrior guild tokens", Source.ITEM, ItemID.WARGUILD_TOKENS, ItemID.WARGUILD_TOKENS),
|
||||
PIECES_OF_EIGHT("Pieces of eight", Source.ITEM, ItemID.BREW_PIECE_OF_EIGHT, ItemID.BREW_PIECE_OF_EIGHT),
|
||||
MINNOWS("Minnows", Source.ITEM, ItemID.MINNOW, ItemID.MINNOW);
|
||||
|
||||
public enum Source
|
||||
{
|
||||
VARBIT, VARP, ITEM, WIDGET
|
||||
}
|
||||
|
||||
public final String label;
|
||||
public final Source source;
|
||||
/** varbit / varp / item id */
|
||||
public final int id;
|
||||
/** item shown as icon, -1 = letter badge */
|
||||
public final int icon;
|
||||
|
||||
Currency(String label, Source source, int id, int icon)
|
||||
{
|
||||
this.label = label;
|
||||
this.source = source;
|
||||
this.id = id;
|
||||
this.icon = icon;
|
||||
}
|
||||
|
||||
public static Currency byName(String name)
|
||||
{
|
||||
if (name != null)
|
||||
{
|
||||
for (Currency c : values())
|
||||
{
|
||||
if (c.name().equals(name))
|
||||
{
|
||||
return c;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
package com.example.essentialsplus.currency;
|
||||
|
||||
import com.example.essentialsplus.ui.TimeFilter;
|
||||
import com.example.essentialsplus.ui.TimeRange;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/** Point / token amounts of one character over time. */
|
||||
public class CurrencyHistory
|
||||
{
|
||||
/** One observed amount. */
|
||||
public static class Sample
|
||||
{
|
||||
public long t;
|
||||
/** amount held */
|
||||
public long v;
|
||||
/** change counted as earned (> 0) or spent (< 0); 0 for a sync (value changed while not watched) */
|
||||
public long g;
|
||||
|
||||
public Sample()
|
||||
{
|
||||
}
|
||||
|
||||
Sample(long t, long v, long g)
|
||||
{
|
||||
this.t = t;
|
||||
this.v = v;
|
||||
this.g = g;
|
||||
}
|
||||
}
|
||||
|
||||
public String key;
|
||||
public String name;
|
||||
/** currency enum name -> samples, oldest first */
|
||||
public Map<String, List<Sample>> series = new HashMap<>();
|
||||
/** currency with the latest earned change and when that happened */
|
||||
public String lastActive;
|
||||
public long lastActiveAt;
|
||||
|
||||
public transient boolean dirty;
|
||||
|
||||
public CurrencyHistory()
|
||||
{
|
||||
}
|
||||
|
||||
public CurrencyHistory(String key, String name)
|
||||
{
|
||||
this.key = key;
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
void normalize()
|
||||
{
|
||||
if (series == null)
|
||||
{
|
||||
series = new HashMap<>();
|
||||
}
|
||||
series.values().removeIf(l -> l == null || l.isEmpty());
|
||||
}
|
||||
|
||||
public synchronized List<Sample> samples(Currency c)
|
||||
{
|
||||
List<Sample> s = series.get(c.name());
|
||||
return s == null ? new ArrayList<>() : new ArrayList<>(s);
|
||||
}
|
||||
|
||||
public synchronized boolean has(Currency c)
|
||||
{
|
||||
List<Sample> s = series.get(c.name());
|
||||
return s != null && !s.isEmpty();
|
||||
}
|
||||
|
||||
/** @return the latest known amount, -1 if never seen */
|
||||
public synchronized long amount(Currency c)
|
||||
{
|
||||
List<Sample> s = series.get(c.name());
|
||||
return s == null || s.isEmpty() ? -1 : s.get(s.size() - 1).v;
|
||||
}
|
||||
|
||||
/** Time of the latest earned change, 0 if none. */
|
||||
public synchronized long lastGain(Currency c)
|
||||
{
|
||||
List<Sample> s = series.get(c.name());
|
||||
if (s != null)
|
||||
{
|
||||
for (int i = s.size() - 1; i >= 0; i--)
|
||||
{
|
||||
if (s.get(i).g > 0)
|
||||
{
|
||||
return s.get(i).t;
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stores an observed amount.
|
||||
*
|
||||
* @param sync the change happened while it could not be watched (login, bank open): not counted as earned
|
||||
* @return the change counted as earned or spent, 0 when nothing was counted
|
||||
*/
|
||||
synchronized long record(Currency c, long value, long now, boolean sync)
|
||||
{
|
||||
List<Sample> s = series.computeIfAbsent(c.name(), k -> new ArrayList<>());
|
||||
Sample last = s.isEmpty() ? null : s.get(s.size() - 1);
|
||||
if (last != null && last.v == value)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
long delta = last == null || sync ? 0 : value - last.v;
|
||||
s.add(new Sample(now, value, delta));
|
||||
dirty = true;
|
||||
if (delta > 0)
|
||||
{
|
||||
lastActive = c.name();
|
||||
lastActiveAt = now;
|
||||
}
|
||||
return delta;
|
||||
}
|
||||
|
||||
/** Earned (positive) and spent (negative) in the range of the filter. */
|
||||
public synchronized long[] change(Currency c, TimeFilter filter, long now)
|
||||
{
|
||||
List<Sample> s = series.get(c.name());
|
||||
long[] out = new long[2];
|
||||
if (s == null || s.isEmpty())
|
||||
{
|
||||
return out;
|
||||
}
|
||||
long start = filter.getRange() == TimeRange.CURRENT
|
||||
? TimeFilter.streakStart(s.size(), i -> s.get(i).t, i -> s.get(i).g > 0, now)
|
||||
: filter.start();
|
||||
for (int i = s.size() - 1; i >= 0 && s.get(i).t >= start; i--)
|
||||
{
|
||||
long g = s.get(i).g;
|
||||
if (g > 0)
|
||||
{
|
||||
out[0] += g;
|
||||
}
|
||||
else
|
||||
{
|
||||
out[1] += g;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Earned per bucket over the range, oldest first (for a small bar chart). */
|
||||
public synchronized long[] earnedBuckets(Currency c, long start, long end, int buckets)
|
||||
{
|
||||
long[] out = new long[buckets];
|
||||
List<Sample> s = series.get(c.name());
|
||||
if (s == null || end <= start)
|
||||
{
|
||||
return out;
|
||||
}
|
||||
for (Sample x : s)
|
||||
{
|
||||
if (x.g > 0 && x.t >= start && x.t <= end)
|
||||
{
|
||||
int b = (int) Math.min(buckets - 1, (x.t - start) * buckets / (end - start));
|
||||
out[b] += x.g;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
package com.example.essentialsplus.currency;
|
||||
|
||||
import com.example.essentialsplus.WealthStore;
|
||||
import com.google.gson.Gson;
|
||||
import java.io.File;
|
||||
import java.io.Reader;
|
||||
import java.io.Writer;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import javax.inject.Inject;
|
||||
import javax.inject.Singleton;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
/** Loads and saves {@link CurrencyHistory} files (one per character) in {@code essentials-plus/currencies}. */
|
||||
@Slf4j
|
||||
@Singleton
|
||||
public class CurrencyStore
|
||||
{
|
||||
public static final File DIR = new File(WealthStore.DIR, "currencies");
|
||||
|
||||
private static final long HOUR = 3_600_000L;
|
||||
private static final long DAY = 24 * HOUR;
|
||||
|
||||
private final Gson gson;
|
||||
private final Map<String, CurrencyHistory> histories = new ConcurrentHashMap<>();
|
||||
|
||||
@Inject
|
||||
public CurrencyStore(Gson gson)
|
||||
{
|
||||
this.gson = gson.newBuilder().disableHtmlEscaping().create();
|
||||
}
|
||||
|
||||
public void load()
|
||||
{
|
||||
File[] files = DIR.listFiles((d, n) -> n.endsWith(".json"));
|
||||
if (files == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
for (File f : files)
|
||||
{
|
||||
try (Reader reader = Files.newBufferedReader(f.toPath(), StandardCharsets.UTF_8))
|
||||
{
|
||||
CurrencyHistory h = gson.fromJson(reader, CurrencyHistory.class);
|
||||
if (h != null && h.key != null)
|
||||
{
|
||||
h.normalize();
|
||||
histories.putIfAbsent(h.key, h);
|
||||
}
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
log.warn("Currencies: could not read {}", f, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public CurrencyHistory get(String key)
|
||||
{
|
||||
return key == null ? null : histories.get(key);
|
||||
}
|
||||
|
||||
CurrencyHistory getOrCreate(String key, String name)
|
||||
{
|
||||
CurrencyHistory h = histories.computeIfAbsent(key, k -> new CurrencyHistory(k, name));
|
||||
h.name = name;
|
||||
return h;
|
||||
}
|
||||
|
||||
public void saveDirty()
|
||||
{
|
||||
for (CurrencyHistory h : histories.values())
|
||||
{
|
||||
if (h.dirty)
|
||||
{
|
||||
save(h);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private synchronized void save(CurrencyHistory h)
|
||||
{
|
||||
String json;
|
||||
synchronized (h)
|
||||
{
|
||||
long now = System.currentTimeMillis();
|
||||
for (Map.Entry<String, List<CurrencyHistory.Sample>> e : h.series.entrySet())
|
||||
{
|
||||
e.setValue(compact(e.getValue(), now));
|
||||
}
|
||||
json = gson.toJson(h);
|
||||
h.dirty = false;
|
||||
}
|
||||
try
|
||||
{
|
||||
Files.createDirectories(DIR.toPath());
|
||||
File target = new File(DIR, h.key.replaceAll("[^A-Za-z0-9 _()\\-]", "_") + ".json");
|
||||
File tmp = new File(DIR, target.getName() + ".tmp");
|
||||
try (Writer w = Files.newBufferedWriter(tmp.toPath(), StandardCharsets.UTF_8))
|
||||
{
|
||||
w.write(json);
|
||||
}
|
||||
Files.move(tmp.toPath(), target.toPath(), StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
h.dirty = true;
|
||||
log.warn("Currencies: could not save {}", h.key, e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Samples older than 7 days are merged per hour, older than 90 days per day: the last amount is kept
|
||||
* and earned / spent are summed separately. The first sample stays as baseline.
|
||||
*/
|
||||
static List<CurrencyHistory.Sample> compact(List<CurrencyHistory.Sample> in, long now)
|
||||
{
|
||||
if (in.size() < 3)
|
||||
{
|
||||
return in;
|
||||
}
|
||||
List<CurrencyHistory.Sample> out = new ArrayList<>(in.size());
|
||||
out.add(in.get(0));
|
||||
for (int i = 1; i < in.size(); i++)
|
||||
{
|
||||
CurrencyHistory.Sample s = in.get(i);
|
||||
long age = now - s.t;
|
||||
long size = age > 90 * DAY ? DAY : age > 7 * DAY ? HOUR : 0;
|
||||
CurrencyHistory.Sample prev = out.get(out.size() - 1);
|
||||
// only merge changes of the same sign, so earned and spent stay apart
|
||||
if (size > 0 && out.size() > 1 && prev.t / size == s.t / size && Long.signum(prev.g) * Long.signum(s.g) >= 0)
|
||||
{
|
||||
out.set(out.size() - 1, new CurrencyHistory.Sample(s.t, s.v, prev.g + s.g));
|
||||
}
|
||||
else
|
||||
{
|
||||
out.add(s);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
package com.example.essentialsplus.currency;
|
||||
|
||||
import java.util.EnumSet;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.CopyOnWriteArrayList;
|
||||
import java.util.function.IntUnaryOperator;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
import net.runelite.api.Client;
|
||||
import net.runelite.api.gameval.InterfaceID;
|
||||
import net.runelite.api.widgets.Widget;
|
||||
|
||||
/**
|
||||
* Reads point varbits / varps every tick and token items after each wealth valuation, records the changes and
|
||||
* works out what the player is doing right now. Client thread only, except the getters.
|
||||
*/
|
||||
public class CurrencyTracker
|
||||
{
|
||||
/** a currency stays "what you are doing" this long after it was last earned */
|
||||
public static final long ACTIVE_WINDOW = 20 * 60_000L;
|
||||
/** varps read 0 or stale values right after login: changes until then are synced, not earned */
|
||||
private static final int SETTLE_TICKS = 10;
|
||||
private static final String[] ITEM_CONTAINERS = {"bank", "inventory", "equipment", "lootingBag", "seedVault"};
|
||||
private static final Pattern NUMBER = Pattern.compile("(\\d[\\d,]*)");
|
||||
private static final Map<Integer, Currency> ITEMS = new HashMap<>();
|
||||
|
||||
static
|
||||
{
|
||||
for (Currency c : Currency.values())
|
||||
{
|
||||
if (c.source == Currency.Source.ITEM)
|
||||
{
|
||||
ITEMS.put(c.id, c);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private final Client client;
|
||||
private final CurrencyStore store;
|
||||
private final List<Runnable> listeners = new CopyOnWriteArrayList<>();
|
||||
/** currencies read at least once since login; the first reading is a sync */
|
||||
private final Set<Currency> seen = EnumSet.noneOf(Currency.class);
|
||||
|
||||
private volatile String key;
|
||||
/** Pest Control lander / game overlay is on screen */
|
||||
private volatile boolean inPestControl;
|
||||
|
||||
public CurrencyTracker(Client client, CurrencyStore store)
|
||||
{
|
||||
this.client = client;
|
||||
this.store = store;
|
||||
}
|
||||
|
||||
public void addListener(Runnable r)
|
||||
{
|
||||
listeners.add(r);
|
||||
}
|
||||
|
||||
public void removeListener(Runnable r)
|
||||
{
|
||||
listeners.remove(r);
|
||||
}
|
||||
|
||||
public String getKey()
|
||||
{
|
||||
return key;
|
||||
}
|
||||
|
||||
public CurrencyHistory history()
|
||||
{
|
||||
return store.get(key);
|
||||
}
|
||||
|
||||
/** Logged out: the next login starts with a sync again. */
|
||||
public void reset()
|
||||
{
|
||||
seen.clear();
|
||||
key = null;
|
||||
inPestControl = false;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
public void onGameTick(String accountKey, String name, int ticksSinceLogin)
|
||||
{
|
||||
if (accountKey == null || ticksSinceLogin < 3)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (!accountKey.equals(key))
|
||||
{
|
||||
seen.clear();
|
||||
key = accountKey;
|
||||
notifyListeners();
|
||||
}
|
||||
CurrencyHistory h = store.getOrCreate(accountKey, name);
|
||||
long now = System.currentTimeMillis();
|
||||
boolean settled = ticksSinceLogin >= SETTLE_TICKS;
|
||||
boolean changed = false;
|
||||
for (Currency c : Currency.values())
|
||||
{
|
||||
long value;
|
||||
switch (c.source)
|
||||
{
|
||||
case VARBIT:
|
||||
value = client.getVarbitValue(c.id);
|
||||
break;
|
||||
case VARP:
|
||||
value = client.getVarpValue(c.id);
|
||||
break;
|
||||
default:
|
||||
continue;
|
||||
}
|
||||
changed |= record(h, c, value, now, !settled);
|
||||
}
|
||||
|
||||
Widget lander = visible(InterfaceID.PestLanderOverlay.PEST_LANDER_OVER_POINTS);
|
||||
boolean pest = lander != null || visible(InterfaceID.PestStatusOverlay.PEST_STATUS_OVER_TIME) != null;
|
||||
if (pest != inPestControl)
|
||||
{
|
||||
inPestControl = pest;
|
||||
changed = true;
|
||||
}
|
||||
long points = parse(lander);
|
||||
if (points < 0)
|
||||
{
|
||||
points = parse(visible(InterfaceID.PestRewardshop.TOTAL));
|
||||
}
|
||||
if (points >= 0)
|
||||
{
|
||||
changed |= record(h, Currency.PEST_CONTROL, points, now, false);
|
||||
}
|
||||
if (changed)
|
||||
{
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Counts token items over all known containers of the character.
|
||||
*
|
||||
* @param sync items moved in a way that is not earning or spending (bank open, login)
|
||||
*/
|
||||
public void onItems(String accountKey, String name, Map<String, Map<Integer, Long>> containers, Map<Integer, Long> bankPending,
|
||||
boolean sync, IntUnaryOperator canonicalize)
|
||||
{
|
||||
if (accountKey == null || containers == null || !accountKey.equals(key))
|
||||
{
|
||||
return;
|
||||
}
|
||||
Map<Currency, Long> totals = new HashMap<>();
|
||||
for (String k : ITEM_CONTAINERS)
|
||||
{
|
||||
count(containers.get(k), canonicalize, totals);
|
||||
}
|
||||
count(bankPending, canonicalize, totals);
|
||||
CurrencyHistory h = store.getOrCreate(accountKey, name);
|
||||
long now = System.currentTimeMillis();
|
||||
boolean changed = false;
|
||||
for (Currency c : ITEMS.values())
|
||||
{
|
||||
long value = totals.getOrDefault(c, 0L);
|
||||
// never seen and none held: nothing to show
|
||||
if (value == 0 && !h.has(c))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
changed |= record(h, c, value, now, sync);
|
||||
}
|
||||
if (changed)
|
||||
{
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
private static void count(Map<Integer, Long> items, IntUnaryOperator canonicalize, Map<Currency, Long> totals)
|
||||
{
|
||||
if (items == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
for (Map.Entry<Integer, Long> e : items.entrySet())
|
||||
{
|
||||
Currency c = ITEMS.get(e.getKey());
|
||||
if (c == null)
|
||||
{
|
||||
c = ITEMS.get(canonicalize.applyAsInt(e.getKey()));
|
||||
}
|
||||
if (c != null)
|
||||
{
|
||||
totals.merge(c, e.getValue(), Long::sum);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private boolean record(CurrencyHistory h, Currency c, long value, long now, boolean sync)
|
||||
{
|
||||
if (value < 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
// a currency never held is not worth a row
|
||||
if (value == 0 && !h.has(c))
|
||||
{
|
||||
seen.add(c);
|
||||
return false;
|
||||
}
|
||||
boolean first = seen.add(c);
|
||||
long before = h.amount(c);
|
||||
h.record(c, value, now, sync || first);
|
||||
return before != value;
|
||||
}
|
||||
|
||||
/**
|
||||
* What the player is doing right now: Pest Control while its overlay is shown, otherwise the currency
|
||||
* earned last within {@link #ACTIVE_WINDOW}; null when idle.
|
||||
*/
|
||||
public Currency active()
|
||||
{
|
||||
if (inPestControl)
|
||||
{
|
||||
return Currency.PEST_CONTROL;
|
||||
}
|
||||
CurrencyHistory h = history();
|
||||
if (h == null || h.lastActive == null || System.currentTimeMillis() - h.lastActiveAt > ACTIVE_WINDOW)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
return Currency.byName(h.lastActive);
|
||||
}
|
||||
|
||||
private Widget visible(int component)
|
||||
{
|
||||
Widget w = client.getWidget(component);
|
||||
return w == null || w.isHidden() ? null : w;
|
||||
}
|
||||
|
||||
private static long parse(Widget w)
|
||||
{
|
||||
if (w == null || w.getText() == null)
|
||||
{
|
||||
return -1;
|
||||
}
|
||||
Matcher m = NUMBER.matcher(w.getText().replaceAll("<[^>]*>", ""));
|
||||
long last = -1;
|
||||
while (m.find())
|
||||
{
|
||||
try
|
||||
{
|
||||
last = Long.parseLong(m.group(1).replace(",", ""));
|
||||
}
|
||||
catch (NumberFormatException ignored)
|
||||
{
|
||||
// keep the previous number
|
||||
}
|
||||
}
|
||||
return last;
|
||||
}
|
||||
|
||||
private void notifyListeners()
|
||||
{
|
||||
for (Runnable r : listeners)
|
||||
{
|
||||
r.run();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
package com.example.essentialsplus.currency.ui;
|
||||
|
||||
import com.example.essentialsplus.currency.Currency;
|
||||
import com.example.essentialsplus.ui.Ui;
|
||||
import java.awt.Cursor;
|
||||
import java.awt.Dimension;
|
||||
import java.awt.Font;
|
||||
import java.awt.FontMetrics;
|
||||
import java.awt.Graphics;
|
||||
import java.awt.Graphics2D;
|
||||
import java.awt.event.MouseAdapter;
|
||||
import java.awt.event.MouseEvent;
|
||||
import java.text.NumberFormat;
|
||||
import java.util.Locale;
|
||||
import javax.swing.JComponent;
|
||||
import javax.swing.SwingUtilities;
|
||||
import net.runelite.client.ui.FontManager;
|
||||
import net.runelite.client.ui.PluginPanel;
|
||||
|
||||
/** Overview entry: the points / marks of what is being played right now, big. */
|
||||
public class CurrencyCard extends JComponent
|
||||
{
|
||||
private static final int ICON = 36;
|
||||
|
||||
private final CurrencyIcons icons;
|
||||
private Currency currency;
|
||||
private long amount;
|
||||
private long earned;
|
||||
private String rangeLabel = "";
|
||||
private boolean now;
|
||||
private long lastAt;
|
||||
private int tracked;
|
||||
private boolean hover;
|
||||
private boolean showTracked = true;
|
||||
private boolean pinned;
|
||||
|
||||
CurrencyCard(CurrencyIcons icons, Runnable onClick)
|
||||
{
|
||||
this.icons = icons;
|
||||
setCursor(Cursor.getPredefinedCursor(Cursor.HAND_CURSOR));
|
||||
addMouseListener(new MouseAdapter()
|
||||
{
|
||||
@Override
|
||||
public void mouseEntered(MouseEvent e)
|
||||
{
|
||||
hover = true;
|
||||
repaint();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void mouseExited(MouseEvent e)
|
||||
{
|
||||
hover = false;
|
||||
repaint();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void mousePressed(MouseEvent e)
|
||||
{
|
||||
if (SwingUtilities.isLeftMouseButton(e))
|
||||
{
|
||||
onClick.run();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param currency shown currency, null when nothing is tracked yet
|
||||
* @param now it is being played right now (otherwise it is the one played last)
|
||||
*/
|
||||
void update(Currency currency, long amount, long earned, String rangeLabel, boolean now, long lastAt, int tracked)
|
||||
{
|
||||
this.currency = currency;
|
||||
this.amount = amount;
|
||||
this.earned = earned;
|
||||
this.rangeLabel = rangeLabel;
|
||||
this.now = now;
|
||||
this.lastAt = lastAt;
|
||||
this.tracked = tracked;
|
||||
repaint();
|
||||
}
|
||||
|
||||
void setOptions(boolean showTracked, boolean pinned)
|
||||
{
|
||||
this.showTracked = showTracked;
|
||||
this.pinned = pinned;
|
||||
repaint();
|
||||
}
|
||||
|
||||
static String number(long v)
|
||||
{
|
||||
return v >= 10_000_000 ? Ui.gp(v) : NumberFormat.getIntegerInstance(Locale.US).format(v);
|
||||
}
|
||||
|
||||
static String ago(long t)
|
||||
{
|
||||
long d = System.currentTimeMillis() - t;
|
||||
if (t <= 0)
|
||||
{
|
||||
return "never";
|
||||
}
|
||||
if (d < Ui.MINUTE)
|
||||
{
|
||||
return "just now";
|
||||
}
|
||||
if (d < Ui.HOUR)
|
||||
{
|
||||
return d / Ui.MINUTE + "m ago";
|
||||
}
|
||||
if (d < Ui.DAY)
|
||||
{
|
||||
return d / Ui.HOUR + "h ago";
|
||||
}
|
||||
return d / Ui.DAY + "d ago";
|
||||
}
|
||||
|
||||
@Override
|
||||
public Dimension getPreferredSize()
|
||||
{
|
||||
return new Dimension(PluginPanel.PANEL_WIDTH - 16, 104);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Dimension getMinimumSize()
|
||||
{
|
||||
return new Dimension(10, 96);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void paintComponent(Graphics g0)
|
||||
{
|
||||
Graphics2D g = Ui.prepare(g0);
|
||||
int w = getWidth();
|
||||
int h = getHeight();
|
||||
g.setColor(hover ? Ui.CARD_HOVER : Ui.CARD);
|
||||
g.fillRoundRect(0, 0, w, h, 8, 8);
|
||||
|
||||
int pad = 10;
|
||||
Font bold = FontManager.getRunescapeBoldFont();
|
||||
Font small = FontManager.getRunescapeSmallFont();
|
||||
|
||||
g.setFont(bold);
|
||||
FontMetrics fm = g.getFontMetrics();
|
||||
int head = 6 + fm.getAscent();
|
||||
g.setColor(Ui.ACCENT);
|
||||
String title = "Points & Marks";
|
||||
g.drawString(title, pad, head);
|
||||
g.setFont(bold.deriveFont(14f));
|
||||
g.setColor(hover ? Ui.ACCENT : Ui.FAINT);
|
||||
Ui.drawRight(g, "›", w - pad, head);
|
||||
|
||||
g.setFont(small);
|
||||
FontMetrics sfm = g.getFontMetrics();
|
||||
int subRight = w - pad - 14;
|
||||
if (currency != null)
|
||||
{
|
||||
String sub = now ? "● Playing now" : pinned ? "Pinned" : "Last: " + ago(lastAt);
|
||||
g.setColor(now ? Ui.UP : Ui.MUTED);
|
||||
if (subRight - sfm.stringWidth(sub) > pad + fm.stringWidth(title) + 8)
|
||||
{
|
||||
Ui.drawRight(g, sub, subRight, head);
|
||||
}
|
||||
}
|
||||
|
||||
if (currency == null)
|
||||
{
|
||||
g.setFont(FontManager.getRunescapeFont());
|
||||
g.setColor(Ui.TEXT);
|
||||
g.drawString("Nothing tracked yet", pad, head + 22);
|
||||
g.setFont(small);
|
||||
g.setColor(Ui.FAINT);
|
||||
g.drawString("Play a minigame or collect marks,", pad, head + 40);
|
||||
g.drawString("tokens or points to start", pad, head + 54);
|
||||
g.dispose();
|
||||
return;
|
||||
}
|
||||
|
||||
int top = head + 8;
|
||||
icons.paint(g, currency, pad - 2, top + 4, ICON, this);
|
||||
int tx = pad + ICON + 8;
|
||||
|
||||
g.setFont(bold.deriveFont(26f));
|
||||
FontMetrics bfm = g.getFontMetrics();
|
||||
int bigBase = top + bfm.getAscent() - 2;
|
||||
g.setColor(Ui.GOLD);
|
||||
g.drawString(number(amount), tx, bigBase);
|
||||
|
||||
g.setFont(small);
|
||||
int labelBase = bigBase + sfm.getAscent() + 3;
|
||||
g.setColor(Ui.TEXT);
|
||||
g.drawString(currency.label, tx, labelBase);
|
||||
|
||||
int earnBase = labelBase + sfm.getAscent() + 6;
|
||||
String gain = "+" + number(earned);
|
||||
g.setFont(bold);
|
||||
FontMetrics gfm = g.getFontMetrics();
|
||||
g.setColor(earned > 0 ? Ui.UP : Ui.FAINT);
|
||||
g.drawString(gain, tx, earnBase);
|
||||
g.setFont(small);
|
||||
g.setColor(Ui.FAINT);
|
||||
int afterGain = tx + gfm.stringWidth(gain);
|
||||
String earnedText = Ui.ellipsize(sfm, " earned " + rangeLabel, w - pad - afterGain);
|
||||
g.drawString(earnedText, afterGain, earnBase);
|
||||
if (showTracked && tracked > 1)
|
||||
{
|
||||
// in the label line, right of the name - never over the earned text
|
||||
String t = tracked + " tracked";
|
||||
if (w - pad - sfm.stringWidth(t) > tx + sfm.stringWidth(currency.label) + 8)
|
||||
{
|
||||
Ui.drawRight(g, t, w - pad, labelBase);
|
||||
}
|
||||
}
|
||||
Ui.paintDragOutline(g, this);
|
||||
g.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package com.example.essentialsplus.currency.ui;
|
||||
|
||||
import com.example.essentialsplus.currency.Currency;
|
||||
import com.example.essentialsplus.ui.Ui;
|
||||
import java.awt.Component;
|
||||
import java.awt.Font;
|
||||
import java.awt.FontMetrics;
|
||||
import java.awt.Graphics2D;
|
||||
import java.util.EnumMap;
|
||||
import java.util.Map;
|
||||
import net.runelite.client.game.ItemManager;
|
||||
import net.runelite.client.ui.FontManager;
|
||||
import net.runelite.client.util.AsyncBufferedImage;
|
||||
|
||||
/** Item icons of the currencies, with a letter badge for those without one (Swing thread only). */
|
||||
final class CurrencyIcons
|
||||
{
|
||||
private final ItemManager itemManager;
|
||||
private final Map<Currency, AsyncBufferedImage> icons = new EnumMap<>(Currency.class);
|
||||
|
||||
CurrencyIcons(ItemManager itemManager)
|
||||
{
|
||||
this.itemManager = itemManager;
|
||||
}
|
||||
|
||||
void paint(Graphics2D g, Currency c, int x, int y, int size, Component repaintTarget)
|
||||
{
|
||||
if (c.icon > 0)
|
||||
{
|
||||
AsyncBufferedImage img = icons.get(c);
|
||||
if (img == null)
|
||||
{
|
||||
img = itemManager.getImage(c.icon);
|
||||
icons.put(c, img);
|
||||
img.onLoaded(repaintTarget::repaint);
|
||||
}
|
||||
int w = img.getWidth();
|
||||
int h = img.getHeight();
|
||||
if (w > 0 && h > 0)
|
||||
{
|
||||
double scale = Math.min(1.0, Math.min((double) size / w, (double) size / h));
|
||||
int dw = (int) Math.round(w * scale);
|
||||
int dh = (int) Math.round(h * scale);
|
||||
g.drawImage(img, x + (size - dw) / 2, y + (size - dh) / 2, dw, dh, null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
g.setColor(Ui.alpha(Ui.ACCENT, 50));
|
||||
g.fillOval(x + 2, y + 2, size - 4, size - 4);
|
||||
Font f = FontManager.getRunescapeBoldFont();
|
||||
g.setFont(f);
|
||||
FontMetrics fm = g.getFontMetrics();
|
||||
String s = c.label.substring(0, 1);
|
||||
g.setColor(Ui.ACCENT);
|
||||
g.drawString(s, x + (size - fm.stringWidth(s)) / 2, y + (size + fm.getAscent()) / 2 - 2);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,353 @@
|
||||
package com.example.essentialsplus.currency.ui;
|
||||
|
||||
import com.example.essentialsplus.currency.Currency;
|
||||
import com.example.essentialsplus.currency.CurrencyHistory;
|
||||
import com.example.essentialsplus.currency.CurrencyTracker;
|
||||
import com.example.essentialsplus.ui.TimeFilter;
|
||||
import com.example.essentialsplus.ui.Ui;
|
||||
import java.awt.BorderLayout;
|
||||
import java.awt.Cursor;
|
||||
import java.awt.Dimension;
|
||||
import java.awt.Font;
|
||||
import java.awt.FontMetrics;
|
||||
import java.awt.Graphics;
|
||||
import java.awt.Graphics2D;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import javax.swing.BorderFactory;
|
||||
import javax.swing.JButton;
|
||||
import javax.swing.JComponent;
|
||||
import javax.swing.JLabel;
|
||||
import javax.swing.JPanel;
|
||||
import javax.swing.JScrollPane;
|
||||
import javax.swing.ScrollPaneConstants;
|
||||
import javax.swing.SwingUtilities;
|
||||
import net.runelite.client.game.ItemManager;
|
||||
import net.runelite.client.ui.DynamicGridLayout;
|
||||
import net.runelite.client.ui.FontManager;
|
||||
import net.runelite.client.ui.PluginPanel;
|
||||
|
||||
/** Points & Marks tab: every tracked minigame point, mark and token with the amount earned in the range. */
|
||||
public class CurrencyPanel extends JPanel
|
||||
{
|
||||
private final CurrencyTracker tracker;
|
||||
private final TimeFilter filter;
|
||||
private final CurrencyIcons icons;
|
||||
private final JPanel list = new JPanel(new DynamicGridLayout(0, 1, 0, 4));
|
||||
private final CurrencyCard hero;
|
||||
private CurrencyCard overviewCard;
|
||||
/** currency always shown on the overview card, null = what is being played */
|
||||
private Currency overviewPin;
|
||||
private boolean overviewShowTracked = true;
|
||||
|
||||
public CurrencyPanel(CurrencyTracker tracker, ItemManager itemManager, TimeFilter filter, Runnable onBack)
|
||||
{
|
||||
this.tracker = tracker;
|
||||
this.filter = filter;
|
||||
this.icons = new CurrencyIcons(itemManager);
|
||||
this.hero = new CurrencyCard(icons, () -> { });
|
||||
hero.setCursor(Cursor.getDefaultCursor());
|
||||
|
||||
setLayout(new BorderLayout(0, 6));
|
||||
setBackground(Ui.BG);
|
||||
setBorder(BorderFactory.createEmptyBorder(10, 8, 8, 8));
|
||||
|
||||
JPanel backRow = new JPanel(new BorderLayout(6, 0));
|
||||
backRow.setOpaque(false);
|
||||
JButton backBtn = new JButton("← Overview");
|
||||
backBtn.setFont(FontManager.getRunescapeSmallFont());
|
||||
backBtn.setForeground(Ui.ACCENT);
|
||||
backBtn.setFocusPainted(false);
|
||||
backBtn.setMargin(new java.awt.Insets(2, 2, 2, 2));
|
||||
backBtn.setCursor(Cursor.getPredefinedCursor(Cursor.HAND_CURSOR));
|
||||
backBtn.addActionListener(e -> onBack.run());
|
||||
backRow.add(backBtn, BorderLayout.WEST);
|
||||
JLabel title = new JLabel("Points & Marks");
|
||||
title.setFont(FontManager.getRunescapeBoldFont());
|
||||
title.setForeground(Ui.ACCENT);
|
||||
title.setHorizontalAlignment(JLabel.RIGHT);
|
||||
backRow.add(title, BorderLayout.CENTER);
|
||||
add(backRow, BorderLayout.NORTH);
|
||||
|
||||
list.setOpaque(false);
|
||||
JPanel listWrap = new JPanel(new BorderLayout());
|
||||
listWrap.setOpaque(false);
|
||||
listWrap.add(list, BorderLayout.NORTH);
|
||||
JScrollPane scroll = new JScrollPane(listWrap);
|
||||
scroll.setBorder(null);
|
||||
scroll.setOpaque(false);
|
||||
scroll.getViewport().setOpaque(false);
|
||||
scroll.setHorizontalScrollBarPolicy(ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER);
|
||||
scroll.getVerticalScrollBar().setUnitIncrement(16);
|
||||
scroll.getVerticalScrollBar().setPreferredSize(new Dimension(8, 0));
|
||||
add(scroll, BorderLayout.CENTER);
|
||||
refresh();
|
||||
}
|
||||
|
||||
public void setOverviewOptions(Currency pin, boolean showTracked)
|
||||
{
|
||||
overviewPin = pin;
|
||||
overviewShowTracked = showTracked;
|
||||
refresh();
|
||||
}
|
||||
|
||||
/** Currencies with data for the logged-in character, in enum order. */
|
||||
public List<Currency> tracked()
|
||||
{
|
||||
List<Currency> out = new ArrayList<>();
|
||||
CurrencyHistory h = tracker.history();
|
||||
if (h != null)
|
||||
{
|
||||
for (Currency c : Currency.values())
|
||||
{
|
||||
if (h.has(c))
|
||||
{
|
||||
out.add(c);
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Card for the overview, kept up to date by {@link #refresh}. */
|
||||
public CurrencyCard createOverviewCard(Runnable onClick)
|
||||
{
|
||||
overviewCard = new CurrencyCard(icons, onClick);
|
||||
refresh();
|
||||
return overviewCard;
|
||||
}
|
||||
|
||||
/** Swing thread. */
|
||||
public void refresh()
|
||||
{
|
||||
CurrencyHistory h = tracker.history();
|
||||
long now = System.currentTimeMillis();
|
||||
String range = filter.inLabel();
|
||||
|
||||
List<Currency> shown = new ArrayList<>();
|
||||
if (h != null)
|
||||
{
|
||||
for (Currency c : Currency.values())
|
||||
{
|
||||
if (h.has(c))
|
||||
{
|
||||
shown.add(c);
|
||||
}
|
||||
}
|
||||
}
|
||||
Currency active = tracker.active();
|
||||
Currency featured = active;
|
||||
if (featured == null && h != null)
|
||||
{
|
||||
featured = Currency.byName(h.lastActive);
|
||||
if (featured == null)
|
||||
{
|
||||
long best = -1;
|
||||
for (Currency c : shown)
|
||||
{
|
||||
List<CurrencyHistory.Sample> s = h.samples(c);
|
||||
long t = s.get(s.size() - 1).t;
|
||||
if (t > best)
|
||||
{
|
||||
best = t;
|
||||
featured = c;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for (CurrencyCard card : new CurrencyCard[]{hero, overviewCard})
|
||||
{
|
||||
if (card == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
Currency c = featured;
|
||||
boolean pinned = card == overviewCard && overviewPin != null && h != null && h.has(overviewPin);
|
||||
if (pinned)
|
||||
{
|
||||
c = overviewPin;
|
||||
}
|
||||
card.setOptions(card != overviewCard || overviewShowTracked, pinned && c != active);
|
||||
if (c == null || h == null)
|
||||
{
|
||||
card.update(null, 0, 0, range, false, 0, 0);
|
||||
}
|
||||
else
|
||||
{
|
||||
card.update(c, Math.max(0, h.amount(c)), h.change(c, filter, now)[0], range,
|
||||
c == active, h.lastGain(c), shown.size());
|
||||
}
|
||||
}
|
||||
|
||||
list.removeAll();
|
||||
list.add(hero);
|
||||
if (h == null || shown.isEmpty())
|
||||
{
|
||||
list.add(hint(h == null
|
||||
? "Log in to start tracking."
|
||||
: "Nothing tracked yet. Points show up once they change or are read in-game."));
|
||||
}
|
||||
else
|
||||
{
|
||||
// earned in the range first, then most recently earned
|
||||
shown.sort((a, b) ->
|
||||
{
|
||||
int byActive = Boolean.compare(b == active, a == active);
|
||||
if (byActive != 0)
|
||||
{
|
||||
return byActive;
|
||||
}
|
||||
return Long.compare(h.lastGain(b), h.lastGain(a));
|
||||
});
|
||||
addSection("POINTS", shown, h, active, now, false);
|
||||
addSection("MARKS & TOKENS", shown, h, active, now, true);
|
||||
}
|
||||
list.add(hint("Pest Control points are read while the lander or reward shop is open. "
|
||||
+ "Items count bank, inventory, gear, looting bag and seed vault."));
|
||||
list.revalidate();
|
||||
list.repaint();
|
||||
}
|
||||
|
||||
private void addSection(String name, List<Currency> shown, CurrencyHistory h, Currency active, long now, boolean items)
|
||||
{
|
||||
boolean header = false;
|
||||
for (Currency c : shown)
|
||||
{
|
||||
if ((c.source == Currency.Source.ITEM) != items)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if (!header)
|
||||
{
|
||||
list.add(header(name));
|
||||
header = true;
|
||||
}
|
||||
long[] change = h.change(c, filter, now);
|
||||
list.add(new Row(c, h.amount(c), change[0], change[1], h.lastGain(c), c == active));
|
||||
}
|
||||
}
|
||||
|
||||
private static JLabel header(String text)
|
||||
{
|
||||
JLabel l = new JLabel(text);
|
||||
l.setFont(FontManager.getRunescapeSmallFont());
|
||||
l.setForeground(Ui.MUTED);
|
||||
l.setBorder(BorderFactory.createEmptyBorder(6, 2, 0, 2));
|
||||
return l;
|
||||
}
|
||||
|
||||
private static JLabel hint(String text)
|
||||
{
|
||||
JLabel l = new JLabel("<html>" + text + "</html>");
|
||||
l.setFont(FontManager.getRunescapeSmallFont());
|
||||
l.setForeground(Ui.FAINT);
|
||||
l.setBorder(BorderFactory.createEmptyBorder(6, 4, 6, 4));
|
||||
return l;
|
||||
}
|
||||
|
||||
/** One currency: icon, name, amount, earned / spent in the range. */
|
||||
private final class Row extends JComponent
|
||||
{
|
||||
private static final int ICON = 28;
|
||||
private final Currency currency;
|
||||
private final long amount;
|
||||
private final long earned;
|
||||
private final long spent;
|
||||
private final long lastGain;
|
||||
private final boolean active;
|
||||
|
||||
Row(Currency currency, long amount, long earned, long spent, long lastGain, boolean active)
|
||||
{
|
||||
this.currency = currency;
|
||||
this.amount = amount;
|
||||
this.earned = earned;
|
||||
this.spent = spent;
|
||||
this.lastGain = lastGain;
|
||||
this.active = active;
|
||||
setToolTipText(currency.label + ": " + CurrencyCard.number(amount)
|
||||
+ (lastGain > 0 ? " - last earned " + CurrencyCard.ago(lastGain) : ""));
|
||||
}
|
||||
|
||||
@Override
|
||||
public Dimension getPreferredSize()
|
||||
{
|
||||
return new Dimension(PluginPanel.PANEL_WIDTH - 24, 44);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void paintComponent(Graphics g0)
|
||||
{
|
||||
Graphics2D g = Ui.prepare(g0);
|
||||
int w = getWidth();
|
||||
int h = getHeight();
|
||||
g.setColor(active ? Ui.CARD_SELECTED : Ui.CARD);
|
||||
g.fillRoundRect(0, 0, w, h, 6, 6);
|
||||
if (active)
|
||||
{
|
||||
g.setColor(Ui.UP);
|
||||
g.fillRoundRect(0, 6, 3, h - 12, 3, 3);
|
||||
}
|
||||
int pad = 8;
|
||||
icons.paint(g, currency, pad - 2, (h - ICON) / 2, ICON, this);
|
||||
int tx = pad + ICON + 6;
|
||||
|
||||
Font bold = FontManager.getRunescapeBoldFont();
|
||||
Font small = FontManager.getRunescapeSmallFont();
|
||||
g.setFont(bold);
|
||||
FontMetrics bfm = g.getFontMetrics();
|
||||
String value = CurrencyCard.number(Math.max(0, amount));
|
||||
int line1 = 5 + bfm.getAscent();
|
||||
g.setColor(Ui.GOLD);
|
||||
Ui.drawRight(g, value, w - pad, line1);
|
||||
|
||||
g.setFont(FontManager.getRunescapeFont());
|
||||
FontMetrics nfm = g.getFontMetrics();
|
||||
g.setColor(Ui.TEXT);
|
||||
int nameMax = w - pad - bfm.stringWidth(value) - 8 - tx;
|
||||
g.drawString(clip(nfm, currency.label, nameMax), tx, line1);
|
||||
|
||||
g.setFont(small);
|
||||
FontMetrics sfm = g.getFontMetrics();
|
||||
int line2 = line1 + sfm.getAscent() + 6;
|
||||
String plus = "+" + CurrencyCard.number(earned);
|
||||
g.setColor(earned > 0 ? Ui.UP : Ui.FAINT);
|
||||
g.drawString(plus, tx, line2);
|
||||
int x = tx + sfm.stringWidth(plus);
|
||||
if (spent < 0)
|
||||
{
|
||||
String minus = " -" + CurrencyCard.number(-spent);
|
||||
g.setColor(Ui.DOWN);
|
||||
g.drawString(minus, x, line2);
|
||||
x += sfm.stringWidth(minus);
|
||||
}
|
||||
g.setColor(Ui.FAINT);
|
||||
g.drawString(" " + filter.getLabel(), x, line2);
|
||||
g.setColor(active ? Ui.UP : Ui.FAINT);
|
||||
Ui.drawRight(g, active ? "now" : lastGain > 0 ? CurrencyCard.ago(lastGain) : "", w - pad, line2);
|
||||
g.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private static String clip(FontMetrics fm, String s, int max)
|
||||
{
|
||||
if (fm.stringWidth(s) <= max)
|
||||
{
|
||||
return s;
|
||||
}
|
||||
for (int i = s.length() - 1; i > 0; i--)
|
||||
{
|
||||
String t = s.substring(0, i) + "..";
|
||||
if (fm.stringWidth(t) <= max)
|
||||
{
|
||||
return t;
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
/** Coalesced refresh from any thread. */
|
||||
public void refreshLater()
|
||||
{
|
||||
SwingUtilities.invokeLater(this::refresh);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,462 @@
|
||||
package com.example.essentialsplus.data;
|
||||
|
||||
import com.example.essentialsplus.WealthCategory;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.CopyOnWriteArrayList;
|
||||
|
||||
/**
|
||||
* Persisted state of one character. Collections are replaced wholesale (never mutated in place,
|
||||
* except history which is copy-on-write) so the UI and the save thread can read them safely.
|
||||
*/
|
||||
public class AccountData
|
||||
{
|
||||
public int schema = 3;
|
||||
public String key;
|
||||
public long accountHash;
|
||||
public String profile;
|
||||
public String name;
|
||||
public long lastUpdated;
|
||||
public boolean bankKnown;
|
||||
public long[] current;
|
||||
/** running sum of value changes caused only by GE price movements (held quantity x price change) */
|
||||
public long market;
|
||||
|
||||
/** Last known item stacks per source ("bank", "inventory", ...): item id -> quantity. */
|
||||
public Map<String, Map<Integer, Long>> containers = new ConcurrentHashMap<>();
|
||||
public List<SourceValue> sources = new ArrayList<>();
|
||||
public List<TopItem> topItems = new ArrayList<>();
|
||||
public List<Snapshot> history = new CopyOnWriteArrayList<>();
|
||||
|
||||
/** Grand Exchange slot state (see GrandExchangeTracker) */
|
||||
public GeSlot[] geSlots;
|
||||
/** Items known to be collected already when GE tracking starts for this character (item id -> qty). */
|
||||
public Map<Integer, Long> geSeedCollected;
|
||||
/** Items that went to the bank while it was not visible; dropped when the bank is seen again. */
|
||||
public Map<Integer, Long> bankPending = new java.util.HashMap<>();
|
||||
/** 4-hour Grand Exchange buy limit tracker: canonical item id -> GeBuyLimit */
|
||||
public Map<Integer, GeBuyLimit> geBuyLimits = new ConcurrentHashMap<>();
|
||||
/** Snapshot of Grand Exchange slots for 4h buy limit tracking, modeled after LimitKeeper */
|
||||
public GeBuyLimitSlot[] geBuyLimitSlots = new GeBuyLimitSlot[8];
|
||||
|
||||
/** Timestamp of last right-click Reset Hourly in Wealth Tracker for 'Now' */
|
||||
public long resetHourlyNowAt;
|
||||
/** Timestamp of last right-click Reset Hourly in Wealth Tracker for 'Session' */
|
||||
public long resetHourlySessionAt;
|
||||
|
||||
public static class GeBuyLimitSlot
|
||||
{
|
||||
public int itemId;
|
||||
public int price;
|
||||
public int total;
|
||||
public int quantity;
|
||||
public int spent;
|
||||
|
||||
public GeBuyLimitSlot()
|
||||
{
|
||||
}
|
||||
|
||||
public GeBuyLimitSlot(int itemId, int price, int total, int quantity, int spent)
|
||||
{
|
||||
this.itemId = itemId;
|
||||
this.price = price;
|
||||
this.total = total;
|
||||
this.quantity = quantity;
|
||||
this.spent = spent;
|
||||
}
|
||||
|
||||
public boolean isSameOffer(GeBuyLimitSlot other)
|
||||
{
|
||||
return other != null && other.itemId == itemId && other.price == price && other.total == total;
|
||||
}
|
||||
}
|
||||
|
||||
public transient long fileModified;
|
||||
|
||||
public long currentTotal()
|
||||
{
|
||||
long sum = 0;
|
||||
if (current != null)
|
||||
{
|
||||
for (long x : current)
|
||||
{
|
||||
sum += x;
|
||||
}
|
||||
}
|
||||
return sum;
|
||||
}
|
||||
|
||||
public Snapshot lastSnapshot()
|
||||
{
|
||||
List<Snapshot> h = history;
|
||||
return h.isEmpty() ? null : h.get(h.size() - 1);
|
||||
}
|
||||
|
||||
public void normalize()
|
||||
{
|
||||
containers = containers == null ? new ConcurrentHashMap<>() : new ConcurrentHashMap<>(containers);
|
||||
sources = sources == null ? new ArrayList<>() : sources;
|
||||
topItems = topItems == null ? new ArrayList<>() : topItems;
|
||||
bankPending = bankPending == null ? new java.util.HashMap<>() : bankPending;
|
||||
geBuyLimits = geBuyLimits == null ? new ConcurrentHashMap<>() : new ConcurrentHashMap<>(geBuyLimits);
|
||||
if (geBuyLimitSlots == null || geBuyLimitSlots.length != 8)
|
||||
{
|
||||
geBuyLimitSlots = geBuyLimitSlots == null ? new GeBuyLimitSlot[8] : java.util.Arrays.copyOf(geBuyLimitSlots, 8);
|
||||
}
|
||||
if (schema < 3)
|
||||
{
|
||||
clearBuyLimits();
|
||||
schema = 3;
|
||||
}
|
||||
List<Snapshot> h = history == null ? new ArrayList<>() : history;
|
||||
h.removeIf(s -> s == null);
|
||||
h.forEach(Snapshot::normalize);
|
||||
h.sort((a, b) -> Long.compare(a.t, b.t));
|
||||
h = sanitizeHistory(h);
|
||||
history = new CopyOnWriteArrayList<>(h);
|
||||
if (current != null && current.length < WealthCategory.COUNT)
|
||||
{
|
||||
current = java.util.Arrays.copyOf(current, WealthCategory.COUNT);
|
||||
}
|
||||
}
|
||||
|
||||
public static final long MAX_SPIKE_WINDOW_MS = 5 * 60 * 1000L;
|
||||
public static final long MIN_SPIKE_ABS_GP = 1_000_000L;
|
||||
public static final double MIN_SPIKE_REL_RATIO = 0.08;
|
||||
|
||||
/**
|
||||
* Checks if a single snapshot at (tSpike, vSpike) represents a transient outlier spike between
|
||||
* (tBefore, vBefore) and (tAfter, vAfter) that jumped away and quickly returned to baseline.
|
||||
*/
|
||||
public static boolean isTransientSpike(long tBefore, long vBefore,
|
||||
long tSpike, long vSpike,
|
||||
long tAfter, long vAfter)
|
||||
{
|
||||
long legBefore = tSpike - tBefore;
|
||||
long legAfter = tAfter - tSpike;
|
||||
if (legBefore <= 0 || legAfter <= 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// The spike itself must be transient:
|
||||
// Either the entire span is within MAX_SPIKE_WINDOW_MS (continuous session),
|
||||
// OR across a session/logout gap (where one leg is long), the spike only persisted for
|
||||
// a brief moment (Math.min(legBefore, legAfter) <= MAX_SPIKE_WINDOW_MS).
|
||||
boolean transientDuration = (tAfter - tBefore <= MAX_SPIKE_WINDOW_MS)
|
||||
|| (Math.min(legBefore, legAfter) <= MAX_SPIKE_WINDOW_MS);
|
||||
if (!transientDuration)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
long d1 = vSpike - vBefore;
|
||||
long d2 = vAfter - vSpike;
|
||||
|
||||
// Must be opposite directions (a jump away and a return)
|
||||
if ((d1 > 0 && d2 >= 0) || (d1 < 0 && d2 <= 0) || d1 == 0 || d2 == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
long absD1 = Math.abs(d1);
|
||||
long absD2 = Math.abs(d2);
|
||||
long spikeMag = Math.min(absD1, absD2);
|
||||
long baseDelta = Math.abs(vAfter - vBefore);
|
||||
|
||||
long baseWealth = Math.max(10_000L, Math.min(Math.abs(vBefore), Math.abs(vAfter)));
|
||||
|
||||
// Magnitude check: must be at least 1M GP or at least 8% of baseline wealth
|
||||
boolean significantSpike = spikeMag >= MIN_SPIKE_ABS_GP || (double) spikeMag / baseWealth >= MIN_SPIKE_REL_RATIO;
|
||||
if (!significantSpike)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Baseline consistency check: after the spike, the value returned close to the starting baseline.
|
||||
return baseDelta <= 0.35 * spikeMag
|
||||
|| baseDelta <= (long) (0.05 * Math.max(Math.abs(vBefore), Math.abs(vAfter)) + 500_000L);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if two consecutive snapshots represent a 2-point transient outlier spike between
|
||||
* (tBefore, vBefore) and (tAfter, vAfter).
|
||||
*/
|
||||
public static boolean isDoubleTransientSpike(long tBefore, long vBefore,
|
||||
long tSpike1, long vSpike1,
|
||||
long tSpike2, long vSpike2,
|
||||
long tAfter, long vAfter)
|
||||
{
|
||||
long legBefore = tSpike1 - tBefore;
|
||||
long legGlitch = tSpike2 - tSpike1;
|
||||
long legAfter = tAfter - tSpike2;
|
||||
if (legBefore <= 0 || legGlitch <= 0 || legAfter <= 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (legGlitch > MAX_SPIKE_WINDOW_MS)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
boolean transientDuration = (tAfter - tBefore <= MAX_SPIKE_WINDOW_MS)
|
||||
|| (Math.min(legBefore, legAfter) + legGlitch <= MAX_SPIKE_WINDOW_MS);
|
||||
if (!transientDuration)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
long d1_1 = vSpike1 - vBefore;
|
||||
long d1_2 = vSpike2 - vBefore;
|
||||
long d2_1 = vAfter - vSpike1;
|
||||
long d2_2 = vAfter - vSpike2;
|
||||
|
||||
// Both points must deviate in the same direction from baseline and return
|
||||
boolean bothPositive = d1_1 > 0 && d1_2 > 0 && d2_1 < 0 && d2_2 < 0;
|
||||
boolean bothNegative = d1_1 < 0 && d1_2 < 0 && d2_1 > 0 && d2_2 > 0;
|
||||
if (!bothPositive && !bothNegative)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
long spikeMag = Math.min(
|
||||
Math.min(Math.abs(d1_1), Math.abs(d1_2)),
|
||||
Math.min(Math.abs(d2_1), Math.abs(d2_2))
|
||||
);
|
||||
long baseDelta = Math.abs(vAfter - vBefore);
|
||||
long baseWealth = Math.max(10_000L, Math.min(Math.abs(vBefore), Math.abs(vAfter)));
|
||||
|
||||
boolean significantSpike = spikeMag >= MIN_SPIKE_ABS_GP || (double) spikeMag / baseWealth >= MIN_SPIKE_REL_RATIO;
|
||||
if (!significantSpike)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return baseDelta <= 0.35 * spikeMag
|
||||
|| baseDelta <= (long) (0.05 * Math.max(Math.abs(vBefore), Math.abs(vAfter)) + 500_000L);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if three consecutive snapshots represent a 3-point transient outlier spike between
|
||||
* (tBefore, vBefore) and (tAfter, vAfter).
|
||||
*/
|
||||
public static boolean isTripleTransientSpike(long tBefore, long vBefore,
|
||||
long tSpike1, long vSpike1,
|
||||
long tSpike2, long vSpike2,
|
||||
long tSpike3, long vSpike3,
|
||||
long tAfter, long vAfter)
|
||||
{
|
||||
long legBefore = tSpike1 - tBefore;
|
||||
long legGlitch1 = tSpike2 - tSpike1;
|
||||
long legGlitch2 = tSpike3 - tSpike2;
|
||||
long totalGlitch = tSpike3 - tSpike1;
|
||||
long legAfter = tAfter - tSpike3;
|
||||
if (legBefore <= 0 || legGlitch1 <= 0 || legGlitch2 <= 0 || legAfter <= 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (totalGlitch > MAX_SPIKE_WINDOW_MS)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
boolean transientDuration = (tAfter - tBefore <= MAX_SPIKE_WINDOW_MS)
|
||||
|| (Math.min(legBefore, legAfter) + totalGlitch <= MAX_SPIKE_WINDOW_MS);
|
||||
if (!transientDuration)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
long d1_1 = vSpike1 - vBefore;
|
||||
long d1_2 = vSpike2 - vBefore;
|
||||
long d1_3 = vSpike3 - vBefore;
|
||||
long d2_1 = vAfter - vSpike1;
|
||||
long d2_2 = vAfter - vSpike2;
|
||||
long d2_3 = vAfter - vSpike3;
|
||||
|
||||
boolean allPositive = d1_1 > 0 && d1_2 > 0 && d1_3 > 0 && d2_1 < 0 && d2_2 < 0 && d2_3 < 0;
|
||||
boolean allNegative = d1_1 < 0 && d1_2 < 0 && d1_3 < 0 && d2_1 > 0 && d2_2 > 0 && d2_3 > 0;
|
||||
if (!allPositive && !allNegative)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
long spikeMag = Math.min(
|
||||
Math.min(Math.abs(d1_1), Math.min(Math.abs(d1_2), Math.abs(d1_3))),
|
||||
Math.min(Math.abs(d2_1), Math.min(Math.abs(d2_2), Math.abs(d2_3)))
|
||||
);
|
||||
long baseDelta = Math.abs(vAfter - vBefore);
|
||||
long baseWealth = Math.max(10_000L, Math.min(Math.abs(vBefore), Math.abs(vAfter)));
|
||||
|
||||
boolean significantSpike = spikeMag >= MIN_SPIKE_ABS_GP || (double) spikeMag / baseWealth >= MIN_SPIKE_REL_RATIO;
|
||||
if (!significantSpike)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return baseDelta <= 0.35 * spikeMag
|
||||
|| baseDelta <= (long) (0.05 * Math.max(Math.abs(vBefore), Math.abs(vAfter)) + 500_000L);
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters out momentary outlier spikes from the timeline (e.g. 1-minute spikes caused by
|
||||
* transient container reloading, temporary GE duplication, or pricing glitches).
|
||||
*/
|
||||
public static List<Snapshot> sanitizeHistory(List<Snapshot> snapshots)
|
||||
{
|
||||
if (snapshots == null || snapshots.size() < 3)
|
||||
{
|
||||
return snapshots == null ? new ArrayList<>() : new ArrayList<>(snapshots);
|
||||
}
|
||||
|
||||
List<Snapshot> list = new ArrayList<>(snapshots);
|
||||
boolean changed = true;
|
||||
int passes = 0;
|
||||
while (changed && passes < 5)
|
||||
{
|
||||
changed = false;
|
||||
passes++;
|
||||
|
||||
// Pass 1: check single-point spikes
|
||||
for (int i = 1; i < list.size() - 1; i++)
|
||||
{
|
||||
Snapshot before = list.get(i - 1);
|
||||
Snapshot cur = list.get(i);
|
||||
Snapshot after = list.get(i + 1);
|
||||
|
||||
if (isTransientSpike(before.t, before.total(), cur.t, cur.total(), after.t, after.total()))
|
||||
{
|
||||
list.remove(i);
|
||||
changed = true;
|
||||
i--;
|
||||
}
|
||||
}
|
||||
|
||||
// Pass 2: check two-point spikes
|
||||
for (int i = 1; i < list.size() - 2; i++)
|
||||
{
|
||||
Snapshot before = list.get(i - 1);
|
||||
Snapshot s1 = list.get(i);
|
||||
Snapshot s2 = list.get(i + 1);
|
||||
Snapshot after = list.get(i + 2);
|
||||
|
||||
if (isDoubleTransientSpike(before.t, before.total(), s1.t, s1.total(), s2.t, s2.total(), after.t, after.total()))
|
||||
{
|
||||
list.remove(i + 1);
|
||||
list.remove(i);
|
||||
changed = true;
|
||||
i--;
|
||||
}
|
||||
}
|
||||
|
||||
// Pass 3: check three-point spikes
|
||||
for (int i = 1; i < list.size() - 3; i++)
|
||||
{
|
||||
Snapshot before = list.get(i - 1);
|
||||
Snapshot s1 = list.get(i);
|
||||
Snapshot s2 = list.get(i + 1);
|
||||
Snapshot s3 = list.get(i + 2);
|
||||
Snapshot after = list.get(i + 3);
|
||||
|
||||
if (isTripleTransientSpike(before.t, before.total(), s1.t, s1.total(), s2.t, s2.total(), s3.t, s3.total(), after.t, after.total()))
|
||||
{
|
||||
list.remove(i + 2);
|
||||
list.remove(i + 1);
|
||||
list.remove(i);
|
||||
changed = true;
|
||||
i--;
|
||||
}
|
||||
}
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
public static final long FOUR_HOURS_MS = 4 * 3600 * 1000L;
|
||||
|
||||
/**
|
||||
* Records a purchase of an item, managing the 4-hour window rollover.
|
||||
*/
|
||||
public void recordBuy(int itemId, int quantity, long now)
|
||||
{
|
||||
if (quantity <= 0 || itemId <= 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (geBuyLimits == null)
|
||||
{
|
||||
geBuyLimits = new ConcurrentHashMap<>();
|
||||
}
|
||||
GeBuyLimit limit = geBuyLimits.computeIfAbsent(itemId, id -> new GeBuyLimit(id, 0, 0, 0));
|
||||
synchronized (limit)
|
||||
{
|
||||
if (limit.windowStart == 0 || now - limit.windowStart >= FOUR_HOURS_MS)
|
||||
{
|
||||
limit.windowStart = now;
|
||||
limit.bought = quantity;
|
||||
}
|
||||
else
|
||||
{
|
||||
limit.bought += quantity;
|
||||
}
|
||||
limit.lastTradeTime = now;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes the current 4-hour buy limit status for an item.
|
||||
*/
|
||||
public GeLimitInfo getBuyLimitInfo(int itemId, int itemGeLimit, long now)
|
||||
{
|
||||
GeBuyLimit limit = geBuyLimits != null ? geBuyLimits.get(itemId) : null;
|
||||
if (limit == null || limit.windowStart == 0)
|
||||
{
|
||||
return new GeLimitInfo(itemId, itemGeLimit, 0, itemGeLimit, 0, false);
|
||||
}
|
||||
synchronized (limit)
|
||||
{
|
||||
long elapsed = now - limit.windowStart;
|
||||
if (elapsed >= FOUR_HOURS_MS)
|
||||
{
|
||||
// Expired: full limit restored
|
||||
return new GeLimitInfo(itemId, itemGeLimit, 0, itemGeLimit, 0, false);
|
||||
}
|
||||
else
|
||||
{
|
||||
int bought = limit.bought;
|
||||
int remaining = Math.max(0, itemGeLimit > 0 ? itemGeLimit - bought : 0);
|
||||
long resetInMs = FOUR_HOURS_MS - elapsed;
|
||||
return new GeLimitInfo(itemId, itemGeLimit, bought, remaining, resetInMs, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets all tracked 4-hour buy limits and slot tracking for this character.
|
||||
*/
|
||||
public void clearBuyLimits()
|
||||
{
|
||||
if (geBuyLimits != null)
|
||||
{
|
||||
geBuyLimits.clear();
|
||||
}
|
||||
geBuyLimitSlots = new GeBuyLimitSlot[8];
|
||||
}
|
||||
|
||||
public void resetHourly(boolean isSession)
|
||||
{
|
||||
long now = System.currentTimeMillis();
|
||||
if (isSession)
|
||||
{
|
||||
resetHourlySessionAt = now;
|
||||
}
|
||||
else
|
||||
{
|
||||
resetHourlyNowAt = now;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.example.essentialsplus.data;
|
||||
|
||||
/**
|
||||
* Tracks purchases of a specific item within the rolling 4-hour Grand Exchange buy limit window.
|
||||
*/
|
||||
public class GeBuyLimit
|
||||
{
|
||||
public int itemId;
|
||||
public int bought;
|
||||
public long windowStart;
|
||||
public long lastTradeTime;
|
||||
|
||||
public GeBuyLimit()
|
||||
{
|
||||
}
|
||||
|
||||
public GeBuyLimit(int itemId, int bought, long windowStart, long lastTradeTime)
|
||||
{
|
||||
this.itemId = itemId;
|
||||
this.bought = bought;
|
||||
this.windowStart = windowStart;
|
||||
this.lastTradeTime = lastTradeTime;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.example.essentialsplus.data;
|
||||
|
||||
/**
|
||||
* Calculated status of an item's 4-hour Grand Exchange buy limit.
|
||||
*/
|
||||
public class GeLimitInfo
|
||||
{
|
||||
public final int itemId;
|
||||
public final int limit;
|
||||
public final int bought;
|
||||
public final int remaining;
|
||||
public final long resetInMs;
|
||||
public final boolean windowActive;
|
||||
|
||||
public GeLimitInfo(int itemId, int limit, int bought, int remaining, long resetInMs, boolean windowActive)
|
||||
{
|
||||
this.itemId = itemId;
|
||||
this.limit = limit;
|
||||
this.bought = bought;
|
||||
this.remaining = remaining;
|
||||
this.resetInMs = resetInMs;
|
||||
this.windowActive = windowActive;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.example.essentialsplus.data;
|
||||
|
||||
/** One Grand Exchange slot prepared for display. */
|
||||
public class GeRow
|
||||
{
|
||||
public final String character;
|
||||
public final int slot;
|
||||
/** null = empty slot */
|
||||
public final GeSlot offer;
|
||||
public final String name;
|
||||
public final int marketPrice;
|
||||
public final GeLimitInfo limitInfo;
|
||||
|
||||
public GeRow(String character, int slot, GeSlot offer, String name, int marketPrice)
|
||||
{
|
||||
this(character, slot, offer, name, marketPrice, null);
|
||||
}
|
||||
|
||||
public GeRow(String character, int slot, GeSlot offer, String name, int marketPrice, GeLimitInfo limitInfo)
|
||||
{
|
||||
this.character = character;
|
||||
this.slot = slot;
|
||||
this.offer = offer;
|
||||
this.name = name;
|
||||
this.marketPrice = marketPrice;
|
||||
this.limitInfo = limitInfo;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.example.essentialsplus.data;
|
||||
|
||||
/** Last known state of one Grand Exchange slot, persisted so collections can be told apart from gains. */
|
||||
public class GeSlot
|
||||
{
|
||||
public int item;
|
||||
public boolean buy;
|
||||
public long total;
|
||||
public long price;
|
||||
|
||||
/** items / coins already taken out of the collection box (fallback tracking) */
|
||||
public long collectedItems;
|
||||
public long collectedCoins;
|
||||
|
||||
/** value held by the slot at the last update */
|
||||
public long escrowItems;
|
||||
public long escrowCoins;
|
||||
public long collectItems;
|
||||
public long collectCoins;
|
||||
|
||||
/** live offer details */
|
||||
public long done;
|
||||
public long spent;
|
||||
public String state;
|
||||
/** when the offer was placed (0 = before tracking started, see trackedSince) */
|
||||
public long placedAt;
|
||||
public long trackedSince;
|
||||
public long trackedDone;
|
||||
public long lastTradeAt;
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package com.example.essentialsplus.data;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.CopyOnWriteArrayList;
|
||||
|
||||
/** Item holdings over time, stored separately from the wealth timeline (<key>.items.json). */
|
||||
public class ItemLedger
|
||||
{
|
||||
public List<ItemSnapshot> snapshots = new CopyOnWriteArrayList<>();
|
||||
|
||||
public transient long fileModified;
|
||||
public transient boolean dirty;
|
||||
|
||||
public ItemSnapshot last()
|
||||
{
|
||||
List<ItemSnapshot> s = snapshots;
|
||||
return s.isEmpty() ? null : s.get(s.size() - 1);
|
||||
}
|
||||
|
||||
/** Latest snapshot taken at or before {@code t}, or the first one after it if none exists. */
|
||||
public ItemSnapshot at(long t)
|
||||
{
|
||||
List<ItemSnapshot> s = snapshots;
|
||||
ItemSnapshot best = null;
|
||||
for (ItemSnapshot x : s)
|
||||
{
|
||||
if (x.t <= t)
|
||||
{
|
||||
best = x;
|
||||
}
|
||||
else
|
||||
{
|
||||
return best != null ? best : x;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
public void normalize()
|
||||
{
|
||||
List<ItemSnapshot> s = snapshots == null ? new ArrayList<>() : new ArrayList<>(snapshots);
|
||||
s.removeIf(x -> x == null || x.ids == null || x.q == null || x.p == null
|
||||
|| x.ids.length != x.q.length || x.ids.length != x.p.length);
|
||||
s.sort((a, b) -> Long.compare(a.t, b.t));
|
||||
snapshots = new CopyOnWriteArrayList<>(s);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.example.essentialsplus.data;
|
||||
|
||||
/** One priced item stack for the item viewer. */
|
||||
public class ItemRow
|
||||
{
|
||||
public final int id;
|
||||
public final String name;
|
||||
public final long qty;
|
||||
public final long price;
|
||||
public final long value;
|
||||
|
||||
public ItemRow(int id, String name, long qty, long price)
|
||||
{
|
||||
this.id = id;
|
||||
this.name = name;
|
||||
this.qty = qty;
|
||||
this.price = price;
|
||||
this.value = qty * price;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package com.example.essentialsplus.data;
|
||||
|
||||
import java.util.Arrays;
|
||||
|
||||
/**
|
||||
* Holdings of a character at one moment: canonical item id, quantity and the GE price at that time.
|
||||
* Allows splitting wealth changes into "market" (price) and "activity" (quantity) effects.
|
||||
*/
|
||||
public class ItemSnapshot
|
||||
{
|
||||
public long t;
|
||||
public int[] ids;
|
||||
public long[] q;
|
||||
public int[] p;
|
||||
/** pinned (session start) - kept by compaction */
|
||||
public Boolean k;
|
||||
|
||||
public ItemSnapshot()
|
||||
{
|
||||
}
|
||||
|
||||
public ItemSnapshot(long t, int[] ids, long[] q, int[] p)
|
||||
{
|
||||
this.t = t;
|
||||
this.ids = ids;
|
||||
this.q = q;
|
||||
this.p = p;
|
||||
}
|
||||
|
||||
public boolean sameAs(ItemSnapshot other)
|
||||
{
|
||||
return other != null && Arrays.equals(ids, other.ids) && Arrays.equals(q, other.q) && Arrays.equals(p, other.p);
|
||||
}
|
||||
|
||||
public boolean sameQuantities(ItemSnapshot other)
|
||||
{
|
||||
return other != null && Arrays.equals(ids, other.ids) && Arrays.equals(q, other.q);
|
||||
}
|
||||
|
||||
public int indexOf(int id)
|
||||
{
|
||||
return ids == null ? -1 : Arrays.binarySearch(ids, id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
package com.example.essentialsplus.data;
|
||||
|
||||
import com.example.essentialsplus.WealthCategory;
|
||||
import java.util.Arrays;
|
||||
|
||||
/**
|
||||
* One point on the wealth timeline.
|
||||
* <p>
|
||||
* Raw points only have {@code t} and {@code v}. Aggregated points (hourly / daily buckets of older data)
|
||||
* additionally hold open, close, low and high of the total and store the time-weighted average
|
||||
* breakdown in {@code v}.
|
||||
*/
|
||||
public class Snapshot
|
||||
{
|
||||
public static final String HOUR = "h";
|
||||
public static final String DAY = "d";
|
||||
|
||||
public long t;
|
||||
public long[] v;
|
||||
/** open / close / low / high total - only set on aggregates */
|
||||
public Long o;
|
||||
public Long c;
|
||||
public Long lo;
|
||||
public Long hi;
|
||||
/** granularity of an aggregate: "h" or "d" */
|
||||
public String g;
|
||||
/** cumulative value change from GE price movements up to this point ({@link AccountData#market}); null on old data */
|
||||
public Long m;
|
||||
|
||||
public Snapshot()
|
||||
{
|
||||
}
|
||||
|
||||
public Snapshot(long t, long[] v)
|
||||
{
|
||||
this.t = t;
|
||||
this.v = v;
|
||||
}
|
||||
|
||||
public long total()
|
||||
{
|
||||
long sum = 0;
|
||||
if (v != null)
|
||||
{
|
||||
for (long x : v)
|
||||
{
|
||||
sum += x;
|
||||
}
|
||||
}
|
||||
return sum;
|
||||
}
|
||||
|
||||
public long get(WealthCategory category)
|
||||
{
|
||||
int i = category.ordinal();
|
||||
return v != null && i < v.length ? v[i] : 0;
|
||||
}
|
||||
|
||||
public long low()
|
||||
{
|
||||
return lo != null ? lo : total();
|
||||
}
|
||||
|
||||
public long high()
|
||||
{
|
||||
return hi != null ? hi : total();
|
||||
}
|
||||
|
||||
public long open()
|
||||
{
|
||||
return o != null ? o : total();
|
||||
}
|
||||
|
||||
public long close()
|
||||
{
|
||||
return c != null ? c : total();
|
||||
}
|
||||
|
||||
public boolean isAggregate()
|
||||
{
|
||||
return g != null || lo != null;
|
||||
}
|
||||
|
||||
/** Pads arrays loaded from older files to the current category count. */
|
||||
public void normalize()
|
||||
{
|
||||
if (v == null)
|
||||
{
|
||||
v = new long[WealthCategory.COUNT];
|
||||
}
|
||||
else if (v.length < WealthCategory.COUNT)
|
||||
{
|
||||
v = Arrays.copyOf(v, WealthCategory.COUNT);
|
||||
}
|
||||
if (lo != null && g == null)
|
||||
{
|
||||
g = DAY;
|
||||
}
|
||||
}
|
||||
|
||||
public long market()
|
||||
{
|
||||
return m != null ? m : 0;
|
||||
}
|
||||
|
||||
/** Copy with {@code delta} removed from the total, spread over the categories by their share. */
|
||||
public Snapshot shifted(long delta)
|
||||
{
|
||||
long[] nv = v == null ? new long[0] : v.clone();
|
||||
if (delta != 0 && nv.length > 0)
|
||||
{
|
||||
long base = 0;
|
||||
for (long x : nv)
|
||||
{
|
||||
base += Math.abs(x);
|
||||
}
|
||||
long left = delta;
|
||||
int largest = 0;
|
||||
for (int i = 0; i < nv.length; i++)
|
||||
{
|
||||
if (Math.abs(nv[i]) > Math.abs(nv[largest]))
|
||||
{
|
||||
largest = i;
|
||||
}
|
||||
if (base > 0)
|
||||
{
|
||||
long part = Math.round((double) delta * Math.abs(nv[i]) / base);
|
||||
nv[i] -= part;
|
||||
left -= part;
|
||||
}
|
||||
}
|
||||
nv[largest] -= left;
|
||||
}
|
||||
Snapshot s = new Snapshot(t, nv);
|
||||
s.g = g;
|
||||
s.m = m;
|
||||
s.o = o == null ? null : o - delta;
|
||||
s.c = c == null ? null : c - delta;
|
||||
s.lo = lo == null ? null : lo - delta;
|
||||
s.hi = hi == null ? null : hi - delta;
|
||||
return s;
|
||||
}
|
||||
|
||||
public boolean sameValues(long[] other)
|
||||
{
|
||||
return Arrays.equals(v, other);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.example.essentialsplus.data;
|
||||
|
||||
/** Value of a single wealth source (e.g. "Looting bag") inside a category. */
|
||||
public class SourceValue
|
||||
{
|
||||
public String name;
|
||||
public int category;
|
||||
public long value;
|
||||
|
||||
public SourceValue()
|
||||
{
|
||||
}
|
||||
|
||||
public SourceValue(String name, int category, long value)
|
||||
{
|
||||
this.name = name;
|
||||
this.category = category;
|
||||
this.value = value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.example.essentialsplus.data;
|
||||
|
||||
public class TopItem
|
||||
{
|
||||
public int id;
|
||||
public String name;
|
||||
public long qty;
|
||||
public long value;
|
||||
|
||||
public TopItem()
|
||||
{
|
||||
}
|
||||
|
||||
public TopItem(int id, String name, long qty, long value)
|
||||
{
|
||||
this.id = id;
|
||||
this.name = name;
|
||||
this.qty = qty;
|
||||
this.value = value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
package com.example.essentialsplus.helpers;
|
||||
|
||||
import com.example.essentialsplus.EssentialsPlusConfig;
|
||||
import java.lang.reflect.Field;
|
||||
import java.lang.reflect.Modifier;
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.WeakHashMap;
|
||||
import javax.inject.Inject;
|
||||
import javax.inject.Singleton;
|
||||
import net.runelite.api.Actor;
|
||||
import net.runelite.api.Client;
|
||||
import net.runelite.api.GameState;
|
||||
import net.runelite.api.NPC;
|
||||
import net.runelite.api.Player;
|
||||
import net.runelite.api.Projectile;
|
||||
import net.runelite.api.events.AnimationChanged;
|
||||
import net.runelite.api.events.GameStateChanged;
|
||||
import net.runelite.api.events.GameTick;
|
||||
import net.runelite.api.events.NpcDespawned;
|
||||
import net.runelite.api.events.ProjectileMoved;
|
||||
import net.runelite.api.gameval.AnimationID;
|
||||
import net.runelite.api.gameval.SpotanimID;
|
||||
import net.runelite.client.eventbus.Subscribe;
|
||||
|
||||
/**
|
||||
* Which style each NPC attacking the local player used last. The style comes from the projectile (by its cache name,
|
||||
* e.g. {@code CRYSTAL_HUNLLEF_RANGE_TRAVEL}), else the attack animation name, else melee when the NPC attacked from
|
||||
* melee distance without a projectile.
|
||||
*/
|
||||
@Singleton
|
||||
public class AttackStyles
|
||||
{
|
||||
/** How long a style stays shown after the NPC's last attack. */
|
||||
private static final int SHOW_TICKS = 12;
|
||||
/** Projectiles whose names do not say the style. */
|
||||
private static final Map<Integer, CombatStyle> PROJECTILE_OVERRIDES = Map.of(
|
||||
SpotanimID.CRYSTAL_HUNLLEF_PRAYER_TRAVEL, CombatStyle.MAGIC,
|
||||
SpotanimID.CRYSTAL_HUNLLEF_PRAYER_TRAVEL_HM, CombatStyle.MAGIC,
|
||||
SpotanimID.ECHO_HUNLLEF_ANTIPRAYER_TRAVEL, CombatStyle.MAGIC);
|
||||
|
||||
private final Client client;
|
||||
private final EssentialsPlusConfig config;
|
||||
|
||||
private Map<Integer, CombatStyle> projectileStyles;
|
||||
private Map<Integer, CombatStyle> animationStyles;
|
||||
|
||||
private final Map<NPC, Shown> styles = new HashMap<>();
|
||||
/** Attack animations without a known style: melee if no projectile follows by the next tick. */
|
||||
private final Map<NPC, Integer> unresolved = new HashMap<>();
|
||||
private final Set<Projectile> seenProjectiles = Collections.newSetFromMap(new WeakHashMap<>());
|
||||
|
||||
private static final class Shown
|
||||
{
|
||||
final CombatStyle style;
|
||||
final int tick;
|
||||
|
||||
Shown(CombatStyle style, int tick)
|
||||
{
|
||||
this.style = style;
|
||||
this.tick = tick;
|
||||
}
|
||||
}
|
||||
|
||||
@Inject
|
||||
AttackStyles(Client client, EssentialsPlusConfig config)
|
||||
{
|
||||
this.client = client;
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
/** Last attack style of every NPC currently attacking the local player. */
|
||||
public Map<NPC, CombatStyle> current()
|
||||
{
|
||||
Map<NPC, CombatStyle> out = new HashMap<>();
|
||||
Player me = client.getLocalPlayer();
|
||||
int tick = client.getTickCount();
|
||||
for (Map.Entry<NPC, Shown> e : styles.entrySet())
|
||||
{
|
||||
NPC npc = e.getKey();
|
||||
if (me != null && !npc.isDead() && npc.getInteracting() == me && tick - e.getValue().tick <= SHOW_TICKS)
|
||||
{
|
||||
out.put(npc, e.getValue().style);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
@Subscribe
|
||||
public void onAnimationChanged(AnimationChanged event)
|
||||
{
|
||||
if (!config.enemyAttackStyle() || !(event.getActor() instanceof NPC))
|
||||
{
|
||||
return;
|
||||
}
|
||||
NPC npc = (NPC) event.getActor();
|
||||
int anim = npc.getAnimation();
|
||||
Player me = client.getLocalPlayer();
|
||||
if (anim == -1 || me == null || npc.getInteracting() != me || npc.isDead())
|
||||
{
|
||||
return;
|
||||
}
|
||||
CombatStyle style = animationStyles().get(anim);
|
||||
if (style != null)
|
||||
{
|
||||
set(npc, style);
|
||||
}
|
||||
else
|
||||
{
|
||||
unresolved.putIfAbsent(npc, client.getTickCount());
|
||||
}
|
||||
}
|
||||
|
||||
@Subscribe
|
||||
public void onProjectileMoved(ProjectileMoved event)
|
||||
{
|
||||
if (!config.enemyAttackStyle())
|
||||
{
|
||||
return;
|
||||
}
|
||||
Projectile p = event.getProjectile();
|
||||
if (!seenProjectiles.add(p))
|
||||
{
|
||||
return;
|
||||
}
|
||||
Player me = client.getLocalPlayer();
|
||||
Actor target = p.getTargetActor();
|
||||
if (me == null || target != null && target != me)
|
||||
{
|
||||
return;
|
||||
}
|
||||
NPC source = p.getSourceActor() instanceof NPC ? (NPC) p.getSourceActor() : null;
|
||||
if (source == null)
|
||||
{
|
||||
// no source: the NPC that just animated at us
|
||||
for (NPC npc : unresolved.keySet())
|
||||
{
|
||||
source = npc;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (source == null || source.getInteracting() != me)
|
||||
{
|
||||
return;
|
||||
}
|
||||
unresolved.remove(source);
|
||||
CombatStyle style = PROJECTILE_OVERRIDES.getOrDefault(p.getId(), projectileStyles().get(p.getId()));
|
||||
if (style != null)
|
||||
{
|
||||
set(source, style);
|
||||
}
|
||||
}
|
||||
|
||||
@Subscribe
|
||||
public void onGameTick(GameTick event)
|
||||
{
|
||||
int tick = client.getTickCount();
|
||||
Player me = client.getLocalPlayer();
|
||||
unresolved.entrySet().removeIf(e ->
|
||||
{
|
||||
if (tick - e.getValue() < 1)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
NPC npc = e.getKey();
|
||||
if (me != null && npc.getWorldArea() != null && me.getWorldArea() != null
|
||||
&& npc.getWorldArea().isInMeleeDistance(me.getWorldArea()))
|
||||
{
|
||||
set(npc, CombatStyle.MELEE);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
styles.values().removeIf(s -> tick - s.tick > SHOW_TICKS);
|
||||
}
|
||||
|
||||
@Subscribe
|
||||
public void onNpcDespawned(NpcDespawned event)
|
||||
{
|
||||
styles.remove(event.getNpc());
|
||||
unresolved.remove(event.getNpc());
|
||||
}
|
||||
|
||||
@Subscribe
|
||||
public void onGameStateChanged(GameStateChanged event)
|
||||
{
|
||||
if (event.getGameState() != GameState.LOGGED_IN)
|
||||
{
|
||||
reset();
|
||||
}
|
||||
}
|
||||
|
||||
public void reset()
|
||||
{
|
||||
styles.clear();
|
||||
unresolved.clear();
|
||||
seenProjectiles.clear();
|
||||
}
|
||||
|
||||
private void set(NPC npc, CombatStyle style)
|
||||
{
|
||||
styles.put(npc, new Shown(style, client.getTickCount()));
|
||||
}
|
||||
|
||||
private Map<Integer, CombatStyle> projectileStyles()
|
||||
{
|
||||
if (projectileStyles == null)
|
||||
{
|
||||
projectileStyles = stylesByName(SpotanimID.class);
|
||||
}
|
||||
return projectileStyles;
|
||||
}
|
||||
|
||||
private Map<Integer, CombatStyle> animationStyles()
|
||||
{
|
||||
if (animationStyles == null)
|
||||
{
|
||||
animationStyles = stylesByName(AnimationID.class);
|
||||
}
|
||||
return animationStyles;
|
||||
}
|
||||
|
||||
/** Id -> style from the constant names of a gameval class; ids whose names disagree are left out. */
|
||||
static Map<Integer, CombatStyle> stylesByName(Class<?> gameval)
|
||||
{
|
||||
Map<Integer, CombatStyle> out = new HashMap<>();
|
||||
Set<Integer> conflicts = new java.util.HashSet<>();
|
||||
for (Field f : gameval.getFields())
|
||||
{
|
||||
if (f.getType() != int.class || !Modifier.isStatic(f.getModifiers()))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
CombatStyle style = CombatStyle.fromName(f.getName());
|
||||
if (style == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
try
|
||||
{
|
||||
int id = f.getInt(null);
|
||||
CombatStyle prev = out.putIfAbsent(id, style);
|
||||
if (prev != null && prev != style)
|
||||
{
|
||||
conflicts.add(id);
|
||||
}
|
||||
}
|
||||
catch (IllegalAccessException ignored)
|
||||
{
|
||||
}
|
||||
}
|
||||
out.keySet().removeAll(conflicts);
|
||||
return out;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
package com.example.essentialsplus.helpers;
|
||||
|
||||
import com.example.essentialsplus.EssentialsPlusConfig;
|
||||
import java.awt.BasicStroke;
|
||||
import java.awt.Color;
|
||||
import java.awt.Dimension;
|
||||
import java.awt.FontMetrics;
|
||||
import java.awt.Graphics2D;
|
||||
import java.awt.RenderingHints;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.util.EnumMap;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import javax.inject.Inject;
|
||||
import net.runelite.api.Actor;
|
||||
import net.runelite.api.Client;
|
||||
import net.runelite.api.NPC;
|
||||
import net.runelite.api.Player;
|
||||
import net.runelite.api.Point;
|
||||
import net.runelite.client.game.SpriteManager;
|
||||
import net.runelite.client.ui.overlay.Overlay;
|
||||
import net.runelite.client.ui.overlay.OverlayLayer;
|
||||
import net.runelite.client.ui.overlay.OverlayPosition;
|
||||
import net.runelite.client.util.ImageUtil;
|
||||
|
||||
/**
|
||||
* Countdown above the player's (and attacking NPCs') head, optionally with one pip per tick of the attack speed,
|
||||
* and the style attacking NPCs used last (prayer icon or text) left of their number.
|
||||
*/
|
||||
public class AttackTimerOverlay extends Overlay
|
||||
{
|
||||
private static final int PIP = 8;
|
||||
private static final int GAP = 2;
|
||||
/** Height of an overhead prayer icon; the player's own timer moves above it. */
|
||||
private static final int OVERHEAD_SHIFT = 26;
|
||||
private static final Color EMPTY = new Color(0, 0, 0, 150);
|
||||
private static final Color OUTLINE = new Color(0, 0, 0, 220);
|
||||
|
||||
private final Client client;
|
||||
private final EssentialsPlusConfig config;
|
||||
private final AttackTimers timers;
|
||||
private final AttackStyles styles;
|
||||
private final SpriteManager spriteManager;
|
||||
/** Scaled prayer icons by size, then style. */
|
||||
private final Map<Integer, Map<CombatStyle, BufferedImage>> icons = new HashMap<>();
|
||||
|
||||
/** Look of one timer kind, read from the config once per frame. */
|
||||
private static final class Style
|
||||
{
|
||||
final boolean bar;
|
||||
final TimerTextSize size;
|
||||
final Color color;
|
||||
final Color ready;
|
||||
final int offset;
|
||||
|
||||
Style(boolean bar, TimerTextSize size, Color color, Color ready, int offset)
|
||||
{
|
||||
this.bar = bar;
|
||||
this.size = size;
|
||||
this.color = color;
|
||||
this.ready = ready;
|
||||
this.offset = offset;
|
||||
}
|
||||
}
|
||||
|
||||
@Inject
|
||||
AttackTimerOverlay(Client client, EssentialsPlusConfig config, AttackTimers timers, AttackStyles styles, SpriteManager spriteManager)
|
||||
{
|
||||
this.client = client;
|
||||
this.config = config;
|
||||
this.timers = timers;
|
||||
this.styles = styles;
|
||||
this.spriteManager = spriteManager;
|
||||
setPosition(OverlayPosition.DYNAMIC);
|
||||
setLayer(OverlayLayer.ABOVE_SCENE);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Dimension render(Graphics2D g)
|
||||
{
|
||||
boolean styleOn = config.enemyAttackStyle();
|
||||
if (!config.attackTimer() && !config.enemyAttackTimer() && !styleOn)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
|
||||
timers.onRender();
|
||||
if (config.attackTimer())
|
||||
{
|
||||
Player me = client.getLocalPlayer();
|
||||
AttackTimers.Timer t = timers.playerTimer();
|
||||
if (me != null && t != null)
|
||||
{
|
||||
Style style = new Style(config.attackTimerBar(), config.attackTimerSize(), config.attackTimerColor(),
|
||||
config.attackTimerReadyColor(), config.attackTimerOffset());
|
||||
draw(g, me, t, style, null);
|
||||
}
|
||||
}
|
||||
Map<NPC, AttackTimers.Timer> npcTimers = config.enemyAttackTimer() ? timers.npcTimers() : Map.of();
|
||||
Map<NPC, CombatStyle> npcStyles = styleOn ? styles.current() : Map.of();
|
||||
if (!npcTimers.isEmpty() || !npcStyles.isEmpty())
|
||||
{
|
||||
Style style = new Style(config.enemyAttackTimerBar(), config.enemyAttackTimerSize(), config.enemyAttackTimerColor(),
|
||||
config.enemyAttackTimerReadyColor(), config.enemyAttackTimerOffset());
|
||||
Set<NPC> npcs = new LinkedHashSet<>(npcTimers.keySet());
|
||||
npcs.addAll(npcStyles.keySet());
|
||||
for (NPC npc : npcs)
|
||||
{
|
||||
draw(g, npc, npcTimers.get(npc), style, npcStyles.get(npc));
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private void draw(Graphics2D g, Actor actor, AttackTimers.Timer t, Style style, CombatStyle attackStyle)
|
||||
{
|
||||
if (t != null)
|
||||
{
|
||||
drawTimer(g, actor, t, style);
|
||||
}
|
||||
if (attackStyle != null)
|
||||
{
|
||||
drawStyle(g, actor, attackStyle);
|
||||
}
|
||||
}
|
||||
|
||||
private void drawTimer(Graphics2D g, Actor actor, AttackTimers.Timer t, Style style)
|
||||
{
|
||||
Point anchor = actor.getCanvasTextLocation(g, "", actor.getLogicalHeight() + style.offset);
|
||||
if (anchor == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
int y = anchor.getY();
|
||||
if (actor instanceof Player && ((Player) actor).getOverheadIcon() != null)
|
||||
{
|
||||
y -= OVERHEAD_SHIFT;
|
||||
}
|
||||
boolean ready = t.remaining == 0;
|
||||
|
||||
if (style.bar)
|
||||
{
|
||||
int width = t.speed * PIP + (t.speed - 1) * GAP;
|
||||
int x0 = anchor.getX() - width / 2;
|
||||
int barY = y - PIP;
|
||||
// the pip of the running tick drains smoothly; later ticks stay full
|
||||
double fill = t.remaining - t.tickProgress;
|
||||
g.setStroke(new BasicStroke(1f));
|
||||
for (int i = 0; i < t.speed; i++)
|
||||
{
|
||||
int x = x0 + i * (PIP + GAP);
|
||||
g.setColor(EMPTY);
|
||||
g.fillRect(x, barY, PIP, PIP);
|
||||
double part = Math.max(0, Math.min(1, fill - i));
|
||||
if (part > 0)
|
||||
{
|
||||
g.setColor(style.color);
|
||||
g.fillRect(x, barY, (int) Math.round(PIP * part), PIP);
|
||||
}
|
||||
g.setColor(ready ? style.ready : OUTLINE);
|
||||
g.drawRect(x, barY, PIP, PIP);
|
||||
}
|
||||
y = barY - 3;
|
||||
}
|
||||
|
||||
g.setFont(style.size.font());
|
||||
String text = String.valueOf(t.remaining);
|
||||
int x = anchor.getX() - g.getFontMetrics().stringWidth(text) / 2;
|
||||
shadowed(g, text, x, y, ready ? style.ready : style.color);
|
||||
}
|
||||
|
||||
/** Attack style with its own size and height, centered above the head. */
|
||||
private void drawStyle(Graphics2D g, Actor actor, CombatStyle attackStyle)
|
||||
{
|
||||
Point anchor = actor.getCanvasTextLocation(g, "", actor.getLogicalHeight() + config.enemyAttackStyleOffset());
|
||||
if (anchor == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
TimerTextSize size = config.enemyAttackStyleSize();
|
||||
if (config.enemyAttackStyleText())
|
||||
{
|
||||
g.setFont(size.font());
|
||||
int x = anchor.getX() - g.getFontMetrics().stringWidth(attackStyle.label) / 2;
|
||||
shadowed(g, attackStyle.label, x, anchor.getY(), attackStyle.color);
|
||||
return;
|
||||
}
|
||||
BufferedImage icon = icon(attackStyle, size.iconSize());
|
||||
if (icon != null)
|
||||
{
|
||||
g.drawImage(icon, anchor.getX() - icon.getWidth() / 2, anchor.getY() - icon.getHeight(), null);
|
||||
}
|
||||
}
|
||||
|
||||
private static void shadowed(Graphics2D g, String text, int x, int y, Color color)
|
||||
{
|
||||
g.setColor(Color.BLACK);
|
||||
g.drawString(text, x + 1, y + 1);
|
||||
g.setColor(color);
|
||||
g.drawString(text, x, y);
|
||||
}
|
||||
|
||||
private BufferedImage icon(CombatStyle style, int size)
|
||||
{
|
||||
Map<CombatStyle, BufferedImage> bySize = icons.computeIfAbsent(size, k -> new EnumMap<>(CombatStyle.class));
|
||||
BufferedImage img = bySize.get(style);
|
||||
if (img == null)
|
||||
{
|
||||
BufferedImage sprite = spriteManager.getSprite(style.prayerSprite, 0);
|
||||
if (sprite == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
img = ImageUtil.resizeImage(sprite, size, size);
|
||||
bySize.put(style, img);
|
||||
}
|
||||
return img;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,565 @@
|
||||
package com.example.essentialsplus.helpers;
|
||||
|
||||
import com.example.essentialsplus.EssentialsPlusConfig;
|
||||
import com.example.essentialsplus.helpers.combat.AnimationData;
|
||||
import com.example.essentialsplus.helpers.combat.AttackSpeed;
|
||||
import com.example.essentialsplus.helpers.combat.Attacking;
|
||||
import com.example.essentialsplus.helpers.combat.CombatUtils;
|
||||
import com.example.essentialsplus.helpers.combat.Spellbook;
|
||||
import com.example.essentialsplus.helpers.combat.variablespeed.VariableSpeed;
|
||||
import com.example.essentialsplus.helpers.combat.variablespeed.state.TickCount;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.regex.Pattern;
|
||||
import javax.inject.Inject;
|
||||
import javax.inject.Singleton;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import net.runelite.api.Actor;
|
||||
import net.runelite.api.Client;
|
||||
import net.runelite.api.GameState;
|
||||
import net.runelite.api.NPC;
|
||||
import net.runelite.api.Player;
|
||||
import net.runelite.api.events.AnimationChanged;
|
||||
import net.runelite.api.events.ChatMessage;
|
||||
import net.runelite.api.events.FakeXpDrop;
|
||||
import net.runelite.api.events.GameStateChanged;
|
||||
import net.runelite.api.events.GameTick;
|
||||
import net.runelite.api.events.HitsplatApplied;
|
||||
import net.runelite.api.events.InteractingChanged;
|
||||
import net.runelite.api.events.NpcDespawned;
|
||||
import net.runelite.api.events.NpcSpawned;
|
||||
import net.runelite.api.events.SoundEffectPlayed;
|
||||
import net.runelite.api.events.StatChanged;
|
||||
import net.runelite.api.events.VarbitChanged;
|
||||
import net.runelite.api.gameval.VarPlayerID;
|
||||
import net.runelite.api.gameval.VarbitID;
|
||||
import net.runelite.client.eventbus.Subscribe;
|
||||
import net.runelite.client.game.ItemManager;
|
||||
import net.runelite.client.game.NPCManager;
|
||||
|
||||
/**
|
||||
* Deterministic attack timer for the local player, incorporating weapon speeds, attack styles,
|
||||
* manual casting, powered staves, boss mechanics, and food delays.
|
||||
* NPC attack speeds are learned from intervals between their verified attack animations.
|
||||
*/
|
||||
@Slf4j
|
||||
@Singleton
|
||||
public class AttackTimers
|
||||
{
|
||||
public enum AttackState
|
||||
{
|
||||
NOT_ATTACKING,
|
||||
DELAYED_FIRST_TICK,
|
||||
DELAYED
|
||||
}
|
||||
|
||||
static final int DEFAULT_SPEED = 4;
|
||||
private static final int MAX_SPEED = 10;
|
||||
private static final int TRUSTED_SAMPLES = 3;
|
||||
private static final long TICK_NANOS = 600_000_000L;
|
||||
private static final int CONFIRM_TICKS = 4;
|
||||
private static final int UI_HIDE_DEBOUNCE_TICKS_MAX = 1;
|
||||
|
||||
private static final int SLOW_FOOD_ATTACK_DELAY_TICKS = 4;
|
||||
private static final int DEFAULT_FOOD_ATTACK_DELAY_TICKS = 3;
|
||||
private static final int FAST_EAT_ATTACK_DELAY_TICKS = 2;
|
||||
|
||||
private static final String GENERIC_EAT = "You eat";
|
||||
private static final String VAMPYRIUM_EAT = "Your stomach doesn't like it... but it heals some health";
|
||||
private static final String BARBARIAN_POTIONS = "You drink the lumpy potion";
|
||||
private static final String JUG_OF_WINE = "You drink the wine";
|
||||
|
||||
private static final Pattern EAT_MESSAGE = Pattern.compile(
|
||||
"^(" + GENERIC_EAT + "|" + BARBARIAN_POTIONS + "|" + JUG_OF_WINE + "|" + VAMPYRIUM_EAT + ")",
|
||||
Pattern.MULTILINE | Pattern.CASE_INSENSITIVE);
|
||||
|
||||
private static final Pattern SLOW_FOOD = Pattern.compile("^(" + VAMPYRIUM_EAT + ")",
|
||||
Pattern.MULTILINE | Pattern.CASE_INSENSITIVE);
|
||||
|
||||
private static final String FAST_GNOME_FOOD = "worm hole|tangled toads legs|veg ball|chocolate bomb|worm crunchies|toad crunchies|"
|
||||
+ "choc chip crunchies|spicy crunchies|fruit batta|cheese and tomato batta|toad batta|vegetable batta|worm batta";
|
||||
private static final String FAST_FOOD = "karambwan|halibut";
|
||||
private static final Pattern FAST_EAT = Pattern.compile("(" + FAST_FOOD + "|" + FAST_GNOME_FOOD + ")",
|
||||
Pattern.CASE_INSENSITIVE);
|
||||
|
||||
private static final Set<Integer> NON_ATTACK_ANIMS = Set.of(829, 424, 1156, 836);
|
||||
|
||||
private final Client client;
|
||||
private final ItemManager itemManager;
|
||||
private final NPCManager npcManager;
|
||||
private final EssentialsPlusConfig config;
|
||||
|
||||
private final TickCount tc = new TickCount();
|
||||
private final VariableSpeed variableSpeed = new VariableSpeed(tc);
|
||||
private final AttackSpeed attackSpeed = new AttackSpeed(tc, variableSpeed);
|
||||
|
||||
public AttackState attackState = AttackState.NOT_ATTACKING;
|
||||
public AttackState renderedState = AttackState.NOT_ATTACKING;
|
||||
|
||||
public int tickPeriod = 0;
|
||||
public int attackDelayHoldoffTicks = 0;
|
||||
private int uiHideDebounceTickCount = 0;
|
||||
public int pendingEatDelayTicks = 0;
|
||||
|
||||
private Spellbook currentSpellBook = Spellbook.STANDARD;
|
||||
private int lastUsedWeaponId = -1;
|
||||
private Actor lastTarget = null;
|
||||
|
||||
private volatile long lastTickNanos = System.nanoTime();
|
||||
|
||||
// NPC Attack Timers state
|
||||
private final Map<NPC, Integer> npcAttackTicks = new HashMap<>();
|
||||
private final Map<NPC, Integer> npcPrevTicks = new HashMap<>();
|
||||
private final Map<Integer, SpeedSamples> npcSpeeds = new HashMap<>();
|
||||
private final Map<NPC, int[]> pendingNpcAnims = new HashMap<>();
|
||||
private final Map<Long, int[]> animOutcomes = new HashMap<>();
|
||||
private final java.util.List<AnimCheck> animChecks = new java.util.ArrayList<>();
|
||||
|
||||
private static final class AnimCheck
|
||||
{
|
||||
final long key;
|
||||
final int tick;
|
||||
boolean confirmed;
|
||||
|
||||
AnimCheck(long key, int tick)
|
||||
{
|
||||
this.key = key;
|
||||
this.tick = tick;
|
||||
}
|
||||
}
|
||||
|
||||
@Inject
|
||||
public AttackTimers(Client client, ItemManager itemManager, NPCManager npcManager, EssentialsPlusConfig config)
|
||||
{
|
||||
this.client = client;
|
||||
this.itemManager = itemManager;
|
||||
this.npcManager = npcManager;
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
public static final class Timer
|
||||
{
|
||||
public final int remaining;
|
||||
public final int speed;
|
||||
public final double tickProgress;
|
||||
|
||||
public Timer(int remaining, int speed, double tickProgress)
|
||||
{
|
||||
this.remaining = remaining;
|
||||
this.speed = speed;
|
||||
this.tickProgress = tickProgress;
|
||||
}
|
||||
}
|
||||
|
||||
public Timer playerTimer()
|
||||
{
|
||||
if (attackState == AttackState.NOT_ATTACKING && uiHideDebounceTickCount <= 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
int remaining = 1 + attackDelayHoldoffTicks;
|
||||
if (remaining < 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
int speed = Math.max(1, tickPeriod);
|
||||
double progress = (remaining == 0) ? 0 : tickProgress();
|
||||
return new Timer(remaining, speed, progress);
|
||||
}
|
||||
|
||||
private double tickProgress()
|
||||
{
|
||||
return Math.min(1, Math.max(0, (System.nanoTime() - lastTickNanos) / (double) TICK_NANOS));
|
||||
}
|
||||
|
||||
public void onRender()
|
||||
{
|
||||
final int delta = variableSpeed.shadowCrash.onRender(client, attackDelayHoldoffTicks, attackSpeed.isUsingMagic());
|
||||
if (delta != 0)
|
||||
{
|
||||
attackDelayHoldoffTicks += delta;
|
||||
if (attackDelayHoldoffTicks < 0)
|
||||
{
|
||||
attackState = AttackState.NOT_ATTACKING;
|
||||
}
|
||||
}
|
||||
if (variableSpeed.maggotKing.onRender(client, itemManager)
|
||||
|| variableSpeed.doomOfMokhaiotl.onRender(client, itemManager, currentSpellBook))
|
||||
{
|
||||
performAttack();
|
||||
}
|
||||
checkForLateWeaponSwaps();
|
||||
renderedState = attackState;
|
||||
}
|
||||
|
||||
private void setAttackDelay()
|
||||
{
|
||||
Player localPlayer = client.getLocalPlayer();
|
||||
final AnimationData curAnimation = localPlayer != null ? AnimationData.fromId(localPlayer.getAnimation()) : null;
|
||||
attackDelayHoldoffTicks = attackSpeed.compute(client, curAnimation, currentSpellBook, itemManager);
|
||||
lastUsedWeaponId = CombatUtils.getWeaponId(client);
|
||||
}
|
||||
|
||||
private void performAttack()
|
||||
{
|
||||
attackState = AttackState.DELAYED_FIRST_TICK;
|
||||
setAttackDelay();
|
||||
tickPeriod = attackDelayHoldoffTicks;
|
||||
uiHideDebounceTickCount = UI_HIDE_DEBOUNCE_TICKS_MAX;
|
||||
Player localPlayer = client.getLocalPlayer();
|
||||
lastTarget = localPlayer != null ? localPlayer.getInteracting() : null;
|
||||
}
|
||||
|
||||
private void checkForLateWeaponSwaps()
|
||||
{
|
||||
final boolean weaponMismatch = CombatUtils.getWeaponId(client) != lastUsedWeaponId;
|
||||
if (inPreAttackWindow() && weaponMismatch)
|
||||
{
|
||||
performAttack();
|
||||
}
|
||||
}
|
||||
|
||||
private boolean inPreAttackWindow()
|
||||
{
|
||||
return attackState == AttackState.DELAYED_FIRST_TICK && renderedState != attackState;
|
||||
}
|
||||
|
||||
private void applyAndClearEats()
|
||||
{
|
||||
int pendingEats = pendingEatDelayTicks;
|
||||
attackDelayHoldoffTicks += pendingEats;
|
||||
pendingEatDelayTicks -= pendingEats;
|
||||
}
|
||||
|
||||
@Subscribe
|
||||
public void onVarbitChanged(final VarbitChanged varbitChanged)
|
||||
{
|
||||
if (varbitChanged.getVarbitId() == VarbitID.SPELLBOOK)
|
||||
{
|
||||
currentSpellBook = Spellbook.fromVarbit(varbitChanged.getValue());
|
||||
}
|
||||
if (varbitChanged.getVarpId() == VarPlayerID.SA_ENERGY)
|
||||
{
|
||||
attackSpeed.varbitSpecialAttackChanged(varbitChanged.getValue());
|
||||
}
|
||||
}
|
||||
|
||||
@Subscribe
|
||||
public void onSoundEffectPlayed(final SoundEffectPlayed event)
|
||||
{
|
||||
attackSpeed.onSoundEffectPlayed(client, event);
|
||||
}
|
||||
|
||||
@Subscribe
|
||||
public void onFakeXpDrop(final FakeXpDrop event)
|
||||
{
|
||||
if (attackSpeed.onXpDrop(event) && inPreAttackWindow())
|
||||
{
|
||||
performAttack();
|
||||
}
|
||||
}
|
||||
|
||||
@Subscribe
|
||||
public void onStatChanged(final StatChanged event)
|
||||
{
|
||||
if (attackSpeed.onXpDrop(event) && inPreAttackWindow())
|
||||
{
|
||||
performAttack();
|
||||
}
|
||||
}
|
||||
|
||||
@Subscribe
|
||||
public void onNpcSpawned(final NpcSpawned npcSpawned)
|
||||
{
|
||||
variableSpeed.onNpcSpawned(client, npcSpawned);
|
||||
}
|
||||
|
||||
@Subscribe
|
||||
public void onNpcDespawned(final NpcDespawned npcDespawned)
|
||||
{
|
||||
variableSpeed.onNpcDespawned(client, npcDespawned);
|
||||
npcAttackTicks.remove(npcDespawned.getNpc());
|
||||
npcPrevTicks.remove(npcDespawned.getNpc());
|
||||
}
|
||||
|
||||
@Subscribe
|
||||
public void onChatMessage(final ChatMessage event)
|
||||
{
|
||||
final String message = event.getMessage();
|
||||
if (message != null && EAT_MESSAGE.matcher(message).find())
|
||||
{
|
||||
int delay;
|
||||
if (FAST_EAT.matcher(message).find())
|
||||
{
|
||||
delay = FAST_EAT_ATTACK_DELAY_TICKS;
|
||||
}
|
||||
else if (SLOW_FOOD.matcher(message).find())
|
||||
{
|
||||
delay = SLOW_FOOD_ATTACK_DELAY_TICKS;
|
||||
}
|
||||
else
|
||||
{
|
||||
delay = DEFAULT_FOOD_ATTACK_DELAY_TICKS;
|
||||
}
|
||||
pendingEatDelayTicks += delay;
|
||||
}
|
||||
variableSpeed.onChatMessage(client, event);
|
||||
if (message != null)
|
||||
{
|
||||
pendingEatDelayTicks += variableSpeed.maggotKing.onChatMessage(client, message);
|
||||
}
|
||||
}
|
||||
|
||||
@Subscribe
|
||||
public void onInteractingChanged(InteractingChanged interactingChanged)
|
||||
{
|
||||
Actor source = interactingChanged.getSource();
|
||||
Actor target = interactingChanged.getTarget();
|
||||
Player p = client.getLocalPlayer();
|
||||
|
||||
if (source != null && source.equals(p) && target instanceof NPC)
|
||||
{
|
||||
if (attackState == AttackState.NOT_ATTACKING && Attacking.isPlayerAttacking(client, npcManager))
|
||||
{
|
||||
performAttack();
|
||||
}
|
||||
}
|
||||
applyAndClearEats();
|
||||
}
|
||||
|
||||
@Subscribe
|
||||
public void onGameTick(GameTick tick)
|
||||
{
|
||||
lastTickNanos = System.nanoTime();
|
||||
variableSpeed.onGameTick(client, tick);
|
||||
final boolean isAttacking = Attacking.isPlayerAttacking(client, npcManager);
|
||||
|
||||
switch (attackState)
|
||||
{
|
||||
case NOT_ATTACKING:
|
||||
if (isAttacking)
|
||||
{
|
||||
performAttack();
|
||||
}
|
||||
else
|
||||
{
|
||||
uiHideDebounceTickCount = Math.max(-20, uiHideDebounceTickCount - 1);
|
||||
}
|
||||
break;
|
||||
case DELAYED_FIRST_TICK:
|
||||
attackState = AttackState.DELAYED;
|
||||
// fallthrough
|
||||
case DELAYED:
|
||||
if (attackDelayHoldoffTicks <= 0)
|
||||
{
|
||||
if (isAttacking)
|
||||
{
|
||||
performAttack();
|
||||
}
|
||||
else
|
||||
{
|
||||
attackState = AttackState.NOT_ATTACKING;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
applyAndClearEats();
|
||||
attackDelayHoldoffTicks = Math.max(-20, attackDelayHoldoffTicks - 1);
|
||||
attackSpeed.onTick();
|
||||
|
||||
// Update NPC attack timer learning
|
||||
updateNpcTimers();
|
||||
}
|
||||
|
||||
private void updateNpcTimers()
|
||||
{
|
||||
int tick = client.getTickCount();
|
||||
animChecks.removeIf(c ->
|
||||
{
|
||||
if (!c.confirmed && tick - c.tick <= CONFIRM_TICKS)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
animOutcomes.computeIfAbsent(c.key, k -> new int[2])[c.confirmed ? 0 : 1]++;
|
||||
return true;
|
||||
});
|
||||
for (Map.Entry<NPC, int[]> pending : pendingNpcAnims.entrySet())
|
||||
{
|
||||
NPC npc = pending.getKey();
|
||||
int animTick = pending.getValue()[0];
|
||||
long animKey = (long) npc.getId() << 32 | pending.getValue()[1];
|
||||
animChecks.add(new AnimCheck(animKey, animTick));
|
||||
int[] outcome = animOutcomes.get(animKey);
|
||||
if (outcome != null && outcome[1] >= 2 && outcome[1] > outcome[0] * 2)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
SpeedSamples samples = npcSpeeds.computeIfAbsent(npc.getId(), id -> new SpeedSamples());
|
||||
Integer last = npcAttackTicks.get(npc);
|
||||
if (last != null)
|
||||
{
|
||||
int gap = animTick - last;
|
||||
if (gap <= 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if (samples.count() >= TRUSTED_SAMPLES && gap < samples.speed())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
samples.add(gap);
|
||||
npcPrevTicks.put(npc, last);
|
||||
}
|
||||
npcAttackTicks.put(npc, animTick);
|
||||
}
|
||||
pendingNpcAnims.clear();
|
||||
npcAttackTicks.values().removeIf(t -> tick - t > MAX_SPEED + 2);
|
||||
npcPrevTicks.keySet().retainAll(npcAttackTicks.keySet());
|
||||
}
|
||||
|
||||
@Subscribe
|
||||
public void onAnimationChanged(AnimationChanged event)
|
||||
{
|
||||
if (!config.enemyAttackTimer())
|
||||
{
|
||||
return;
|
||||
}
|
||||
Actor actor = event.getActor();
|
||||
int anim = actor.getAnimation();
|
||||
if (anim == -1 || NON_ATTACK_ANIMS.contains(anim))
|
||||
{
|
||||
return;
|
||||
}
|
||||
Player me = client.getLocalPlayer();
|
||||
if (actor instanceof NPC && me != null && actor.getInteracting() == me && !actor.isDead())
|
||||
{
|
||||
pendingNpcAnims.putIfAbsent((NPC) actor, new int[]{client.getTickCount(), anim});
|
||||
}
|
||||
}
|
||||
|
||||
@Subscribe
|
||||
public void onHitsplatApplied(HitsplatApplied event)
|
||||
{
|
||||
if (event.getActor() == client.getLocalPlayer() && !event.getHitsplat().isMine())
|
||||
{
|
||||
for (AnimCheck c : animChecks)
|
||||
{
|
||||
if (!c.confirmed)
|
||||
{
|
||||
c.confirmed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Subscribe
|
||||
public void onGameStateChanged(GameStateChanged event)
|
||||
{
|
||||
if (event.getGameState() != GameState.LOGGED_IN)
|
||||
{
|
||||
reset();
|
||||
}
|
||||
}
|
||||
|
||||
public void reset()
|
||||
{
|
||||
attackState = AttackState.NOT_ATTACKING;
|
||||
renderedState = AttackState.NOT_ATTACKING;
|
||||
tickPeriod = 0;
|
||||
attackDelayHoldoffTicks = 0;
|
||||
uiHideDebounceTickCount = 0;
|
||||
pendingEatDelayTicks = 0;
|
||||
lastUsedWeaponId = -1;
|
||||
lastTarget = null;
|
||||
tc.reset();
|
||||
attackSpeed.reset();
|
||||
npcAttackTicks.clear();
|
||||
npcPrevTicks.clear();
|
||||
pendingNpcAnims.clear();
|
||||
animChecks.clear();
|
||||
}
|
||||
|
||||
public Map<NPC, Timer> npcTimers()
|
||||
{
|
||||
Map<NPC, Timer> out = new HashMap<>();
|
||||
Player me = client.getLocalPlayer();
|
||||
for (Map.Entry<NPC, Integer> e : npcAttackTicks.entrySet())
|
||||
{
|
||||
NPC npc = e.getKey();
|
||||
if (npc.isDead() || me == null || npc.getInteracting() != me)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
int attackTick = e.getValue();
|
||||
if (attackTick >= client.getTickCount())
|
||||
{
|
||||
Integer prev = npcPrevTicks.get(npc);
|
||||
attackTick = prev == null ? -1 : prev;
|
||||
}
|
||||
Timer t = npcTimer(attackTick, npcSpeed(npc.getId()));
|
||||
if (t != null)
|
||||
{
|
||||
out.put(npc, t);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private Timer npcTimer(int attackTick, int speed)
|
||||
{
|
||||
if (attackTick < 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
int remaining = speed - (client.getTickCount() - attackTick - 1);
|
||||
if (remaining < 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
return new Timer(remaining, speed, remaining == 0 ? 0 : tickProgress());
|
||||
}
|
||||
|
||||
int npcSpeed(int npcId)
|
||||
{
|
||||
SpeedSamples s = npcSpeeds.get(npcId);
|
||||
return s == null ? DEFAULT_SPEED : s.speed();
|
||||
}
|
||||
|
||||
static final class SpeedSamples
|
||||
{
|
||||
private final int[] counts = new int[MAX_SPEED + 1];
|
||||
private int total;
|
||||
|
||||
void add(int gap)
|
||||
{
|
||||
if (gap >= 1 && gap <= MAX_SPEED)
|
||||
{
|
||||
counts[gap]++;
|
||||
total++;
|
||||
}
|
||||
}
|
||||
|
||||
int count()
|
||||
{
|
||||
return total;
|
||||
}
|
||||
|
||||
int speed()
|
||||
{
|
||||
int best = DEFAULT_SPEED;
|
||||
int bestCount = 0;
|
||||
for (int gap = 1; gap <= MAX_SPEED; gap++)
|
||||
{
|
||||
if (counts[gap] > bestCount)
|
||||
{
|
||||
best = gap;
|
||||
bestCount = counts[gap];
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package com.example.essentialsplus.helpers;
|
||||
|
||||
import java.awt.Color;
|
||||
import net.runelite.api.gameval.SpriteID;
|
||||
|
||||
/** Attack style of an NPC attack, with the protection prayer that blocks it. */
|
||||
public enum CombatStyle
|
||||
{
|
||||
MELEE("Melee", new Color(0xE05050), SpriteID.Prayeron.PROTECT_FROM_MELEE),
|
||||
RANGED("Range", new Color(0x6BD66B), SpriteID.Prayeron.PROTECT_FROM_MISSILES),
|
||||
MAGIC("Mage", new Color(0x5AA9FF), SpriteID.Prayeron.PROTECT_FROM_MAGIC);
|
||||
|
||||
public final String label;
|
||||
public final Color color;
|
||||
public final int prayerSprite;
|
||||
|
||||
CombatStyle(String label, Color color, int prayerSprite)
|
||||
{
|
||||
this.label = label;
|
||||
this.color = color;
|
||||
this.prayerSprite = prayerSprite;
|
||||
}
|
||||
|
||||
/**
|
||||
* Style from a cache name such as {@code HUNLLEF_ATTACK_RANGED} or {@code ZEBAK_MAGE_PROJANIM_INITIAL};
|
||||
* null when the name mentions no style or more than one.
|
||||
*/
|
||||
static CombatStyle fromName(String name)
|
||||
{
|
||||
CombatStyle found = null;
|
||||
for (String token : name.toUpperCase(java.util.Locale.ROOT).split("_"))
|
||||
{
|
||||
CombatStyle s = fromToken(token);
|
||||
if (s == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if (found != null && found != s)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
found = s;
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
private static CombatStyle fromToken(String token)
|
||||
{
|
||||
if (token.startsWith("MELEE"))
|
||||
{
|
||||
return MELEE;
|
||||
}
|
||||
if (token.startsWith("RANGE") || token.startsWith("ARROW") || token.startsWith("DART") || token.startsWith("JAVELIN"))
|
||||
{
|
||||
return RANGED;
|
||||
}
|
||||
if (token.startsWith("MAGIC") || token.startsWith("MAGE") && !token.startsWith("MAGENTA"))
|
||||
{
|
||||
return MAGIC;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,105 @@
|
||||
package com.example.essentialsplus.helpers;
|
||||
|
||||
import com.example.essentialsplus.EssentialsPlusConfig;
|
||||
import com.example.essentialsplus.EssentialsPlusPlugin;
|
||||
import java.awt.Dimension;
|
||||
import java.awt.Font;
|
||||
import java.awt.FontMetrics;
|
||||
import java.awt.Graphics2D;
|
||||
import java.awt.Rectangle;
|
||||
import java.awt.RenderingHints;
|
||||
import java.util.List;
|
||||
import javax.inject.Inject;
|
||||
import net.runelite.api.widgets.WidgetItem;
|
||||
import net.runelite.client.ui.FontManager;
|
||||
import net.runelite.client.ui.overlay.OverlayLayer;
|
||||
import net.runelite.client.ui.overlay.OverlayPosition;
|
||||
import net.runelite.client.ui.overlay.WidgetItemOverlay;
|
||||
import net.runelite.client.ui.overlay.components.TextComponent;
|
||||
|
||||
/**
|
||||
* Overlay displaying charge counts / statuses on item icons in Inventory, Equipment and (optionally) Bank.
|
||||
*/
|
||||
public class ItemChargesOverlay extends WidgetItemOverlay
|
||||
{
|
||||
private final EssentialsPlusConfig config;
|
||||
private final EssentialsPlusPlugin plugin;
|
||||
private final ItemCharges itemCharges;
|
||||
|
||||
@Inject
|
||||
public ItemChargesOverlay(EssentialsPlusConfig config, EssentialsPlusPlugin plugin, ItemCharges itemCharges)
|
||||
{
|
||||
this.config = config;
|
||||
this.plugin = plugin;
|
||||
this.itemCharges = itemCharges;
|
||||
|
||||
showOnInventory();
|
||||
showOnEquipment();
|
||||
showOnBank();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void renderItemOverlay(Graphics2D g, int itemId, WidgetItem widgetItem)
|
||||
{
|
||||
if (!config.itemCharges())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if on bank and if bank display is allowed
|
||||
boolean showBank = plugin.getHelperValue("itemChargesBank", Boolean.class, true);
|
||||
if (!showBank && widgetItem.getWidget() != null)
|
||||
{
|
||||
int group = net.runelite.api.widgets.WidgetUtil.componentToInterface(widgetItem.getWidget().getId());
|
||||
if (group == net.runelite.api.gameval.InterfaceID.BANKMAIN || group == 12) // 12 = InterfaceID.BANK
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
List<ItemCharges.ChargeValue> charges = itemCharges.chargesFor(itemId);
|
||||
if (charges == null || charges.isEmpty())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
g.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON);
|
||||
|
||||
TimerTextSize size = plugin.getHelperValue("itemChargesSize", TimerTextSize.class, TimerTextSize.SMALL);
|
||||
Font font = (size == TimerTextSize.SMALL) ? FontManager.getRunescapeSmallFont() : size.font();
|
||||
g.setFont(font);
|
||||
|
||||
Rectangle bounds = widgetItem.getCanvasBounds();
|
||||
if (bounds == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Single charge or Top charge (top-left: bounds.x - 1, bounds.y + 11)
|
||||
ItemCharges.ChargeValue primary = charges.get(0);
|
||||
if (primary != null && primary.getText() != null && !primary.getText().isEmpty())
|
||||
{
|
||||
TextComponent textComponent = new TextComponent();
|
||||
textComponent.setFont(font);
|
||||
textComponent.setColor(primary.getColor());
|
||||
textComponent.setText(primary.getText());
|
||||
textComponent.setPosition(new java.awt.Point(bounds.x - 1, bounds.y + 11));
|
||||
textComponent.render(g);
|
||||
}
|
||||
|
||||
// Secondary charge (bottom-left: bounds.x - 1, bounds.y + bounds.height - 2)
|
||||
if (charges.size() > 1)
|
||||
{
|
||||
ItemCharges.ChargeValue secondary = charges.get(1);
|
||||
if (secondary != null && secondary.getText() != null && !secondary.getText().isEmpty())
|
||||
{
|
||||
TextComponent secondaryComponent = new TextComponent();
|
||||
secondaryComponent.setFont(font);
|
||||
secondaryComponent.setColor(secondary.getColor());
|
||||
secondaryComponent.setText(secondary.getText());
|
||||
secondaryComponent.setPosition(new java.awt.Point(bounds.x - 1, bounds.y + bounds.height - 2));
|
||||
secondaryComponent.render(g);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
package com.example.essentialsplus.helpers;
|
||||
|
||||
import com.example.essentialsplus.EssentialsPlusConfig;
|
||||
import java.awt.Color;
|
||||
import java.awt.Dimension;
|
||||
import java.awt.Graphics2D;
|
||||
import javax.inject.Inject;
|
||||
import net.runelite.api.Client;
|
||||
import net.runelite.api.ItemComposition;
|
||||
import net.runelite.api.MenuEntry;
|
||||
import net.runelite.api.gameval.ItemID;
|
||||
import net.runelite.api.widgets.Widget;
|
||||
import net.runelite.api.widgets.WidgetUtil;
|
||||
import net.runelite.client.game.ItemManager;
|
||||
import net.runelite.client.ui.overlay.Overlay;
|
||||
import net.runelite.client.ui.overlay.OverlayLayer;
|
||||
import net.runelite.client.ui.overlay.OverlayPosition;
|
||||
import net.runelite.client.ui.overlay.tooltip.Tooltip;
|
||||
import net.runelite.client.ui.overlay.tooltip.TooltipManager;
|
||||
import net.runelite.client.util.ColorUtil;
|
||||
import net.runelite.client.util.QuantityFormatter;
|
||||
|
||||
/** Tooltip line with the GE and/or high alch value of the inventory item under the mouse. */
|
||||
public class ItemPriceOverlay extends Overlay
|
||||
{
|
||||
private static final int INVENTORY = 149;
|
||||
private static final int BANK_INVENTORY = 15;
|
||||
private static final Color GE = new Color(0xFFDC33);
|
||||
private static final Color HA = new Color(0xFF9040);
|
||||
|
||||
private final Client client;
|
||||
private final EssentialsPlusConfig config;
|
||||
private final ItemManager itemManager;
|
||||
private final TooltipManager tooltipManager;
|
||||
|
||||
@Inject
|
||||
ItemPriceOverlay(Client client, EssentialsPlusConfig config, ItemManager itemManager, TooltipManager tooltipManager)
|
||||
{
|
||||
this.client = client;
|
||||
this.config = config;
|
||||
this.itemManager = itemManager;
|
||||
this.tooltipManager = tooltipManager;
|
||||
setPosition(OverlayPosition.DYNAMIC);
|
||||
setLayer(OverlayLayer.ABOVE_WIDGETS);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Dimension render(Graphics2D g)
|
||||
{
|
||||
if (!config.itemPrices() || (!config.itemPricesGe() && !config.itemPricesHa()) || client.isMenuOpen())
|
||||
{
|
||||
return null;
|
||||
}
|
||||
MenuEntry[] entries = client.getMenuEntries();
|
||||
if (entries.length == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
Widget w = entries[entries.length - 1].getWidget();
|
||||
if (w == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
int group = WidgetUtil.componentToInterface(w.getId());
|
||||
if (group != INVENTORY && group != BANK_INVENTORY)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
int id = w.getItemId();
|
||||
if (id <= 0 || id == ItemID.COINS || id == ItemID.PLATINUM)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
long qty = Math.max(1, w.getItemQuantity());
|
||||
String text = text(itemManager.canonicalize(id), qty);
|
||||
if (!text.isEmpty())
|
||||
{
|
||||
tooltipManager.add(new Tooltip(text));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private String text(int id, long qty)
|
||||
{
|
||||
StringBuilder sb = new StringBuilder();
|
||||
if (config.itemPricesGe())
|
||||
{
|
||||
long ge = (long) itemManager.getItemPrice(id) * qty;
|
||||
if (ge > 0)
|
||||
{
|
||||
sb.append("GE: ").append(ColorUtil.wrapWithColorTag(gp(ge), GE));
|
||||
}
|
||||
}
|
||||
if (config.itemPricesHa())
|
||||
{
|
||||
ItemComposition comp = itemManager.getItemComposition(id);
|
||||
long ha = comp == null ? 0 : (long) comp.getHaPrice() * qty;
|
||||
if (ha > 0)
|
||||
{
|
||||
if (sb.length() > 0)
|
||||
{
|
||||
sb.append(" ");
|
||||
}
|
||||
sb.append("HA: ").append(ColorUtil.wrapWithColorTag(gp(ha), HA));
|
||||
}
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
private static String gp(long v)
|
||||
{
|
||||
return QuantityFormatter.quantityToStackSize(v) + " gp";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package com.example.essentialsplus.helpers;
|
||||
|
||||
import java.awt.Font;
|
||||
import net.runelite.client.ui.FontManager;
|
||||
|
||||
/** Font of the attack timer numbers. */
|
||||
public enum TimerTextSize
|
||||
{
|
||||
SMALL("Small"),
|
||||
NORMAL("Normal"),
|
||||
LARGE("Large");
|
||||
|
||||
private final String label;
|
||||
|
||||
TimerTextSize(String label)
|
||||
{
|
||||
this.label = label;
|
||||
}
|
||||
|
||||
public Font font()
|
||||
{
|
||||
switch (this)
|
||||
{
|
||||
case SMALL:
|
||||
return FontManager.getRunescapeSmallFont();
|
||||
case LARGE:
|
||||
return FontManager.getRunescapeBoldFont().deriveFont(24f);
|
||||
default:
|
||||
return FontManager.getRunescapeBoldFont();
|
||||
}
|
||||
}
|
||||
|
||||
/** Icon edge length in pixels. */
|
||||
public int iconSize()
|
||||
{
|
||||
switch (this)
|
||||
{
|
||||
case SMALL:
|
||||
return 16;
|
||||
case LARGE:
|
||||
return 32;
|
||||
default:
|
||||
return 22;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString()
|
||||
{
|
||||
return label;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
package com.example.essentialsplus.helpers;
|
||||
|
||||
import com.example.essentialsplus.EssentialsPlusConfig;
|
||||
import com.example.essentialsplus.EssentialsPlusPlugin;
|
||||
import java.awt.Color;
|
||||
import java.awt.Dimension;
|
||||
import java.awt.FontMetrics;
|
||||
import java.awt.Graphics2D;
|
||||
import java.awt.RenderingHints;
|
||||
import javax.inject.Inject;
|
||||
import net.runelite.api.Client;
|
||||
import net.runelite.api.Perspective;
|
||||
import net.runelite.api.Point;
|
||||
import net.runelite.api.coords.LocalPoint;
|
||||
import net.runelite.client.ui.overlay.Overlay;
|
||||
import net.runelite.client.ui.overlay.OverlayLayer;
|
||||
import net.runelite.client.ui.overlay.OverlayPosition;
|
||||
|
||||
/**
|
||||
* Overlay displaying:
|
||||
* - Remaining seconds until stump respawn
|
||||
* - Remaining chop duration on high tier trees (Maple+)
|
||||
* - Optional progress bar and customizable styling
|
||||
*/
|
||||
public class TreeCooldownOverlay extends Overlay
|
||||
{
|
||||
private static final Color BAR_BG = new Color(0, 0, 0, 160);
|
||||
private static final Color BAR_BORDER = new Color(0, 0, 0, 220);
|
||||
private static final int BAR_WIDTH = 32;
|
||||
private static final int BAR_HEIGHT = 4;
|
||||
|
||||
private final Client client;
|
||||
private final EssentialsPlusConfig config;
|
||||
private final EssentialsPlusPlugin plugin;
|
||||
private final TreeCooldowns treeCooldowns;
|
||||
|
||||
@Inject
|
||||
public TreeCooldownOverlay(Client client, EssentialsPlusConfig config, EssentialsPlusPlugin plugin, TreeCooldowns treeCooldowns)
|
||||
{
|
||||
this.client = client;
|
||||
this.config = config;
|
||||
this.plugin = plugin;
|
||||
this.treeCooldowns = treeCooldowns;
|
||||
setPosition(OverlayPosition.DYNAMIC);
|
||||
setLayer(OverlayLayer.ABOVE_SCENE);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Dimension render(Graphics2D g)
|
||||
{
|
||||
if (!config.treeCooldowns())
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
|
||||
|
||||
boolean showRespawn = plugin.getHelperValue("treeCooldownRespawn", Boolean.class, true);
|
||||
boolean showChop = plugin.getHelperValue("treeCooldownChop", Boolean.class, true);
|
||||
boolean showBar = plugin.getHelperValue("treeCooldownBar", Boolean.class, false);
|
||||
TimerTextSize size = plugin.getHelperValue("treeCooldownSize", TimerTextSize.class, TimerTextSize.NORMAL);
|
||||
int offset = plugin.getHelperValue("treeCooldownOffset", Integer.class, 50);
|
||||
Color textColor = plugin.getHelperValue("treeCooldownColor", Color.class, new Color(0x8FBF3F));
|
||||
Color readyColor = plugin.getHelperValue("treeCooldownReadyColor", Color.class, new Color(0x66FF66));
|
||||
|
||||
int plane = client.getPlane();
|
||||
|
||||
if (showRespawn)
|
||||
{
|
||||
for (TreeCooldowns.RespawnTimer timer : treeCooldowns.getActiveRespawnTimers())
|
||||
{
|
||||
if (timer.worldPoint.getPlane() != plane)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
int sec = timer.getRemainingSeconds();
|
||||
if (sec <= 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
Point anchor = null;
|
||||
if (timer.stumpObject != null)
|
||||
{
|
||||
anchor = timer.stumpObject.getCanvasTextLocation(g, "", offset);
|
||||
}
|
||||
if (anchor == null)
|
||||
{
|
||||
LocalPoint lp = LocalPoint.fromWorld(client, timer.worldPoint);
|
||||
if (lp != null)
|
||||
{
|
||||
anchor = Perspective.getCanvasTextLocation(client, g, lp, "", offset);
|
||||
}
|
||||
}
|
||||
if (anchor == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
Color c = (sec <= 2) ? readyColor : textColor;
|
||||
drawTimer(g, anchor, sec, c, size, showBar, timer.getProgress());
|
||||
}
|
||||
}
|
||||
|
||||
if (showChop)
|
||||
{
|
||||
for (TreeCooldowns.ChopTimer timer : treeCooldowns.getActiveChopTimers())
|
||||
{
|
||||
if (timer.worldPoint.getPlane() != plane)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
int sec = timer.getRemainingSeconds();
|
||||
if (sec <= 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
Point anchor = null;
|
||||
if (timer.treeObject != null)
|
||||
{
|
||||
anchor = timer.treeObject.getCanvasTextLocation(g, "", offset + 80);
|
||||
}
|
||||
if (anchor == null)
|
||||
{
|
||||
LocalPoint lp = LocalPoint.fromWorld(client, timer.worldPoint);
|
||||
if (lp != null)
|
||||
{
|
||||
anchor = Perspective.getCanvasTextLocation(client, g, lp, "", offset + 80);
|
||||
}
|
||||
}
|
||||
if (anchor == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
Color c = (sec <= 5) ? readyColor : textColor;
|
||||
drawTimer(g, anchor, sec, c, size, showBar, timer.getProgress());
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private void drawTimer(Graphics2D g, Point anchor, int seconds, Color color, TimerTextSize size, boolean showBar, double progress)
|
||||
{
|
||||
g.setFont(size.font());
|
||||
String text = String.valueOf(seconds);
|
||||
FontMetrics fm = g.getFontMetrics();
|
||||
int textX = anchor.getX() - fm.stringWidth(text) / 2;
|
||||
int textY = anchor.getY();
|
||||
|
||||
shadowed(g, text, textX, textY, color);
|
||||
|
||||
if (showBar)
|
||||
{
|
||||
int barX = anchor.getX() - BAR_WIDTH / 2;
|
||||
int barY = textY + 4;
|
||||
|
||||
g.setColor(BAR_BG);
|
||||
g.fillRect(barX, barY, BAR_WIDTH, BAR_HEIGHT);
|
||||
|
||||
int fill = (int) Math.round(BAR_WIDTH * Math.max(0.0, Math.min(1.0, progress)));
|
||||
if (fill > 0)
|
||||
{
|
||||
g.setColor(color);
|
||||
g.fillRect(barX, barY, fill, BAR_HEIGHT);
|
||||
}
|
||||
|
||||
g.setColor(BAR_BORDER);
|
||||
g.drawRect(barX, barY, BAR_WIDTH, BAR_HEIGHT);
|
||||
}
|
||||
}
|
||||
|
||||
private static void shadowed(Graphics2D g, String text, int x, int y, Color color)
|
||||
{
|
||||
g.setColor(Color.BLACK);
|
||||
g.drawString(text, x + 1, y + 1);
|
||||
g.setColor(color);
|
||||
g.drawString(text, x, y);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,790 @@
|
||||
package com.example.essentialsplus.helpers;
|
||||
|
||||
import com.example.essentialsplus.EssentialsPlusConfig;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import javax.inject.Inject;
|
||||
import javax.inject.Singleton;
|
||||
import net.runelite.api.Client;
|
||||
import net.runelite.api.GameObject;
|
||||
import net.runelite.api.GameState;
|
||||
import net.runelite.api.ObjectComposition;
|
||||
import net.runelite.api.Perspective;
|
||||
import net.runelite.api.Player;
|
||||
import net.runelite.api.ScriptID;
|
||||
import net.runelite.api.Tile;
|
||||
import net.runelite.api.coords.LocalPoint;
|
||||
import net.runelite.api.coords.WorldPoint;
|
||||
import net.runelite.api.events.GameObjectDespawned;
|
||||
import net.runelite.api.events.GameObjectSpawned;
|
||||
import net.runelite.api.events.GameStateChanged;
|
||||
import net.runelite.api.events.GameTick;
|
||||
import net.runelite.api.events.ScriptPreFired;
|
||||
import net.runelite.api.gameval.AnimationID;
|
||||
import net.runelite.api.gameval.ObjectID;
|
||||
import net.runelite.client.eventbus.Subscribe;
|
||||
|
||||
/**
|
||||
* Tracks tree cooldowns:
|
||||
* 1. Remaining chop duration for long-lived trees (Maple+, despawnSec > 30) with regeneration when idle.
|
||||
* 2. Respawn timers on stumps felled nearby (exact tick synchronization via game script and object events).
|
||||
*/
|
||||
@Singleton
|
||||
public class TreeCooldowns
|
||||
{
|
||||
/** Woodcutting animations for standard, gilded, infernal, crystal, 3rd age, trailblazer, and forestry 2H axes. */
|
||||
public static final Set<Integer> WOODCUT_ANIMS = Set.of(
|
||||
AnimationID.HUMAN_WOODCUTTING_BRONZE_AXE,
|
||||
AnimationID.HUMAN_WOODCUTTING_IRON_AXE,
|
||||
AnimationID.HUMAN_WOODCUTTING_STEEL_AXE,
|
||||
AnimationID.HUMAN_WOODCUTTING_BLACK_AXE,
|
||||
AnimationID.HUMAN_WOODCUTTING_MITHRIL_AXE,
|
||||
AnimationID.HUMAN_WOODCUTTING_ADAMANT_AXE,
|
||||
AnimationID.HUMAN_WOODCUTTING_RUNE_AXE,
|
||||
AnimationID.HUMAN_WOODCUTTING_GILDED_AXE,
|
||||
AnimationID.HUMAN_WOODCUTTING_DRAGON_AXE,
|
||||
AnimationID.HUMAN_WOODCUTTING_TRAILBLAZER_AXE_NO_INFERNAL,
|
||||
AnimationID.HUMAN_WOODCUTTING_INFERNAL_AXE,
|
||||
AnimationID.HUMAN_WOODCUTTING_3A_AXE,
|
||||
AnimationID.HUMAN_WOODCUTTING_CRYSTAL_AXE,
|
||||
AnimationID.HUMAN_WOODCUTTING_TRAILBLAZER_RELOADED_AXE_NO_INFERNAL,
|
||||
AnimationID.HUMAN_WOODCUTTING_TRAILBLAZER_AXE,
|
||||
AnimationID.HUMAN_WOODCUTTING_TRAILBLAZER_RELOADED_AXE,
|
||||
AnimationID.FORESTRY_2H_AXE_CHOPPING_BRONZE,
|
||||
AnimationID.FORESTRY_2H_AXE_CHOPPING_IRON,
|
||||
AnimationID.FORESTRY_2H_AXE_CHOPPING_STEEL,
|
||||
AnimationID.FORESTRY_2H_AXE_CHOPPING_BLACK,
|
||||
AnimationID.FORESTRY_2H_AXE_CHOPPING_MITHRIL,
|
||||
AnimationID.FORESTRY_2H_AXE_CHOPPING_ADAMANT,
|
||||
AnimationID.FORESTRY_2H_AXE_CHOPPING_RUNE,
|
||||
AnimationID.FORESTRY_2H_AXE_CHOPPING_DRAGON,
|
||||
AnimationID.FORESTRY_2H_AXE_CHOPPING_CRYSTAL,
|
||||
AnimationID.FORESTRY_2H_AXE_CHOPPING_CRYSTAL_INACTIVE,
|
||||
AnimationID.FORESTRY_2H_AXE_CHOPPING_3A
|
||||
);
|
||||
|
||||
/** Classification of tree species with their official despawn and respawn periods. */
|
||||
public enum TreeType
|
||||
{
|
||||
OAK("Oak", 27, 8.4, 14),
|
||||
WILLOW("Willow", 30, 8.4, 14),
|
||||
TEAK("Teak", 30, 9.0, 15),
|
||||
MAPLE("Maple", 60, 35.4, 59),
|
||||
HOLLOW("Hollow", 36, 25.8, 43),
|
||||
MAHOGANY("Mahogany", 60, 8.4, 14),
|
||||
ARCTIC_PINE("Arctic pine", 84, 8.4, 14),
|
||||
YEW("Yew", 114, 59.4, 99),
|
||||
CAMPHOR("Camphor", 120, 59.4, 99),
|
||||
MAGIC("Magic", 234, 119.4, 199),
|
||||
IRONWOOD("Ironwood", 240, 119.4, 199),
|
||||
REDWOOD("Redwood", 264, 119.4, 199),
|
||||
ROSEWOOD("Rosewood", 275, 123.0, 205),
|
||||
NORMAL("Tree", 0, 10.0, 16);
|
||||
|
||||
public final String displayName;
|
||||
public final int despawnSec;
|
||||
public final double respawnSec;
|
||||
public final int respawnTicks;
|
||||
|
||||
TreeType(String displayName, int despawnSec, double respawnSec, int respawnTicks)
|
||||
{
|
||||
this.displayName = displayName;
|
||||
this.despawnSec = despawnSec;
|
||||
this.respawnSec = respawnSec;
|
||||
this.respawnTicks = respawnTicks;
|
||||
}
|
||||
|
||||
public int getDespawnTicks()
|
||||
{
|
||||
return (int) Math.round(despawnSec / 0.6);
|
||||
}
|
||||
|
||||
public boolean hasChopTimer()
|
||||
{
|
||||
return despawnSec > 30;
|
||||
}
|
||||
|
||||
public static TreeType fromRespawnTicks(int ticks)
|
||||
{
|
||||
for (TreeType t : values())
|
||||
{
|
||||
if (t.respawnTicks == ticks)
|
||||
{
|
||||
return t;
|
||||
}
|
||||
}
|
||||
if (ticks <= 14) return OAK;
|
||||
if (ticks <= 16) return NORMAL;
|
||||
if (ticks <= 45) return HOLLOW;
|
||||
if (ticks <= 65) return MAPLE;
|
||||
if (ticks <= 105) return YEW;
|
||||
if (ticks <= 150) return ROSEWOOD;
|
||||
return MAGIC;
|
||||
}
|
||||
}
|
||||
|
||||
/** Information about a tree currently in scene. */
|
||||
public static class TrackedTree
|
||||
{
|
||||
public final GameObject gameObject;
|
||||
public final TreeType treeType;
|
||||
public final WorldPoint worldPoint;
|
||||
public final int sizeX;
|
||||
public final int sizeY;
|
||||
|
||||
public int choppedTicks;
|
||||
public int lastChoppedTick;
|
||||
|
||||
public TrackedTree(GameObject gameObject, TreeType treeType)
|
||||
{
|
||||
this.gameObject = gameObject;
|
||||
this.treeType = treeType;
|
||||
this.worldPoint = gameObject.getWorldLocation();
|
||||
this.sizeX = gameObject.sizeX();
|
||||
this.sizeY = gameObject.sizeY();
|
||||
this.choppedTicks = 0;
|
||||
this.lastChoppedTick = -1;
|
||||
}
|
||||
}
|
||||
|
||||
/** Respawn timer for a felled tree stump. */
|
||||
public static class RespawnTimer
|
||||
{
|
||||
public final TreeType treeType;
|
||||
public final WorldPoint worldPoint;
|
||||
public final long startTimeMs;
|
||||
public final long deadlineMs;
|
||||
public final int totalTicks;
|
||||
public final int startTick;
|
||||
public GameObject stumpObject;
|
||||
|
||||
public RespawnTimer(TreeType treeType, WorldPoint worldPoint, long startTimeMs, long deadlineMs, int totalTicks, int startTick)
|
||||
{
|
||||
this.treeType = treeType;
|
||||
this.worldPoint = worldPoint;
|
||||
this.startTimeMs = startTimeMs;
|
||||
this.deadlineMs = deadlineMs;
|
||||
this.totalTicks = totalTicks;
|
||||
this.startTick = startTick;
|
||||
}
|
||||
|
||||
public int getRemainingSeconds()
|
||||
{
|
||||
long remaining = deadlineMs - System.currentTimeMillis();
|
||||
return (int) Math.max(0, Math.ceil(remaining / 1000.0));
|
||||
}
|
||||
|
||||
public double getProgress()
|
||||
{
|
||||
long total = deadlineMs - startTimeMs;
|
||||
if (total <= 0)
|
||||
{
|
||||
return 1.0;
|
||||
}
|
||||
long elapsed = System.currentTimeMillis() - startTimeMs;
|
||||
return Math.max(0.0, Math.min(1.0, (double) elapsed / total));
|
||||
}
|
||||
}
|
||||
|
||||
/** Active chop timer for a tree being harvested. */
|
||||
public static class ChopTimer
|
||||
{
|
||||
public final TreeType treeType;
|
||||
public final WorldPoint worldPoint;
|
||||
public final GameObject treeObject;
|
||||
public final int remainingTicks;
|
||||
public final int totalTicks;
|
||||
|
||||
public ChopTimer(TreeType treeType, WorldPoint worldPoint, GameObject treeObject, int remainingTicks, int totalTicks)
|
||||
{
|
||||
this.treeType = treeType;
|
||||
this.worldPoint = worldPoint;
|
||||
this.treeObject = treeObject;
|
||||
this.remainingTicks = remainingTicks;
|
||||
this.totalTicks = totalTicks;
|
||||
}
|
||||
|
||||
public int getRemainingSeconds()
|
||||
{
|
||||
return (int) Math.max(0, Math.round(remainingTicks * 0.6));
|
||||
}
|
||||
|
||||
public double getProgress()
|
||||
{
|
||||
if (totalTicks <= 0)
|
||||
{
|
||||
return 1.0;
|
||||
}
|
||||
return Math.max(0.0, Math.min(1.0, (double) remainingTicks / totalTicks));
|
||||
}
|
||||
}
|
||||
|
||||
private final Client client;
|
||||
private final EssentialsPlusConfig config;
|
||||
|
||||
private final Map<WorldPoint, TrackedTree> trackedTrees = new ConcurrentHashMap<>();
|
||||
private final Map<WorldPoint, RespawnTimer> respawnTimers = new ConcurrentHashMap<>();
|
||||
private final Map<Integer, TreeType> knownTreeIds = new ConcurrentHashMap<>();
|
||||
private final Map<Integer, TreeType> knownStumpIds = new ConcurrentHashMap<>();
|
||||
private final Set<Integer> nonTreeIds = Collections.synchronizedSet(new HashSet<>());
|
||||
|
||||
private TrackedTree activeTree;
|
||||
private int lastLoginTick;
|
||||
|
||||
@Inject
|
||||
public TreeCooldowns(Client client, EssentialsPlusConfig config)
|
||||
{
|
||||
this.client = client;
|
||||
this.config = config;
|
||||
initKnownIds();
|
||||
}
|
||||
|
||||
private void initKnownIds()
|
||||
{
|
||||
// Common stumps
|
||||
registerStump(ObjectID.OAKTREE_STUMP, TreeType.OAK);
|
||||
registerStump(ObjectID.OAK_TREE_STUMP, TreeType.OAK);
|
||||
registerStump(ObjectID.TREE_OAK_STUMP01, TreeType.OAK);
|
||||
|
||||
registerStump(ObjectID.WILLOW_TREE_STUMP, TreeType.WILLOW);
|
||||
registerStump(ObjectID.WILLOW_TREE2OR3OR4_STUMP, TreeType.WILLOW);
|
||||
registerStump(ObjectID.WILLOW_TREE_STUMP_NEW, TreeType.WILLOW);
|
||||
|
||||
registerStump(ObjectID.TEAK_STUMP, TreeType.TEAK);
|
||||
registerStump(ObjectID.TEAK_TREE_STUMP, TreeType.TEAK);
|
||||
registerStump(ObjectID.PRIF_TEAK_STUMP, TreeType.TEAK);
|
||||
registerStump(ObjectID.TEAKTREE_UPDATE_STUMP, TreeType.TEAK);
|
||||
|
||||
registerStump(ObjectID.MAPLE_TREE_STUMP, TreeType.MAPLE);
|
||||
registerStump(ObjectID.MAPLE_TREE_STUMP_NEW, TreeType.MAPLE);
|
||||
registerStump(ObjectID.MAPLETREE_UPDATE_STUMP, TreeType.MAPLE);
|
||||
|
||||
registerStump(ObjectID.HOLLOW_TREE_STUMP, TreeType.HOLLOW);
|
||||
registerStump(ObjectID.HOLLOW_TREE_STUMP_BIG, TreeType.HOLLOW);
|
||||
|
||||
registerStump(ObjectID.MAHOGANY_STUMP, TreeType.MAHOGANY);
|
||||
registerStump(ObjectID.MAHOGANY_TREE_STUMP, TreeType.MAHOGANY);
|
||||
registerStump(ObjectID.PRIF_MAHOGANY_STUMP, TreeType.MAHOGANY);
|
||||
registerStump(ObjectID.MAHOGANYTREE_UPDATE_STUMP, TreeType.MAHOGANY);
|
||||
|
||||
registerStump(ObjectID.ARCTIC_PINE_TREE_STUMP, TreeType.ARCTIC_PINE);
|
||||
|
||||
registerStump(ObjectID.YEWTREE_STUMP, TreeType.YEW);
|
||||
registerStump(ObjectID.YEW_TREE_STUMP, TreeType.YEW);
|
||||
registerStump(ObjectID.YEW_TREE_STUMP_NEW, TreeType.YEW);
|
||||
registerStump(ObjectID.PRIF_YEWTREE_STUMP, TreeType.YEW);
|
||||
registerStump(ObjectID.TREE_YEW_STUMP01, TreeType.YEW);
|
||||
registerStump(ObjectID.YEWTREE_UPDATE_STUMP, TreeType.YEW);
|
||||
registerStump(ObjectID.YEWTREE_UPDATE2_STUMP, TreeType.YEW);
|
||||
|
||||
registerStump(ObjectID.CAMPHOR_TREE_UPDATE_STUMP, TreeType.CAMPHOR);
|
||||
registerStump(ObjectID.CAMPHOR_TREE_STUMP, TreeType.CAMPHOR);
|
||||
|
||||
registerStump(ObjectID.MAGIC_TREE_STUMP, TreeType.MAGIC);
|
||||
registerStump(ObjectID.MAGIC_TREE_STUMP_NEW, TreeType.MAGIC);
|
||||
|
||||
registerStump(ObjectID.IRONWOOD_TREE_UPDATE_STUMP, TreeType.IRONWOOD);
|
||||
registerStump(ObjectID.IRONWOOD_TREE_STUMP, TreeType.IRONWOOD);
|
||||
|
||||
registerStump(ObjectID.REDWOODTREE_L_CUT, TreeType.REDWOOD);
|
||||
registerStump(ObjectID.REDWOODTREE_R_CUT, TreeType.REDWOOD);
|
||||
|
||||
registerStump(ObjectID.ROSEWOOD_TREE_UPDATE_STUMP, TreeType.ROSEWOOD);
|
||||
registerStump(ObjectID.ROSEWOOD_TREE_STUMP, TreeType.ROSEWOOD);
|
||||
|
||||
registerStump(ObjectID.TREESTUMP, TreeType.NORMAL);
|
||||
registerStump(ObjectID.TREESTUMP2, TreeType.NORMAL);
|
||||
registerStump(ObjectID.TREESTUMP2_LIGHT, TreeType.NORMAL);
|
||||
registerStump(ObjectID.TREESTUMP2_GREEN, TreeType.NORMAL);
|
||||
registerStump(ObjectID.TREESTUMP2_SMALL, TreeType.NORMAL);
|
||||
registerStump(ObjectID.TREESTUMP2_BIG, TreeType.NORMAL);
|
||||
registerStump(ObjectID.DEADTREE1_STUMP, TreeType.NORMAL);
|
||||
registerStump(ObjectID.DEADTREE2_STUMP, TreeType.NORMAL);
|
||||
registerStump(ObjectID.DEADTREE3_STUMP, TreeType.NORMAL);
|
||||
registerStump(ObjectID.DEADTREE4_STUMP, TreeType.NORMAL);
|
||||
registerStump(ObjectID.DEADTREE6_STUMP, TreeType.NORMAL);
|
||||
registerStump(ObjectID.EVERGREEN_LARGE_STUMP, TreeType.NORMAL);
|
||||
registerStump(ObjectID.ACHEY_TREE_STUMP, TreeType.NORMAL);
|
||||
registerStump(ObjectID.JUNGLESTUMP_KHARAZI, TreeType.NORMAL);
|
||||
|
||||
// Common trees
|
||||
registerTree(ObjectID.OAKTREE, TreeType.OAK);
|
||||
registerTree(ObjectID.WILLOWTREE, TreeType.WILLOW);
|
||||
registerTree(ObjectID.TEAKTREE, TreeType.TEAK);
|
||||
registerTree(ObjectID.TEAKTREE_UPDATE, TreeType.TEAK);
|
||||
registerTree(ObjectID.MAPLETREE, TreeType.MAPLE);
|
||||
registerTree(ObjectID.MAPLETREE_UPDATE, TreeType.MAPLE);
|
||||
registerTree(ObjectID.HOLLOW_TREE, TreeType.HOLLOW);
|
||||
registerTree(ObjectID.MAHOGANYTREE, TreeType.MAHOGANY);
|
||||
registerTree(ObjectID.MAHOGANYTREE_UPDATE, TreeType.MAHOGANY);
|
||||
registerTree(ObjectID.YEWTREE, TreeType.YEW);
|
||||
registerTree(ObjectID.YEWTREE_UPDATE, TreeType.YEW);
|
||||
registerTree(ObjectID.YEWTREE_UPDATE2, TreeType.YEW);
|
||||
registerTree(ObjectID.CAMPHOR_TREE, TreeType.CAMPHOR);
|
||||
registerTree(ObjectID.MAGICTREE, TreeType.MAGIC);
|
||||
registerTree(ObjectID.IRONWOOD_TREE, TreeType.IRONWOOD);
|
||||
registerTree(ObjectID.REDWOODTREE_L, TreeType.REDWOOD);
|
||||
registerTree(ObjectID.REDWOODTREE_R, TreeType.REDWOOD);
|
||||
registerTree(ObjectID.ROSEWOOD_TREE, TreeType.ROSEWOOD);
|
||||
registerTree(ObjectID.TREE, TreeType.NORMAL);
|
||||
}
|
||||
|
||||
private void registerTree(int id, TreeType type)
|
||||
{
|
||||
knownTreeIds.put(id, type);
|
||||
}
|
||||
|
||||
private void registerStump(int id, TreeType type)
|
||||
{
|
||||
knownStumpIds.put(id, type);
|
||||
}
|
||||
|
||||
public void reset()
|
||||
{
|
||||
trackedTrees.clear();
|
||||
respawnTimers.clear();
|
||||
activeTree = null;
|
||||
}
|
||||
|
||||
@Subscribe
|
||||
public void onGameStateChanged(GameStateChanged event)
|
||||
{
|
||||
if (event.getGameState() != GameState.LOGGED_IN)
|
||||
{
|
||||
reset();
|
||||
}
|
||||
else
|
||||
{
|
||||
lastLoginTick = client.getTickCount();
|
||||
scanScene();
|
||||
}
|
||||
}
|
||||
|
||||
@Subscribe
|
||||
public void onGameTick(GameTick event)
|
||||
{
|
||||
if (!config.treeCooldowns())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Player player = client.getLocalPlayer();
|
||||
if (player == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
int currentTick = client.getTickCount();
|
||||
boolean isChopping = WOODCUT_ANIMS.contains(player.getAnimation());
|
||||
|
||||
if (isChopping)
|
||||
{
|
||||
TrackedTree tree = findChoppedTree(player);
|
||||
if (tree != null)
|
||||
{
|
||||
tree.choppedTicks++;
|
||||
tree.lastChoppedTick = currentTick;
|
||||
activeTree = tree;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
activeTree = null;
|
||||
}
|
||||
|
||||
// Depletion regeneration for idle trees
|
||||
long now = System.currentTimeMillis();
|
||||
for (TrackedTree tree : trackedTrees.values())
|
||||
{
|
||||
if (tree.choppedTicks > 0 && tree.lastChoppedTick != currentTick)
|
||||
{
|
||||
tree.choppedTicks = Math.max(0, tree.choppedTicks - 1);
|
||||
}
|
||||
}
|
||||
|
||||
// Prune expired respawn timers
|
||||
respawnTimers.values().removeIf(timer -> now >= timer.deadlineMs + 2000L);
|
||||
}
|
||||
|
||||
@Subscribe
|
||||
public void onScriptPreFired(ScriptPreFired scriptPreFired)
|
||||
{
|
||||
if (!config.treeCooldowns() || scriptPreFired.getScriptId() != ScriptID.ADD_OVERLAYTIMER_LOC)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Object[] args = scriptPreFired.getScriptEvent().getArguments();
|
||||
if (args.length < 6)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
int locCoord = (int) args[1];
|
||||
int locId = (int) args[2];
|
||||
int ticks = (int) args[5];
|
||||
|
||||
WorldPoint worldPoint = WorldPoint.fromCoord(locCoord);
|
||||
TreeType treeType = knownStumpIds.get(locId);
|
||||
if (treeType == null)
|
||||
{
|
||||
TrackedTree prev = trackedTrees.get(worldPoint);
|
||||
treeType = (prev != null) ? prev.treeType : TreeType.fromRespawnTicks(ticks);
|
||||
}
|
||||
|
||||
long now = System.currentTimeMillis();
|
||||
long durationMs = (long) ticks * 600L;
|
||||
RespawnTimer timer = new RespawnTimer(
|
||||
treeType,
|
||||
worldPoint,
|
||||
now,
|
||||
now + durationMs,
|
||||
ticks,
|
||||
client.getTickCount()
|
||||
);
|
||||
respawnTimers.put(worldPoint, timer);
|
||||
}
|
||||
|
||||
@Subscribe
|
||||
public void onGameObjectSpawned(GameObjectSpawned event)
|
||||
{
|
||||
if (!config.treeCooldowns())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
GameObject go = event.getGameObject();
|
||||
if (go == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
TreeType treeType = resolveTreeType(go);
|
||||
if (treeType != null)
|
||||
{
|
||||
TrackedTree tree = new TrackedTree(go, treeType);
|
||||
trackedTrees.put(go.getWorldLocation(), tree);
|
||||
// Regrowth: remove any active respawn timer on this tile or within bounds
|
||||
removeRespawnTimersAt(go.getWorldLocation(), go.sizeX(), go.sizeY());
|
||||
return;
|
||||
}
|
||||
|
||||
TreeType stumpType = resolveStumpType(go);
|
||||
if (stumpType != null)
|
||||
{
|
||||
WorldPoint pt = go.getWorldLocation();
|
||||
RespawnTimer timer = respawnTimers.get(pt);
|
||||
if (timer != null)
|
||||
{
|
||||
timer.stumpObject = go;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Witnessed felling in player's vicinity
|
||||
Player player = client.getLocalPlayer();
|
||||
int ticksSinceLoad = client.getTickCount() - lastLoginTick;
|
||||
if (player != null && ticksSinceLoad > 2 && player.getWorldLocation().distanceTo(pt) <= 16)
|
||||
{
|
||||
TrackedTree prev = trackedTrees.remove(pt);
|
||||
TreeType actualType = (prev != null) ? prev.treeType : stumpType;
|
||||
long now = System.currentTimeMillis();
|
||||
long durationMs = (long) (actualType.respawnSec * 1000L);
|
||||
RespawnTimer newTimer = new RespawnTimer(
|
||||
actualType,
|
||||
pt,
|
||||
now,
|
||||
now + durationMs,
|
||||
actualType.respawnTicks,
|
||||
client.getTickCount()
|
||||
);
|
||||
newTimer.stumpObject = go;
|
||||
respawnTimers.put(pt, newTimer);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Subscribe
|
||||
public void onGameObjectDespawned(GameObjectDespawned event)
|
||||
{
|
||||
if (!config.treeCooldowns())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
GameObject go = event.getGameObject();
|
||||
if (go == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
WorldPoint pt = go.getWorldLocation();
|
||||
TrackedTree tree = trackedTrees.remove(pt);
|
||||
if (tree != null)
|
||||
{
|
||||
if (!respawnTimers.containsKey(pt))
|
||||
{
|
||||
long now = System.currentTimeMillis();
|
||||
long durationMs = (long) (tree.treeType.respawnSec * 1000L);
|
||||
RespawnTimer timer = new RespawnTimer(
|
||||
tree.treeType,
|
||||
pt,
|
||||
now,
|
||||
now + durationMs,
|
||||
tree.treeType.respawnTicks,
|
||||
client.getTickCount()
|
||||
);
|
||||
respawnTimers.put(pt, timer);
|
||||
}
|
||||
}
|
||||
|
||||
// If stump despawned, remove respawn timer
|
||||
if (resolveStumpType(go) != null)
|
||||
{
|
||||
respawnTimers.remove(pt);
|
||||
}
|
||||
}
|
||||
|
||||
private void removeRespawnTimersAt(WorldPoint origin, int sizeX, int sizeY)
|
||||
{
|
||||
for (int x = 0; x < sizeX; x++)
|
||||
{
|
||||
for (int y = 0; y < sizeY; y++)
|
||||
{
|
||||
respawnTimers.remove(new WorldPoint(origin.getX() + x, origin.getY() + y, origin.getPlane()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private TrackedTree findChoppedTree(Player player)
|
||||
{
|
||||
WorldPoint playerLoc = player.getWorldLocation();
|
||||
|
||||
// Prefer continuing with active tree if still adjacent
|
||||
if (activeTree != null && isAdjacent(playerLoc, activeTree))
|
||||
{
|
||||
return activeTree;
|
||||
}
|
||||
|
||||
// Find closest adjacent tree aligned with player orientation
|
||||
TrackedTree bestTree = null;
|
||||
int bestScore = Integer.MAX_VALUE;
|
||||
|
||||
int orientation = player.getOrientation(); // 0 = South, 512 = West, 1024 = North, 1536 = East
|
||||
double facingAngle = orientation * (2.0 * Math.PI / 2048.0);
|
||||
double facingDx = -Math.sin(facingAngle);
|
||||
double facingDy = -Math.cos(facingAngle);
|
||||
|
||||
for (TrackedTree tree : trackedTrees.values())
|
||||
{
|
||||
if (tree.worldPoint.getPlane() != playerLoc.getPlane())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isAdjacent(playerLoc, tree))
|
||||
{
|
||||
double centerX = tree.worldPoint.getX() + (tree.sizeX - 1) / 2.0;
|
||||
double centerY = tree.worldPoint.getY() + (tree.sizeY - 1) / 2.0;
|
||||
double dx = centerX - playerLoc.getX();
|
||||
double dy = centerY - playerLoc.getY();
|
||||
|
||||
// Dot product with facing direction
|
||||
double dot = dx * facingDx + dy * facingDy;
|
||||
int score = (int) Math.round((1.0 - dot) * 100.0);
|
||||
if (score < bestScore)
|
||||
{
|
||||
bestScore = score;
|
||||
bestTree = tree;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return bestTree;
|
||||
}
|
||||
|
||||
private static boolean isAdjacent(WorldPoint playerLoc, TrackedTree tree)
|
||||
{
|
||||
int minX = tree.worldPoint.getX();
|
||||
int maxX = tree.worldPoint.getX() + tree.sizeX - 1;
|
||||
int minY = tree.worldPoint.getY();
|
||||
int maxY = tree.worldPoint.getY() + tree.sizeY - 1;
|
||||
|
||||
int px = playerLoc.getX();
|
||||
int py = playerLoc.getY();
|
||||
|
||||
int dx = Math.max(0, Math.max(minX - px, px - maxX));
|
||||
int dy = Math.max(0, Math.max(minY - py, py - maxY));
|
||||
|
||||
return dx <= 1 && dy <= 1;
|
||||
}
|
||||
|
||||
private void scanScene()
|
||||
{
|
||||
Tile[][][] tiles = client.getScene().getTiles();
|
||||
int plane = client.getPlane();
|
||||
if (tiles == null || plane < 0 || plane >= tiles.length)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Tile[][] planeTiles = tiles[plane];
|
||||
for (Tile[] row : planeTiles)
|
||||
{
|
||||
if (row == null) continue;
|
||||
for (Tile tile : row)
|
||||
{
|
||||
if (tile == null) continue;
|
||||
GameObject[] objects = tile.getGameObjects();
|
||||
if (objects != null)
|
||||
{
|
||||
for (GameObject go : objects)
|
||||
{
|
||||
if (go != null)
|
||||
{
|
||||
TreeType treeType = resolveTreeType(go);
|
||||
if (treeType != null)
|
||||
{
|
||||
trackedTrees.put(go.getWorldLocation(), new TrackedTree(go, treeType));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private TreeType resolveTreeType(GameObject obj)
|
||||
{
|
||||
int id = obj.getId();
|
||||
if (nonTreeIds.contains(id))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
TreeType known = knownTreeIds.get(id);
|
||||
if (known != null)
|
||||
{
|
||||
return known;
|
||||
}
|
||||
|
||||
ObjectComposition comp = client.getObjectDefinition(id);
|
||||
if (comp == null || comp.getName() == null)
|
||||
{
|
||||
nonTreeIds.add(id);
|
||||
return null;
|
||||
}
|
||||
|
||||
String name = comp.getName().toLowerCase();
|
||||
if (name.contains("stump"))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
boolean hasChop = false;
|
||||
String[] actions = comp.getActions();
|
||||
if (actions != null)
|
||||
{
|
||||
for (String a : actions)
|
||||
{
|
||||
if (a != null && a.equalsIgnoreCase("Chop down"))
|
||||
{
|
||||
hasChop = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasChop && !name.contains("tree") && !name.contains("redwood"))
|
||||
{
|
||||
nonTreeIds.add(id);
|
||||
return null;
|
||||
}
|
||||
|
||||
TreeType matched = matchTreeName(name);
|
||||
if (matched != null)
|
||||
{
|
||||
knownTreeIds.put(id, matched);
|
||||
return matched;
|
||||
}
|
||||
|
||||
nonTreeIds.add(id);
|
||||
return null;
|
||||
}
|
||||
|
||||
private TreeType resolveStumpType(GameObject obj)
|
||||
{
|
||||
int id = obj.getId();
|
||||
TreeType known = knownStumpIds.get(id);
|
||||
if (known != null)
|
||||
{
|
||||
return known;
|
||||
}
|
||||
|
||||
ObjectComposition comp = client.getObjectDefinition(id);
|
||||
if (comp == null || comp.getName() == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
String name = comp.getName().toLowerCase();
|
||||
if (name.contains("stump"))
|
||||
{
|
||||
TreeType matched = matchTreeName(name);
|
||||
TreeType result = (matched != null) ? matched : TreeType.NORMAL;
|
||||
knownStumpIds.put(id, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static TreeType matchTreeName(String name)
|
||||
{
|
||||
if (name.contains("redwood")) return TreeType.REDWOOD;
|
||||
if (name.contains("magic")) return TreeType.MAGIC;
|
||||
if (name.contains("yew")) return TreeType.YEW;
|
||||
if (name.contains("camphor")) return TreeType.CAMPHOR;
|
||||
if (name.contains("ironwood")) return TreeType.IRONWOOD;
|
||||
if (name.contains("rosewood")) return TreeType.ROSEWOOD;
|
||||
if (name.contains("arctic pine")) return TreeType.ARCTIC_PINE;
|
||||
if (name.contains("mahogany")) return TreeType.MAHOGANY;
|
||||
if (name.contains("maple")) return TreeType.MAPLE;
|
||||
if (name.contains("hollow")) return TreeType.HOLLOW;
|
||||
if (name.contains("teak")) return TreeType.TEAK;
|
||||
if (name.contains("willow")) return TreeType.WILLOW;
|
||||
if (name.contains("oak")) return TreeType.OAK;
|
||||
if (name.contains("tree") || name.contains("evergreen")) return TreeType.NORMAL;
|
||||
return null;
|
||||
}
|
||||
|
||||
public Collection<RespawnTimer> getActiveRespawnTimers()
|
||||
{
|
||||
return respawnTimers.values();
|
||||
}
|
||||
|
||||
public Collection<ChopTimer> getActiveChopTimers()
|
||||
{
|
||||
List<ChopTimer> result = new ArrayList<>();
|
||||
for (TrackedTree tree : trackedTrees.values())
|
||||
{
|
||||
if (tree.treeType.hasChopTimer() && tree.choppedTicks > 0)
|
||||
{
|
||||
int totalTicks = tree.treeType.getDespawnTicks();
|
||||
int remaining = totalTicks - tree.choppedTicks;
|
||||
if (remaining > 0)
|
||||
{
|
||||
result.add(new ChopTimer(tree.treeType, tree.worldPoint, tree.gameObject, remaining, totalTicks));
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,445 @@
|
||||
package com.example.essentialsplus.helpers.combat;
|
||||
|
||||
import com.google.common.collect.ImmutableMap;
|
||||
import com.google.common.collect.ImmutableSet;
|
||||
import java.security.InvalidParameterException;
|
||||
import java.util.Arrays;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
import lombok.Getter;
|
||||
import net.runelite.api.gameval.AnimationID;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
|
||||
public enum AnimationData
|
||||
{
|
||||
// MELEE
|
||||
MELEE_VIGGORAS_CHAINMACE(AnimationID.WILD_CAVE_CHAINMACE_CRUSH, AttackStyle.MELEE),
|
||||
MELEE_DAGGER_SLASH(AnimationID.HUMAN_DDAGGER_LUNGE, AttackStyle.MELEE),
|
||||
MELEE_SPEAR_STAB(AnimationID.HUMAN_DSPEAR_STAB, AttackStyle.MELEE),
|
||||
MELEE_SWORD_STAB(AnimationID.HUMAN_SWORD_STAB, AttackStyle.MELEE),
|
||||
MELEE_SCIM_SLASH(AnimationID.HUMAN_SWORD_SLASH, AttackStyle.MELEE),
|
||||
|
||||
MELEE_LANCE_STAB(AnimationID.HUMAN_DHUNTER_LANCE_ATTACK, AttackStyle.MELEE),
|
||||
MELEE_LANCE_CRUSH(AnimationID.HUMAN_DHUNTER_LANCE_CRUSH, AttackStyle.MELEE),
|
||||
MELEE_LANCE_SLASH(AnimationID.HUMAN_DHUNTER_LANCE_SLASH, AttackStyle.MELEE),
|
||||
|
||||
MELEE_FANG_STAB(AnimationID.HUMAN_OSMUMTENS_FANG, AttackStyle.MELEE),
|
||||
MELEE_FANG_SPEC(AnimationID.OLAF2_BRINE_SABRE_SPECIAL, AttackStyle.MELEE, MetaData.SPECIAL_ATTACK.d),
|
||||
|
||||
MELEE_GENERIC_SLASH(AnimationID.HUMAN_AXE_CHOP, AttackStyle.MELEE),
|
||||
|
||||
MELEE_BATTLEAXE_SLASH(AnimationID.HUMAN_AXE_HACK, AttackStyle.MELEE),
|
||||
MELEE_MACE_STAB(AnimationID.HUMAN_BLUNT_SPIKE, AttackStyle.MELEE),
|
||||
MELEE_BATTLEAXE_CRUSH(AnimationID.HUMAN_BLUNT_POUND, AttackStyle.MELEE),
|
||||
MELEE_2H_CRUSH(AnimationID.HUMAN_DHSWORD_CHOP, AttackStyle.MELEE),
|
||||
MELEE_2H_SLASH(AnimationID.HUMAN_DHSWORD_SLASH, AttackStyle.MELEE),
|
||||
MELEE_STAFF_CRUSH_2(AnimationID.HUMAN_STAFF_PUMMEL, AttackStyle.MELEE),
|
||||
MELEE_STAFF_CRUSH_3(AnimationID.HUMAN_STAFFORB_PUMMEL, AttackStyle.MELEE),
|
||||
MELEE_PUNCH(AnimationID.HUMAN_UNARMEDPUNCH, AttackStyle.MELEE),
|
||||
MELEE_KICK(AnimationID.HUMAN_UNARMEDKICK, AttackStyle.MELEE),
|
||||
MELEE_STAFF_STAB(AnimationID.HUMAN_SPEAR_SPIKE, AttackStyle.MELEE),
|
||||
MELEE_SPEAR_CRUSH(AnimationID.HUMAN_SPEAR_LUNGE, AttackStyle.MELEE),
|
||||
MELEE_STAFF_SLASH(AnimationID.HUMAN_SCYTHE_SWEEP, AttackStyle.MELEE),
|
||||
MELEE_DLONG_SPEC(AnimationID.CLEAVE, AttackStyle.MELEE, MetaData.SPECIAL_ATTACK.d),
|
||||
MELEE_DRAGON_MACE_SPEC(AnimationID.SHATTER, AttackStyle.MELEE, MetaData.SPECIAL_ATTACK.d),
|
||||
MELEE_DRAGON_DAGGER_SPEC(AnimationID.PUNCTURE, AttackStyle.MELEE, MetaData.SPECIAL_ATTACK.d),
|
||||
MELEE_DRAGON_WARHAMMER_SPEC(AnimationID.DRAGON_WARHAMMER_SA_PLAYER, AttackStyle.MELEE, MetaData.SPECIAL_ATTACK.d),
|
||||
MELEE_ABYSSAL_WHIP(AnimationID.SLAYER_ABYSSAL_WHIP_ATTACK, AttackStyle.MELEE),
|
||||
MELEE_GRANITE_MAUL(AnimationID.SLAYER_GRANITE_MAUL_ATTACK, AttackStyle.MELEE),
|
||||
MELEE_GRANITE_MAUL_SPEC(AnimationID.SLAYER_GRANITE_MAUL_SPECIAL_ATTACK, AttackStyle.MELEE, MetaData.SPECIAL_ATTACK.d),
|
||||
MELEE_DHAROKS_GREATAXE_CRUSH(AnimationID.BARROW_DHAROK_SLASH, AttackStyle.MELEE),
|
||||
MELEE_DHAROKS_GREATAXE_SLASH(AnimationID.BARROW_DHAROK_CRUSH, AttackStyle.MELEE),
|
||||
MELEE_AHRIMS_STAFF_CRUSH(AnimationID.BARROWS_QUARTERSTAFF_ATTACK, AttackStyle.MELEE),
|
||||
MELEE_OBBY_MAUL_CRUSH(AnimationID.DRAGON_PICKAXE_ANIM, AttackStyle.MELEE),
|
||||
MELEE_ABYSSAL_DAGGER_STAB(AnimationID.ABYSSAL_DAGGER_LUNGE, AttackStyle.MELEE),
|
||||
MELEE_ABYSSAL_BLUDGEON_CRUSH(AnimationID.ABYSSAL_BLUDGEON_CRUSH, AttackStyle.MELEE),
|
||||
MELEE_ABYSSAL_BLUDGEON_SPEC(AnimationID.ABYSSAL_BLUDGEON_SPECIAL_ATTACK, AttackStyle.MELEE, MetaData.SPECIAL_ATTACK.d),
|
||||
MELEE_LEAF_BLADED_BATTLEAXE_CRUSH(AnimationID.BATTLEAXE_CRUSH, AttackStyle.MELEE),
|
||||
MELEE_INQUISITORS_MACE(AnimationID.HUMAN_INQUISITORS_MACE_CRUSH, AttackStyle.MELEE),
|
||||
MELEE_BARRELCHEST_ANCHOR_CRUSH(AnimationID.BRAIN_PLAYER_ANCHOR_ATTACK, AttackStyle.MELEE),
|
||||
MELEE_BARRELCHEST_ANCHOR_CRUSH_SPEC(AnimationID.BRAIN_PLAYER_ANCHOR_SPECIAL_ATTACK, AttackStyle.MELEE, MetaData.SPECIAL_ATTACK.d),
|
||||
MELEE_LEAF_BLADED_BATTLEAXE_SLASH(AnimationID.GODWARS_GODSWORD_ZAMORAK_PLAYER, AttackStyle.MELEE),
|
||||
MELEE_GODSWORD_SLASH(AnimationID.DH_SWORD_UPDATE_SLASH, AttackStyle.MELEE),
|
||||
MELEE_GODSWORD_CRUSH(AnimationID.DH_SWORD_UPDATE_SMASH, AttackStyle.MELEE),
|
||||
MELEE_GODSWORD_DEFENSIVE(AnimationID.DH_SWORD_UPDATE_BLOCK, AttackStyle.MELEE),
|
||||
MELEE_RUNE_CLAWS_SPEC(AnimationID.IMPALE, AttackStyle.MELEE, MetaData.SPECIAL_ATTACK.d),
|
||||
MELEE_DRAGON_CLAWS_SPEC(AnimationID.HUMAN_DRAGON_CLAWS_SPEC, AttackStyle.MELEE, MetaData.SPECIAL_ATTACK.d),
|
||||
MELEE_VLS_SPEC(AnimationID.HUMAN_DRAGON_SWORD_SPEC, AttackStyle.MELEE, MetaData.SPECIAL_ATTACK.d),
|
||||
MELEE_ELDER_MAUL(AnimationID.HUMAN_ELDER_MAUL_ATTACK, AttackStyle.MELEE),
|
||||
MELEE_ZAMORAK_GODSWORD_SPEC(AnimationID.ZGS_SPECIAL_PLAYER, AttackStyle.MELEE, MetaData.SPECIAL_ATTACK.d),
|
||||
MELEE_ELDER_MAUL_SPEC(AnimationID.HUMAN_ELDER_MAUL_SPEC, AttackStyle.MELEE),
|
||||
MELEE_ZAMORAK_GODSWORD_OR_SPEC(AnimationID.ZGS_SPECIAL_ORNATE_PLAYER, AttackStyle.MELEE, MetaData.SPECIAL_ATTACK.d),
|
||||
MELEE_SARADOMIN_GODSWORD_SPEC(AnimationID.SGS_SPECIAL_PLAYER, AttackStyle.MELEE, MetaData.SPECIAL_ATTACK.d),
|
||||
MELEE_SARADOMIN_GODSWORD_OR_SPEC(AnimationID.SGS_SPECIAL_ORNATE_PLAYER, AttackStyle.MELEE, MetaData.SPECIAL_ATTACK.d),
|
||||
MELEE_BANDOS_GODSWORD_SPEC(AnimationID.BGS_SPECIAL_PLAYER, AttackStyle.MELEE, MetaData.SPECIAL_ATTACK.d),
|
||||
MELEE_BANDOS_GODSWORD_OR_SPEC(AnimationID.BGS_SPECIAL_ORNATE_PLAYER, AttackStyle.MELEE, MetaData.SPECIAL_ATTACK.d),
|
||||
MELEE_ARMADYL_GODSWORD_SPEC(AnimationID.AGS_SPECIAL_PLAYER, AttackStyle.MELEE, MetaData.SPECIAL_ATTACK.d),
|
||||
MELEE_ARMADYL_GODSWORD_OR_SPEC(AnimationID.AGS_SPECIAL_ORNATE_PLAYER, AttackStyle.MELEE, MetaData.SPECIAL_ATTACK.d),
|
||||
MELEE_SCYTHE(AnimationID.SCYTHE_OF_VITUR_ATTACK, AttackStyle.MELEE),
|
||||
MELEE_GHAZI_RAPIER_STAB(AnimationID.GHRAZI_RAPIER_ATTACK, AttackStyle.MELEE),
|
||||
MELEE_ANCIENT_GODSWORD_SPEC(AnimationID.NGS_SPECIAL_PLAYER, AttackStyle.MELEE, MetaData.SPECIAL_ATTACK.d),
|
||||
MELEE_CRYSTAL_HALBERD_SPEC(AnimationID.DRAGON_HALBERD_SPECIAL_ATTACK, AttackStyle.MELEE, MetaData.SPECIAL_ATTACK.d),
|
||||
MELEE_SOULREAPER_AXE(AnimationID.ANCIENT_AXE_CRUSH, AttackStyle.MELEE, MetaData.SPECIAL_ATTACK.d),
|
||||
MELEE_SOULREAPER_AXE_SPEC(AnimationID.ANCIENT_AXE_SPECIAL, AttackStyle.MELEE, MetaData.SPECIAL_ATTACK.d),
|
||||
MELEE_GUTHANS_LUNGE(AnimationID.BARROWS_WAR_SPEAR_STAB, AttackStyle.MELEE),
|
||||
MELEE_GUTHANS_SWIPE(AnimationID.BARROWS_WAR_SPEAR_SLASH, AttackStyle.MELEE),
|
||||
MELEE_GUTHANS_POUNDMA(AnimationID.BARROWS_WAR_SPEAR_CRUSH, AttackStyle.MELEE),
|
||||
MELEE_TORAG_HAMMERS(AnimationID.BARROW_TORAG_CRUSH, AttackStyle.MELEE),
|
||||
MELEE_VERACS_FLAIL(AnimationID.BARROW_GUTHAN_CRUSH, AttackStyle.MELEE),
|
||||
MELEE_BLISTERWOOD_FLAIL_CRUSH(AnimationID.IVANDIS_FLAIL_ATTACK, AttackStyle.MELEE),
|
||||
MELEE_BONE_DAGGER_SPEC(AnimationID.DTTD_PLAYER_STAB_BONE_DAGGER, AttackStyle.MELEE, MetaData.SPECIAL_ATTACK.d),
|
||||
MELEE_DUAL_MACUAHUITL(AnimationID.PMOON_MACUAHUITL_CRUSH, AttackStyle.MELEE),
|
||||
MELEE_BLUE_MOON_SPEAR_SPEC(AnimationID.HUMAN_ZAMORAKSPEAR_LUNGE, AttackStyle.MELEE, MetaData.SPECIAL_ATTACK.d),
|
||||
MELEE_BLUE_MOON_SPEAR(AnimationID.HUMAN_ZAMORAKSPEAR_STAB, AttackStyle.MELEE),
|
||||
MELEE_DHINS(AnimationID.HUMAN_DINHS_BULWARK_BASH, AttackStyle.MELEE),
|
||||
MELEE_URSINE_CHAINMACE_SPEC(AnimationID.HUMAN_SPECIAL02_URSINE, AttackStyle.MELEE, MetaData.SPECIAL_ATTACK.d),
|
||||
MELEE_ANCIENT_MACE_SPEC(AnimationID.SLICE_PLAYER_MACE_SPECIAL_ATTACK, AttackStyle.MELEE, MetaData.SPECIAL_ATTACK.d),
|
||||
MELEE_DSCIM_SPEC(AnimationID.SP_ATTACK_DRAGON_SCIMITAR, AttackStyle.MELEE, MetaData.SPECIAL_ATTACK.d),
|
||||
MELEE_D2H_SPEC(AnimationID.DRAGON_TWO_HANDED_SWORD, AttackStyle.MELEE, MetaData.SPECIAL_ATTACK.d),
|
||||
MELEE_ARCLIGHT_SPEC(AnimationID.DARK_SPEC_PLAYER, AttackStyle.MELEE, MetaData.SPECIAL_ATTACK.d),
|
||||
MELEE_SARA_SWORD_SPEC(AnimationID.SARADOMIN_SWORD_SPECIAL_PLAYER, AttackStyle.MELEE, MetaData.SPECIAL_ATTACK.d),
|
||||
MELEE_RED_KERIS_SPEC(AnimationID.TOA_KERIS_PARTISAN_SPECIAL01, AttackStyle.MELEE, MetaData.SPECIAL_ATTACK.d),
|
||||
MELEE_SALAMANDER(AnimationID.HUMAN_ATTACK_SALAMANDER, AttackStyle.MELEE),
|
||||
MELEE_INFERNAL_TECPATL(AnimationID.TECPATL_STAB, AttackStyle.MELEE),
|
||||
MELEE_HALLOWED_FLAIL(AnimationID.HUMAN_WEAPONS_HALLOWED_FLAIL01_ATTACK01, AttackStyle.MELEE),
|
||||
MELEE_FELLING_AXE(AnimationID.FORESTRY_2H_AXE_ATTACK, AttackStyle.MELEE),
|
||||
MELEE_HALLOWFELL(AnimationID.HUMAN_HALLOWFELL_SLASH, AttackStyle.MELEE),
|
||||
|
||||
// RANGED
|
||||
RANGED_CHINCHOMPA(AnimationID.HUMAN_CHINCHOMPA_ATTACK_PVN, AttackStyle.RANGED),
|
||||
RANGED_SHORTBOW(AnimationID.HUMAN_BOW, AttackStyle.RANGED, MetaData.STANDARD_BOW_ATTACK.d),
|
||||
RANGED_RUNE_KNIFE_PVP(AnimationID.HUMAN_STAKE2, AttackStyle.RANGED),
|
||||
RANGED_MAGIC_SHORTBOW_SPEC(AnimationID.SNAPSHOT, AttackStyle.RANGED, MetaData.STANDARD_BOW_ATTACK.d | MetaData.SPECIAL_ATTACK.d),
|
||||
RANGED_CROSSBOW_PVP(AnimationID.XBOWS_HUMAN_FIRE_AND_RELOAD, AttackStyle.RANGED),
|
||||
RANGED_BLOWPIPE(AnimationID.SNAKEBOSS_BLOWPIPE_ATTACK, AttackStyle.RANGED),
|
||||
RANGED_DARTS(AnimationID.II_HUMAN_DART_THROW_PVN, AttackStyle.RANGED),
|
||||
RANGED_BALLISTA(AnimationID.BALLISTA_ATTACK, AttackStyle.RANGED),
|
||||
RANGED_BALLISTA_SPEC(AnimationID.BALLISTA_SPECIAL_ATTACK_PVN, AttackStyle.RANGED, MetaData.SPECIAL_ATTACK.d),
|
||||
RANGED_RUNE_THROWNAXE_SPEC(AnimationID.CHAINHIT, AttackStyle.RANGED, MetaData.SPECIAL_ATTACK.d),
|
||||
RANGED_DRAGON_THROWNAXE_SPEC(AnimationID.HUMAN_DRAGON_TAXE_SPEC, AttackStyle.RANGED, MetaData.SPECIAL_ATTACK.d),
|
||||
RANGED_RUNE_CROSSBOW(AnimationID.XBOWS_HUMAN_FIRE_AND_RELOAD_PVN, AttackStyle.RANGED),
|
||||
RANGED_RUNE_CROSSBOW_OR(AnimationID.HUMAN_XBOWS_LEAGUE03_ATTACK_PVN, AttackStyle.RANGED),
|
||||
RANGED_BALLISTA_2(AnimationID.BALLISTA_ATTACK_PVN, AttackStyle.RANGED),
|
||||
RANGED_RUNE_KNIFE(AnimationID.HUMAN_STAKE2_PVN, AttackStyle.RANGED),
|
||||
RANGED_DRAGON_KNIFE(AnimationID.HUMAN_DRAGON_KNIFE, AttackStyle.RANGED),
|
||||
RANGED_DRAGON_KNIFE_SPEC(AnimationID.HUMAN_DRAGON_TKNIVES_SPEC, AttackStyle.RANGED, MetaData.SPECIAL_ATTACK.d),
|
||||
RANGED_DRAGON_KNIFE_POISONED(AnimationID.HUMAN_DRAGON_KNIFE_P, AttackStyle.RANGED),
|
||||
RANGED_DRAGON_KNIFE_POISONED_SPEC(AnimationID.HUMAN_DRAGON_TKNIVES_SPEC_POISON, AttackStyle.RANGED, MetaData.SPECIAL_ATTACK.d),
|
||||
RANGED_ZARYTE_CROSSBOW(AnimationID.ZCB_ATTACK_PVN, AttackStyle.RANGED),
|
||||
RANGED_ZARYTE_CROSSBOW_PVP(AnimationID.ZCB_ATTACK, AttackStyle.RANGED),
|
||||
RANGED_BLAZING_BLOWPIPE(AnimationID.SNAKEBOSS_BLOWPIPE_ATTACK_ORNAMENT, AttackStyle.RANGED),
|
||||
RANGED_VENATOR_BOW(AnimationID.HUMAN_WEAPON_BOW_VENATOR01_SHOOT, AttackStyle.RANGED, MetaData.STANDARD_BOW_ATTACK.d),
|
||||
RANGED_KARIL_CROSSBOW(AnimationID.BARROWS_REPEATING_CROSSBOW_FIRE, AttackStyle.RANGED),
|
||||
RANGED_ATLATL(AnimationID.HUMAN_ATLATL_ATTACK_RANGED_01, AttackStyle.RANGED, MetaData.STANDARD_BOW_ATTACK.d),
|
||||
RANGED_ATLATL_SPEC(AnimationID.HUMAN_SPECIAL_ATLATL_01, AttackStyle.RANGED, MetaData.STANDARD_BOW_ATTACK.d | MetaData.SPECIAL_ATTACK.d),
|
||||
RANGED_TONALZTICS(AnimationID.HUMAN_GLAIVE_RALOS01_CHARGED_THROW, AttackStyle.RANGED),
|
||||
RANGED_TONALZTICS_SPEC(AnimationID.HUMAN_GLAIVE_RALOS01_CHARGED_SPECIAL, AttackStyle.RANGED, MetaData.SPECIAL_ATTACK.d),
|
||||
RANGED_WEBWEAVER_SPEC(AnimationID.HUMAN_SPECIAL01_WEBWEAVER, AttackStyle.RANGED, MetaData.STANDARD_BOW_ATTACK.d | MetaData.SPECIAL_ATTACK.d),
|
||||
RANGED_BONE_CROSSBOW_SPEC(AnimationID.DTTD_PLAYER_FIRE_BONE_CROSSBOW_PVN, AttackStyle.RANGED, MetaData.SPECIAL_ATTACK.d),
|
||||
RANGED_SCORCHING_BOW_SPEC(AnimationID.HUMAN_WEAPON_BOW_SCORCHED_01_SPEC, AttackStyle.RANGED, MetaData.STANDARD_BOW_ATTACK.d | MetaData.SPECIAL_ATTACK.d),
|
||||
|
||||
// MAGIC
|
||||
MAGIC_GOD_SPELL(AnimationID.HUMAN_CASTING, AttackStyle.MAGIC, Spellbook.STANDARD, MetaData.NO_DATA.d),
|
||||
MAGIC_IBAN_BLAST(AnimationID.HUMAN_CASTIBANBLAST, AttackStyle.MAGIC, Spellbook.STANDARD, MetaData.NO_DATA.d),
|
||||
MAGIC_SLAYER_DART(AnimationID.SLAYER_MAGICDART_CAST, AttackStyle.MAGIC, Spellbook.STANDARD, MetaData.NO_DATA.d),
|
||||
MAGIC_STANDARD_BIND(AnimationID.HUMAN_CASTENTANGLE, AttackStyle.MAGIC, Spellbook.STANDARD, MetaData.NO_DATA.d),
|
||||
MAGIC_STANDARD_BIND_STAFF(AnimationID.HUMAN_CASTENTANGLE_STAFF, AttackStyle.MAGIC, Spellbook.STANDARD, MetaData.NO_DATA.d),
|
||||
MAGIC_STANDARD_CONFUSE(AnimationID.HUMAN_CASTCONFUSE, AttackStyle.MAGIC, Spellbook.STANDARD, MetaData.NO_DATA.d),
|
||||
MAGIC_STANDARD_CONFUSE_STAFF(AnimationID.HUMAN_CASTCONFUSE_STAFF, AttackStyle.MAGIC, Spellbook.STANDARD, MetaData.NO_DATA.d),
|
||||
MAGIC_STANDARD_CONFUSE_STAFF_WALK(AnimationID.HUMAN_CASTCONFUSE_STAFF_WALKMERGE, AttackStyle.MAGIC, Spellbook.STANDARD, MetaData.NO_DATA.d),
|
||||
MAGIC_STANDARD_CONFUSE_WALK(AnimationID.HUMAN_CASTCONFUSE_WALKMERGE, AttackStyle.MAGIC, Spellbook.STANDARD, MetaData.NO_DATA.d),
|
||||
MAGIC_STANDARD_CRUMBLE_UNDEAD(AnimationID.HUMAN_CASTCRUMBLEUNDEAD, AttackStyle.MAGIC, Spellbook.STANDARD, MetaData.NO_DATA.d),
|
||||
MAGIC_STANDARD_CRUMBLE_UNDEAD_HOLDING_STAFF(AnimationID.HUMAN_CASTCRUMBLEUNDEAD_STAFF, AttackStyle.MAGIC, Spellbook.STANDARD, MetaData.NO_DATA.d),
|
||||
MAGIC_STANDARD_ENFEEBLE(AnimationID.HUMAN_CASTENFEEBLE_STAFF, AttackStyle.MAGIC, Spellbook.STANDARD, MetaData.NO_DATA.d),
|
||||
MAGIC_STANDARD_STRIKE_BOLT_BLAST(AnimationID.HUMAN_CASTSTRIKE, AttackStyle.MAGIC, Spellbook.STANDARD, MetaData.NO_DATA.d),
|
||||
MAGIC_STANDARD_STRIKE_BOLT_BLAST_STAFF(AnimationID.HUMAN_CASTSTRIKE_STAFF, AttackStyle.MAGIC, Spellbook.STANDARD, MetaData.NO_DATA.d),
|
||||
MAGIC_STANDARD_STRIKE_BOLT_BLAST_STAFF_WALK(AnimationID.HUMAN_CASTSTRIKE_STAFF_WALKMERGE, AttackStyle.MAGIC, Spellbook.STANDARD, MetaData.NO_DATA.d),
|
||||
MAGIC_STANDARD_STRIKE_BOLT_BLAST_WALK(AnimationID.HUMAN_CASTSTRIKE_WALKMERGE, AttackStyle.MAGIC, Spellbook.STANDARD, MetaData.NO_DATA.d),
|
||||
MAGIC_STANDARD_STUN(AnimationID.HUMAN_CASTSTUN_STAFF, AttackStyle.MAGIC, Spellbook.STANDARD, MetaData.NO_DATA.d),
|
||||
MAGIC_STANDARD_SURGE(AnimationID.HUMAN_CAST_SURGE, AttackStyle.MAGIC, Spellbook.STANDARD, MetaData.NO_DATA.d),
|
||||
MAGIC_STANDARD_SURGE_STAFF(AnimationID.HUMAN_CAST_SURGE_FAST, AttackStyle.MAGIC, Spellbook.STANDARD, MetaData.NO_DATA.d),
|
||||
MAGIC_STANDARD_SURGE_STAFF_WALK(AnimationID.HUMAN_CAST_SURGE_WALKMERGE, AttackStyle.MAGIC, Spellbook.STANDARD, MetaData.NO_DATA.d),
|
||||
MAGIC_STANDARD_VULNERABILITY_CURSE(AnimationID.HUMAN_CASTCURSE, AttackStyle.MAGIC, Spellbook.STANDARD, MetaData.NO_DATA.d),
|
||||
MAGIC_STANDARD_VULNERABILITY_CURSE_STAFF(AnimationID.HUMAN_CASTCURSE_STAFF, AttackStyle.MAGIC, Spellbook.STANDARD, MetaData.NO_DATA.d),
|
||||
MAGIC_STANDARD_VULNERABILITY_CURSE_STAFF_WALK(AnimationID.HUMAN_CASTCURSE_STAFF_WALKMERGE, AttackStyle.MAGIC, Spellbook.STANDARD, MetaData.NO_DATA.d),
|
||||
MAGIC_STANDARD_VULNERABILITY_CURSE_WALK(AnimationID.HUMAN_CASTCURSE_WALKMERGE, AttackStyle.MAGIC, Spellbook.STANDARD, MetaData.NO_DATA.d),
|
||||
MAGIC_STANDARD_WAVE(AnimationID.HUMAN_CASTWAVE, AttackStyle.MAGIC, Spellbook.STANDARD, MetaData.NO_DATA.d),
|
||||
MAGIC_STANDARD_WAVE_STAFF(AnimationID.HUMAN_CASTWAVE_STAFF, AttackStyle.MAGIC, Spellbook.STANDARD, MetaData.NO_DATA.d),
|
||||
MAGIC_STANDARD_WAVE_STAFF_WALK(AnimationID.HUMAN_CASTWAVE_STAFF_WALKMERGE, AttackStyle.MAGIC, Spellbook.STANDARD, MetaData.NO_DATA.d),
|
||||
MAGIC_STANDARD_WAVE_WALK(AnimationID.HUMAN_CASTWAVE_WALKMERGE, AttackStyle.MAGIC, Spellbook.STANDARD, MetaData.NO_DATA.d),
|
||||
MAGIC_STANDARD_WEAKEN(AnimationID.HUMAN_CASTWEAKEN, AttackStyle.MAGIC, Spellbook.STANDARD, MetaData.NO_DATA.d),
|
||||
MAGIC_STANDARD_WEAKEN_STAFF(AnimationID.HUMAN_CASTWEAKEN_STAFF, AttackStyle.MAGIC, Spellbook.STANDARD, MetaData.NO_DATA.d),
|
||||
MAGIC_STANDARD_WEAKEN_STAFF_WALK(AnimationID.HUMAN_CASTWEAKEN_STAFF_WALKMERGE, AttackStyle.MAGIC, Spellbook.STANDARD, MetaData.NO_DATA.d),
|
||||
MAGIC_STANDARD_WEAKEN_WALK(AnimationID.HUMAN_CASTWEAKEN_WALKMERGE, AttackStyle.MAGIC, Spellbook.STANDARD, MetaData.NO_DATA.d),
|
||||
|
||||
MAGIC_ANCIENT_MULTI_TARGET(AnimationID.ZAROS_VERTICAL_CASTING_WALKMERGE, AttackStyle.MAGIC, Spellbook.ANCIENT, MetaData.NO_DATA.d),
|
||||
MAGIC_ANCIENT_MULTI_TARGET_PVP(AnimationID.ZAROS_VERTICAL_CASTING, AttackStyle.MAGIC, Spellbook.ANCIENT, MetaData.NO_DATA.d),
|
||||
MAGIC_ANCIENT_SINGLE_TARGET(AnimationID.ZAROS_CASTING_WALKMERGE, AttackStyle.MAGIC, Spellbook.ANCIENT, MetaData.NO_DATA.d),
|
||||
MAGIC_ANCIENT_SINGLE_TARGET_PVP(AnimationID.ZAROS_CASTING, AttackStyle.MAGIC, Spellbook.ANCIENT, MetaData.NO_DATA.d),
|
||||
|
||||
MAGIC_ARCEUUS_DEMONBANE(AnimationID.HUMAN_SPELLCAST_DEMONBANE, AttackStyle.MAGIC, Spellbook.ARCEUUS, MetaData.NO_DATA.d),
|
||||
MAGIC_ARCEUUS_GRASP(AnimationID.HUMAN_SPELLCAST_GRASP, AttackStyle.MAGIC, Spellbook.ARCEUUS, MetaData.NO_DATA.d),
|
||||
|
||||
MAGIC_ACCURSED_SCEPTRE_SPEC(AnimationID.HUMAN_SPECIAL_ACCURSED, AttackStyle.MAGIC, MetaData.SPECIAL_ATTACK.d),
|
||||
MAGIC_EYE_OF_AYAK(AnimationID.HUMAN_EYE_OF_AYAK_NORMAL, AttackStyle.MAGIC, MetaData.NO_DATA.d),
|
||||
MAGIC_EYE_OF_AYAK_SPEC(AnimationID.HUMAN_EYE_OF_AYAK_SPECIAL, AttackStyle.MAGIC, MetaData.SPECIAL_ATTACK.d),
|
||||
MAGIC_TUMEKENS_SHADOW(AnimationID.TOA_SOT_CAST_B, AttackStyle.MAGIC, MetaData.NO_DATA.d),
|
||||
MAGIC_VOLATILE_NIGHTMARE_STAFF_SPEC(AnimationID.NIGHTMARE_STAFF_SPECIAL, AttackStyle.MAGIC, MetaData.SPECIAL_ATTACK.d),
|
||||
MAGIC_WARPED_SCEPTRE(AnimationID.POG_WARPED_SCEPTRE_ATTACK, AttackStyle.MAGIC, MetaData.NO_DATA.d),
|
||||
|
||||
// Misc Non-attacks
|
||||
MAGIC_IMBUE(AnimationID.HUMAN_CASTBONESTOBANANAS, AttackStyle.NON_ATTACK),
|
||||
SPELLBOOK_SWAP(AnimationID.DREAM_PLAYER_SPELLBOOK_SWAP, AttackStyle.NON_ATTACK),
|
||||
LUNAR_GROUP(AnimationID.QUEST_LUNAR_SPELL_CAST_SPELL_ON_GROUP, AttackStyle.NON_ATTACK),
|
||||
LUNAR_OTHER(AnimationID.QUEST_LUNAR_PUSHING_MAGIC_ANIMATION, AttackStyle.NON_ATTACK),
|
||||
NPC_CONTACT(AnimationID.LUNAR_HUMAN_MAGIC_SUMMON2, AttackStyle.NON_ATTACK),
|
||||
VENGEANCE_1(AnimationID.VENGEANCE_SPELL_ANIM_NOSTALLING, AttackStyle.NON_ATTACK),
|
||||
VENGEANCE_2(AnimationID.VENGEANCE_SPELL_ANIM_STALLING, AttackStyle.NON_ATTACK),
|
||||
REANIMATION(AnimationID.ARCEUUS_NECROMANCY_PLAYERANIM, AttackStyle.NON_ATTACK),
|
||||
DEMONIC_OFFERING(AnimationID.HUMAN_CAST_OFFERING, AttackStyle.NON_ATTACK),
|
||||
SHADOW_VEIL(AnimationID.HUMAN_SPELLCAST_SHADOWVEIL, AttackStyle.NON_ATTACK),
|
||||
MARK_OF_DARKNESS(AnimationID.HUMAN_CAST_SELFIMBUE, AttackStyle.NON_ATTACK),
|
||||
PICK_POCKETING(AnimationID.HUMAN_PICKPOCKET, AttackStyle.NON_ATTACK),
|
||||
SUMMON_THRALL(AnimationID.HUMAN_SPELLCAST_RESURRECT, AttackStyle.NON_ATTACK),
|
||||
LUNAR_TELEPORT(AnimationID.HUMAN_TELEPORT_OTHER_IMPACT, AttackStyle.NON_ATTACK),
|
||||
MONSTER_EXAMINE(AnimationID.DREAM_PLAYER_MONSTEREXAM_STATSPY, AttackStyle.NON_ATTACK),
|
||||
HUMIDIFY(AnimationID.DREAM_PLAYER_HUMIDIFY_SPELL, AttackStyle.NON_ATTACK),
|
||||
GEOMANCY(AnimationID.LUNAR_HUMAN_MAGIC_GEOMANCY, AttackStyle.NON_ATTACK),
|
||||
DREAM(AnimationID.FOSSIL_LOC_CLAM_IDLE_SHUT, AttackStyle.NON_ATTACK),
|
||||
ROCKSLUG_BAG_OF_SALT(AnimationID.SLAYER_SALT_SPRINKLE, AttackStyle.NON_ATTACK),
|
||||
DESSET_LIZARD_ICE_COOLER(AnimationID.HUMAN_CHINCHOMPA_ATTACK, AttackStyle.NON_ATTACK),
|
||||
FLECTHING_KNIFE(AnimationID.HUMAN_FLETCHING, AttackStyle.NON_ATTACK),
|
||||
FLECTHING_KEBBIT(AnimationID.HUMAN_CRAFTING_SPIKEDVAMBRACES, AttackStyle.NON_ATTACK),
|
||||
FLECTHING_CHISEL(AnimationID.HUMAN_FLETCHING_HUNTINGBOLTS, AttackStyle.NON_ATTACK),
|
||||
FLECTHING_DART_TIP_1(AnimationID.HUMAN_FLETCHING_ADD_DART_FEATHERS_BRONZE, AttackStyle.NON_ATTACK),
|
||||
FLECTHING_DART_TIP_2(AnimationID.HUMAN_FLETCHING_ADD_DART_FEATHERS_IRON, AttackStyle.NON_ATTACK),
|
||||
FLECTHING_DART_TIP_3(AnimationID.HUMAN_FLETCHING_ADD_DART_FEATHERS_STEEL, AttackStyle.NON_ATTACK),
|
||||
FLECTHING_DART_TIP_4(AnimationID.HUMAN_FLETCHING_ADD_DART_FEATHERS_MITHRIL, AttackStyle.NON_ATTACK),
|
||||
FLECTHING_DART_TIP_5(AnimationID.HUMAN_FLETCHING_ADD_DART_FEATHERS_ADAMANT, AttackStyle.NON_ATTACK),
|
||||
FLECTHING_DART_TIP_6(AnimationID.HUMAN_FLETCHING_ADD_DART_FEATHERS_RUNE, AttackStyle.NON_ATTACK),
|
||||
FLECTHING_DART_TIP_7(AnimationID.HUMAN_FLETCHING_ADD_DART_FEATHERS_DRAGON, AttackStyle.NON_ATTACK),
|
||||
FLECTHING_DART_TIP_8(AnimationID.HUMAN_FLETCHING_ADD_DART_FEATHERS_AMETHYST, AttackStyle.NON_ATTACK),
|
||||
HERB_TAR(AnimationID.HUMAN_SALAMANDER_TAR_GRIND, AttackStyle.NON_ATTACK),
|
||||
SETUP_HUNTER_TRAP(AnimationID.HUMAN_LAYTRAP, AttackStyle.NON_ATTACK),
|
||||
RESET_SNARE_TRAP(AnimationID.HUMAN_HUNTING_DISMANTLE_NET, AttackStyle.NON_ATTACK),
|
||||
RESET_BOX_TRAP(AnimationID.HUNTING_SETTING_TRAP_SMALL, AttackStyle.NON_ATTACK),
|
||||
DESERT_AMMY(AnimationID.TELEPORT_NARDAH_HUMAN, AttackStyle.NON_ATTACK),
|
||||
EAT_FOOD_OR_POTION(AnimationID.HUMAN_EAT, AttackStyle.NON_ATTACK),
|
||||
OVERLOAD_HIT(AnimationID.HUMAN_KILLERWATT_ELECTRICSHOCK, AttackStyle.NON_ATTACK),
|
||||
TAKING_HIT_1HANDED_UNARMED(AnimationID.HUMAN_AXE_BLOCK, AttackStyle.NON_ATTACK),
|
||||
TAKING_HIT_2H_SWORD(AnimationID.HUMAN_DHSWORD_BLOCK, AttackStyle.NON_ATTACK),
|
||||
TAKING_HIT_ANCHOR(AnimationID.BRAIN_PLAYER_ANCHOR_DEFEND, AttackStyle.NON_ATTACK),
|
||||
TAKING_HIT_BLISTERWOOD_FLAIL(AnimationID.IVANDIS_FLAIL_DEFEND, AttackStyle.NON_ATTACK),
|
||||
TAKING_HIT_BLOWPIPE(AnimationID.HUMAN_SPEAR_BLOCK, AttackStyle.NON_ATTACK),
|
||||
TAKING_HIT_BULWARK(AnimationID.HUMAN_DINHS_BULWARK_BLOCK, AttackStyle.NON_ATTACK),
|
||||
TAKING_HIT_CHAINMACE(AnimationID.WILD_CAVE_CHAINMACE_DEFEND, AttackStyle.NON_ATTACK),
|
||||
TAKING_HIT_CHIN_CHOMPA(AnimationID.HUMAN_CHINCHOMPA_DEFEND, AttackStyle.NON_ATTACK),
|
||||
TAKING_HIT_DAGGER(AnimationID.HUMAN_DDAGGER_BLOCK, AttackStyle.NON_ATTACK),
|
||||
TAKING_HIT_DEFENDER(AnimationID.WARGUILD_PARRY_DEFEND, AttackStyle.NON_ATTACK),
|
||||
TAKING_HIT_FANG(AnimationID.HUMAN_SWORD_DEF, AttackStyle.NON_ATTACK),
|
||||
TAKING_HIT_GODSWORD(AnimationID.DH_SWORD_UPDATE_DEFEND, AttackStyle.NON_ATTACK),
|
||||
TAKING_HIT_KERIS(AnimationID.HUMAN_DSPEAR_BLOCK, AttackStyle.NON_ATTACK),
|
||||
TAKING_HIT_LARGE_STAFF(AnimationID.HUMAN_STAFFORB_BLOCK, AttackStyle.NON_ATTACK),
|
||||
TAKING_HIT_MACE(AnimationID.HUMAN_BLUNT_BLOCK, AttackStyle.NON_ATTACK),
|
||||
TAKING_HIT_OBBY_MAUL(AnimationID.SLAYER_GRANITE_MAUL_DEFEND, AttackStyle.NON_ATTACK),
|
||||
TAKING_HIT_SCYTHE(AnimationID.HUMAN_SCYTHE_BLOCK, AttackStyle.NON_ATTACK),
|
||||
TAKING_HIT_SHIELD(AnimationID.HUMAN_SHIELD_DEFENCE, AttackStyle.NON_ATTACK),
|
||||
TAKING_HIT_SPEAR(AnimationID.HUMAN_ZAMORAKSPEAR_BLOCK, AttackStyle.NON_ATTACK),
|
||||
TAKING_HIT_STAFF(AnimationID.HUMAN_STAFF_BLOCK, AttackStyle.NON_ATTACK),
|
||||
TAKING_HIT_UNARMED(AnimationID.HUMAN_UNARMEDBLOCK, AttackStyle.NON_ATTACK),
|
||||
TAKING_HIT_VERACS_FLAIL(AnimationID.BARROW_GUTHAN_DEFEND, AttackStyle.NON_ATTACK),
|
||||
TAKING_HIT_WHIP(AnimationID.SLAYER_ABYSSAL_WHIP_DEFEND, AttackStyle.NON_ATTACK),
|
||||
TAKING_HIT_KISTEN(AnimationID.HUMAN_WEAPONS_CRIMSON_KISTEN_DEF, AttackStyle.NON_ATTACK),
|
||||
TAKING_HIT_HALLOWFELL(AnimationID.HUMAN_HALLOWFELL_DEFEND, AttackStyle.NON_ATTACK),
|
||||
LOW_ALCH(AnimationID.HUMAN_CASTLOWLVLALCHEMY, AttackStyle.NON_ATTACK),
|
||||
HIGH_ALCH(AnimationID.HUMAN_CASTHIGHLVLALCHEMY, AttackStyle.NON_ATTACK);
|
||||
|
||||
private static final Map<Integer, AnimationData> DATA;
|
||||
private static final Map<Spellbook, Set<AnimationData>> SPELL_BOOK_ANIMATIONS;
|
||||
private static final Map<Integer, AnimationData> NOT_ATTACKS;
|
||||
private static final Set<Integer> STANDARD_BOW_ATTACKS;
|
||||
|
||||
public final int animationId;
|
||||
public final long metaData;
|
||||
@Getter
|
||||
public final AttackStyle attackStyle;
|
||||
@Getter
|
||||
private final Spellbook spellbook;
|
||||
|
||||
AnimationData(int animationId, AttackStyle attackStyle)
|
||||
{
|
||||
if (attackStyle == null)
|
||||
{
|
||||
throw new InvalidParameterException("Attack Style must be valid for AnimationData");
|
||||
}
|
||||
this.animationId = animationId;
|
||||
this.attackStyle = attackStyle;
|
||||
this.metaData = MetaData.NO_DATA.d;
|
||||
this.spellbook = null;
|
||||
}
|
||||
|
||||
AnimationData(int animationId, AttackStyle attackStyle, long data)
|
||||
{
|
||||
if (attackStyle == null)
|
||||
{
|
||||
throw new InvalidParameterException("Attack Style must be valid for AnimationData");
|
||||
}
|
||||
this.animationId = animationId;
|
||||
this.attackStyle = attackStyle;
|
||||
this.metaData = data;
|
||||
this.spellbook = null;
|
||||
}
|
||||
|
||||
AnimationData(int animationId, AttackStyle attackStyle, Spellbook book, long data)
|
||||
{
|
||||
if (attackStyle == null)
|
||||
{
|
||||
throw new InvalidParameterException("Attack Style must be valid for AnimationData");
|
||||
}
|
||||
this.animationId = animationId;
|
||||
this.attackStyle = attackStyle;
|
||||
this.metaData = data;
|
||||
this.spellbook = book;
|
||||
}
|
||||
|
||||
static
|
||||
{
|
||||
ImmutableMap.Builder<Integer, AnimationData> builder = new ImmutableMap.Builder<>();
|
||||
ImmutableMap.Builder<Integer, AnimationData> notAttacksBuilder = new ImmutableMap.Builder<>();
|
||||
Map<Spellbook, Set<AnimationData>> spellBookBuilder = new HashMap<>();
|
||||
ImmutableSet.Builder<Integer> standardBowBuilder = new ImmutableSet.Builder<>();
|
||||
|
||||
for (Spellbook s : Spellbook.values())
|
||||
{
|
||||
spellBookBuilder.put(s, new HashSet<>());
|
||||
}
|
||||
|
||||
for (AnimationData data : values())
|
||||
{
|
||||
builder.put(data.animationId, data);
|
||||
|
||||
if (data.spellbook != null)
|
||||
{
|
||||
if (data.attackStyle != AttackStyle.MAGIC)
|
||||
{
|
||||
throw new InvalidParameterException("Spell book should only be magic animations");
|
||||
}
|
||||
spellBookBuilder.get(data.spellbook).add(data);
|
||||
}
|
||||
if (data.attackStyle == AttackStyle.NON_ATTACK)
|
||||
{
|
||||
notAttacksBuilder.put(data.animationId, data);
|
||||
}
|
||||
|
||||
if (MetaData.hasFlagSet(data.metaData, MetaData.STANDARD_BOW_ATTACK))
|
||||
{
|
||||
standardBowBuilder.add(data.animationId);
|
||||
}
|
||||
}
|
||||
|
||||
DATA = builder.build();
|
||||
NOT_ATTACKS = notAttacksBuilder.build();
|
||||
SPELL_BOOK_ANIMATIONS = spellBookBuilder;
|
||||
STANDARD_BOW_ATTACKS = standardBowBuilder.build();
|
||||
}
|
||||
|
||||
public static AnimationData fromId(int animationId)
|
||||
{
|
||||
return DATA.get(animationId);
|
||||
}
|
||||
|
||||
public static Set<AnimationData> getAnimationsForSpellbook(Spellbook s)
|
||||
{
|
||||
return SPELL_BOOK_ANIMATIONS.get(s);
|
||||
}
|
||||
|
||||
public static boolean isManualCasting(AnimationData animationData)
|
||||
{
|
||||
if (animationData != null && animationData.spellbook != null)
|
||||
{
|
||||
return animationData.attackStyle == AttackStyle.MAGIC &&
|
||||
SPELL_BOOK_ANIMATIONS.get(animationData.spellbook).contains(animationData);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public static boolean isBlockListAnimation(int animationId)
|
||||
{
|
||||
return NOT_ATTACKS.containsKey(animationId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString()
|
||||
{
|
||||
String[] words = super.toString().toLowerCase().split("_");
|
||||
Arrays.stream(words)
|
||||
.map(StringUtils::capitalize).collect(Collectors.toList()).toArray(words);
|
||||
|
||||
return String.join(" ", words);
|
||||
}
|
||||
|
||||
public boolean matchesSpellbook(Spellbook s)
|
||||
{
|
||||
if (this.spellbook != null)
|
||||
{
|
||||
return this.spellbook == s;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public boolean isStandardBowAttack()
|
||||
{
|
||||
return STANDARD_BOW_ATTACKS.contains(this.animationId);
|
||||
}
|
||||
|
||||
public boolean isBlockListAnimation()
|
||||
{
|
||||
return NOT_ATTACKS.containsKey(this.animationId);
|
||||
}
|
||||
|
||||
public enum AttackStyle
|
||||
{
|
||||
MELEE,
|
||||
RANGED,
|
||||
MAGIC,
|
||||
NON_ATTACK;
|
||||
|
||||
@Override
|
||||
public String toString()
|
||||
{
|
||||
return StringUtils.capitalize(super.toString().toLowerCase());
|
||||
}
|
||||
}
|
||||
|
||||
public enum MetaData
|
||||
{
|
||||
NO_DATA(),
|
||||
SPECIAL_ATTACK(1),
|
||||
STANDARD_BOW_ATTACK(2);
|
||||
|
||||
MetaData(int bitshift)
|
||||
{
|
||||
this.d = 1 << bitshift;
|
||||
}
|
||||
MetaData()
|
||||
{
|
||||
this.d = 0;
|
||||
}
|
||||
|
||||
public static boolean hasFlagSet(long input, MetaData data)
|
||||
{
|
||||
return (input & data.d) == data.d;
|
||||
}
|
||||
|
||||
private final long d;
|
||||
|
||||
@Override
|
||||
public String toString()
|
||||
{
|
||||
return StringUtils.capitalize(super.toString().toLowerCase());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.example.essentialsplus.helpers.combat;
|
||||
|
||||
public enum AttackProcedure
|
||||
{
|
||||
POWERED_STAVE,
|
||||
MANUAL_AUTO_CAST,
|
||||
MELEE_OR_RANGE
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
package com.example.essentialsplus.helpers.combat;
|
||||
|
||||
import com.example.essentialsplus.helpers.combat.variablespeed.VariableSpeed;
|
||||
import com.example.essentialsplus.helpers.combat.variablespeed.state.TickCount;
|
||||
import com.google.common.collect.ImmutableMap;
|
||||
import java.util.ArrayDeque;
|
||||
import java.util.Map;
|
||||
import lombok.Getter;
|
||||
import net.runelite.api.Client;
|
||||
import net.runelite.api.events.FakeXpDrop;
|
||||
import net.runelite.api.events.SoundEffectPlayed;
|
||||
import net.runelite.api.events.StatChanged;
|
||||
import net.runelite.api.gameval.ItemID;
|
||||
import net.runelite.client.game.ItemManager;
|
||||
|
||||
public class AttackSpeed
|
||||
{
|
||||
private final ArrayDeque<Integer> specialPercentageEvents = new ArrayDeque<>();
|
||||
private final Damage damage = new Damage();
|
||||
@Getter
|
||||
private int dmgDealt = -1;
|
||||
private final TickCount tickCount;
|
||||
private final VariableSpeed variableSpeed;
|
||||
@Getter
|
||||
private int soundEffectTick = -1;
|
||||
@Getter
|
||||
private int soundEffectId = -1;
|
||||
@Getter
|
||||
private boolean isUsingMagic = false;
|
||||
|
||||
public AttackSpeed(final TickCount tc, final VariableSpeed vs)
|
||||
{
|
||||
this.tickCount = tc;
|
||||
this.variableSpeed = vs;
|
||||
}
|
||||
|
||||
public int compute(final Client client, final AnimationData curAnimation, final Spellbook spellbook, final ItemManager itemManager)
|
||||
{
|
||||
final int weaponId = CombatUtils.getWeaponId(client);
|
||||
final PoweredStaves stave = PoweredStaves.getPoweredStaves(weaponId, curAnimation);
|
||||
final int specDelta = CombatUtils.getLastDelta(specialPercentageEvents);
|
||||
dmgDealt = damage.compute(tickCount);
|
||||
if (stave != null && stave.getAnimations() != null && stave.getAnimations().contains(curAnimation))
|
||||
{
|
||||
isUsingMagic = true;
|
||||
return variableSpeed.compute(client, curAnimation, AttackProcedure.POWERED_STAVE, spellbook, dmgDealt, specDelta, 4);
|
||||
}
|
||||
|
||||
if (matchesSpellbook(client, curAnimation, spellbook) && ManualCasting.is(client, curAnimation, soundEffectTick, soundEffectId))
|
||||
{
|
||||
isUsingMagic = true;
|
||||
return variableSpeed.compute(client, curAnimation, AttackProcedure.MANUAL_AUTO_CAST, spellbook, dmgDealt, specDelta, getMagicBaseSpeed(weaponId));
|
||||
}
|
||||
|
||||
isUsingMagic = false;
|
||||
final int aspeed = CombatUtils.getWeaponSpeed(client, itemManager, weaponId);
|
||||
return variableSpeed.compute(client, curAnimation, AttackProcedure.MELEE_OR_RANGE, spellbook, dmgDealt, specDelta, aspeed);
|
||||
}
|
||||
|
||||
public void onTick()
|
||||
{
|
||||
while (specialPercentageEvents.size() > 5)
|
||||
{
|
||||
specialPercentageEvents.removeFirst();
|
||||
}
|
||||
damage.onTick();
|
||||
}
|
||||
|
||||
public void varbitSpecialAttackChanged(final int value)
|
||||
{
|
||||
specialPercentageEvents.addLast(value);
|
||||
}
|
||||
|
||||
public boolean onXpDrop(final FakeXpDrop event)
|
||||
{
|
||||
return damage.onXpDrop(event, tickCount);
|
||||
}
|
||||
|
||||
public boolean onXpDrop(final StatChanged event)
|
||||
{
|
||||
return damage.onXpDrop(event, tickCount);
|
||||
}
|
||||
|
||||
public void onSoundEffectPlayed(final Client client, final SoundEffectPlayed event)
|
||||
{
|
||||
soundEffectTick = client.getTickCount();
|
||||
soundEffectId = event.getSoundId();
|
||||
}
|
||||
|
||||
private static final Map<Integer, Integer> NON_STANDARD_MAGIC_WEAPON_SPEEDS = new ImmutableMap.Builder<Integer, Integer>()
|
||||
.put(ItemID.TWINFLAME_STAFF, 6)
|
||||
.build();
|
||||
|
||||
private static int getMagicBaseSpeed(final int weaponId)
|
||||
{
|
||||
if (weaponId == ItemID.NIGHTMARE_STAFF_HARMONISED)
|
||||
{
|
||||
return 4;
|
||||
}
|
||||
return NON_STANDARD_MAGIC_WEAPON_SPEEDS.getOrDefault(weaponId, 5);
|
||||
}
|
||||
|
||||
private boolean matchesSpellbook(final Client client, final AnimationData curAnimation, final Spellbook currentSpellBook)
|
||||
{
|
||||
if (curAnimation != null && curAnimation.getAttackStyle() == AnimationData.AttackStyle.MAGIC && curAnimation.getSpellbook() == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if (curAnimation != null && curAnimation.matchesSpellbook(currentSpellBook))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
if (client.getTickCount() == soundEffectTick)
|
||||
{
|
||||
return CastingSoundData.getSpellBookFromId(soundEffectId) == currentSpellBook;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public void reset()
|
||||
{
|
||||
dmgDealt = -1;
|
||||
soundEffectId = -1;
|
||||
soundEffectTick = -1;
|
||||
isUsingMagic = false;
|
||||
specialPercentageEvents.clear();
|
||||
damage.reset();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package com.example.essentialsplus.helpers.combat;
|
||||
|
||||
import lombok.Getter;
|
||||
import net.runelite.api.Skill;
|
||||
|
||||
public enum AttackStyle
|
||||
{
|
||||
ACCURATE("Accurate", Skill.ATTACK),
|
||||
AGGRESSIVE("Aggressive", Skill.STRENGTH),
|
||||
DEFENSIVE("Defensive", Skill.DEFENCE),
|
||||
CONTROLLED("Controlled", Skill.ATTACK, Skill.STRENGTH, Skill.DEFENCE),
|
||||
RANGING("Ranging", Skill.RANGED),
|
||||
LONGRANGE("Longrange", Skill.RANGED, Skill.DEFENCE),
|
||||
CASTING("Casting", Skill.MAGIC),
|
||||
DEFENSIVE_CASTING("Defensive Casting", Skill.MAGIC, Skill.DEFENCE),
|
||||
OTHER("Other");
|
||||
|
||||
@Getter
|
||||
private final String name;
|
||||
@Getter
|
||||
private final Skill[] skills;
|
||||
|
||||
AttackStyle(String name, Skill... skills)
|
||||
{
|
||||
this.name = name;
|
||||
this.skills = skills;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString()
|
||||
{
|
||||
return this.name;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.example.essentialsplus.helpers.combat;
|
||||
|
||||
public enum AttackType
|
||||
{
|
||||
CRUSH,
|
||||
SLASH,
|
||||
STAB,
|
||||
RANGED,
|
||||
MAGIC,
|
||||
NONE;
|
||||
|
||||
public boolean IsMelee()
|
||||
{
|
||||
return this.equals(CRUSH) || this.equals(SLASH) || this.equals(STAB);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package com.example.essentialsplus.helpers.combat;
|
||||
|
||||
import com.google.common.collect.ImmutableSet;
|
||||
import java.util.Arrays;
|
||||
import java.util.Set;
|
||||
import lombok.Getter;
|
||||
import net.runelite.api.Actor;
|
||||
import net.runelite.api.Client;
|
||||
import net.runelite.api.NPC;
|
||||
import net.runelite.api.Player;
|
||||
import net.runelite.api.gameval.NpcID;
|
||||
import net.runelite.api.gameval.SpotanimID;
|
||||
import net.runelite.client.game.NPCManager;
|
||||
|
||||
public class Attacking
|
||||
{
|
||||
private static final Set<AnimationData> UNRELIABLE_ANIMATIONS = new ImmutableSet.Builder<AnimationData>()
|
||||
.add(AnimationData.RANGED_BLOWPIPE).add(AnimationData.RANGED_BLAZING_BLOWPIPE)
|
||||
.add(AnimationData.MAGIC_EYE_OF_AYAK).add(AnimationData.MAGIC_EYE_OF_AYAK_SPEC).build();
|
||||
|
||||
private static final Set<Integer> SPECIAL_NPCS = new ImmutableSet.Builder<Integer>()
|
||||
.add(NpcID.POH_COMBAT_DUMMY_UPGRADED_UNDEADSLAYER_NPC, NpcID.POH_COMBAT_DUMMY_UPGRADED_ETHER_NPC,
|
||||
NpcID.POH_COMBAT_DUMMY_UPGRADED_KQ_NPC, NpcID.POH_COMBAT_DUMMY_UPGRADED_KURASK_NPC,
|
||||
NpcID.POH_COMBAT_DUMMY_UPGRADED_VAMPIRE_NPC, NpcID.POH_COMBAT_DUMMY_UPGRADED_VORKATH_NPC,
|
||||
NpcID.POH_COMBAT_DUMMY_NPC, NpcID.POH_COMBAT_DUMMY_UNDEADSLAYER_NPC,
|
||||
NpcID.POH_COMBAT_DUMMY_UPGRADED_NPC, NpcID.NIGHTMARE_TOTEM_1_READY, NpcID.NIGHTMARE_TOTEM_2_READY,
|
||||
NpcID.NIGHTMARE_TOTEM_3_READY, NpcID.NIGHTMARE_TOTEM_4_READY)
|
||||
.build();
|
||||
|
||||
public static boolean isPlayerAttacking(final Client client, final NPCManager npcManager)
|
||||
{
|
||||
final Player localPlayer = client.getLocalPlayer();
|
||||
if (localPlayer == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
final int animationId = localPlayer.getAnimation();
|
||||
if (AnimationData.isBlockListAnimation(animationId))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
final boolean notWalking = animationId != -1 || getSalamanderAttack(client);
|
||||
|
||||
final Actor target = localPlayer.getInteracting();
|
||||
if (target instanceof NPC)
|
||||
{
|
||||
final NPC npc = (NPC) target;
|
||||
final boolean containsAttackOption = npc.getComposition() != null
|
||||
&& npc.getComposition().getActions() != null
|
||||
&& Arrays.asList(npc.getComposition().getActions()).contains("Attack");
|
||||
Integer health = null;
|
||||
if (npcManager != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
health = npcManager.getHealth(npc.getId());
|
||||
}
|
||||
catch (Exception ignored)
|
||||
{
|
||||
}
|
||||
}
|
||||
final boolean hasHealthAndLevel = health != null && health > 0 && target.getCombatLevel() > 0;
|
||||
final boolean attackingNPC = hasHealthAndLevel || SPECIAL_NPCS.contains(npc.getId()) || containsAttackOption;
|
||||
return attackingNPC && notWalking;
|
||||
}
|
||||
if (target instanceof Player)
|
||||
{
|
||||
return notWalking;
|
||||
}
|
||||
if (target == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
final AnimationData fromId = AnimationData.fromId(animationId);
|
||||
if (UNRELIABLE_ANIMATIONS.contains(fromId))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
return fromId != null;
|
||||
}
|
||||
|
||||
public static Attack playerAttack(final Client client)
|
||||
{
|
||||
final Player localPlayer = client.getLocalPlayer();
|
||||
if (localPlayer == null)
|
||||
{
|
||||
return new Attack(-1, false, null);
|
||||
}
|
||||
final int animationId = localPlayer.getAnimation();
|
||||
final Actor target = localPlayer.getInteracting();
|
||||
return new Attack(animationId, getSalamanderAttack(client), target);
|
||||
}
|
||||
|
||||
private static boolean getSalamanderAttack(final Client client)
|
||||
{
|
||||
Player localPlayer = client.getLocalPlayer();
|
||||
return localPlayer != null && localPlayer.hasSpotAnim(SpotanimID.FIREBREATH);
|
||||
}
|
||||
|
||||
public static final class Attack
|
||||
{
|
||||
@Getter
|
||||
private final int animationId;
|
||||
@Getter
|
||||
private final boolean salamander;
|
||||
@Getter
|
||||
private final Actor target;
|
||||
|
||||
public Attack(final int animationId, final boolean salamander, final Actor target)
|
||||
{
|
||||
this.animationId = animationId;
|
||||
this.salamander = salamander;
|
||||
this.target = target;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package com.example.essentialsplus.helpers.combat;
|
||||
|
||||
import com.google.common.collect.ImmutableMap;
|
||||
|
||||
public enum CastingSoundData
|
||||
{
|
||||
STANDARD_BIND(101, Spellbook.STANDARD),
|
||||
STANDARD_CONFUSE(119, Spellbook.STANDARD),
|
||||
STANDARD_CRUMBLE_UNDEAD(122, Spellbook.STANDARD),
|
||||
STANDARD_CURSE(127, Spellbook.STANDARD),
|
||||
STANDARD_EARTH_BLAST(128, Spellbook.STANDARD),
|
||||
STANDARD_EARTH_BOLT(130, Spellbook.STANDARD),
|
||||
STANDARD_EARTH_STRIKE(132, Spellbook.STANDARD),
|
||||
STANDARD_EARTH_SURGE(4025, Spellbook.STANDARD),
|
||||
STANDARD_EARTH_WAVE(134, Spellbook.STANDARD),
|
||||
STANDARD_ENFEEBLE(148, Spellbook.STANDARD),
|
||||
STANDARD_ENTANGLE(151, Spellbook.STANDARD),
|
||||
STANDARD_FIRE_BLAST(155, Spellbook.STANDARD),
|
||||
STANDARD_FIRE_BOLT(157, Spellbook.STANDARD),
|
||||
STANDARD_FIRE_STRIKE(160, Spellbook.STANDARD),
|
||||
STANDARD_FIRE_SURGE(4032, Spellbook.STANDARD),
|
||||
STANDARD_IBANS_BLAST_FIRE_WAVE(162, Spellbook.STANDARD),
|
||||
STANDARD_SLAYER_DART(1718, Spellbook.STANDARD),
|
||||
STANDARD_SNARE(3003, Spellbook.STANDARD),
|
||||
STANDARD_STUN(3004, Spellbook.STANDARD),
|
||||
STANDARD_VULNERABILITY(3009, Spellbook.STANDARD),
|
||||
STANDARD_WATER_BLAST(207, Spellbook.STANDARD),
|
||||
STANDARD_WATER_BOLT(209, Spellbook.STANDARD),
|
||||
STANDARD_WATER_STRIKE(221, Spellbook.STANDARD),
|
||||
STANDARD_WATER_SURGE(4030, Spellbook.STANDARD),
|
||||
STANDARD_WATER_WAVE(213, Spellbook.STANDARD),
|
||||
STANDARD_WEAKEN(3011, Spellbook.STANDARD),
|
||||
STANDARD_WIND_BLAST(216, Spellbook.STANDARD),
|
||||
STANDARD_WIND_BOLT(218, Spellbook.STANDARD),
|
||||
STANDARD_WIND_STRIKE(220, Spellbook.STANDARD),
|
||||
STANDARD_WIND_SURGE(4028, Spellbook.STANDARD),
|
||||
STANDARD_WIND_WAVE(222, Spellbook.STANDARD),
|
||||
|
||||
ANCIENT_BLOOD_SPELL(106, Spellbook.ANCIENT),
|
||||
ANCIENT_ICE_SPELL(171, Spellbook.ANCIENT),
|
||||
ANCIENT_SHADOW_SPELL(178, Spellbook.ANCIENT),
|
||||
ANCIENT_SMOKE_SPELL(183, Spellbook.ANCIENT),
|
||||
|
||||
ARCEUUS_DARK_DEMONBANE(5053, Spellbook.ARCEUUS),
|
||||
ARCEUUS_GHOSTLY_GRASP(5042, Spellbook.ARCEUUS),
|
||||
ARCEUUS_INFERIOR_DEMONBANE(5038, Spellbook.ARCEUUS),
|
||||
ARCEUUS_SKELETAL_GRASP(5026, Spellbook.ARCEUUS),
|
||||
ARCEUUS_SUPERIOR_DEMONBANE(5027, Spellbook.ARCEUUS),
|
||||
ARCEUUS_UNDEAD_GRASP(5030, Spellbook.ARCEUUS);
|
||||
|
||||
private final int id;
|
||||
private final Spellbook spellbook;
|
||||
|
||||
CastingSoundData(int id, Spellbook b)
|
||||
{
|
||||
this.id = id;
|
||||
this.spellbook = b;
|
||||
}
|
||||
|
||||
private static final ImmutableMap<Integer, CastingSoundData> SOUNDS;
|
||||
|
||||
static
|
||||
{
|
||||
ImmutableMap.Builder<Integer, CastingSoundData> builder = new ImmutableMap.Builder<>();
|
||||
for (CastingSoundData data : values())
|
||||
{
|
||||
builder.put(data.id, data);
|
||||
}
|
||||
SOUNDS = builder.build();
|
||||
}
|
||||
|
||||
public static boolean isCastingSound(int id)
|
||||
{
|
||||
return SOUNDS.containsKey(id);
|
||||
}
|
||||
|
||||
public static Spellbook getSpellBookFromId(int id)
|
||||
{
|
||||
if (!SOUNDS.containsKey(id))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
return SOUNDS.get(id).spellbook;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
package com.example.essentialsplus.helpers.combat;
|
||||
|
||||
import com.google.common.collect.ImmutableMap;
|
||||
import java.util.ArrayDeque;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import net.runelite.api.Actor;
|
||||
import net.runelite.api.Client;
|
||||
import net.runelite.api.EquipmentInventorySlot;
|
||||
import net.runelite.api.Item;
|
||||
import net.runelite.api.ItemContainer;
|
||||
import net.runelite.api.NPC;
|
||||
import net.runelite.api.WorldView;
|
||||
import net.runelite.api.coords.LocalPoint;
|
||||
import net.runelite.api.coords.WorldPoint;
|
||||
import net.runelite.api.gameval.InventoryID;
|
||||
import net.runelite.api.gameval.ItemID;
|
||||
import net.runelite.api.gameval.VarPlayerID;
|
||||
import net.runelite.api.gameval.VarbitID;
|
||||
import net.runelite.client.game.ItemEquipmentStats;
|
||||
import net.runelite.client.game.ItemManager;
|
||||
import net.runelite.client.game.ItemStats;
|
||||
import org.apache.commons.lang3.ArrayUtils;
|
||||
|
||||
public class CombatUtils
|
||||
{
|
||||
public static int getItemIdFromContainer(final ItemContainer container, final int slotID)
|
||||
{
|
||||
if (container == null)
|
||||
{
|
||||
return -1;
|
||||
}
|
||||
final Item item = container.getItem(slotID);
|
||||
return (item != null) ? item.getId() : -1;
|
||||
}
|
||||
|
||||
public static int getWeaponIdRaw(final Client client)
|
||||
{
|
||||
return getItemIdFromContainer(client.getItemContainer(InventoryID.WORN),
|
||||
EquipmentInventorySlot.WEAPON.getSlotIdx());
|
||||
}
|
||||
|
||||
public static WorldPoint getLocalLocation(final Client client)
|
||||
{
|
||||
final LocalPoint localPoint = client.getLocalPlayer().getLocalLocation();
|
||||
return WorldPoint.fromLocalInstance(client, localPoint);
|
||||
}
|
||||
|
||||
public static AttackStyle getAttackStyle(final Client client)
|
||||
{
|
||||
final AttackStyle[] attackStyles = getWeaponType(client).getAttackStyles(client);
|
||||
int currentAttackStyleVarbit = client.getVarpValue(VarPlayerID.COM_MODE);
|
||||
final int castingMode = client.getVarbitValue(VarbitID.AUTOCAST_DEFMODE);
|
||||
if (currentAttackStyleVarbit < attackStyles.length)
|
||||
{
|
||||
if (currentAttackStyleVarbit == 4)
|
||||
{
|
||||
currentAttackStyleVarbit += castingMode;
|
||||
}
|
||||
return attackStyles[currentAttackStyleVarbit];
|
||||
}
|
||||
return AttackStyle.ACCURATE;
|
||||
}
|
||||
|
||||
public static WeaponType getWeaponType(final Client client)
|
||||
{
|
||||
final int currentEquippedWeaponTypeVarbit = client.getVarbitValue(VarbitID.COMBAT_WEAPON_CATEGORY);
|
||||
return WeaponType.getWeaponType(currentEquippedWeaponTypeVarbit);
|
||||
}
|
||||
|
||||
public static AttackType getAttackType(final Client client)
|
||||
{
|
||||
final WeaponType weaponType = getWeaponType(client);
|
||||
final int currentAttackStyleVarbit = client.getVarpValue(VarPlayerID.COM_MODE);
|
||||
if (currentAttackStyleVarbit < weaponType.getAttackTypes().length)
|
||||
{
|
||||
return weaponType.getAttackTypes()[currentAttackStyleVarbit];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public static int getTargetId(final Client client)
|
||||
{
|
||||
final NPC target = getTargetNPC(client);
|
||||
int targetId = 0;
|
||||
if (target != null)
|
||||
{
|
||||
targetId = target.getId();
|
||||
}
|
||||
return targetId;
|
||||
}
|
||||
|
||||
public static NPC getTargetNPC(final Client client)
|
||||
{
|
||||
final Actor target = client.getLocalPlayer().getInteracting();
|
||||
if (target != null && (target instanceof NPC))
|
||||
{
|
||||
return (NPC) target;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public static boolean isInRegionId(final Client client, final int id)
|
||||
{
|
||||
final int[] regions = regions(client);
|
||||
if (regions == null || regions.length == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
return ArrayUtils.contains(regions, id);
|
||||
}
|
||||
|
||||
public static boolean isInRegionId(final Client client, final Set<Integer> ids)
|
||||
{
|
||||
final int[] regions = regions(client);
|
||||
if (regions == null || regions.length == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
for (final int id : regions)
|
||||
{
|
||||
if (ids.contains(id))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static int[] regions(final Client client)
|
||||
{
|
||||
final WorldView wv = client.getTopLevelWorldView();
|
||||
if (wv == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
return wv.getMapRegions();
|
||||
}
|
||||
|
||||
public static int getLastDelta(final ArrayDeque<Integer> events)
|
||||
{
|
||||
int i = 0, last = -1, secondLast = -1;
|
||||
final var it = events.descendingIterator();
|
||||
while (it.hasNext())
|
||||
{
|
||||
if (i == 0)
|
||||
{
|
||||
last = it.next();
|
||||
}
|
||||
else if (i == 1)
|
||||
{
|
||||
secondLast = it.next();
|
||||
}
|
||||
else
|
||||
{
|
||||
break;
|
||||
}
|
||||
i++;
|
||||
}
|
||||
return last - secondLast;
|
||||
}
|
||||
|
||||
private static final Map<Integer, Integer> WEAPON_ID_MAPPING_WORKAROUNDS = new ImmutableMap.Builder<Integer, Integer>()
|
||||
.put(ItemID.VENATOR_BOW_ORNAMENT, ItemID.VENATOR_BOW)
|
||||
.build();
|
||||
|
||||
private static final Map<Integer, Integer> NON_STANDARD_ATTACK_SPEEDS = new ImmutableMap.Builder<Integer, Integer>()
|
||||
.put(ItemID.HALLOWFELL, 6)
|
||||
.build();
|
||||
|
||||
public static int getWeaponId(final Client client)
|
||||
{
|
||||
final int weaponId = getWeaponIdRaw(client);
|
||||
return WEAPON_ID_MAPPING_WORKAROUNDS.getOrDefault(weaponId, weaponId);
|
||||
}
|
||||
|
||||
public static ItemStats getWeaponStats(final Client client, final ItemManager itemManager, final int weaponId)
|
||||
{
|
||||
if (NON_STANDARD_ATTACK_SPEEDS.containsKey(weaponId))
|
||||
{
|
||||
return new ItemStats(true, -1, -1,
|
||||
ItemEquipmentStats.builder().aspeed(NON_STANDARD_ATTACK_SPEEDS.get(weaponId)).build());
|
||||
}
|
||||
return itemManager.getItemStats(weaponId);
|
||||
}
|
||||
|
||||
public static int getWeaponSpeed(final Client client, final ItemManager itemManager, final int weaponId)
|
||||
{
|
||||
final ItemStats weaponStats = getWeaponStats(client, itemManager, weaponId);
|
||||
if (weaponStats == null || weaponStats.getEquipment() == null)
|
||||
{
|
||||
return 4;
|
||||
}
|
||||
return weaponStats.getEquipment().getAspeed();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package com.example.essentialsplus.helpers.combat;
|
||||
|
||||
import com.example.essentialsplus.helpers.combat.variablespeed.state.TickCount;
|
||||
import java.util.ArrayDeque;
|
||||
import net.runelite.api.Skill;
|
||||
import net.runelite.api.events.FakeXpDrop;
|
||||
import net.runelite.api.events.StatChanged;
|
||||
|
||||
public class Damage
|
||||
{
|
||||
private static final double MODIFIER = 1;
|
||||
private static final double GLOBAL_MODIFIER = 1;
|
||||
|
||||
private final ArrayDeque<Integer> hpExpEarned = new ArrayDeque<>();
|
||||
private final ArrayDeque<Integer> hpExpEarnedTickCount = new ArrayDeque<>();
|
||||
|
||||
public boolean onXpDrop(StatChanged event, TickCount tc)
|
||||
{
|
||||
if (event.getSkill() != Skill.HITPOINTS)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
hpExpEarnedTickCount.addLast(tc.get());
|
||||
hpExpEarned.addLast(event.getXp());
|
||||
return true;
|
||||
}
|
||||
|
||||
public boolean onXpDrop(FakeXpDrop event, TickCount tc)
|
||||
{
|
||||
if (event.getSkill() != Skill.HITPOINTS)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
hpExpEarnedTickCount.addLast(tc.get());
|
||||
hpExpEarnedTickCount.addLast(tc.get());
|
||||
hpExpEarned.addLast(0);
|
||||
hpExpEarned.addLast(event.getXp());
|
||||
return true;
|
||||
}
|
||||
|
||||
public int compute(TickCount tc)
|
||||
{
|
||||
if (hpExpEarnedTickCount.isEmpty())
|
||||
{
|
||||
return -1;
|
||||
}
|
||||
final int lastTc = hpExpEarnedTickCount.getLast();
|
||||
if (!tc.isWithinNTicks(lastTc, 1))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
final double xp = (double) CombatUtils.getLastDelta(hpExpEarned);
|
||||
return (int) Math.round(xp * (3.0d / 4.0d) * MODIFIER * GLOBAL_MODIFIER);
|
||||
}
|
||||
|
||||
public void onTick()
|
||||
{
|
||||
while (hpExpEarnedTickCount.size() > 5)
|
||||
{
|
||||
hpExpEarnedTickCount.removeFirst();
|
||||
}
|
||||
while (hpExpEarned.size() > 5)
|
||||
{
|
||||
hpExpEarned.removeFirst();
|
||||
}
|
||||
}
|
||||
|
||||
public void reset()
|
||||
{
|
||||
hpExpEarned.clear();
|
||||
hpExpEarnedTickCount.clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.example.essentialsplus.helpers.combat;
|
||||
|
||||
import net.runelite.api.Client;
|
||||
|
||||
public class ManualCasting
|
||||
{
|
||||
public static boolean is(final Client client, final AnimationData curId, final int soundEffectTick, final int soundEffectId)
|
||||
{
|
||||
final boolean castingFromSound = client.getTickCount() == soundEffectTick
|
||||
? CastingSoundData.isCastingSound(soundEffectId)
|
||||
: false;
|
||||
final boolean castingFromAnimation = AnimationData.isManualCasting(curId);
|
||||
return castingFromSound || castingFromAnimation;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package com.example.essentialsplus.helpers.combat;
|
||||
|
||||
import com.google.common.collect.ImmutableMap;
|
||||
import com.google.common.collect.ImmutableSet;
|
||||
import java.util.Arrays;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
import lombok.Getter;
|
||||
import net.runelite.api.gameval.ItemID;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
|
||||
public enum PoweredStaves
|
||||
{
|
||||
WEAPON_ACCURSED(Set.of(AnimationData.MAGIC_STANDARD_WAVE_STAFF, AnimationData.MAGIC_STANDARD_WAVE_STAFF_WALK,
|
||||
AnimationData.MAGIC_ACCURSED_SCEPTRE_SPEC), ItemID.WILD_CAVE_ACCURSED_CHARGED),
|
||||
WEAPON_GAUNTLET_STAFF(Set.of(AnimationData.MAGIC_STANDARD_WAVE_STAFF, AnimationData.MAGIC_STANDARD_WAVE_STAFF_WALK),
|
||||
ItemID.GAUNTLET_MAGIC_T1, ItemID.GAUNTLET_MAGIC_T2, ItemID.GAUNTLET_MAGIC_T3, ItemID.GAUNTLET_MAGIC_T1_HM,
|
||||
ItemID.GAUNTLET_MAGIC_T2_HM, ItemID.GAUNTLET_MAGIC_T3_HM),
|
||||
WEAPON_BONE_STAFF(AnimationData.MELEE_GENERIC_SLASH, ItemID.RAT_BONE_STAFF),
|
||||
WEAPON_DAWNBRINGER(Set.of(AnimationData.MAGIC_STANDARD_WAVE_STAFF, AnimationData.MAGIC_STANDARD_WAVE_STAFF_WALK),
|
||||
ItemID.VERZIK_SPECIAL_WEAPON),
|
||||
WEAPON_HARM(Set.of(AnimationData.MAGIC_STANDARD_STRIKE_BOLT_BLAST_STAFF, AnimationData.MAGIC_STANDARD_WAVE_STAFF,
|
||||
AnimationData.MAGIC_STANDARD_SURGE_STAFF, AnimationData.MAGIC_STANDARD_WAVE_STAFF_WALK,
|
||||
AnimationData.MAGIC_STANDARD_STRIKE_BOLT_BLAST_STAFF_WALK, AnimationData.MAGIC_STANDARD_SURGE_STAFF_WALK),
|
||||
ItemID.NIGHTMARE_STAFF_HARMONISED),
|
||||
WEAPON_SANG(Set.of(AnimationData.MAGIC_STANDARD_WAVE_STAFF, AnimationData.MAGIC_STANDARD_WAVE_STAFF_WALK),
|
||||
ItemID.SANGUINESTI_STAFF, ItemID.SANGUINESTI_STAFF_OR),
|
||||
WEAPON_STARTER_STAFF(ItemID.DEADMAN_STARTER_STAFF, ItemID.DEADMAN_APOCALYPSE_STAFF),
|
||||
WEAPON_SWAMP(Set.of(AnimationData.MAGIC_STANDARD_WAVE_STAFF, AnimationData.MAGIC_STANDARD_WAVE_STAFF_WALK),
|
||||
ItemID.TOXIC_TOTS_CHARGED, ItemID.TOXIC_TOTS_I_CHARGED, ItemID.TOXIC_TOTS_CHARGED_ORN,
|
||||
ItemID.TOXIC_TOTS_I_CHARGED_ORN),
|
||||
WEAPON_THAMMARON(Set.of(AnimationData.MAGIC_STANDARD_WAVE_STAFF, AnimationData.MAGIC_STANDARD_WAVE_STAFF_WALK),
|
||||
ItemID.WILD_CAVE_SCEPTRE_CHARGED),
|
||||
WEAPON_TRIDENT(Set.of(AnimationData.MAGIC_STANDARD_WAVE_STAFF, AnimationData.MAGIC_STANDARD_WAVE_STAFF_WALK),
|
||||
ItemID.TOTS, ItemID.TOTS_CHARGED, ItemID.TOTS_I_CHARGED, ItemID.TOTS_CHARGED_ORN, ItemID.TOTS_ORN,
|
||||
ItemID.TOTS_I_CHARGED_ORN),
|
||||
WEAPON_WARPED_SCEPTRE(AnimationData.MAGIC_WARPED_SCEPTRE, ItemID.WARPED_SCEPTRE);
|
||||
|
||||
@Getter
|
||||
private final Set<Integer> ids;
|
||||
@Getter
|
||||
private final Set<AnimationData> animations;
|
||||
|
||||
private static Set<Integer> ListToSet(int... id)
|
||||
{
|
||||
ImmutableSet.Builder<Integer> builder = new ImmutableSet.Builder<>();
|
||||
if (id.length == 0)
|
||||
{
|
||||
return builder.build();
|
||||
}
|
||||
for (int i : id)
|
||||
{
|
||||
builder.add(i);
|
||||
}
|
||||
return builder.build();
|
||||
}
|
||||
|
||||
PoweredStaves(int... id)
|
||||
{
|
||||
this.ids = ListToSet(id);
|
||||
this.animations = null;
|
||||
}
|
||||
|
||||
PoweredStaves(AnimationData spell, int... id)
|
||||
{
|
||||
this.ids = ListToSet(id);
|
||||
this.animations = Set.of(spell);
|
||||
}
|
||||
|
||||
PoweredStaves(Set<AnimationData> spell, int... id)
|
||||
{
|
||||
this.ids = ListToSet(id);
|
||||
this.animations = spell;
|
||||
}
|
||||
|
||||
protected static final int UNKNOWN_SPELL = 0xDEADBEEF;
|
||||
protected static final ImmutableMap<Integer, ImmutableMap<Integer, PoweredStaves>> POWERED_STAVES;
|
||||
|
||||
static
|
||||
{
|
||||
ImmutableMap.Builder<Integer, ImmutableMap<Integer, PoweredStaves>> builder = new ImmutableMap.Builder<>();
|
||||
|
||||
for (PoweredStaves p : values())
|
||||
{
|
||||
for (int id : p.ids)
|
||||
{
|
||||
ImmutableMap.Builder<Integer, PoweredStaves> spellMap = new ImmutableMap.Builder<>();
|
||||
if (p.animations == null)
|
||||
{
|
||||
spellMap.put(UNKNOWN_SPELL, p);
|
||||
}
|
||||
else
|
||||
{
|
||||
for (AnimationData spell : p.animations)
|
||||
{
|
||||
spellMap.put(spell.animationId, p);
|
||||
}
|
||||
}
|
||||
builder.put(id, spellMap.build());
|
||||
}
|
||||
}
|
||||
|
||||
POWERED_STAVES = builder.build();
|
||||
}
|
||||
|
||||
public static PoweredStaves getPoweredStaves(int weaponId, AnimationData animation)
|
||||
{
|
||||
ImmutableMap<Integer, PoweredStaves> weaponMap = POWERED_STAVES.get(weaponId);
|
||||
if (weaponMap == null || animation == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
if (weaponMap.containsKey(UNKNOWN_SPELL))
|
||||
{
|
||||
return weaponMap.get(UNKNOWN_SPELL);
|
||||
}
|
||||
return weaponMap.get(animation.animationId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString()
|
||||
{
|
||||
String[] words = super.toString().toLowerCase().split("_");
|
||||
Arrays.stream(words).map(StringUtils::capitalize).collect(Collectors.toList()).toArray(words);
|
||||
return String.join(" ", words);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package com.example.essentialsplus.helpers.combat;
|
||||
|
||||
import com.google.common.collect.ImmutableMap;
|
||||
|
||||
public enum Spellbook
|
||||
{
|
||||
STANDARD(0),
|
||||
ANCIENT(1),
|
||||
LUNAR(2),
|
||||
ARCEUUS(3);
|
||||
|
||||
private final int id;
|
||||
|
||||
Spellbook(int id)
|
||||
{
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
private static final ImmutableMap<Integer, Spellbook> BOOKS;
|
||||
|
||||
static
|
||||
{
|
||||
ImmutableMap.Builder<Integer, Spellbook> builder = new ImmutableMap.Builder<>();
|
||||
for (Spellbook data : values())
|
||||
{
|
||||
builder.put(data.id, data);
|
||||
}
|
||||
BOOKS = builder.build();
|
||||
}
|
||||
|
||||
public static Spellbook fromVarbit(int varbit)
|
||||
{
|
||||
return BOOKS.get(varbit);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package com.example.essentialsplus.helpers.combat;
|
||||
|
||||
import static com.example.essentialsplus.helpers.combat.AttackStyle.ACCURATE;
|
||||
import static com.example.essentialsplus.helpers.combat.AttackStyle.AGGRESSIVE;
|
||||
import static com.example.essentialsplus.helpers.combat.AttackStyle.CASTING;
|
||||
import static com.example.essentialsplus.helpers.combat.AttackStyle.DEFENSIVE;
|
||||
import static com.example.essentialsplus.helpers.combat.AttackStyle.DEFENSIVE_CASTING;
|
||||
import static com.example.essentialsplus.helpers.combat.AttackStyle.OTHER;
|
||||
import static com.example.essentialsplus.helpers.combat.AttackType.CRUSH;
|
||||
import static com.example.essentialsplus.helpers.combat.AttackType.MAGIC;
|
||||
import static com.example.essentialsplus.helpers.combat.AttackType.NONE;
|
||||
import static com.example.essentialsplus.helpers.combat.AttackType.RANGED;
|
||||
import static com.example.essentialsplus.helpers.combat.AttackType.SLASH;
|
||||
import static com.example.essentialsplus.helpers.combat.AttackType.STAB;
|
||||
|
||||
import com.google.common.collect.ImmutableMap;
|
||||
import java.util.Map;
|
||||
import lombok.Getter;
|
||||
import net.runelite.api.Client;
|
||||
import net.runelite.api.EnumID;
|
||||
import net.runelite.api.ParamID;
|
||||
import net.runelite.api.StructComposition;
|
||||
|
||||
public enum WeaponType
|
||||
{
|
||||
UNARMED(CRUSH, CRUSH, null, CRUSH),
|
||||
AXE(SLASH, SLASH, CRUSH, SLASH),
|
||||
BLUNT(CRUSH, CRUSH, null, CRUSH),
|
||||
BOW(RANGED, RANGED, null, RANGED),
|
||||
CLAW(SLASH, SLASH, STAB, SLASH),
|
||||
HEAVY_RANGED(RANGED, RANGED, null, RANGED),
|
||||
SALAMANDER(SLASH, RANGED, MAGIC, null),
|
||||
CHINCHOMPA(RANGED, RANGED, null, RANGED),
|
||||
GUN(NONE, CRUSH, null, null),
|
||||
SLASH_SWORD(SLASH, SLASH, STAB, SLASH),
|
||||
TWO_HANDED_SWORD(SLASH, SLASH, CRUSH, SLASH),
|
||||
PICKAXE(STAB, STAB, CRUSH, STAB),
|
||||
POLEARM(STAB, SLASH, null, STAB),
|
||||
POLESTAFF(CRUSH, CRUSH, null, CRUSH),
|
||||
SCYTHE(SLASH, SLASH, CRUSH, SLASH),
|
||||
SPEAR(STAB, SLASH, CRUSH, STAB),
|
||||
SPIKED(CRUSH, CRUSH, STAB, CRUSH),
|
||||
STAB_SWORD(STAB, STAB, SLASH, STAB),
|
||||
STAFF(CRUSH, CRUSH, null, CRUSH, MAGIC, MAGIC),
|
||||
THROWN(RANGED, RANGED, null, RANGED),
|
||||
WHIP(SLASH, SLASH, null, SLASH),
|
||||
BLADED_STAFF(STAB, SLASH, null, CRUSH, MAGIC, MAGIC),
|
||||
TWO_HANDED_SWORD_GODSWORD(STAB, SLASH, null, CRUSH, MAGIC, MAGIC),
|
||||
POWERED_STAFF(SLASH, SLASH, CRUSH, SLASH),
|
||||
BANNER(MAGIC, MAGIC, null, MAGIC),
|
||||
POLEARM_CRYSTAL_HALBERD(STAB, SLASH, CRUSH, STAB),
|
||||
BLUDGEON(STAB, SLASH, null, STAB),
|
||||
BULWARK(CRUSH, CRUSH, null, CRUSH),
|
||||
POWERED_WAND(CRUSH, null, null, NONE),
|
||||
PARTISAN(MAGIC, MAGIC, null, MAGIC),
|
||||
PARTISAN_2(STAB, STAB, CRUSH, STAB),
|
||||
PLACE_HOLDER_0(null, null, null),
|
||||
PLACE_HOLDER_1(null, null, null),
|
||||
PLACE_HOLDER_2(null, null, null),
|
||||
PLACE_HOLDER_3(null, null, null),
|
||||
FLAIL(SLASH, SLASH, null, SLASH);
|
||||
|
||||
private static final Map<Integer, WeaponType> WEAPON_TYPES;
|
||||
|
||||
@Getter
|
||||
private final AttackType[] attackTypes;
|
||||
|
||||
static
|
||||
{
|
||||
ImmutableMap.Builder<Integer, WeaponType> builder = new ImmutableMap.Builder<>();
|
||||
for (WeaponType weaponType : values())
|
||||
{
|
||||
builder.put(weaponType.ordinal(), weaponType);
|
||||
}
|
||||
WEAPON_TYPES = builder.build();
|
||||
}
|
||||
|
||||
WeaponType(AttackType... attackTypes)
|
||||
{
|
||||
this.attackTypes = attackTypes;
|
||||
}
|
||||
|
||||
public AttackStyle[] getAttackStyles(Client client)
|
||||
{
|
||||
int weaponType = this.ordinal();
|
||||
int weaponStyleEnum = client.getEnum(EnumID.WEAPON_STYLES).getIntValue(weaponType);
|
||||
if (weaponStyleEnum == -1)
|
||||
{
|
||||
if (weaponType == 22)
|
||||
{
|
||||
return new AttackStyle[]{
|
||||
ACCURATE, AGGRESSIVE, null, DEFENSIVE, CASTING, DEFENSIVE_CASTING,
|
||||
};
|
||||
}
|
||||
|
||||
if (weaponType == 30)
|
||||
{
|
||||
return new AttackStyle[]{
|
||||
ACCURATE, AGGRESSIVE, AGGRESSIVE, DEFENSIVE,
|
||||
};
|
||||
}
|
||||
return new AttackStyle[0];
|
||||
}
|
||||
int[] weaponStyleStructs = client.getEnum(weaponStyleEnum).getIntVals();
|
||||
|
||||
AttackStyle[] styles = new AttackStyle[weaponStyleStructs.length];
|
||||
int i = 0;
|
||||
for (int style : weaponStyleStructs)
|
||||
{
|
||||
StructComposition attackStyleStruct = client.getStructComposition(style);
|
||||
String attackStyleName = attackStyleStruct.getStringValue(ParamID.ATTACK_STYLE_NAME);
|
||||
AttackStyle attackStyle = AttackStyle.valueOf(attackStyleName.toUpperCase());
|
||||
if (attackStyle == OTHER)
|
||||
{
|
||||
++i;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (i == 5 && attackStyle == DEFENSIVE)
|
||||
{
|
||||
attackStyle = DEFENSIVE_CASTING;
|
||||
}
|
||||
|
||||
styles[i++] = attackStyle;
|
||||
}
|
||||
return styles;
|
||||
}
|
||||
|
||||
public static WeaponType getWeaponType(int id)
|
||||
{
|
||||
return WEAPON_TYPES.get(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.example.essentialsplus.helpers.combat.variablespeed;
|
||||
|
||||
import com.example.essentialsplus.helpers.combat.AnimationData;
|
||||
import com.example.essentialsplus.helpers.combat.AttackProcedure;
|
||||
import com.example.essentialsplus.helpers.combat.AttackType;
|
||||
import com.example.essentialsplus.helpers.combat.CombatUtils;
|
||||
import com.example.essentialsplus.helpers.combat.Spellbook;
|
||||
import net.runelite.api.Client;
|
||||
import net.runelite.api.coords.WorldPoint;
|
||||
import net.runelite.api.gameval.NpcID;
|
||||
|
||||
public class Amoxliatl implements IVariableSpeed
|
||||
{
|
||||
private static final int AMOXLIATL_REGION_ID = 5446;
|
||||
|
||||
@Override
|
||||
public int apply(final Client client, final AnimationData curAnimation, final AttackProcedure atkType,
|
||||
final Spellbook spellbook, final int damageDealt, final int lastSpecDelta, final int baseSpeed,
|
||||
final int curSpeed)
|
||||
{
|
||||
final WorldPoint location = CombatUtils.getLocalLocation(client);
|
||||
final int targetId = CombatUtils.getTargetId(client);
|
||||
final AttackType attkType = CombatUtils.getAttackType(client);
|
||||
if (location.getRegionID() == AMOXLIATL_REGION_ID && targetId == NpcID.AMOXLIATL_ICE_BLOCK && attkType != null && attkType.IsMelee())
|
||||
{
|
||||
return 1;
|
||||
}
|
||||
return curSpeed;
|
||||
}
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
package com.example.essentialsplus.helpers.combat.variablespeed;
|
||||
|
||||
import com.example.essentialsplus.helpers.combat.AnimationData;
|
||||
import com.example.essentialsplus.helpers.combat.AttackProcedure;
|
||||
import com.example.essentialsplus.helpers.combat.Spellbook;
|
||||
import net.runelite.api.Client;
|
||||
import net.runelite.api.gameval.SpotanimID;
|
||||
|
||||
public class BloodMoonSet implements IVariableSpeed
|
||||
{
|
||||
@Override
|
||||
public int apply(final Client client, final AnimationData curAnimation, final AttackProcedure atkType,
|
||||
final Spellbook spellbook, final int damageDealt, final int lastSpecDelta, final int baseSpeed,
|
||||
final int curSpeed)
|
||||
{
|
||||
if (client.getLocalPlayer() != null && client.getLocalPlayer().hasSpotAnim(SpotanimID.SPECIAL_DUAL_MACUAHUITL_SPOTANIM))
|
||||
{
|
||||
return curSpeed - 1;
|
||||
}
|
||||
return curSpeed;
|
||||
}
|
||||
}
|
||||
+127
@@ -0,0 +1,127 @@
|
||||
package com.example.essentialsplus.helpers.combat.variablespeed;
|
||||
|
||||
import com.example.essentialsplus.helpers.combat.AnimationData;
|
||||
import com.example.essentialsplus.helpers.combat.AttackProcedure;
|
||||
import com.example.essentialsplus.helpers.combat.Attacking;
|
||||
import com.example.essentialsplus.helpers.combat.CombatUtils;
|
||||
import com.example.essentialsplus.helpers.combat.Spellbook;
|
||||
import com.example.essentialsplus.helpers.combat.variablespeed.state.TickCount;
|
||||
import com.google.common.collect.ImmutableSet;
|
||||
import java.util.Set;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import net.runelite.api.Client;
|
||||
import net.runelite.api.NPC;
|
||||
import net.runelite.api.gameval.AnimationID;
|
||||
import net.runelite.api.gameval.ItemID;
|
||||
import net.runelite.api.gameval.NpcID;
|
||||
import net.runelite.client.game.ItemManager;
|
||||
|
||||
@Slf4j
|
||||
public class DoomOfMokhaiotl implements IVariableSpeed
|
||||
{
|
||||
private static final Set<Integer> DOOM_REGION_IDS = new ImmutableSet.Builder<Integer>()
|
||||
.add(5269)
|
||||
.add(13668)
|
||||
.add(14180)
|
||||
.build();
|
||||
|
||||
private static final Set<Integer> COOLDOWN_OVERWRITES_IDS = new ImmutableSet.Builder<Integer>()
|
||||
.add(NpcID.DOM_DEMONIC_ENERGY)
|
||||
.add(NpcID.DOM_DEMONIC_ENERGY_GIANT_MAGE)
|
||||
.add(NpcID.DOM_DEMONIC_ENERGY_GIANT_RANGE)
|
||||
.add(NpcID.DOM_DEMONIC_ENERGY_MAGE)
|
||||
.add(NpcID.DOM_DEMONIC_ENERGY_RANGE)
|
||||
.add(NpcID.DOM_DEMONIC_ENERGY_MELEE)
|
||||
.add(NpcID.DOM_SHOCKWAVE_PATH_NODE)
|
||||
.build();
|
||||
|
||||
private static final Set<Integer> NO_COOLDOWN_WEAPON = new ImmutableSet.Builder<Integer>()
|
||||
.add(ItemID.SILVERLIGHT)
|
||||
.add(ItemID.DARKLIGHT)
|
||||
.add(ItemID.ARCLIGHT)
|
||||
.add(ItemID.EMBERLIGHT)
|
||||
.add(ItemID.BONE_CLAWS)
|
||||
.add(ItemID.SCORCHING_BOW)
|
||||
.add(ItemID.HOLY_WATER)
|
||||
.add(ItemID.EYE_OF_AYAK)
|
||||
.build();
|
||||
|
||||
private final TickCount tickCount;
|
||||
private int larvaeConsumed = -1;
|
||||
private int shieldConsumed = -1;
|
||||
|
||||
public DoomOfMokhaiotl(final TickCount tc)
|
||||
{
|
||||
this.tickCount = tc;
|
||||
}
|
||||
|
||||
public boolean onRender(final Client client, final ItemManager itemManager, final Spellbook spellbook)
|
||||
{
|
||||
if (!CombatUtils.isInRegionId(client, DOOM_REGION_IDS))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
final var atk = Attacking.playerAttack(client);
|
||||
final AnimationData anim = AnimationData.fromId(atk.getAnimationId());
|
||||
if (anim == null || atk.getTarget() == null || !(atk.getTarget() instanceof NPC) || anim.isBlockListAnimation())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
final NPC npc = (NPC) atk.getTarget();
|
||||
final int npcId = npc.getId();
|
||||
if (COOLDOWN_OVERWRITES_IDS.contains(npcId))
|
||||
{
|
||||
if (tickCount.isWithinNTicks(larvaeConsumed, 1))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
final int weaponId = CombatUtils.getWeaponId(client);
|
||||
final boolean isDemonbaneSpell = spellbook == Spellbook.ARCEUUS && AnimationData.isManualCasting(anim) && anim == AnimationData.MAGIC_ARCEUUS_DEMONBANE;
|
||||
if (NO_COOLDOWN_WEAPON.contains(weaponId) || isDemonbaneSpell)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
larvaeConsumed = tickCount.get();
|
||||
return true;
|
||||
}
|
||||
else if (npcId == NpcID.DOM_BOSS)
|
||||
{
|
||||
if (tickCount.isWithinNTicks(shieldConsumed, 30))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
final int animId = npc.getAnimation();
|
||||
if (animId == AnimationID.DOM_BEAM_CHARGE_LOOP || animId == AnimationID.DOM_BEAM_CHARGE)
|
||||
{
|
||||
if (CombatUtils.getAttackType(client) != null && CombatUtils.getAttackType(client).IsMelee())
|
||||
{
|
||||
shieldConsumed = tickCount.get();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int apply(final Client client, final AnimationData curAnimation, final AttackProcedure atkType,
|
||||
final Spellbook spellbook, final int damageDealt, final int lastSpecDelta, final int baseSpeed,
|
||||
final int curSpeed)
|
||||
{
|
||||
final int targetId = CombatUtils.getTargetId(client);
|
||||
final boolean inDoom = CombatUtils.isInRegionId(client, DOOM_REGION_IDS);
|
||||
if (inDoom && COOLDOWN_OVERWRITES_IDS.contains(targetId))
|
||||
{
|
||||
final int weaponId = CombatUtils.getWeaponId(client);
|
||||
final boolean isDemonbaneSpell = spellbook == Spellbook.ARCEUUS && AnimationData.isManualCasting(curAnimation) && curAnimation == AnimationData.MAGIC_ARCEUUS_DEMONBANE;
|
||||
if (NO_COOLDOWN_WEAPON.contains(weaponId) || isDemonbaneSpell)
|
||||
{
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
return curSpeed;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.example.essentialsplus.helpers.combat.variablespeed;
|
||||
|
||||
import com.example.essentialsplus.helpers.combat.AnimationData;
|
||||
import com.example.essentialsplus.helpers.combat.AttackProcedure;
|
||||
import com.example.essentialsplus.helpers.combat.Spellbook;
|
||||
import net.runelite.api.Client;
|
||||
|
||||
public class EyeOfAyak implements IVariableSpeed
|
||||
{
|
||||
@Override
|
||||
public int apply(final Client client, final AnimationData curAnimation, final AttackProcedure atkType,
|
||||
final Spellbook spellbook, final int damageDealt, final int lastSpecDelta, final int baseSpeed,
|
||||
final int curSpeed)
|
||||
{
|
||||
if (curAnimation == AnimationData.MAGIC_EYE_OF_AYAK_SPEC)
|
||||
{
|
||||
return 5;
|
||||
}
|
||||
return curSpeed;
|
||||
}
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
package com.example.essentialsplus.helpers.combat.variablespeed;
|
||||
|
||||
import com.example.essentialsplus.helpers.combat.AnimationData;
|
||||
import com.example.essentialsplus.helpers.combat.AttackProcedure;
|
||||
import com.example.essentialsplus.helpers.combat.Spellbook;
|
||||
import com.example.essentialsplus.helpers.combat.variablespeed.state.IStateTracker;
|
||||
import net.runelite.api.Client;
|
||||
|
||||
public interface IVariableSpeed extends IStateTracker
|
||||
{
|
||||
int apply(
|
||||
final Client client,
|
||||
final AnimationData curAnimation,
|
||||
final AttackProcedure atkType,
|
||||
final Spellbook spellbook,
|
||||
final int damageDealt,
|
||||
final int lastSpecDelta,
|
||||
final int baseSpeed,
|
||||
final int curSpeed
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package com.example.essentialsplus.helpers.combat.variablespeed;
|
||||
|
||||
import com.example.essentialsplus.helpers.combat.AnimationData;
|
||||
import com.example.essentialsplus.helpers.combat.Attacking;
|
||||
import com.example.essentialsplus.helpers.combat.CombatUtils;
|
||||
import com.example.essentialsplus.helpers.combat.variablespeed.state.TickCount;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import net.runelite.api.Client;
|
||||
import net.runelite.api.NPC;
|
||||
import net.runelite.api.gameval.AnimationID;
|
||||
import net.runelite.api.gameval.NpcID;
|
||||
import net.runelite.client.game.ItemManager;
|
||||
|
||||
@Slf4j
|
||||
public class MaggotKing
|
||||
{
|
||||
private static final int MAGGOT_KING_REGION_ID = 11645;
|
||||
|
||||
private final TickCount tickCount;
|
||||
private int consumed = -1;
|
||||
|
||||
public MaggotKing(final TickCount tc)
|
||||
{
|
||||
this.tickCount = tc;
|
||||
}
|
||||
|
||||
public boolean onRender(final Client client, final ItemManager itemManager)
|
||||
{
|
||||
if (!CombatUtils.isInRegionId(client, MAGGOT_KING_REGION_ID) || tickCount.isWithinNTicks(consumed, 1))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
final var atk = Attacking.playerAttack(client);
|
||||
final AnimationData anim = AnimationData.fromId(atk.getAnimationId());
|
||||
if (anim == null || atk.getTarget() == null || !(atk.getTarget() instanceof NPC))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
final NPC npc = (NPC) atk.getTarget();
|
||||
if (npc.getId() != NpcID.UR_MAGGOT_LARVAE || !anim.isStandardBowAttack()
|
||||
|| npc.getAnimation() != AnimationID.UR_MAGGOT_LARVAE_FLY)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
consumed = tickCount.get();
|
||||
return true;
|
||||
}
|
||||
|
||||
public int onChatMessage(final Client client, final String message)
|
||||
{
|
||||
if (message == null)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
if (message.contains("The sticky acid hampers your ability to attack!"))
|
||||
{
|
||||
return 1;
|
||||
}
|
||||
else if (message.contains("The Maggot King's screech disrupts your concentration!"))
|
||||
{
|
||||
return 3;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
package com.example.essentialsplus.helpers.combat.variablespeed;
|
||||
|
||||
import com.example.essentialsplus.helpers.combat.AnimationData;
|
||||
import com.example.essentialsplus.helpers.combat.AttackProcedure;
|
||||
import com.example.essentialsplus.helpers.combat.CombatUtils;
|
||||
import com.example.essentialsplus.helpers.combat.Spellbook;
|
||||
import com.example.essentialsplus.helpers.combat.variablespeed.state.Yama;
|
||||
import net.runelite.api.Client;
|
||||
import net.runelite.api.NPC;
|
||||
import net.runelite.api.gameval.ItemID;
|
||||
|
||||
public class PurgingStaffSpec implements IVariableSpeed
|
||||
{
|
||||
private final Yama yama;
|
||||
public NPC lastTarget;
|
||||
|
||||
public PurgingStaffSpec(Yama yama)
|
||||
{
|
||||
this.yama = yama;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int apply(final Client client, final AnimationData curAnimation, final AttackProcedure atkType,
|
||||
final Spellbook spellbook, final int damageDealt, final int lastSpecDelta, final int baseSpeed,
|
||||
final int curSpeed)
|
||||
{
|
||||
if (this.yama == null || !this.yama.inYamaRegion)
|
||||
{
|
||||
return curSpeed;
|
||||
}
|
||||
final NPC target = CombatUtils.getTargetNPC(client);
|
||||
final NPC flare = Yama.isEitherVoidFlare(target, lastTarget);
|
||||
lastTarget = target;
|
||||
if (flare == null)
|
||||
{
|
||||
return curSpeed;
|
||||
}
|
||||
|
||||
yama.dealVoidFlareDamage(flare, damageDealt);
|
||||
if (lastSpecDelta != -250 || CombatUtils.getWeaponId(client) != ItemID.PURGING_STAFF || spellbook != Spellbook.ARCEUUS)
|
||||
{
|
||||
return curSpeed;
|
||||
}
|
||||
|
||||
if (yama.getVoidFlareDead(flare))
|
||||
{
|
||||
return curSpeed - 2;
|
||||
}
|
||||
return curSpeed;
|
||||
}
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
package com.example.essentialsplus.helpers.combat.variablespeed;
|
||||
|
||||
import com.example.essentialsplus.helpers.combat.AnimationData;
|
||||
import com.example.essentialsplus.helpers.combat.AttackProcedure;
|
||||
import com.example.essentialsplus.helpers.combat.AttackStyle;
|
||||
import com.example.essentialsplus.helpers.combat.CombatUtils;
|
||||
import com.example.essentialsplus.helpers.combat.Spellbook;
|
||||
import net.runelite.api.Client;
|
||||
import net.runelite.api.gameval.VarPlayerID;
|
||||
|
||||
public class RapidAttackStyle implements IVariableSpeed
|
||||
{
|
||||
@Override
|
||||
public int apply(final Client client, final AnimationData curAnimation, final AttackProcedure atkType,
|
||||
final Spellbook spellbook, final int damageDealt, final int lastSpecDelta, final int baseSpeed,
|
||||
final int curSpeed)
|
||||
{
|
||||
final boolean isRapid = client.getVarpValue(VarPlayerID.COM_MODE) == 1;
|
||||
if (atkType == AttackProcedure.MELEE_OR_RANGE && CombatUtils.getAttackStyle(client) == AttackStyle.RANGING && isRapid)
|
||||
{
|
||||
return curSpeed - 1;
|
||||
}
|
||||
return curSpeed;
|
||||
}
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
package com.example.essentialsplus.helpers.combat.variablespeed;
|
||||
|
||||
import com.example.essentialsplus.helpers.combat.AnimationData;
|
||||
import com.example.essentialsplus.helpers.combat.AttackProcedure;
|
||||
import com.example.essentialsplus.helpers.combat.Spellbook;
|
||||
import net.runelite.api.Client;
|
||||
|
||||
public class RedKerisSpec implements IVariableSpeed
|
||||
{
|
||||
@Override
|
||||
public int apply(final Client client, final AnimationData curAnimation, final AttackProcedure atkType,
|
||||
final Spellbook spellbook, final int damageDealt, final int lastSpecDelta, final int baseSpeed,
|
||||
final int curSpeed)
|
||||
{
|
||||
if (lastSpecDelta != -750 || curAnimation != AnimationData.MELEE_RED_KERIS_SPEC)
|
||||
{
|
||||
return curSpeed;
|
||||
}
|
||||
if (damageDealt > 0)
|
||||
{
|
||||
return curSpeed + 4;
|
||||
}
|
||||
return curSpeed;
|
||||
}
|
||||
}
|
||||
+174
@@ -0,0 +1,174 @@
|
||||
package com.example.essentialsplus.helpers.combat.variablespeed;
|
||||
|
||||
import com.example.essentialsplus.helpers.combat.AnimationData;
|
||||
import com.example.essentialsplus.helpers.combat.AttackProcedure;
|
||||
import com.example.essentialsplus.helpers.combat.CombatUtils;
|
||||
import com.example.essentialsplus.helpers.combat.Spellbook;
|
||||
import com.google.common.collect.ImmutableSet;
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
import net.runelite.api.Client;
|
||||
import net.runelite.api.NPC;
|
||||
import net.runelite.api.coords.WorldPoint;
|
||||
import net.runelite.api.events.GameTick;
|
||||
import net.runelite.api.events.NpcDespawned;
|
||||
import net.runelite.api.events.NpcSpawned;
|
||||
import net.runelite.api.gameval.ItemID;
|
||||
import net.runelite.api.gameval.NpcID;
|
||||
|
||||
public class RoyalTitans implements IVariableSpeed
|
||||
{
|
||||
private static final int ROYAL_TITANS_REGION_ID = 11669;
|
||||
private static final int HP_FUDGE = 1;
|
||||
private static final int ELEMENTAL_HP = 40 - HP_FUDGE;
|
||||
|
||||
private final Set<NPC> iceElementals = new HashSet<>();
|
||||
private final Set<NPC> fireElementals = new HashSet<>();
|
||||
private boolean removeDead = false;
|
||||
|
||||
private static final Set<AnimationData> ONE_SHOT_SPELLS = new ImmutableSet.Builder<AnimationData>()
|
||||
.add(AnimationData.MAGIC_STANDARD_STRIKE_BOLT_BLAST)
|
||||
.add(AnimationData.MAGIC_STANDARD_STRIKE_BOLT_BLAST_STAFF)
|
||||
.add(AnimationData.MAGIC_STANDARD_STRIKE_BOLT_BLAST_STAFF_WALK)
|
||||
.add(AnimationData.MAGIC_STANDARD_STRIKE_BOLT_BLAST_WALK)
|
||||
.add(AnimationData.MAGIC_STANDARD_SURGE)
|
||||
.add(AnimationData.MAGIC_STANDARD_SURGE_STAFF)
|
||||
.add(AnimationData.MAGIC_STANDARD_SURGE_STAFF_WALK)
|
||||
.add(AnimationData.MAGIC_STANDARD_WAVE)
|
||||
.add(AnimationData.MAGIC_STANDARD_WAVE_STAFF)
|
||||
.add(AnimationData.MAGIC_STANDARD_WAVE_STAFF_WALK)
|
||||
.add(AnimationData.MAGIC_STANDARD_WAVE_WALK)
|
||||
.build();
|
||||
|
||||
@Override
|
||||
public int apply(final Client client, final AnimationData curAnimation, final AttackProcedure atkType,
|
||||
final Spellbook spellbook, final int damageDealt, final int lastSpecDelta, final int baseSpeed,
|
||||
final int curSpeed)
|
||||
{
|
||||
if (earlyExit(client, atkType, damageDealt, spellbook))
|
||||
{
|
||||
return curSpeed;
|
||||
}
|
||||
final int targetId = CombatUtils.getTargetId(client);
|
||||
if (spellbook != Spellbook.STANDARD || !isElemental(targetId) || !ONE_SHOT_SPELLS.contains(curAnimation))
|
||||
{
|
||||
return curSpeed;
|
||||
}
|
||||
final NPC target = CombatUtils.getTargetNPC(client);
|
||||
if (target == null)
|
||||
{
|
||||
return curSpeed;
|
||||
}
|
||||
|
||||
final boolean wieldingTwinflame = CombatUtils.getWeaponId(client) == ItemID.TWINFLAME_STAFF;
|
||||
final int computedDamage = wieldingTwinflame ? damageDealt + ((damageDealt * 4) / 10) : damageDealt;
|
||||
|
||||
if (computedDamage < ELEMENTAL_HP)
|
||||
{
|
||||
return curSpeed;
|
||||
}
|
||||
|
||||
final var set = targetId == NpcID.RT_SUMMON_ELEMENTAL_FIRE ? fireElementals : iceElementals;
|
||||
int count = 1;
|
||||
final var reference = target.getWorldLocation();
|
||||
for (final NPC elemental : set)
|
||||
{
|
||||
if (elemental == target)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
final WorldPoint worldLocation = elemental.getWorldLocation();
|
||||
final int distanceTo2D = reference.distanceTo2D(worldLocation);
|
||||
if (distanceTo2D <= 1)
|
||||
{
|
||||
count++;
|
||||
}
|
||||
}
|
||||
|
||||
final boolean isTwinflameKillOnSecondProjectile = wieldingTwinflame &&
|
||||
curAnimation == AnimationData.MAGIC_STANDARD_STRIKE_BOLT_BLAST_STAFF;
|
||||
final int extraHitOffset = isTwinflameKillOnSecondProjectile ? 1 : 0;
|
||||
|
||||
final WorldPoint playerLoc = client.getLocalPlayer().getWorldLocation();
|
||||
final int distance = playerLoc.distanceTo2D(reference);
|
||||
final int hitDelay = 2 + (distance / 3) + extraHitOffset;
|
||||
|
||||
final int remainingAtImpact = Math.max(0, curSpeed - hitDelay);
|
||||
final int remainingAfterReduction = Math.max(0, remainingAtImpact - count);
|
||||
final int finalSpeed = Math.min(curSpeed, hitDelay + remainingAfterReduction);
|
||||
|
||||
removeDead = true;
|
||||
return finalSpeed;
|
||||
}
|
||||
|
||||
private static boolean earlyExit(final Client client, final AttackProcedure atkType, final int damageDealt,
|
||||
final Spellbook spellbook)
|
||||
{
|
||||
return notInRegion(client)
|
||||
|| atkType != AttackProcedure.MANUAL_AUTO_CAST
|
||||
|| damageDealt <= 0
|
||||
|| spellbook != Spellbook.STANDARD;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onGameTick(Client client, GameTick tick)
|
||||
{
|
||||
if (removeDead)
|
||||
{
|
||||
boolean removed = iceElementals.removeIf(NPC::isDead);
|
||||
removed |= fireElementals.removeIf(NPC::isDead);
|
||||
if (removed)
|
||||
{
|
||||
removeDead = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onNpcSpawned(final Client client, final NpcSpawned npcSpawned)
|
||||
{
|
||||
if (notInRegion(client))
|
||||
{
|
||||
return;
|
||||
}
|
||||
final NPC npc = npcSpawned.getNpc();
|
||||
final int id = npc.getId();
|
||||
if (id == NpcID.RT_SUMMON_ELEMENTAL_ICE)
|
||||
{
|
||||
iceElementals.add(npc);
|
||||
}
|
||||
else if (id == NpcID.RT_SUMMON_ELEMENTAL_FIRE)
|
||||
{
|
||||
fireElementals.add(npc);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onNpcDespawned(final Client client, final NpcDespawned npcDespawned)
|
||||
{
|
||||
if (notInRegion(client))
|
||||
{
|
||||
return;
|
||||
}
|
||||
final NPC npc = npcDespawned.getNpc();
|
||||
final int id = npc.getId();
|
||||
if (id == NpcID.RT_SUMMON_ELEMENTAL_ICE)
|
||||
{
|
||||
iceElementals.remove(npc);
|
||||
}
|
||||
else if (id == NpcID.RT_SUMMON_ELEMENTAL_FIRE)
|
||||
{
|
||||
fireElementals.remove(npc);
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean isElemental(int id)
|
||||
{
|
||||
return id == NpcID.RT_SUMMON_ELEMENTAL_FIRE || id == NpcID.RT_SUMMON_ELEMENTAL_ICE;
|
||||
}
|
||||
|
||||
private static boolean notInRegion(final Client client)
|
||||
{
|
||||
return CombatUtils.getLocalLocation(client).getRegionID() != ROYAL_TITANS_REGION_ID;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package com.example.essentialsplus.helpers.combat.variablespeed;
|
||||
|
||||
import com.example.essentialsplus.helpers.combat.AnimationData;
|
||||
import com.example.essentialsplus.helpers.combat.AttackProcedure;
|
||||
import com.example.essentialsplus.helpers.combat.CombatUtils;
|
||||
import com.example.essentialsplus.helpers.combat.Spellbook;
|
||||
import net.runelite.api.Client;
|
||||
import net.runelite.api.coords.WorldPoint;
|
||||
import net.runelite.api.gameval.ItemID;
|
||||
import net.runelite.api.gameval.NpcID;
|
||||
|
||||
public class Scurrius implements IVariableSpeed
|
||||
{
|
||||
private static final int SCURRIUS_REGION_ID = 13210;
|
||||
private static final int SCURRIUS_MIN_X = 3290;
|
||||
private static final int SCURRIUS_MAX_X = 3307;
|
||||
private static final int SCURRIUS_MIN_Y = 9859;
|
||||
private static final int SCURRIUS_MAX_Y = 9876;
|
||||
|
||||
private static boolean attackingGiantRatWithBoneWeapon(final int equipped, final int regionId, final int x,
|
||||
final int y, final int target)
|
||||
{
|
||||
final boolean correctWeapon = equipped == ItemID.RAT_BONE_STAFF || equipped == ItemID.RAT_BONE_MACE || equipped == ItemID.RAT_BONE_BOW;
|
||||
final boolean correctCoords = x >= SCURRIUS_MIN_X && x <= SCURRIUS_MAX_X && y >= SCURRIUS_MIN_Y && y <= SCURRIUS_MAX_Y;
|
||||
final boolean correctRegion = regionId == SCURRIUS_REGION_ID;
|
||||
final boolean correctEnemy = target == NpcID.RAT_BOSS_GIANT_RAT;
|
||||
return correctWeapon && correctCoords && correctRegion && correctEnemy;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int apply(final Client client, final AnimationData curAnimation, final AttackProcedure atkType,
|
||||
final Spellbook spellbook, final int damageDealt, final int lastSpecDelta, final int baseSpeed,
|
||||
final int curSpeed)
|
||||
{
|
||||
final WorldPoint location = CombatUtils.getLocalLocation(client);
|
||||
final int weaponId = CombatUtils.getWeaponId(client);
|
||||
final int targetId = CombatUtils.getTargetId(client);
|
||||
if (attackingGiantRatWithBoneWeapon(weaponId, location.getRegionID(), location.getX(), location.getY(), targetId))
|
||||
{
|
||||
return 1;
|
||||
}
|
||||
return curSpeed;
|
||||
}
|
||||
}
|
||||
+257
@@ -0,0 +1,257 @@
|
||||
package com.example.essentialsplus.helpers.combat.variablespeed;
|
||||
|
||||
import com.example.essentialsplus.helpers.combat.variablespeed.state.MarkOfDarkness;
|
||||
import com.example.essentialsplus.helpers.combat.variablespeed.state.TickCount;
|
||||
import com.example.essentialsplus.helpers.combat.variablespeed.state.Yama;
|
||||
import com.example.essentialsplus.helpers.combat.variablespeed.state.YamaPhase;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import net.runelite.api.Client;
|
||||
import net.runelite.api.GraphicsObject;
|
||||
import net.runelite.api.Player;
|
||||
import net.runelite.api.WorldView;
|
||||
import net.runelite.api.coords.LocalPoint;
|
||||
import net.runelite.api.coords.WorldPoint;
|
||||
|
||||
@Slf4j
|
||||
public class ShadowCrash
|
||||
{
|
||||
private static final int FIREBALL_ID = 3262;
|
||||
private static final int TILE = 128;
|
||||
|
||||
private static final int HV_GAP_1 = 3;
|
||||
private static final int HV_GAP_2 = 6;
|
||||
private static final int HV_DELTA_1 = HV_GAP_1 * TILE;
|
||||
private static final int HV_DELTA_2 = HV_GAP_2 * TILE;
|
||||
private static final int D_GAP_1 = 2;
|
||||
private static final int D_GAP_2 = 4;
|
||||
private static final int D_DELTA_1 = D_GAP_1 * TILE;
|
||||
private static final int D_DELTA_2 = D_GAP_2 * TILE;
|
||||
|
||||
private final TickCount tickCount;
|
||||
private final Yama yama;
|
||||
private final MarkOfDarkness mod;
|
||||
|
||||
private int consumed = -1;
|
||||
private final Map<LocalPoint, GraphicsObject> fireballs;
|
||||
private ArrayList<FireballLine> lines;
|
||||
private WorldPoint player;
|
||||
|
||||
public ShadowCrash(Yama yama, MarkOfDarkness mod, TickCount tc)
|
||||
{
|
||||
this.yama = yama;
|
||||
this.mod = mod;
|
||||
this.tickCount = tc;
|
||||
this.fireballs = new HashMap<>();
|
||||
this.lines = new ArrayList<>();
|
||||
}
|
||||
|
||||
public int onRender(final Client client, final int attackDelayHoldoffTicks, final boolean isUsingMagic)
|
||||
{
|
||||
if (yama == null || !yama.inYamaRegion || yama.phase() != YamaPhase.P3 || !mod.isActive())
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
if (!tickCount.isWithinNTicks(consumed, 2) && shadowCrashSweetSpot(client))
|
||||
{
|
||||
consumed = tickCount.get();
|
||||
if (attackDelayHoldoffTicks <= 1 && isUsingMagic)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
private boolean shadowCrashSweetSpot(final Client client)
|
||||
{
|
||||
getState(client);
|
||||
if (lines.isEmpty())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
for (var line : lines)
|
||||
{
|
||||
if (line.isActive())
|
||||
{
|
||||
return line.inSweetSpot(client, player);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private void getState(final Client client)
|
||||
{
|
||||
fireballs.clear();
|
||||
lines.clear();
|
||||
final WorldView tlwv = client.getTopLevelWorldView();
|
||||
final Player localPlayer = client.getLocalPlayer();
|
||||
if (localPlayer == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
final WorldView playerWv = localPlayer.getWorldView();
|
||||
if (tlwv != null)
|
||||
{
|
||||
getFireballs(tlwv);
|
||||
}
|
||||
if (playerWv != null && playerWv != tlwv)
|
||||
{
|
||||
getFireballs(playerWv);
|
||||
}
|
||||
if (fireballs.size() < 3)
|
||||
{
|
||||
return;
|
||||
}
|
||||
player = localPlayer.getWorldLocation();
|
||||
lines = getLines();
|
||||
}
|
||||
|
||||
private void getFireballs(final WorldView wv)
|
||||
{
|
||||
for (GraphicsObject graphicsObject : wv.getGraphicsObjects())
|
||||
{
|
||||
if (graphicsObject.getId() == FIREBALL_ID)
|
||||
{
|
||||
fireballs.put(graphicsObject.getLocation(), graphicsObject);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private ArrayList<FireballLine> getLines()
|
||||
{
|
||||
final var result = new ArrayList<FireballLine>();
|
||||
|
||||
while (fireballs.size() >= 3)
|
||||
{
|
||||
LocalPoint first = null;
|
||||
final Set<LocalPoint> fireballsLocations = fireballs.keySet();
|
||||
for (var location : fireballsLocations)
|
||||
{
|
||||
if (first == null)
|
||||
{
|
||||
first = location;
|
||||
}
|
||||
else
|
||||
{
|
||||
if (first.getX() > location.getX())
|
||||
{
|
||||
first = location;
|
||||
}
|
||||
else if (first.getX() == location.getX())
|
||||
{
|
||||
if (first.getY() > location.getY())
|
||||
{
|
||||
first = location;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (first == null)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
var centre = first.plus(D_DELTA_1, D_DELTA_1);
|
||||
var end = first.plus(D_DELTA_2, D_DELTA_2);
|
||||
if (fireballsLocations.contains(centre) && fireballsLocations.contains(end))
|
||||
{
|
||||
result.add(new FireballLine(LineType.D2, centre, fireballs.get(centre)));
|
||||
removeLine(first, centre, end);
|
||||
continue;
|
||||
}
|
||||
|
||||
centre = first.plus(HV_DELTA_1, 0);
|
||||
end = first.plus(HV_DELTA_2, 0);
|
||||
if (fireballsLocations.contains(centre) && fireballsLocations.contains(end))
|
||||
{
|
||||
result.add(new FireballLine(LineType.H, centre, fireballs.get(centre)));
|
||||
removeLine(first, centre, end);
|
||||
continue;
|
||||
}
|
||||
|
||||
centre = first.plus(0, HV_DELTA_1);
|
||||
end = first.plus(0, HV_DELTA_2);
|
||||
if (fireballsLocations.contains(centre) && fireballsLocations.contains(end))
|
||||
{
|
||||
result.add(new FireballLine(LineType.V, centre, fireballs.get(centre)));
|
||||
removeLine(first, centre, end);
|
||||
continue;
|
||||
}
|
||||
|
||||
centre = first.plus(D_DELTA_1, -D_DELTA_1);
|
||||
end = first.plus(D_DELTA_2, -D_DELTA_2);
|
||||
if (fireballsLocations.contains(centre) && fireballsLocations.contains(end))
|
||||
{
|
||||
result.add(new FireballLine(LineType.D1, centre, fireballs.get(centre)));
|
||||
removeLine(first, centre, end);
|
||||
continue;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private void removeLine(final LocalPoint a, final LocalPoint b, final LocalPoint c)
|
||||
{
|
||||
fireballs.remove(a);
|
||||
fireballs.remove(b);
|
||||
fireballs.remove(c);
|
||||
}
|
||||
|
||||
public static boolean eq(final WorldPoint t, final int dx, final int dy, final WorldPoint other)
|
||||
{
|
||||
if (t == null || other == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
return t.getX() + dx == other.getX() && t.getY() + dy == other.getY();
|
||||
}
|
||||
|
||||
static class FireballLine
|
||||
{
|
||||
private final LineType type;
|
||||
private final LocalPoint centre;
|
||||
private final GraphicsObject cObject;
|
||||
|
||||
FireballLine(final LineType type, final LocalPoint centre, final GraphicsObject cObject)
|
||||
{
|
||||
this.centre = centre;
|
||||
this.cObject = cObject;
|
||||
this.type = type;
|
||||
}
|
||||
|
||||
public boolean isActive()
|
||||
{
|
||||
final int animationFrame = cObject.getAnimationFrame();
|
||||
return animationFrame >= 30 && animationFrame <= 40;
|
||||
}
|
||||
|
||||
public boolean inSweetSpot(final Client client, final WorldPoint lp)
|
||||
{
|
||||
final WorldPoint wp = WorldPoint.fromLocal(client, centre);
|
||||
switch (this.type)
|
||||
{
|
||||
case V:
|
||||
return eq(wp, 1, 0, lp) || eq(wp, -1, 0, lp);
|
||||
case H:
|
||||
return eq(wp, 0, 1, lp) || eq(wp, 0, -1, lp);
|
||||
case D1:
|
||||
return eq(wp, 1, 1, lp) || eq(wp, -1, -1, lp);
|
||||
case D2:
|
||||
return eq(wp, -1, 1, lp) || eq(wp, 1, -1, lp);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
enum LineType
|
||||
{
|
||||
V, H, D1, D2
|
||||
}
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
package com.example.essentialsplus.helpers.combat.variablespeed;
|
||||
|
||||
import com.example.essentialsplus.helpers.combat.AnimationData;
|
||||
import com.example.essentialsplus.helpers.combat.AttackProcedure;
|
||||
import com.example.essentialsplus.helpers.combat.AttackType;
|
||||
import com.example.essentialsplus.helpers.combat.CombatUtils;
|
||||
import com.example.essentialsplus.helpers.combat.Spellbook;
|
||||
import net.runelite.api.Client;
|
||||
import net.runelite.api.gameval.NpcID;
|
||||
|
||||
public class TombsOfAmascut implements IVariableSpeed
|
||||
{
|
||||
@Override
|
||||
public int apply(final Client client, final AnimationData curAnimation, final AttackProcedure atkType,
|
||||
final Spellbook spellbook, final int damageDealt, final int lastSpecDelta, final int baseSpeed,
|
||||
final int curSpeed)
|
||||
{
|
||||
final int targetId = CombatUtils.getTargetId(client);
|
||||
final AttackType attkType = CombatUtils.getAttackType(client);
|
||||
if (targetId == NpcID.WARDENS_P3_ORB_BLUE && attkType != null && attkType.IsMelee())
|
||||
{
|
||||
return 1;
|
||||
}
|
||||
return curSpeed;
|
||||
}
|
||||
}
|
||||
+184
@@ -0,0 +1,184 @@
|
||||
package com.example.essentialsplus.helpers.combat.variablespeed;
|
||||
|
||||
import com.example.essentialsplus.helpers.combat.AnimationData;
|
||||
import com.example.essentialsplus.helpers.combat.AttackProcedure;
|
||||
import com.example.essentialsplus.helpers.combat.AttackType;
|
||||
import com.example.essentialsplus.helpers.combat.CombatUtils;
|
||||
import com.example.essentialsplus.helpers.combat.Spellbook;
|
||||
import com.example.essentialsplus.helpers.combat.WeaponType;
|
||||
import com.example.essentialsplus.helpers.combat.variablespeed.state.TickCount;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import net.runelite.api.Client;
|
||||
import net.runelite.api.NPC;
|
||||
import net.runelite.api.events.GameTick;
|
||||
import net.runelite.api.events.NpcDespawned;
|
||||
import net.runelite.api.events.NpcSpawned;
|
||||
import net.runelite.api.gameval.NpcID;
|
||||
import net.runelite.api.gameval.SpotanimID;
|
||||
|
||||
public class TormentedDemons implements IVariableSpeed
|
||||
{
|
||||
private final TickCount tickCount;
|
||||
private final Map<NPC, DemonData> tormentedDemons = new HashMap<>();
|
||||
|
||||
public TormentedDemons(TickCount tc)
|
||||
{
|
||||
this.tickCount = tc;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int apply(final Client client, final AnimationData curAnimation, final AttackProcedure atkType,
|
||||
final Spellbook spellbook, final int damageDealt, final int lastSpecDelta, final int baseSpeed,
|
||||
final int curSpeed)
|
||||
{
|
||||
final int targetId = CombatUtils.getTargetId(client);
|
||||
if (!isTormentedDemon(targetId))
|
||||
{
|
||||
return curSpeed;
|
||||
}
|
||||
final NPC target = CombatUtils.getTargetNPC(client);
|
||||
if (!tormentedDemons.containsKey(target))
|
||||
{
|
||||
return curSpeed;
|
||||
}
|
||||
final DemonData targetDemon = tormentedDemons.get(target);
|
||||
if (!targetDemon.isVulnerable(tickCount.get()))
|
||||
{
|
||||
return curSpeed;
|
||||
}
|
||||
if (targetDemon.vulnConsumed(tickCount.get()))
|
||||
{
|
||||
return curSpeed;
|
||||
}
|
||||
switch (atkType)
|
||||
{
|
||||
case POWERED_STAVE:
|
||||
return curSpeed;
|
||||
case MANUAL_AUTO_CAST:
|
||||
break;
|
||||
case MELEE_OR_RANGE:
|
||||
final WeaponType wep = CombatUtils.getWeaponType(client);
|
||||
final AttackType attkType = CombatUtils.getAttackType(client);
|
||||
if (wep != WeaponType.HEAVY_RANGED && (attkType == null || attkType != AttackType.CRUSH))
|
||||
{
|
||||
return curSpeed;
|
||||
}
|
||||
break;
|
||||
}
|
||||
if (curSpeed > 4)
|
||||
{
|
||||
return 4;
|
||||
}
|
||||
return curSpeed;
|
||||
}
|
||||
|
||||
private static boolean isTormentedDemon(int targetId)
|
||||
{
|
||||
return targetId == NpcID.TORMENTED_DEMON_1 || targetId == NpcID.TORMENTED_DEMON_2;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onGameTick(Client client, GameTick tick)
|
||||
{
|
||||
for (final Map.Entry<NPC, DemonData> td : tormentedDemons.entrySet())
|
||||
{
|
||||
final boolean isVulnerable = td.getKey().hasSpotAnim(SpotanimID.LUC2_UNDEAD_DEMON_EXPLOSION_FIRE_SPOT);
|
||||
td.getValue().update(tickCount.get(), isVulnerable);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onNpcSpawned(final Client client, final NpcSpawned npcSpawned)
|
||||
{
|
||||
final NPC npc = npcSpawned.getNpc();
|
||||
maybeInsertDemon(npc);
|
||||
}
|
||||
|
||||
private void maybeInsertDemon(final NPC npc)
|
||||
{
|
||||
if (isTormentedDemon(npc.getId()))
|
||||
{
|
||||
final boolean isVulnerable = npc.hasSpotAnim(SpotanimID.LUC2_UNDEAD_DEMON_EXPLOSION_FIRE_SPOT);
|
||||
tormentedDemons.put(npc, new DemonData(tickCount.get(), isVulnerable));
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onNpcDespawned(final Client client, final NpcDespawned npcDespawned)
|
||||
{
|
||||
final NPC npc = npcDespawned.getNpc();
|
||||
if (isTormentedDemon(npc.getId()))
|
||||
{
|
||||
tormentedDemons.remove(npc);
|
||||
}
|
||||
}
|
||||
|
||||
static class DemonData
|
||||
{
|
||||
private static final int VULN_TICKS_AFTER_END = 10;
|
||||
private Integer vulnerableStart;
|
||||
private Integer vulnerableFinish;
|
||||
private int attacked;
|
||||
|
||||
DemonData(int tick, boolean vuln)
|
||||
{
|
||||
this.update(tick, vuln);
|
||||
this.attacked = -1;
|
||||
}
|
||||
|
||||
void update(int tick, boolean vuln)
|
||||
{
|
||||
if (vuln && this.vulnerableStart == null)
|
||||
{
|
||||
this.vulnerableStart = tick;
|
||||
this.vulnerableFinish = tick;
|
||||
}
|
||||
else if (vuln && this.vulnerableStart != null)
|
||||
{
|
||||
this.vulnerableFinish = tick;
|
||||
}
|
||||
|
||||
if (!vuln)
|
||||
{
|
||||
this.vulnerableStart = null;
|
||||
}
|
||||
if (this.attacked != -1 && this.attacked + 1 >= tick)
|
||||
{
|
||||
this.attacked = -1;
|
||||
}
|
||||
}
|
||||
|
||||
boolean isVulnerable(int tick)
|
||||
{
|
||||
if (this.vulnerableFinish == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
return (this.vulnerableFinish + VULN_TICKS_AFTER_END) > tick;
|
||||
}
|
||||
|
||||
boolean vulnConsumed(int tick)
|
||||
{
|
||||
if (this.attacked == tick || this.attacked == -1)
|
||||
{
|
||||
this.consumeVuln(tick);
|
||||
return false;
|
||||
}
|
||||
else if (this.attacked > tick + VULN_TICKS_AFTER_END)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
else
|
||||
{
|
||||
this.consumeVuln(tick);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
void consumeVuln(int tick)
|
||||
{
|
||||
this.attacked = tick;
|
||||
}
|
||||
}
|
||||
}
|
||||
+117
@@ -0,0 +1,117 @@
|
||||
package com.example.essentialsplus.helpers.combat.variablespeed;
|
||||
|
||||
import com.example.essentialsplus.helpers.combat.AnimationData;
|
||||
import com.example.essentialsplus.helpers.combat.AttackProcedure;
|
||||
import com.example.essentialsplus.helpers.combat.Spellbook;
|
||||
import com.example.essentialsplus.helpers.combat.variablespeed.state.IStateTracker;
|
||||
import com.example.essentialsplus.helpers.combat.variablespeed.state.MarkOfDarkness;
|
||||
import com.example.essentialsplus.helpers.combat.variablespeed.state.TickCount;
|
||||
import com.example.essentialsplus.helpers.combat.variablespeed.state.Yama;
|
||||
import net.runelite.api.Client;
|
||||
import net.runelite.api.events.ChatMessage;
|
||||
import net.runelite.api.events.GameTick;
|
||||
import net.runelite.api.events.NpcDespawned;
|
||||
import net.runelite.api.events.NpcSpawned;
|
||||
|
||||
public class VariableSpeed
|
||||
{
|
||||
private final TickCount tickCount;
|
||||
private final Yama yama;
|
||||
private final MarkOfDarkness markOfDarkness;
|
||||
|
||||
public final ShadowCrash shadowCrash;
|
||||
public final MaggotKing maggotKing;
|
||||
public final DoomOfMokhaiotl doomOfMokhaiotl;
|
||||
|
||||
private final IStateTracker[] toTrack;
|
||||
private final IVariableSpeed[] toApply;
|
||||
|
||||
public VariableSpeed(TickCount tc)
|
||||
{
|
||||
this.tickCount = tc;
|
||||
this.yama = new Yama();
|
||||
this.markOfDarkness = new MarkOfDarkness();
|
||||
this.shadowCrash = new ShadowCrash(yama, markOfDarkness, tc);
|
||||
this.maggotKing = new MaggotKing(tc);
|
||||
this.doomOfMokhaiotl = new DoomOfMokhaiotl(tc);
|
||||
|
||||
this.toTrack = new IStateTracker[]{
|
||||
tc,
|
||||
yama,
|
||||
markOfDarkness
|
||||
};
|
||||
|
||||
this.toApply = new IVariableSpeed[]{
|
||||
new BloodMoonSet(),
|
||||
new RapidAttackStyle(),
|
||||
new RedKerisSpec(),
|
||||
new PurgingStaffSpec(yama),
|
||||
new EyeOfAyak(),
|
||||
new TormentedDemons(tc),
|
||||
new RoyalTitans(),
|
||||
new Amoxliatl(),
|
||||
new Scurrius(),
|
||||
new TombsOfAmascut(),
|
||||
doomOfMokhaiotl
|
||||
};
|
||||
}
|
||||
|
||||
public int compute(final Client client, final AnimationData curAnimation, final AttackProcedure atkType,
|
||||
final Spellbook spellbook, final int damageDealt, final int lastSpecDelta, final int baseSpeed)
|
||||
{
|
||||
int newSpeed = baseSpeed;
|
||||
for (final IVariableSpeed i : toApply)
|
||||
{
|
||||
newSpeed = i.apply(client, curAnimation, atkType, spellbook, damageDealt, lastSpecDelta, baseSpeed, newSpeed);
|
||||
}
|
||||
return newSpeed;
|
||||
}
|
||||
|
||||
public void onGameTick(final Client client, final GameTick tick)
|
||||
{
|
||||
for (final IStateTracker i : toTrack)
|
||||
{
|
||||
i.onGameTick(client, tick);
|
||||
}
|
||||
for (final IStateTracker i : toApply)
|
||||
{
|
||||
i.onGameTick(client, tick);
|
||||
}
|
||||
}
|
||||
|
||||
public void onChatMessage(final Client client, final ChatMessage event)
|
||||
{
|
||||
for (final IStateTracker i : toTrack)
|
||||
{
|
||||
i.onChatMessage(client, event);
|
||||
}
|
||||
for (final IStateTracker i : toApply)
|
||||
{
|
||||
i.onChatMessage(client, event);
|
||||
}
|
||||
}
|
||||
|
||||
public void onNpcSpawned(final Client client, final NpcSpawned npcSpawned)
|
||||
{
|
||||
for (final IStateTracker i : toTrack)
|
||||
{
|
||||
i.onNpcSpawned(client, npcSpawned);
|
||||
}
|
||||
for (final IStateTracker i : toApply)
|
||||
{
|
||||
i.onNpcSpawned(client, npcSpawned);
|
||||
}
|
||||
}
|
||||
|
||||
public void onNpcDespawned(final Client client, final NpcDespawned npcDespawned)
|
||||
{
|
||||
for (final IStateTracker i : toTrack)
|
||||
{
|
||||
i.onNpcDespawned(client, npcDespawned);
|
||||
}
|
||||
for (final IStateTracker i : toApply)
|
||||
{
|
||||
i.onNpcDespawned(client, npcDespawned);
|
||||
}
|
||||
}
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
package com.example.essentialsplus.helpers.combat.variablespeed.state;
|
||||
|
||||
import net.runelite.api.Client;
|
||||
import net.runelite.api.events.ChatMessage;
|
||||
import net.runelite.api.events.GameTick;
|
||||
import net.runelite.api.events.NpcDespawned;
|
||||
import net.runelite.api.events.NpcSpawned;
|
||||
|
||||
public interface IStateTracker
|
||||
{
|
||||
default void onGameTick(final Client client, final GameTick tick)
|
||||
{
|
||||
}
|
||||
|
||||
default void onChatMessage(final Client client, final ChatMessage event)
|
||||
{
|
||||
}
|
||||
|
||||
default void onNpcSpawned(final Client client, final NpcSpawned npcSpawned)
|
||||
{
|
||||
}
|
||||
|
||||
default void onNpcDespawned(final Client client, final NpcDespawned npcDespawned)
|
||||
{
|
||||
}
|
||||
}
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
package com.example.essentialsplus.helpers.combat.variablespeed.state;
|
||||
|
||||
import com.example.essentialsplus.helpers.combat.CombatUtils;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import net.runelite.api.Client;
|
||||
import net.runelite.api.Skill;
|
||||
import net.runelite.api.events.ChatMessage;
|
||||
import net.runelite.api.gameval.ItemID;
|
||||
import net.runelite.client.util.RSTimeUnit;
|
||||
|
||||
public class MarkOfDarkness implements IStateTracker
|
||||
{
|
||||
private static final String MARK_OF_DARKNESS_MESSAGE = "You have placed a Mark of Darkness upon yourself.</col>";
|
||||
|
||||
private Instant modStartTime = Instant.now();
|
||||
private Instant modEndTime = Instant.now();
|
||||
|
||||
@Override
|
||||
public void onChatMessage(Client client, ChatMessage event)
|
||||
{
|
||||
final String message = event.getMessage();
|
||||
if (message != null && message.endsWith(MARK_OF_DARKNESS_MESSAGE))
|
||||
{
|
||||
final Duration duration = getMarkOfDarknessDuration(client);
|
||||
this.modStartTime = Instant.now();
|
||||
this.modEndTime = modStartTime.plus(duration);
|
||||
}
|
||||
}
|
||||
|
||||
public boolean isActive()
|
||||
{
|
||||
final Instant n = Instant.now();
|
||||
return this.modStartTime.isBefore(n) && this.modEndTime.isAfter(n);
|
||||
}
|
||||
|
||||
private static Duration getMarkOfDarknessDuration(Client client)
|
||||
{
|
||||
final int magicLevel = client.getRealSkillLevel(Skill.MAGIC);
|
||||
final Duration markOfDarknessDuration = Duration.of((long) magicLevel * 3, RSTimeUnit.GAME_TICKS);
|
||||
if (CombatUtils.getWeaponId(client) == ItemID.PURGING_STAFF)
|
||||
{
|
||||
return markOfDarknessDuration.multipliedBy(5);
|
||||
}
|
||||
return markOfDarknessDuration;
|
||||
}
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
package com.example.essentialsplus.helpers.combat.variablespeed.state;
|
||||
|
||||
import net.runelite.api.Client;
|
||||
import net.runelite.api.events.GameTick;
|
||||
|
||||
public class TickCount implements IStateTracker
|
||||
{
|
||||
private int tickCount = 0;
|
||||
|
||||
public int get()
|
||||
{
|
||||
return tickCount;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onGameTick(Client client, GameTick tick)
|
||||
{
|
||||
tickCount++;
|
||||
}
|
||||
|
||||
public boolean isWithinNTicks(int toCheckAgainst, int n)
|
||||
{
|
||||
return this.tickCount <= toCheckAgainst + n && this.tickCount >= toCheckAgainst;
|
||||
}
|
||||
|
||||
public void reset()
|
||||
{
|
||||
tickCount = 0;
|
||||
}
|
||||
}
|
||||
+177
@@ -0,0 +1,177 @@
|
||||
package com.example.essentialsplus.helpers.combat.variablespeed.state;
|
||||
|
||||
import com.example.essentialsplus.helpers.combat.CombatUtils;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import lombok.NonNull;
|
||||
import net.runelite.api.Client;
|
||||
import net.runelite.api.NPC;
|
||||
import net.runelite.api.events.ChatMessage;
|
||||
import net.runelite.api.events.GameTick;
|
||||
import net.runelite.api.events.NpcSpawned;
|
||||
import net.runelite.api.gameval.NpcID;
|
||||
|
||||
public class Yama implements IStateTracker
|
||||
{
|
||||
private static final int HP_FUDGE = 2;
|
||||
private static final int VOID_FLARE_HP_P1_P2 = 140 - HP_FUDGE;
|
||||
private static final int VOID_FLARE_HP_P3 = 71 - HP_FUDGE;
|
||||
private static final int YAMA_REGION_ID = 6045;
|
||||
private static final int YAMA_PHASE_TRANSITION_ANIMATION_ID = 12147;
|
||||
|
||||
public YamaData yama;
|
||||
public boolean inYamaRegion;
|
||||
public NPC lastTarget;
|
||||
|
||||
@Override
|
||||
public void onGameTick(Client client, GameTick tick)
|
||||
{
|
||||
inYamaRegion = CombatUtils.isInRegionId(client, YAMA_REGION_ID);
|
||||
if (!inYamaRegion)
|
||||
{
|
||||
yama = null;
|
||||
return;
|
||||
}
|
||||
if (yama != null)
|
||||
{
|
||||
yama.determineYamaPhase();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onChatMessage(Client client, ChatMessage event)
|
||||
{
|
||||
if (!inYamaRegion || yama == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (event.getMessage().startsWith("Your Yama success count is:"))
|
||||
{
|
||||
yama.killed();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onNpcSpawned(final Client client, final NpcSpawned npcSpawned)
|
||||
{
|
||||
if (!inYamaRegion || yama != null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
final NPC npc = npcSpawned.getNpc();
|
||||
if (npc.getId() == NpcID.YAMA)
|
||||
{
|
||||
yama = new YamaData(npc);
|
||||
}
|
||||
}
|
||||
|
||||
public static NPC isEitherVoidFlare(NPC a, NPC b)
|
||||
{
|
||||
if (a != null && a.getId() == NpcID.YAMA_VOIDFLARE)
|
||||
{
|
||||
return a;
|
||||
}
|
||||
if (b != null && b.getId() == NpcID.YAMA_VOIDFLARE)
|
||||
{
|
||||
return b;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public void dealVoidFlareDamage(NPC target, int damageDealt)
|
||||
{
|
||||
if (yama == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
this.yama.dealVoidFlareDamage(target, damageDealt);
|
||||
}
|
||||
|
||||
public boolean getVoidFlareDead(NPC target)
|
||||
{
|
||||
if (yama == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
return this.yama.getVoidFlareDead(target);
|
||||
}
|
||||
|
||||
public YamaPhase phase()
|
||||
{
|
||||
if (yama == null)
|
||||
{
|
||||
return YamaPhase.P1;
|
||||
}
|
||||
return this.yama.phase;
|
||||
}
|
||||
|
||||
public static class YamaData
|
||||
{
|
||||
@NonNull
|
||||
private final NPC yama;
|
||||
private YamaPhase phase;
|
||||
private int phaseChangeCooldown;
|
||||
private final Map<NPC, Integer> voidFlares;
|
||||
|
||||
YamaData(@NonNull NPC yama)
|
||||
{
|
||||
this.yama = yama;
|
||||
this.phase = YamaPhase.P1;
|
||||
this.phaseChangeCooldown = 0;
|
||||
this.voidFlares = new HashMap<>();
|
||||
}
|
||||
|
||||
public boolean getVoidFlareDead(NPC target)
|
||||
{
|
||||
if (!this.voidFlares.containsKey(target))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
return this.voidFlares.get(target) <= 0;
|
||||
}
|
||||
|
||||
public void dealVoidFlareDamage(NPC target, int damageDealt)
|
||||
{
|
||||
if (this.voidFlares.containsKey(target))
|
||||
{
|
||||
this.voidFlares.put(target, this.voidFlares.get(target) - damageDealt);
|
||||
}
|
||||
else
|
||||
{
|
||||
final int hp = this.getVoidFlareHp() - damageDealt;
|
||||
this.voidFlares.put(target, hp);
|
||||
}
|
||||
}
|
||||
|
||||
private void determineYamaPhase()
|
||||
{
|
||||
if (phaseChangeCooldown == 0 && this.yama.getAnimation() == YAMA_PHASE_TRANSITION_ANIMATION_ID)
|
||||
{
|
||||
this.phaseChangeCooldown = 20;
|
||||
this.phase = this.phase.nextPhase();
|
||||
}
|
||||
if (this.phaseChangeCooldown > 0)
|
||||
{
|
||||
this.phaseChangeCooldown--;
|
||||
}
|
||||
}
|
||||
|
||||
private int getVoidFlareHp()
|
||||
{
|
||||
switch (this.phase)
|
||||
{
|
||||
case P1:
|
||||
case P2:
|
||||
return VOID_FLARE_HP_P1_P2;
|
||||
default:
|
||||
return VOID_FLARE_HP_P3;
|
||||
}
|
||||
}
|
||||
|
||||
private void killed()
|
||||
{
|
||||
this.phase = YamaPhase.P1;
|
||||
this.voidFlares.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
package com.example.essentialsplus.helpers.combat.variablespeed.state;
|
||||
|
||||
public enum YamaPhase
|
||||
{
|
||||
P1, P2, P3;
|
||||
|
||||
public YamaPhase nextPhase()
|
||||
{
|
||||
switch (this)
|
||||
{
|
||||
case P1:
|
||||
return P2;
|
||||
case P2:
|
||||
return P3;
|
||||
case P3:
|
||||
return P3;
|
||||
}
|
||||
return P1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.example.essentialsplus.loot;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Kills and drops of one source within a small time bucket. Recent events are kept per minute,
|
||||
* older ones are merged into hourly and daily buckets (see {@link LootRecord#compactEvents}).
|
||||
*/
|
||||
public class LootEvent
|
||||
{
|
||||
/** bucket start (epoch millis) */
|
||||
public long t;
|
||||
/** kills in this bucket */
|
||||
public int k;
|
||||
/** item id -> quantity */
|
||||
public Map<Integer, Long> i = new LinkedHashMap<>();
|
||||
|
||||
public LootEvent()
|
||||
{
|
||||
}
|
||||
|
||||
public LootEvent(long t)
|
||||
{
|
||||
this.t = t;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
package com.example.essentialsplus.loot;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
import net.runelite.client.game.ItemManager;
|
||||
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
public class LootRecord
|
||||
{
|
||||
private String name;
|
||||
private String type; // "NPC", "CHEST", "CLUE", "EVENT", "PLAYER"
|
||||
private int kills;
|
||||
private long firstSeen;
|
||||
private long lastSeen;
|
||||
private Map<Integer, TrackedLootItem> items = new LinkedHashMap<>();
|
||||
private com.example.essentialsplus.loot.combat.CombatStats combatStats;
|
||||
/** Time-bucketed kills/drops, used for time range filtering. */
|
||||
private List<LootEvent> events = new ArrayList<>();
|
||||
|
||||
private static final long MINUTE = 60_000L;
|
||||
private static final long HOUR = 60 * MINUTE;
|
||||
private static final long DAY = 24 * HOUR;
|
||||
|
||||
public boolean hasCombatStats()
|
||||
{
|
||||
return combatStats != null && combatStats.getFightsCount() > 0;
|
||||
}
|
||||
|
||||
public synchronized void addCombatFight(com.example.essentialsplus.loot.combat.FightSession session)
|
||||
{
|
||||
addCombatFight(session, 0);
|
||||
}
|
||||
|
||||
public synchronized void addCombatFight(com.example.essentialsplus.loot.combat.FightSession session, long lootValue)
|
||||
{
|
||||
if (combatStats == null)
|
||||
{
|
||||
combatStats = new com.example.essentialsplus.loot.combat.CombatStats();
|
||||
}
|
||||
combatStats.addFight(session, lootValue);
|
||||
}
|
||||
|
||||
public LootRecord(String name, String type)
|
||||
{
|
||||
this.name = name;
|
||||
this.type = type != null ? type : "NPC";
|
||||
this.kills = 0;
|
||||
this.firstSeen = System.currentTimeMillis();
|
||||
this.lastSeen = this.firstSeen;
|
||||
}
|
||||
|
||||
public synchronized void addDrop(int itemId, String itemName, int quantity, int gePrice)
|
||||
{
|
||||
this.lastSeen = System.currentTimeMillis();
|
||||
currentEvent(lastSeen).i.merge(itemId, (long) quantity, Long::sum);
|
||||
TrackedLootItem item = items.computeIfAbsent(itemId, id -> new TrackedLootItem(id, itemName, 0, gePrice));
|
||||
item.addQuantity(quantity);
|
||||
if (gePrice > 0)
|
||||
{
|
||||
item.setGePrice(gePrice);
|
||||
}
|
||||
if (itemName != null && !itemName.isEmpty())
|
||||
{
|
||||
item.setName(itemName);
|
||||
}
|
||||
}
|
||||
|
||||
public synchronized void addKills(int count)
|
||||
{
|
||||
this.kills += count;
|
||||
this.lastSeen = System.currentTimeMillis();
|
||||
currentEvent(lastSeen).k += count;
|
||||
}
|
||||
|
||||
public synchronized long getTotalValue()
|
||||
{
|
||||
long total = 0;
|
||||
for (TrackedLootItem item : items.values())
|
||||
{
|
||||
total += item.getTotalPrice();
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
public synchronized long getTotalValue(ItemManager itemManager)
|
||||
{
|
||||
return getTotalValue();
|
||||
}
|
||||
|
||||
public synchronized List<TrackedLootItem> getSortedItems()
|
||||
{
|
||||
List<TrackedLootItem> list = new ArrayList<>(items.values());
|
||||
list.sort((a, b) -> Long.compare(b.getTotalPrice(), a.getTotalPrice()));
|
||||
return list;
|
||||
}
|
||||
|
||||
public synchronized List<TrackedLootItem> getSortedItems(ItemManager itemManager)
|
||||
{
|
||||
return getSortedItems();
|
||||
}
|
||||
|
||||
public synchronized int getDistinctItemCount()
|
||||
{
|
||||
return items.size();
|
||||
}
|
||||
|
||||
public synchronized long getTotalItemQuantity()
|
||||
{
|
||||
long sum = 0;
|
||||
for (TrackedLootItem item : items.values())
|
||||
{
|
||||
sum += item.getQuantity();
|
||||
}
|
||||
return sum;
|
||||
}
|
||||
|
||||
public synchronized List<LootEvent> getEvents()
|
||||
{
|
||||
if (events == null)
|
||||
{
|
||||
events = new ArrayList<>();
|
||||
}
|
||||
return events;
|
||||
}
|
||||
|
||||
private LootEvent currentEvent(long now)
|
||||
{
|
||||
List<LootEvent> list = getEvents();
|
||||
long bucket = now - now % MINUTE;
|
||||
LootEvent last = list.isEmpty() ? null : list.get(list.size() - 1);
|
||||
if (last == null || last.t != bucket)
|
||||
{
|
||||
last = new LootEvent(bucket);
|
||||
list.add(last);
|
||||
}
|
||||
return last;
|
||||
}
|
||||
|
||||
/** Merges events older than 2 days into hours and older than 60 days into days. */
|
||||
public synchronized void compactEvents(long now)
|
||||
{
|
||||
List<LootEvent> list = getEvents();
|
||||
if (list.size() < 2)
|
||||
{
|
||||
return;
|
||||
}
|
||||
List<LootEvent> out = new ArrayList<>(list.size());
|
||||
for (LootEvent e : list)
|
||||
{
|
||||
long age = now - e.t;
|
||||
long size = age > 60 * DAY ? DAY : age > 2 * DAY ? HOUR : MINUTE;
|
||||
long bucket = e.t - e.t % size;
|
||||
LootEvent last = out.isEmpty() ? null : out.get(out.size() - 1);
|
||||
if (last != null && last.t == bucket)
|
||||
{
|
||||
last.k += e.k;
|
||||
e.i.forEach((id, q) -> last.i.merge(id, q, Long::sum));
|
||||
}
|
||||
else
|
||||
{
|
||||
LootEvent copy = new LootEvent(bucket);
|
||||
copy.k = e.k;
|
||||
copy.i.putAll(e.i);
|
||||
out.add(copy);
|
||||
}
|
||||
}
|
||||
events = out;
|
||||
}
|
||||
|
||||
/** The part of this record in the filter's range; for "Now" only the current streak of kills/drops. */
|
||||
public synchronized LootRecord view(com.example.essentialsplus.ui.TimeFilter filter)
|
||||
{
|
||||
if (!filter.isCurrent())
|
||||
{
|
||||
return since(filter.start());
|
||||
}
|
||||
List<LootEvent> list = getEvents();
|
||||
long start = com.example.essentialsplus.ui.TimeFilter.streakStart(list.size(), i -> list.get(i).t, i -> true, System.currentTimeMillis());
|
||||
return since(start == Long.MAX_VALUE ? Long.MAX_VALUE / 2 : start);
|
||||
}
|
||||
|
||||
/**
|
||||
* A view of this record limited to kills, drops and fights at or after {@code start}.
|
||||
* Returns this record itself for {@code start <= 0} (all time).
|
||||
*/
|
||||
public synchronized LootRecord since(long start)
|
||||
{
|
||||
if (start <= 0)
|
||||
{
|
||||
return this;
|
||||
}
|
||||
LootRecord r = new LootRecord();
|
||||
r.name = name;
|
||||
r.type = type;
|
||||
r.firstSeen = 0;
|
||||
r.lastSeen = 0;
|
||||
for (LootEvent e : getEvents())
|
||||
{
|
||||
if (e.t + MINUTE <= start)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
r.kills += e.k;
|
||||
if (r.firstSeen == 0)
|
||||
{
|
||||
r.firstSeen = e.t;
|
||||
}
|
||||
r.lastSeen = Math.max(r.lastSeen, e.t);
|
||||
for (Map.Entry<Integer, Long> d : e.i.entrySet())
|
||||
{
|
||||
TrackedLootItem base = items.get(d.getKey());
|
||||
String itemName = base != null ? base.getName() : "Item " + d.getKey();
|
||||
long price = base != null ? base.getGePrice() : 0;
|
||||
r.items.computeIfAbsent(d.getKey(), id -> new TrackedLootItem(id, itemName, 0, price)).addQuantity(d.getValue());
|
||||
}
|
||||
}
|
||||
if (combatStats != null)
|
||||
{
|
||||
List<com.example.essentialsplus.loot.combat.SingleFightRecord> fights = new ArrayList<>();
|
||||
for (com.example.essentialsplus.loot.combat.SingleFightRecord f : combatStats.getFightHistory())
|
||||
{
|
||||
if (f.getTimestamp() >= start)
|
||||
{
|
||||
fights.add(f);
|
||||
}
|
||||
}
|
||||
if (!fights.isEmpty())
|
||||
{
|
||||
r.combatStats = com.example.essentialsplus.loot.combat.CombatStats.fromFights(fights);
|
||||
r.lastSeen = Math.max(r.lastSeen, fights.get(fights.size() - 1).getTimestamp());
|
||||
}
|
||||
}
|
||||
return r;
|
||||
}
|
||||
|
||||
/** True if nothing was recorded (e.g. a filtered view outside the range). */
|
||||
public synchronized boolean isEmpty()
|
||||
{
|
||||
return kills <= 0 && items.isEmpty() && !hasCombatStats();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,456 @@
|
||||
package com.example.essentialsplus.loot;
|
||||
|
||||
import com.example.essentialsplus.WealthStore;
|
||||
import com.google.gson.Gson;
|
||||
import com.google.gson.reflect.TypeToken;
|
||||
import java.io.File;
|
||||
import java.io.Reader;
|
||||
import java.io.Writer;
|
||||
import java.lang.reflect.Type;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.CopyOnWriteArrayList;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
import java.util.concurrent.ScheduledFuture;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import javax.inject.Inject;
|
||||
import javax.inject.Singleton;
|
||||
import javax.swing.SwingUtilities;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import net.runelite.api.ItemComposition;
|
||||
import net.runelite.client.game.ItemManager;
|
||||
import net.runelite.client.game.ItemStack;
|
||||
|
||||
@Slf4j
|
||||
@Singleton
|
||||
public class LootTrackerStore
|
||||
{
|
||||
private static final String FILE_NAME = "loot_tracker.json";
|
||||
private static final Type RECORD_MAP_TYPE = new TypeToken<Map<String, LootRecord>>() {}.getType();
|
||||
|
||||
private final Gson gson;
|
||||
private final ScheduledExecutorService executor;
|
||||
private final Map<String, LootRecord> records = new ConcurrentHashMap<>();
|
||||
private final List<Runnable> listeners = new CopyOnWriteArrayList<>();
|
||||
|
||||
private final Map<Long, Boolean> recentDropHashes = Collections.synchronizedMap(new LinkedHashMap<Long, Boolean>()
|
||||
{
|
||||
@Override
|
||||
protected boolean removeEldestEntry(Map.Entry<Long, Boolean> eldest)
|
||||
{
|
||||
return size() > 128;
|
||||
}
|
||||
});
|
||||
|
||||
private ScheduledFuture<?> pendingSaveTask;
|
||||
|
||||
@Inject
|
||||
public LootTrackerStore(Gson gson, ScheduledExecutorService executor)
|
||||
{
|
||||
this.gson = gson.newBuilder().setPrettyPrinting().disableHtmlEscaping().create();
|
||||
this.executor = executor;
|
||||
}
|
||||
|
||||
public void load()
|
||||
{
|
||||
File file = new File(WealthStore.DIR, FILE_NAME);
|
||||
if (!file.exists())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try (Reader reader = Files.newBufferedReader(file.toPath(), StandardCharsets.UTF_8))
|
||||
{
|
||||
Map<String, LootRecord> loaded = gson.fromJson(reader, RECORD_MAP_TYPE);
|
||||
if (loaded != null)
|
||||
{
|
||||
records.clear();
|
||||
records.putAll(loaded);
|
||||
|
||||
// Clean up orphaned Doom minions so they do not clutter the overview
|
||||
LootRecord earth = records.remove("Volatile earth");
|
||||
LootRecord larva = records.remove("Demonic larva");
|
||||
if (earth != null || larva != null)
|
||||
{
|
||||
log.info("Cleaned orphaned Doom minions (Volatile earth / Demonic larva) from records");
|
||||
saveDebounced();
|
||||
}
|
||||
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
log.warn("Failed to load loot tracker records from {}", file, e);
|
||||
}
|
||||
}
|
||||
|
||||
public synchronized void save()
|
||||
{
|
||||
try
|
||||
{
|
||||
Files.createDirectories(WealthStore.DIR.toPath());
|
||||
File target = new File(WealthStore.DIR, FILE_NAME);
|
||||
File tmp = new File(WealthStore.DIR, FILE_NAME + ".tmp");
|
||||
|
||||
long now = System.currentTimeMillis();
|
||||
for (LootRecord record : records.values())
|
||||
{
|
||||
record.compactEvents(now);
|
||||
}
|
||||
|
||||
try (Writer writer = Files.newBufferedWriter(tmp.toPath(), StandardCharsets.UTF_8))
|
||||
{
|
||||
gson.toJson(records, RECORD_MAP_TYPE, writer);
|
||||
}
|
||||
|
||||
Files.move(tmp.toPath(), target.toPath(), StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
log.error("Failed to save loot tracker records", e);
|
||||
}
|
||||
}
|
||||
|
||||
public synchronized void saveDebounced()
|
||||
{
|
||||
if (pendingSaveTask != null && !pendingSaveTask.isDone())
|
||||
{
|
||||
return;
|
||||
}
|
||||
pendingSaveTask = executor.schedule(this::save, 3, TimeUnit.SECONDS);
|
||||
}
|
||||
|
||||
public boolean isDuplicate(int tick, String source, Collection<ItemStack> items)
|
||||
{
|
||||
long hash = ((long) tick << 32) ^ ((long) source.hashCode() & 0xFFFFFFFFL);
|
||||
if (items != null)
|
||||
{
|
||||
for (ItemStack item : items)
|
||||
{
|
||||
if (item != null)
|
||||
{
|
||||
hash = hash * 31 + ((long) item.getId() * 10007L + item.getQuantity());
|
||||
}
|
||||
}
|
||||
}
|
||||
return recentDropHashes.put(hash, Boolean.TRUE) != null;
|
||||
}
|
||||
|
||||
public void addLoot(String source, String type, Collection<ItemStack> items, int amount, ItemManager itemManager, int currentTick)
|
||||
{
|
||||
if (source == null || source.trim().isEmpty() || items == null || items.isEmpty())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (isDuplicate(currentTick, source, items))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
String cleanSource = com.example.essentialsplus.loot.combat.CombatTracker.getCanonicalEncounterName(source.trim());
|
||||
LootRecord record = records.computeIfAbsent(cleanSource, s -> new LootRecord(s, type));
|
||||
// amount < 0: loot without a kill (e.g. Doom reward claim; kills come from CombatTracker)
|
||||
if (amount >= 0)
|
||||
{
|
||||
record.addKills(Math.max(1, amount));
|
||||
}
|
||||
|
||||
for (ItemStack item : items)
|
||||
{
|
||||
if (item == null || item.getId() <= 0 || item.getQuantity() <= 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
int id = item.getId();
|
||||
int qty = item.getQuantity();
|
||||
|
||||
String name = "Item " + id;
|
||||
int gePrice = 0;
|
||||
if (itemManager != null)
|
||||
{
|
||||
ItemComposition comp = itemManager.getItemComposition(id);
|
||||
if (comp != null && comp.getName() != null)
|
||||
{
|
||||
name = comp.getName();
|
||||
}
|
||||
gePrice = itemManager.getItemPrice(id);
|
||||
}
|
||||
|
||||
record.addDrop(id, name, qty, gePrice);
|
||||
}
|
||||
|
||||
saveDebounced();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
public void deleteRecord(String source)
|
||||
{
|
||||
if (source != null && records.remove(source) != null)
|
||||
{
|
||||
saveDebounced();
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
public void clearAll()
|
||||
{
|
||||
records.clear();
|
||||
save();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
public Collection<LootRecord> getRecords()
|
||||
{
|
||||
return records.values();
|
||||
}
|
||||
|
||||
public LootRecord getRecord(String source)
|
||||
{
|
||||
if (source == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
String canonical = com.example.essentialsplus.loot.combat.CombatTracker.getCanonicalEncounterName(source.trim());
|
||||
LootRecord r = records.get(canonical);
|
||||
if (r != null)
|
||||
{
|
||||
return r;
|
||||
}
|
||||
for (Map.Entry<String, LootRecord> entry : records.entrySet())
|
||||
{
|
||||
if (entry.getKey().equalsIgnoreCase(canonical))
|
||||
{
|
||||
return entry.getValue();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public LootRecord getOrCreateRecord(String source, String type)
|
||||
{
|
||||
if (source == null || source.trim().isEmpty())
|
||||
{
|
||||
return null;
|
||||
}
|
||||
String clean = com.example.essentialsplus.loot.combat.CombatTracker.getCanonicalEncounterName(source.trim());
|
||||
LootRecord existing = getRecord(clean);
|
||||
if (existing != null)
|
||||
{
|
||||
return existing;
|
||||
}
|
||||
LootRecord r = new LootRecord(clean, type != null ? type : "NPC");
|
||||
records.put(clean, r);
|
||||
return r;
|
||||
}
|
||||
|
||||
/** Records limited to {@code start} (0 = all time); sources without activity in the range are left out. */
|
||||
public List<LootRecord> getRecords(com.example.essentialsplus.ui.TimeFilter filter)
|
||||
{
|
||||
if (!filter.isCurrent())
|
||||
{
|
||||
return getRecords(filter.start());
|
||||
}
|
||||
List<LootRecord> out = new ArrayList<>();
|
||||
for (LootRecord record : records.values())
|
||||
{
|
||||
LootRecord view = record.view(filter);
|
||||
if (!view.isEmpty())
|
||||
{
|
||||
out.add(view);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
public List<LootRecord> getRecords(long start)
|
||||
{
|
||||
List<LootRecord> out = new ArrayList<>();
|
||||
for (LootRecord record : records.values())
|
||||
{
|
||||
LootRecord view = record.since(start);
|
||||
if (start <= 0 || !view.isEmpty())
|
||||
{
|
||||
out.add(view);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Totals over a set of (possibly filtered) records. */
|
||||
public static long totalValue(Collection<LootRecord> list)
|
||||
{
|
||||
long total = 0;
|
||||
for (LootRecord record : list)
|
||||
{
|
||||
total += record.getTotalValue();
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
public static int totalKills(Collection<LootRecord> list)
|
||||
{
|
||||
int sum = 0;
|
||||
for (LootRecord record : list)
|
||||
{
|
||||
sum += record.getKills();
|
||||
}
|
||||
return sum;
|
||||
}
|
||||
|
||||
public static long totalSuppliesCost(Collection<LootRecord> list)
|
||||
{
|
||||
long sum = 0;
|
||||
for (LootRecord record : list)
|
||||
{
|
||||
if (record.hasCombatStats())
|
||||
{
|
||||
sum += record.getCombatStats().getTotalSuppliesCost();
|
||||
}
|
||||
}
|
||||
return sum;
|
||||
}
|
||||
|
||||
public static double overallEfficiency(Collection<LootRecord> list)
|
||||
{
|
||||
long totalDuration = 0;
|
||||
long totalIdle = 0;
|
||||
for (LootRecord record : list)
|
||||
{
|
||||
if (record.hasCombatStats())
|
||||
{
|
||||
totalDuration += record.getCombatStats().getTotalDurationTicks();
|
||||
totalIdle += record.getCombatStats().getTotalIdleTicks();
|
||||
}
|
||||
}
|
||||
if (totalDuration <= 0)
|
||||
{
|
||||
return 100.0;
|
||||
}
|
||||
return Math.max(0.0, Math.min(100.0, (1.0 - (double) totalIdle / totalDuration) * 100.0));
|
||||
}
|
||||
|
||||
public long getTotalValue()
|
||||
{
|
||||
long total = 0;
|
||||
for (LootRecord record : records.values())
|
||||
{
|
||||
total += record.getTotalValue();
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
public long getTotalValue(ItemManager itemManager)
|
||||
{
|
||||
return getTotalValue();
|
||||
}
|
||||
|
||||
public int getTotalKills()
|
||||
{
|
||||
int sum = 0;
|
||||
for (LootRecord record : records.values())
|
||||
{
|
||||
sum += record.getKills();
|
||||
}
|
||||
return sum;
|
||||
}
|
||||
|
||||
public long getTotalSuppliesCost()
|
||||
{
|
||||
long sum = 0;
|
||||
for (LootRecord record : records.values())
|
||||
{
|
||||
if (record.hasCombatStats())
|
||||
{
|
||||
sum += record.getCombatStats().getTotalSuppliesCost();
|
||||
}
|
||||
}
|
||||
return sum;
|
||||
}
|
||||
|
||||
public long getTotalNetProfit()
|
||||
{
|
||||
return getTotalValue() - getTotalSuppliesCost();
|
||||
}
|
||||
|
||||
public double getOverallEfficiency()
|
||||
{
|
||||
long totalDuration = 0;
|
||||
long totalIdle = 0;
|
||||
for (LootRecord record : records.values())
|
||||
{
|
||||
if (record.hasCombatStats())
|
||||
{
|
||||
totalDuration += record.getCombatStats().getTotalDurationTicks();
|
||||
totalIdle += record.getCombatStats().getTotalIdleTicks();
|
||||
}
|
||||
}
|
||||
if (totalDuration <= 0)
|
||||
{
|
||||
return 100.0;
|
||||
}
|
||||
return Math.max(0.0, Math.min(100.0, (1.0 - (double) totalIdle / totalDuration) * 100.0));
|
||||
}
|
||||
|
||||
public List<TrackedLootItem> getTopOverallItems(int limit)
|
||||
{
|
||||
Map<Integer, TrackedLootItem> merged = new LinkedHashMap<>();
|
||||
for (LootRecord r : records.values())
|
||||
{
|
||||
for (TrackedLootItem item : r.getItems().values())
|
||||
{
|
||||
TrackedLootItem existing = merged.computeIfAbsent(item.getId(), id -> new TrackedLootItem(id, item.getName(), 0, item.getGePrice()));
|
||||
existing.addQuantity(item.getQuantity());
|
||||
}
|
||||
}
|
||||
List<TrackedLootItem> list = new ArrayList<>(merged.values());
|
||||
list.sort((a, b) -> Long.compare(b.getTotalPrice(), a.getTotalPrice()));
|
||||
if (list.size() > limit)
|
||||
{
|
||||
list = new ArrayList<>(list.subList(0, limit));
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
public List<TrackedLootItem> getTopOverallItems(ItemManager itemManager, int limit)
|
||||
{
|
||||
return getTopOverallItems(limit);
|
||||
}
|
||||
|
||||
public void addChangeListener(Runnable r)
|
||||
{
|
||||
listeners.add(r);
|
||||
}
|
||||
|
||||
public void removeChangeListener(Runnable r)
|
||||
{
|
||||
listeners.remove(r);
|
||||
}
|
||||
|
||||
public void notifyListeners()
|
||||
{
|
||||
SwingUtilities.invokeLater(() ->
|
||||
{
|
||||
for (Runnable r : listeners)
|
||||
{
|
||||
try
|
||||
{
|
||||
r.run();
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
log.warn("Error running loot tracker listener", e);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package com.example.essentialsplus.loot;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
import net.runelite.client.game.ItemManager;
|
||||
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class TrackedLootItem
|
||||
{
|
||||
private int id;
|
||||
private String name;
|
||||
private long quantity;
|
||||
private long gePrice;
|
||||
|
||||
public void addQuantity(long qty)
|
||||
{
|
||||
this.quantity += qty;
|
||||
}
|
||||
|
||||
public long getTotalPrice()
|
||||
{
|
||||
return gePrice * quantity;
|
||||
}
|
||||
|
||||
public long getTotalPrice(ItemManager itemManager)
|
||||
{
|
||||
return getTotalPrice();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,342 @@
|
||||
package com.example.essentialsplus.loot.combat;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
public class CombatStats
|
||||
{
|
||||
// Cumulative totals across all kills of this enemy
|
||||
private int fightsCount;
|
||||
private long totalAttacksDealt;
|
||||
private long totalDamageDealt;
|
||||
private long totalHitsReceived;
|
||||
private long totalDamageTaken;
|
||||
private long totalDurationTicks;
|
||||
private long totalIdleTicks;
|
||||
private long totalTilesMoved;
|
||||
private long totalHpHealed;
|
||||
private long totalSuppliesCost;
|
||||
private Map<String, Integer> totalFoodUsed = new LinkedHashMap<>();
|
||||
private Map<String, Integer> totalPotionsUsed = new LinkedHashMap<>();
|
||||
private Map<String, Integer> totalAmmoUsed = new LinkedHashMap<>();
|
||||
|
||||
// Cumulative Prayer & Weapon Metrics
|
||||
private long totalPrayerBlockedHits;
|
||||
private long totalPrayerMissedHits;
|
||||
private long totalOffPrayerDamageTaken;
|
||||
private long totalOnPrayerAttacks;
|
||||
private long totalOffPrayerAttacks;
|
||||
private long totalCorrectWeaponAttacks;
|
||||
private long totalIncorrectWeaponAttacks;
|
||||
|
||||
// Delve metrics
|
||||
private int maxDelve;
|
||||
private int lastDelve;
|
||||
|
||||
// Most recent fight snapshot
|
||||
private int lastAttacksDealt;
|
||||
private int lastDamageDealt;
|
||||
private int lastHitsReceived;
|
||||
private int lastDamageTaken;
|
||||
private int lastDurationTicks;
|
||||
private int lastIdleTicks;
|
||||
private int lastTilesMoved;
|
||||
private int lastHpHealed;
|
||||
private long lastSuppliesCost;
|
||||
private double lastEfficiency;
|
||||
private double lastPerformanceScore;
|
||||
private Map<String, Integer> lastFoodUsed = new LinkedHashMap<>();
|
||||
private Map<String, Integer> lastPotionsUsed = new LinkedHashMap<>();
|
||||
private Map<String, Integer> lastAmmoUsed = new LinkedHashMap<>();
|
||||
|
||||
private int lastPrayerBlockedHits;
|
||||
private int lastPrayerMissedHits;
|
||||
private int lastOffPrayerDamageTaken;
|
||||
private int lastOnPrayerAttacks;
|
||||
private int lastOffPrayerAttacks;
|
||||
private int lastCorrectWeaponAttacks;
|
||||
private int lastIncorrectWeaponAttacks;
|
||||
|
||||
/** Individual fights kept per enemy; also bounds how far back time range filters see fights. */
|
||||
private static final int MAX_HISTORY = 1000;
|
||||
|
||||
// List of all individual fights recorded
|
||||
private List<SingleFightRecord> fightHistory = new ArrayList<>();
|
||||
|
||||
public synchronized void addFight(FightSession session)
|
||||
{
|
||||
addFight(session, 0);
|
||||
}
|
||||
|
||||
public synchronized void addFight(FightSession session, long lootValue)
|
||||
{
|
||||
this.fightsCount++;
|
||||
|
||||
this.totalAttacksDealt += session.getAttacksDealt();
|
||||
this.totalDamageDealt += session.getDamageDealt();
|
||||
this.totalHitsReceived += session.getHitsReceived();
|
||||
this.totalDamageTaken += session.getDamageTaken();
|
||||
this.totalDurationTicks += session.getDurationTicks();
|
||||
this.totalIdleTicks += session.getIdleTicks();
|
||||
this.totalTilesMoved += session.getTilesMoved();
|
||||
this.totalHpHealed += session.getHpHealed();
|
||||
this.totalSuppliesCost += session.getSuppliesCost();
|
||||
|
||||
session.getFoodUsed().forEach((k, v) -> this.totalFoodUsed.merge(k, v, Integer::sum));
|
||||
session.getPotionsUsed().forEach((k, v) -> this.totalPotionsUsed.merge(k, v, Integer::sum));
|
||||
session.getAmmoUsed().forEach((k, v) -> this.totalAmmoUsed.merge(k, v, Integer::sum));
|
||||
|
||||
// Prayer & Weapon totals
|
||||
this.totalPrayerBlockedHits += session.getPrayerBlockedHits();
|
||||
this.totalPrayerMissedHits += session.getPrayerMissedHits();
|
||||
this.totalOffPrayerDamageTaken += session.getOffPrayerDamageTaken();
|
||||
this.totalOnPrayerAttacks += session.getOnPrayerAttacks();
|
||||
this.totalOffPrayerAttacks += session.getOffPrayerAttacks();
|
||||
this.totalCorrectWeaponAttacks += session.getCorrectWeaponAttacks();
|
||||
this.totalIncorrectWeaponAttacks += session.getIncorrectWeaponAttacks();
|
||||
|
||||
// Store last fight
|
||||
this.lastAttacksDealt = session.getAttacksDealt();
|
||||
this.lastDamageDealt = session.getDamageDealt();
|
||||
this.lastHitsReceived = session.getHitsReceived();
|
||||
this.lastDamageTaken = session.getDamageTaken();
|
||||
this.lastDurationTicks = session.getDurationTicks();
|
||||
this.lastIdleTicks = session.getIdleTicks();
|
||||
this.lastTilesMoved = session.getTilesMoved();
|
||||
this.lastHpHealed = session.getHpHealed();
|
||||
this.lastSuppliesCost = session.getSuppliesCost();
|
||||
this.lastEfficiency = session.getEfficiencyPct();
|
||||
this.lastPerformanceScore = session.getPerformanceScore();
|
||||
this.lastFoodUsed = new LinkedHashMap<>(session.getFoodUsed());
|
||||
this.lastPotionsUsed = new LinkedHashMap<>(session.getPotionsUsed());
|
||||
this.lastAmmoUsed = new LinkedHashMap<>(session.getAmmoUsed());
|
||||
|
||||
this.lastPrayerBlockedHits = session.getPrayerBlockedHits();
|
||||
this.lastPrayerMissedHits = session.getPrayerMissedHits();
|
||||
this.lastOffPrayerDamageTaken = session.getOffPrayerDamageTaken();
|
||||
this.lastOnPrayerAttacks = session.getOnPrayerAttacks();
|
||||
this.lastOffPrayerAttacks = session.getOffPrayerAttacks();
|
||||
this.lastCorrectWeaponAttacks = session.getCorrectWeaponAttacks();
|
||||
this.lastIncorrectWeaponAttacks = session.getIncorrectWeaponAttacks();
|
||||
|
||||
int delve = session.getDelve();
|
||||
if (delve > this.maxDelve)
|
||||
{
|
||||
this.maxDelve = delve;
|
||||
}
|
||||
this.lastDelve = delve;
|
||||
|
||||
// Create SingleFightRecord for history
|
||||
SingleFightRecord single = new SingleFightRecord(
|
||||
this.fightsCount,
|
||||
System.currentTimeMillis(),
|
||||
session.getDurationTicks(),
|
||||
session.getIdleTicks(),
|
||||
session.getEfficiencyPct(),
|
||||
session.getGrade(),
|
||||
session.getAttacksDealt(),
|
||||
session.getDamageDealt(),
|
||||
session.getHitsReceived(),
|
||||
session.getDamageTaken(),
|
||||
session.getHpHealed(),
|
||||
session.getTilesMoved(),
|
||||
session.getSuppliesCost(),
|
||||
lootValue,
|
||||
lootValue - session.getSuppliesCost(),
|
||||
new LinkedHashMap<>(session.getFoodUsed()),
|
||||
new LinkedHashMap<>(session.getPotionsUsed()),
|
||||
new LinkedHashMap<>(session.getAmmoUsed()),
|
||||
session.getPrayerBlockedHits(),
|
||||
session.getPrayerMissedHits(),
|
||||
session.getOffPrayerDamageTaken(),
|
||||
session.getOnPrayerAttacks(),
|
||||
session.getOffPrayerAttacks(),
|
||||
session.getCorrectWeaponAttacks(),
|
||||
session.getIncorrectWeaponAttacks(),
|
||||
session.getPerformanceScore(),
|
||||
delve
|
||||
);
|
||||
|
||||
if (this.fightHistory == null)
|
||||
{
|
||||
this.fightHistory = new ArrayList<>();
|
||||
}
|
||||
this.fightHistory.add(single);
|
||||
if (this.fightHistory.size() > MAX_HISTORY)
|
||||
{
|
||||
this.fightHistory.remove(0);
|
||||
}
|
||||
}
|
||||
|
||||
/** Rebuilds cumulative stats from a subset of fights (used for time range filtering). */
|
||||
public static CombatStats fromFights(List<SingleFightRecord> fights)
|
||||
{
|
||||
CombatStats cs = new CombatStats();
|
||||
for (SingleFightRecord f : fights)
|
||||
{
|
||||
cs.fightsCount++;
|
||||
cs.totalAttacksDealt += f.getAttacksDealt();
|
||||
cs.totalDamageDealt += f.getDamageDealt();
|
||||
cs.totalHitsReceived += f.getHitsReceived();
|
||||
cs.totalDamageTaken += f.getDamageTaken();
|
||||
cs.totalDurationTicks += f.getDurationTicks();
|
||||
cs.totalIdleTicks += f.getIdleTicks();
|
||||
cs.totalTilesMoved += f.getTilesMoved();
|
||||
cs.totalHpHealed += f.getHpHealed();
|
||||
cs.totalSuppliesCost += f.getSuppliesCost();
|
||||
if (f.getFoodUsed() != null)
|
||||
{
|
||||
f.getFoodUsed().forEach((k, v) -> cs.totalFoodUsed.merge(k, v, Integer::sum));
|
||||
}
|
||||
if (f.getPotionsUsed() != null)
|
||||
{
|
||||
f.getPotionsUsed().forEach((k, v) -> cs.totalPotionsUsed.merge(k, v, Integer::sum));
|
||||
}
|
||||
if (f.getAmmoUsed() != null)
|
||||
{
|
||||
f.getAmmoUsed().forEach((k, v) -> cs.totalAmmoUsed.merge(k, v, Integer::sum));
|
||||
}
|
||||
cs.totalPrayerBlockedHits += f.getPrayerBlockedHits();
|
||||
cs.totalPrayerMissedHits += f.getPrayerMissedHits();
|
||||
cs.totalOffPrayerDamageTaken += f.getOffPrayerDamageTaken();
|
||||
cs.totalOnPrayerAttacks += f.getOnPrayerAttacks();
|
||||
cs.totalOffPrayerAttacks += f.getOffPrayerAttacks();
|
||||
cs.totalCorrectWeaponAttacks += f.getCorrectWeaponAttacks();
|
||||
cs.totalIncorrectWeaponAttacks += f.getIncorrectWeaponAttacks();
|
||||
cs.maxDelve = Math.max(cs.maxDelve, f.getDelve());
|
||||
|
||||
cs.lastAttacksDealt = f.getAttacksDealt();
|
||||
cs.lastDamageDealt = f.getDamageDealt();
|
||||
cs.lastHitsReceived = f.getHitsReceived();
|
||||
cs.lastDamageTaken = f.getDamageTaken();
|
||||
cs.lastDurationTicks = f.getDurationTicks();
|
||||
cs.lastIdleTicks = f.getIdleTicks();
|
||||
cs.lastTilesMoved = f.getTilesMoved();
|
||||
cs.lastHpHealed = f.getHpHealed();
|
||||
cs.lastSuppliesCost = f.getSuppliesCost();
|
||||
cs.lastEfficiency = f.getEfficiency();
|
||||
cs.lastPerformanceScore = f.getPerformanceScore();
|
||||
cs.lastFoodUsed = f.getFoodUsed() == null ? new LinkedHashMap<>() : new LinkedHashMap<>(f.getFoodUsed());
|
||||
cs.lastPotionsUsed = f.getPotionsUsed() == null ? new LinkedHashMap<>() : new LinkedHashMap<>(f.getPotionsUsed());
|
||||
cs.lastAmmoUsed = f.getAmmoUsed() == null ? new LinkedHashMap<>() : new LinkedHashMap<>(f.getAmmoUsed());
|
||||
cs.lastPrayerBlockedHits = f.getPrayerBlockedHits();
|
||||
cs.lastPrayerMissedHits = f.getPrayerMissedHits();
|
||||
cs.lastOffPrayerDamageTaken = f.getOffPrayerDamageTaken();
|
||||
cs.lastOnPrayerAttacks = f.getOnPrayerAttacks();
|
||||
cs.lastOffPrayerAttacks = f.getOffPrayerAttacks();
|
||||
cs.lastCorrectWeaponAttacks = f.getCorrectWeaponAttacks();
|
||||
cs.lastIncorrectWeaponAttacks = f.getIncorrectWeaponAttacks();
|
||||
cs.lastDelve = f.getDelve();
|
||||
}
|
||||
cs.fightHistory = new ArrayList<>(fights);
|
||||
return cs;
|
||||
}
|
||||
|
||||
public List<SingleFightRecord> getFightHistory()
|
||||
{
|
||||
if (fightHistory == null)
|
||||
{
|
||||
fightHistory = new ArrayList<>();
|
||||
}
|
||||
return fightHistory;
|
||||
}
|
||||
|
||||
public double getAvgEfficiency()
|
||||
{
|
||||
if (totalDurationTicks <= 0)
|
||||
{
|
||||
return 100.0;
|
||||
}
|
||||
double idleRatio = (double) totalIdleTicks / totalDurationTicks;
|
||||
return Math.max(0.0, Math.min(100.0, (1.0 - idleRatio) * 100.0));
|
||||
}
|
||||
|
||||
public double getAvgPrayerDefensePct()
|
||||
{
|
||||
long total = totalPrayerBlockedHits + totalPrayerMissedHits;
|
||||
if (total <= 0)
|
||||
{
|
||||
return 100.0;
|
||||
}
|
||||
return ((double) totalPrayerBlockedHits / total) * 100.0;
|
||||
}
|
||||
|
||||
public double getAvgOffPrayerAttackPct()
|
||||
{
|
||||
long total = totalOnPrayerAttacks + totalOffPrayerAttacks;
|
||||
if (total <= 0)
|
||||
{
|
||||
return 100.0;
|
||||
}
|
||||
return ((double) totalOffPrayerAttacks / total) * 100.0;
|
||||
}
|
||||
|
||||
public double getAvgCorrectWeaponPct()
|
||||
{
|
||||
long total = totalCorrectWeaponAttacks + totalIncorrectWeaponAttacks;
|
||||
if (total <= 0)
|
||||
{
|
||||
return 100.0;
|
||||
}
|
||||
return ((double) totalCorrectWeaponAttacks / total) * 100.0;
|
||||
}
|
||||
|
||||
public double getAvgPerformanceScore()
|
||||
{
|
||||
double uptime = getAvgEfficiency();
|
||||
long totalIncoming = totalPrayerBlockedHits + totalPrayerMissedHits;
|
||||
boolean defenseTested = (totalIncoming > 0);
|
||||
double defScore = getAvgPrayerDefensePct();
|
||||
|
||||
long totalOverheadAttacks = totalOnPrayerAttacks + totalOffPrayerAttacks;
|
||||
boolean overheadTested = (totalOverheadAttacks > 0 && totalOnPrayerAttacks > 0);
|
||||
double offScore = getAvgOffPrayerAttackPct();
|
||||
|
||||
long totalWep = totalCorrectWeaponAttacks + totalIncorrectWeaponAttacks;
|
||||
boolean wepTested = (totalWep > 0 && totalIncorrectWeaponAttacks > 0);
|
||||
double wepScore = getAvgCorrectWeaponPct();
|
||||
|
||||
if (defenseTested && (overheadTested || wepTested))
|
||||
{
|
||||
double offense = (offScore * 0.5) + (wepScore * 0.5);
|
||||
return Math.max(0.0, Math.min(100.0, (0.40 * uptime) + (0.35 * defScore) + (0.25 * offense)));
|
||||
}
|
||||
else if (defenseTested)
|
||||
{
|
||||
return Math.max(0.0, Math.min(100.0, (0.60 * uptime) + (0.40 * defScore)));
|
||||
}
|
||||
else if (overheadTested || wepTested)
|
||||
{
|
||||
double offense = (offScore * 0.5) + (wepScore * 0.5);
|
||||
return Math.max(0.0, Math.min(100.0, (0.60 * uptime) + (0.40 * offense)));
|
||||
}
|
||||
|
||||
return uptime;
|
||||
}
|
||||
|
||||
public String getLastGrade()
|
||||
{
|
||||
return getGrade(lastPerformanceScore > 0 ? lastPerformanceScore : lastEfficiency);
|
||||
}
|
||||
|
||||
public String getAvgGrade()
|
||||
{
|
||||
return getGrade(getAvgPerformanceScore());
|
||||
}
|
||||
|
||||
public static String getGrade(double score)
|
||||
{
|
||||
if (score >= 95.0) return "S+";
|
||||
if (score >= 90.0) return "A+";
|
||||
if (score >= 80.0) return "A";
|
||||
if (score >= 70.0) return "B";
|
||||
if (score >= 55.0) return "C";
|
||||
return "D";
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,279 @@
|
||||
package com.example.essentialsplus.loot.combat;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
import net.runelite.api.coords.WorldPoint;
|
||||
|
||||
@Getter
|
||||
public class FightSession
|
||||
{
|
||||
private final String targetName;
|
||||
@Setter
|
||||
private int targetIndex;
|
||||
private final int startTick;
|
||||
private int targetCombatLevel;
|
||||
|
||||
@Setter
|
||||
private int lastCombatTick;
|
||||
|
||||
@Setter
|
||||
private int delve = 0;
|
||||
|
||||
private int attacksDealt;
|
||||
private int damageDealt;
|
||||
private int hitsReceived;
|
||||
private int damageTaken;
|
||||
private int durationTicks;
|
||||
private int idleTicks;
|
||||
private int tilesMoved;
|
||||
private int hpHealed;
|
||||
private long suppliesCost;
|
||||
|
||||
private int weaponSpeed = 4;
|
||||
private int ticksSinceLastAttack = 0;
|
||||
private int lastAttackTick = -1;
|
||||
private WorldPoint lastLocation;
|
||||
|
||||
private final Map<String, Integer> foodUsed = new LinkedHashMap<>();
|
||||
private final Map<String, Integer> potionsUsed = new LinkedHashMap<>();
|
||||
private final Map<String, Integer> ammoUsed = new LinkedHashMap<>();
|
||||
|
||||
// Prayer & Weapon Performance metrics
|
||||
private int prayerBlockedHits;
|
||||
private int prayerMissedHits;
|
||||
private int offPrayerDamageTaken;
|
||||
private int onPrayerAttacks;
|
||||
private int offPrayerAttacks;
|
||||
private int correctWeaponAttacks;
|
||||
private int incorrectWeaponAttacks;
|
||||
|
||||
public FightSession(String targetName, int targetIndex, int startTick, WorldPoint initialLocation, int initialWeaponSpeed)
|
||||
{
|
||||
this(targetName, targetIndex, startTick, initialLocation, initialWeaponSpeed, 0);
|
||||
}
|
||||
|
||||
public FightSession(String targetName, int targetIndex, int startTick, WorldPoint initialLocation, int initialWeaponSpeed, int targetCombatLevel)
|
||||
{
|
||||
this.targetName = targetName;
|
||||
this.targetIndex = targetIndex;
|
||||
this.startTick = startTick;
|
||||
this.lastCombatTick = startTick;
|
||||
this.lastLocation = initialLocation;
|
||||
this.weaponSpeed = Math.max(2, initialWeaponSpeed);
|
||||
this.ticksSinceLastAttack = 0;
|
||||
this.targetCombatLevel = targetCombatLevel;
|
||||
}
|
||||
|
||||
public void recordAttack(int attackSpeed, int currentTick)
|
||||
{
|
||||
if (currentTick == lastAttackTick && currentTick > 0)
|
||||
{
|
||||
return; // Avoid duplicate attack recording on the exact same tick
|
||||
}
|
||||
this.lastAttackTick = currentTick;
|
||||
this.attacksDealt++;
|
||||
this.weaponSpeed = Math.max(2, attackSpeed);
|
||||
this.ticksSinceLastAttack = 0;
|
||||
}
|
||||
|
||||
public void recordAttackAgainstEnemy(boolean isOnPrayer, boolean isCorrectWeapon)
|
||||
{
|
||||
if (isOnPrayer)
|
||||
{
|
||||
this.onPrayerAttacks++;
|
||||
}
|
||||
else
|
||||
{
|
||||
this.offPrayerAttacks++;
|
||||
}
|
||||
|
||||
if (isCorrectWeapon)
|
||||
{
|
||||
this.correctWeaponAttacks++;
|
||||
}
|
||||
else
|
||||
{
|
||||
this.incorrectWeaponAttacks++;
|
||||
}
|
||||
}
|
||||
|
||||
public void recordDamageDealt(int amount)
|
||||
{
|
||||
if (amount > 0)
|
||||
{
|
||||
this.damageDealt += amount;
|
||||
}
|
||||
}
|
||||
|
||||
public void recordHitReceived(int amount)
|
||||
{
|
||||
this.hitsReceived++;
|
||||
if (amount > 0)
|
||||
{
|
||||
this.damageTaken += amount;
|
||||
}
|
||||
}
|
||||
|
||||
public void recordPrayerBlock()
|
||||
{
|
||||
this.prayerBlockedHits++;
|
||||
}
|
||||
|
||||
public void recordPrayerMiss(int damageAmount)
|
||||
{
|
||||
this.prayerMissedHits++;
|
||||
if (damageAmount > 0)
|
||||
{
|
||||
this.offPrayerDamageTaken += damageAmount;
|
||||
}
|
||||
}
|
||||
|
||||
public void resetTicksSinceLastAttack()
|
||||
{
|
||||
this.ticksSinceLastAttack = 0;
|
||||
}
|
||||
|
||||
public void recordFood(String foodName, int heal, long cost)
|
||||
{
|
||||
recordFood(foodName, 1, heal, cost);
|
||||
}
|
||||
|
||||
public void recordFood(String foodName, int quantity, int heal, long cost)
|
||||
{
|
||||
int q = Math.max(1, quantity);
|
||||
this.foodUsed.merge(foodName, q, Integer::sum);
|
||||
this.hpHealed += heal;
|
||||
this.suppliesCost += cost;
|
||||
}
|
||||
|
||||
public void recordPotion(String potionName, long costPerDose)
|
||||
{
|
||||
recordPotion(potionName, 1, costPerDose);
|
||||
}
|
||||
|
||||
public void recordPotion(String potionName, int quantity, long costPerDose)
|
||||
{
|
||||
int q = Math.max(1, quantity);
|
||||
this.potionsUsed.merge(potionName, q, Integer::sum);
|
||||
this.suppliesCost += costPerDose;
|
||||
}
|
||||
|
||||
public void recordAmmo(String ammoName, int count, long totalCost)
|
||||
{
|
||||
this.ammoUsed.merge(ammoName, count, Integer::sum);
|
||||
this.suppliesCost += totalCost;
|
||||
}
|
||||
|
||||
public void addHpHealed(int amount)
|
||||
{
|
||||
this.hpHealed += amount;
|
||||
}
|
||||
|
||||
public void onGameTick(WorldPoint currentLocation)
|
||||
{
|
||||
this.durationTicks++;
|
||||
|
||||
if (lastLocation != null && currentLocation != null)
|
||||
{
|
||||
int dist = currentLocation.distanceTo(lastLocation);
|
||||
if (dist > 0 && dist < 15)
|
||||
{
|
||||
this.tilesMoved += dist;
|
||||
}
|
||||
}
|
||||
this.lastLocation = currentLocation;
|
||||
|
||||
// Tick elapsed
|
||||
this.ticksSinceLastAttack++;
|
||||
|
||||
// If weapon takes 4 ticks, off cooldown starting at tick 5:
|
||||
// When ticksSinceLastAttack > weaponSpeed (e.g. > 4), player was off cooldown and did not attack -> idle tick!
|
||||
if (this.ticksSinceLastAttack > this.weaponSpeed)
|
||||
{
|
||||
this.idleTicks++;
|
||||
}
|
||||
}
|
||||
|
||||
public double getEfficiencyPct()
|
||||
{
|
||||
if (durationTicks <= 0)
|
||||
{
|
||||
return 100.0;
|
||||
}
|
||||
double idleRatio = (double) idleTicks / durationTicks;
|
||||
return Math.max(0.0, Math.min(100.0, (1.0 - idleRatio) * 100.0));
|
||||
}
|
||||
|
||||
public double getPrayerDefensePct()
|
||||
{
|
||||
int total = prayerBlockedHits + prayerMissedHits;
|
||||
if (total <= 0)
|
||||
{
|
||||
return 100.0;
|
||||
}
|
||||
return ((double) prayerBlockedHits / total) * 100.0;
|
||||
}
|
||||
|
||||
public double getOffPrayerAttackPct()
|
||||
{
|
||||
int total = onPrayerAttacks + offPrayerAttacks;
|
||||
if (total <= 0)
|
||||
{
|
||||
return 100.0;
|
||||
}
|
||||
return ((double) offPrayerAttacks / total) * 100.0;
|
||||
}
|
||||
|
||||
public double getCorrectWeaponPct()
|
||||
{
|
||||
int total = correctWeaponAttacks + incorrectWeaponAttacks;
|
||||
if (total <= 0)
|
||||
{
|
||||
return 100.0;
|
||||
}
|
||||
return ((double) correctWeaponAttacks / total) * 100.0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates composite performance score taking uptime, defensive prayer, and offensive weapon/prayer into account.
|
||||
*/
|
||||
public double getPerformanceScore()
|
||||
{
|
||||
double uptime = getEfficiencyPct();
|
||||
int totalIncoming = prayerBlockedHits + prayerMissedHits;
|
||||
boolean defenseTested = (totalIncoming > 0);
|
||||
double defScore = getPrayerDefensePct();
|
||||
|
||||
int totalOverheadAttacks = onPrayerAttacks + offPrayerAttacks;
|
||||
boolean overheadTested = (totalOverheadAttacks > 0 && onPrayerAttacks > 0);
|
||||
double offScore = getOffPrayerAttackPct();
|
||||
|
||||
int totalWep = correctWeaponAttacks + incorrectWeaponAttacks;
|
||||
boolean wepTested = (totalWep > 0 && incorrectWeaponAttacks > 0);
|
||||
double wepScore = getCorrectWeaponPct();
|
||||
|
||||
if (defenseTested && (overheadTested || wepTested))
|
||||
{
|
||||
double offense = (offScore * 0.5) + (wepScore * 0.5);
|
||||
return Math.max(0.0, Math.min(100.0, (0.40 * uptime) + (0.35 * defScore) + (0.25 * offense)));
|
||||
}
|
||||
else if (defenseTested)
|
||||
{
|
||||
return Math.max(0.0, Math.min(100.0, (0.60 * uptime) + (0.40 * defScore)));
|
||||
}
|
||||
else if (overheadTested || wepTested)
|
||||
{
|
||||
double offense = (offScore * 0.5) + (wepScore * 0.5);
|
||||
return Math.max(0.0, Math.min(100.0, (0.60 * uptime) + (0.40 * offense)));
|
||||
}
|
||||
|
||||
return uptime;
|
||||
}
|
||||
|
||||
public String getGrade()
|
||||
{
|
||||
return CombatStats.getGrade(getPerformanceScore());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package com.example.essentialsplus.loot.combat;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class SingleFightRecord
|
||||
{
|
||||
private int fightNumber;
|
||||
private long timestamp;
|
||||
private int durationTicks;
|
||||
private int idleTicks;
|
||||
private double efficiency;
|
||||
private String grade;
|
||||
private int attacksDealt;
|
||||
private int damageDealt;
|
||||
private int hitsReceived;
|
||||
private int damageTaken;
|
||||
private int hpHealed;
|
||||
private int tilesMoved;
|
||||
private long suppliesCost;
|
||||
private long lootValue;
|
||||
private long netProfit;
|
||||
private Map<String, Integer> foodUsed = new LinkedHashMap<>();
|
||||
private Map<String, Integer> potionsUsed = new LinkedHashMap<>();
|
||||
private Map<String, Integer> ammoUsed = new LinkedHashMap<>();
|
||||
|
||||
// Prayer & Weapon Performance metrics
|
||||
private int prayerBlockedHits;
|
||||
private int prayerMissedHits;
|
||||
private int offPrayerDamageTaken;
|
||||
private int onPrayerAttacks;
|
||||
private int offPrayerAttacks;
|
||||
private int correctWeaponAttacks;
|
||||
private int incorrectWeaponAttacks;
|
||||
private double performanceScore;
|
||||
|
||||
// Delve level (if Doom of Mokhaiotl or delve encounter)
|
||||
private int delve;
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package com.example.essentialsplus.loot.droptable;
|
||||
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/** One row of a wiki drop table ("dropsline" bucket). */
|
||||
public class DropLine
|
||||
{
|
||||
private static final Pattern FRACTION = Pattern.compile("([\\d.]+)\\s*/\\s*([\\d.]+)");
|
||||
|
||||
public final String source;
|
||||
public final String item;
|
||||
public final String rarityText;
|
||||
/** chance per roll, NaN when the wiki gives no number (e.g. "Varies") */
|
||||
public final double chance;
|
||||
public final int rolls;
|
||||
public final int qtyLow;
|
||||
public final int qtyHigh;
|
||||
|
||||
public DropLine(String source, String item, String rarityText, int rolls, int qtyLow, int qtyHigh)
|
||||
{
|
||||
this.source = source == null ? "" : source;
|
||||
this.item = item == null ? "" : item;
|
||||
this.rarityText = rarityText == null ? "" : rarityText;
|
||||
this.chance = parseRarity(this.rarityText);
|
||||
this.rolls = Math.max(1, rolls);
|
||||
this.qtyLow = Math.max(0, qtyLow);
|
||||
this.qtyHigh = Math.max(this.qtyLow, qtyHigh);
|
||||
}
|
||||
|
||||
/** Expected number of times this line is hit per kill. */
|
||||
public double hitsPerKill()
|
||||
{
|
||||
return Double.isNaN(chance) ? Double.NaN : Math.min(chance * rolls, rolls);
|
||||
}
|
||||
|
||||
/** "Always" -> 1, "3/128" or "~1/1,024" -> fraction, anything else -> NaN. */
|
||||
static double parseRarity(String text)
|
||||
{
|
||||
String t = text.replace(",", "").trim();
|
||||
if (t.equalsIgnoreCase("always"))
|
||||
{
|
||||
return 1;
|
||||
}
|
||||
Matcher m = FRACTION.matcher(t);
|
||||
if (!m.find())
|
||||
{
|
||||
return Double.NaN;
|
||||
}
|
||||
try
|
||||
{
|
||||
double num = Double.parseDouble(m.group(1));
|
||||
double den = Double.parseDouble(m.group(2));
|
||||
return den > 0 ? Math.min(1, num / den) : Double.NaN;
|
||||
}
|
||||
catch (NumberFormatException e)
|
||||
{
|
||||
return Double.NaN;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
package com.example.essentialsplus.loot.droptable;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
|
||||
/** The wiki drop table of one page, split into its variants (e.g. "Standard", "Wilderness Slayer Cave"). */
|
||||
public class DropTable
|
||||
{
|
||||
public final String page;
|
||||
public final List<Variant> variants;
|
||||
|
||||
public static final class Variant
|
||||
{
|
||||
public final String name;
|
||||
public final List<DropLine> lines;
|
||||
|
||||
Variant(String name, List<DropLine> lines)
|
||||
{
|
||||
this.name = name;
|
||||
this.lines = lines;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString()
|
||||
{
|
||||
return name;
|
||||
}
|
||||
}
|
||||
|
||||
/** Combined wiki rate of one item. */
|
||||
public static final class Rate
|
||||
{
|
||||
public final String item;
|
||||
public final String text;
|
||||
/** expected hits per kill (sum over all lines of the item), NaN if unknown */
|
||||
public final double perKill;
|
||||
|
||||
Rate(String item, String text, double perKill)
|
||||
{
|
||||
this.item = item;
|
||||
this.text = text;
|
||||
this.perKill = perKill;
|
||||
}
|
||||
}
|
||||
|
||||
public DropTable(String page, List<DropLine> lines)
|
||||
{
|
||||
this.page = page;
|
||||
this.variants = buildVariants(page, lines);
|
||||
}
|
||||
|
||||
public boolean isEmpty()
|
||||
{
|
||||
return variants.isEmpty();
|
||||
}
|
||||
|
||||
/**
|
||||
* Groups lines by their "Dropped from" section. Small sections (a few extra drops such as Catacombs
|
||||
* shards) are added on top of the main table; large ones are separate tables.
|
||||
*/
|
||||
static List<Variant> buildVariants(String page, List<DropLine> lines)
|
||||
{
|
||||
Map<String, List<DropLine>> groups = new LinkedHashMap<>();
|
||||
for (DropLine l : lines)
|
||||
{
|
||||
groups.computeIfAbsent(l.source, k -> new ArrayList<>()).add(l);
|
||||
}
|
||||
List<Variant> out = new ArrayList<>();
|
||||
if (groups.isEmpty())
|
||||
{
|
||||
return out;
|
||||
}
|
||||
int max = groups.values().stream().mapToInt(List::size).max().orElse(0);
|
||||
List<String> mains = new ArrayList<>();
|
||||
List<String> extras = new ArrayList<>();
|
||||
for (Map.Entry<String, List<DropLine>> g : groups.entrySet())
|
||||
{
|
||||
boolean base = !g.getKey().contains("#");
|
||||
(base || g.getValue().size() * 3 >= max ? mains : extras).add(g.getKey());
|
||||
}
|
||||
// a base section without '#' always comes first
|
||||
mains.sort(Comparator.comparing(s -> s.contains("#")));
|
||||
if (mains.isEmpty())
|
||||
{
|
||||
mains.add(extras.remove(0));
|
||||
}
|
||||
for (String m : mains)
|
||||
{
|
||||
out.add(new Variant(label(page, m), groups.get(m)));
|
||||
}
|
||||
String first = mains.get(0);
|
||||
for (String e : extras)
|
||||
{
|
||||
List<DropLine> combined = new ArrayList<>(groups.get(first));
|
||||
combined.addAll(groups.get(e));
|
||||
out.add(new Variant(label(page, first) + " + " + label(page, e), combined));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private static String label(String page, String source)
|
||||
{
|
||||
int hash = source.indexOf('#');
|
||||
if (hash >= 0 && hash + 1 < source.length())
|
||||
{
|
||||
return source.substring(hash + 1);
|
||||
}
|
||||
return source.isEmpty() ? page : "Standard";
|
||||
}
|
||||
|
||||
/** Drop rate per item of the variant, keyed by lower-case item name. */
|
||||
public static Map<String, Rate> rates(Variant variant)
|
||||
{
|
||||
Map<String, List<DropLine>> byItem = new LinkedHashMap<>();
|
||||
for (DropLine l : variant.lines)
|
||||
{
|
||||
if (!l.item.isEmpty() && !l.item.equalsIgnoreCase("Nothing"))
|
||||
{
|
||||
byItem.computeIfAbsent(key(l.item), k -> new ArrayList<>()).add(l);
|
||||
}
|
||||
}
|
||||
Map<String, Rate> out = new LinkedHashMap<>();
|
||||
for (Map.Entry<String, List<DropLine>> e : byItem.entrySet())
|
||||
{
|
||||
List<DropLine> ls = e.getValue();
|
||||
double hits = 0;
|
||||
for (DropLine l : ls)
|
||||
{
|
||||
hits += l.hitsPerKill();
|
||||
}
|
||||
String text = ls.size() == 1 || Double.isNaN(hits) ? ls.get(0).rarityText : formatChance(hits);
|
||||
out.put(e.getKey(), new Rate(ls.get(0).item, text, hits));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
static String formatChance(double perKill)
|
||||
{
|
||||
if (perKill >= 1)
|
||||
{
|
||||
return "Always";
|
||||
}
|
||||
if (perKill <= 0)
|
||||
{
|
||||
return "?";
|
||||
}
|
||||
double inv = 1 / perKill;
|
||||
return inv >= 100 ? String.format(Locale.ROOT, "1/%,d", Math.round(inv)) : String.format(Locale.ROOT, "1/%.1f", inv);
|
||||
}
|
||||
|
||||
public static String key(String name)
|
||||
{
|
||||
return name.trim().toLowerCase(Locale.ROOT);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,320 @@
|
||||
package com.example.essentialsplus.loot.gear;
|
||||
|
||||
import java.util.Locale;
|
||||
|
||||
/** How an attack is made: attack type, stance and (for magic) the spell or powered staff. */
|
||||
public final class CombatChoice
|
||||
{
|
||||
public enum Type
|
||||
{
|
||||
STAB("Stab"), SLASH("Slash"), CRUSH("Crush"), RANGED("Ranged"), MAGIC("Magic");
|
||||
|
||||
public final String label;
|
||||
|
||||
Type(String label)
|
||||
{
|
||||
this.label = label;
|
||||
}
|
||||
|
||||
public boolean melee()
|
||||
{
|
||||
return this == STAB || this == SLASH || this == CRUSH;
|
||||
}
|
||||
}
|
||||
|
||||
public enum Stance
|
||||
{
|
||||
ACCURATE("Accurate"), AGGRESSIVE("Aggressive"), CONTROLLED("Controlled"), DEFENSIVE("Defensive"),
|
||||
RAPID("Rapid"), LONGRANGE("Longrange"), CASTING("Autocast"), MANUAL("Manual cast");
|
||||
|
||||
public final String label;
|
||||
|
||||
Stance(String label)
|
||||
{
|
||||
this.label = label;
|
||||
}
|
||||
}
|
||||
|
||||
/** A castable spell: base max hit (-1 when it scales with the magic level) and element. */
|
||||
public static final class Spell
|
||||
{
|
||||
public final String name;
|
||||
public final int base;
|
||||
public final String element;
|
||||
|
||||
Spell(String name, int base, String element)
|
||||
{
|
||||
this.name = name;
|
||||
this.base = base;
|
||||
this.element = element;
|
||||
}
|
||||
}
|
||||
|
||||
public final Type type;
|
||||
public final Stance stance;
|
||||
/** null when not magic or when a powered staff attacks on its own */
|
||||
public final Spell spell;
|
||||
|
||||
public CombatChoice(Type type, Stance stance, Spell spell)
|
||||
{
|
||||
this.type = type;
|
||||
this.stance = stance;
|
||||
this.spell = spell;
|
||||
}
|
||||
|
||||
public String label()
|
||||
{
|
||||
if (type == Type.MAGIC)
|
||||
{
|
||||
return spell != null ? spell.name : "Powered staff";
|
||||
}
|
||||
return type.label + " · " + stance.label;
|
||||
}
|
||||
|
||||
// COM_MODE button -> stance per weapon category (varbit COMBAT_WEAPON_CATEGORY), null = no button
|
||||
private static final Stance A = Stance.ACCURATE, G = Stance.AGGRESSIVE, C = Stance.CONTROLLED, D = Stance.DEFENSIVE,
|
||||
R = Stance.RAPID, L = Stance.LONGRANGE;
|
||||
private static final Type ST = Type.STAB, SL = Type.SLASH, CR = Type.CRUSH, RA = Type.RANGED;
|
||||
|
||||
private static final Object[][][] CATEGORIES = {
|
||||
/* 0 unarmed */ {{CR, A}, {CR, G}, null, {CR, D}},
|
||||
/* 1 axe */ {{SL, A}, {SL, G}, {CR, G}, {SL, D}},
|
||||
/* 2 blunt */ {{CR, A}, {CR, G}, null, {CR, D}},
|
||||
/* 3 bow */ {{RA, A}, {RA, R}, null, {RA, L}},
|
||||
/* 4 claw */ {{SL, A}, {SL, G}, {ST, C}, {SL, D}},
|
||||
/* 5 crossbow */ {{RA, A}, {RA, R}, null, {RA, L}},
|
||||
/* 6 salamander */ {{SL, G}, {RA, A}, null, null},
|
||||
/* 7 chinchompa */ {{RA, A}, {RA, R}, null, {RA, L}},
|
||||
/* 8 gun */ null,
|
||||
/* 9 slash sword */ {{SL, A}, {SL, G}, {ST, C}, {SL, D}},
|
||||
/* 10 2h sword */ {{SL, A}, {SL, G}, {CR, G}, {SL, D}},
|
||||
/* 11 pickaxe */ {{ST, A}, {ST, G}, {CR, G}, {ST, D}},
|
||||
/* 12 halberd */ {{ST, C}, {SL, G}, null, {ST, D}},
|
||||
/* 13 polestaff */ {{CR, A}, {CR, G}, null, {CR, D}},
|
||||
/* 14 scythe */ {{SL, A}, {SL, G}, {CR, G}, {SL, D}},
|
||||
/* 15 spear */ {{ST, C}, {SL, C}, {CR, C}, {ST, D}},
|
||||
/* 16 spiked */ {{CR, A}, {CR, G}, {ST, C}, {CR, D}},
|
||||
/* 17 stab sword */ {{ST, A}, {ST, G}, {SL, G}, {ST, D}},
|
||||
/* 18 staff */ {{CR, A}, {CR, G}, null, {CR, D}},
|
||||
/* 19 thrown */ {{RA, A}, {RA, R}, null, {RA, L}},
|
||||
/* 20 whip */ {{SL, A}, {SL, C}, null, {SL, D}},
|
||||
/* 21 bladed staff */ {{ST, A}, {SL, G}, null, {CR, D}},
|
||||
/* 22 */ null,
|
||||
/* 23 powered staff */ null,
|
||||
/* 24 banner */ {{ST, A}, {SL, G}, {CR, C}, {ST, D}},
|
||||
/* 25 polearm */ {{ST, C}, {SL, G}, null, {ST, D}},
|
||||
/* 26 bludgeon */ {{CR, G}, {CR, G}, null, {CR, G}},
|
||||
/* 27 bulwark */ {{CR, A}, null, null, null},
|
||||
/* 28 */ null,
|
||||
/* 29 partisan */ {{ST, A}, {ST, G}, {CR, G}, {ST, D}},
|
||||
};
|
||||
|
||||
/** The attack style the player has selected for the worn weapon, or null if the category is not known. */
|
||||
public static CombatChoice fromVarbits(int category, int comMode, int autocastSpell, int magicLevel, GearItem weapon)
|
||||
{
|
||||
if (category == 23 || isPoweredStaff(weapon))
|
||||
{
|
||||
return new CombatChoice(Type.MAGIC, Stance.CASTING, null);
|
||||
}
|
||||
if ((category == 18 || category == 21) && comMode >= 4)
|
||||
{
|
||||
Spell spell = spell(autocastSpell);
|
||||
return new CombatChoice(Type.MAGIC, Stance.CASTING, spell != null ? spell : bestStandardSpell(magicLevel));
|
||||
}
|
||||
if (category < 0 || category >= CATEGORIES.length || CATEGORIES[category] == null
|
||||
|| comMode < 0 || comMode > 3 || CATEGORIES[category][comMode] == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
Object[] entry = CATEGORIES[category][comMode];
|
||||
return new CombatChoice((Type) entry[0], (Stance) entry[1], null);
|
||||
}
|
||||
|
||||
/** Best guess for a weapon that is not worn: its main attack type with the damage stance. */
|
||||
public static CombatChoice infer(GearItem weapon, int magicLevel, int autocastSpell)
|
||||
{
|
||||
switch (weaponClass(weapon))
|
||||
{
|
||||
case RANGED:
|
||||
return new CombatChoice(Type.RANGED, Stance.RAPID, null);
|
||||
case MAGIC:
|
||||
if (isPoweredStaff(weapon))
|
||||
{
|
||||
return new CombatChoice(Type.MAGIC, Stance.CASTING, null);
|
||||
}
|
||||
Spell s = spell(autocastSpell);
|
||||
return new CombatChoice(Type.MAGIC, Stance.CASTING, s != null ? s : bestStandardSpell(magicLevel));
|
||||
default:
|
||||
return new CombatChoice(bestMeleeType(weapon), Stance.AGGRESSIVE, null);
|
||||
}
|
||||
}
|
||||
|
||||
/** Melee choice of a weapon for one attack type (optimizer): aggressive when possible. */
|
||||
public static CombatChoice melee(Type type)
|
||||
{
|
||||
return new CombatChoice(type, Stance.AGGRESSIVE, null);
|
||||
}
|
||||
|
||||
public static Type bestMeleeType(GearItem w)
|
||||
{
|
||||
if (w == null)
|
||||
{
|
||||
return Type.CRUSH;
|
||||
}
|
||||
if (w.astab >= w.aslash && w.astab >= w.acrush)
|
||||
{
|
||||
return Type.STAB;
|
||||
}
|
||||
return w.aslash >= w.acrush ? Type.SLASH : Type.CRUSH;
|
||||
}
|
||||
|
||||
/** RANGED, MAGIC or melee (STAB) class of a weapon. */
|
||||
public static Type weaponClass(GearItem w)
|
||||
{
|
||||
if (w == null)
|
||||
{
|
||||
return Type.STAB;
|
||||
}
|
||||
if (isPoweredStaff(w))
|
||||
{
|
||||
return Type.MAGIC;
|
||||
}
|
||||
boolean rangedName = w.has("bow") || w.has("dart") || w.has("knife") || w.has("thrownaxe") || w.has("chinchompa")
|
||||
|| w.has("ballista") || w.has("blowpipe") || w.has("javelin") || w.has("atlatl") || w.has("tonalztics");
|
||||
int meleeMax = Math.max(w.astab, Math.max(w.aslash, w.acrush));
|
||||
if (rangedName && w.arange > 0 || w.arange > 0 && w.arange > meleeMax && w.amagic <= 0)
|
||||
{
|
||||
return Type.RANGED;
|
||||
}
|
||||
boolean magicName = w.has("staff") || w.has("wand") || w.has("sceptre") || w.has("trident");
|
||||
if (magicName && w.amagic >= meleeMax)
|
||||
{
|
||||
return Type.MAGIC;
|
||||
}
|
||||
return Type.STAB;
|
||||
}
|
||||
|
||||
public static boolean isPoweredStaff(GearItem w)
|
||||
{
|
||||
return poweredBase(w, 99) != Integer.MIN_VALUE;
|
||||
}
|
||||
|
||||
/** Base max hit of a powered staff at the given (boosted) magic level, MIN_VALUE if not one. */
|
||||
public static int poweredBase(GearItem w, int magic)
|
||||
{
|
||||
if (w == null)
|
||||
{
|
||||
return Integer.MIN_VALUE;
|
||||
}
|
||||
String n = w.lower;
|
||||
if (n.startsWith("trident of the seas"))
|
||||
{
|
||||
return magic / 3 - 5;
|
||||
}
|
||||
if (n.startsWith("trident of the swamp"))
|
||||
{
|
||||
return magic / 3 - 2;
|
||||
}
|
||||
if (n.startsWith("sanguinesti staff") || n.startsWith("holy sanguinesti staff"))
|
||||
{
|
||||
return magic / 3 - 1;
|
||||
}
|
||||
if (n.startsWith("tumeken's shadow"))
|
||||
{
|
||||
return magic / 3 + 1;
|
||||
}
|
||||
if (n.startsWith("thammaron's sceptre"))
|
||||
{
|
||||
return magic / 3 - 8;
|
||||
}
|
||||
if (n.startsWith("accursed sceptre"))
|
||||
{
|
||||
return magic / 3 - 6;
|
||||
}
|
||||
if (n.startsWith("warped sceptre"))
|
||||
{
|
||||
return (8 * magic + 96) / 37;
|
||||
}
|
||||
if (n.startsWith("crystal staff (basic)") || n.startsWith("corrupted staff (basic)"))
|
||||
{
|
||||
return 23;
|
||||
}
|
||||
if (n.startsWith("crystal staff (attuned)") || n.startsWith("corrupted staff (attuned)"))
|
||||
{
|
||||
return 31;
|
||||
}
|
||||
if (n.startsWith("crystal staff (perfected)") || n.startsWith("corrupted staff (perfected)"))
|
||||
{
|
||||
return 39;
|
||||
}
|
||||
return Integer.MIN_VALUE;
|
||||
}
|
||||
|
||||
/** Spell of varbit AUTOCAST_SPELL, null for none or unknown. */
|
||||
public static Spell spell(int autocast)
|
||||
{
|
||||
switch (autocast)
|
||||
{
|
||||
case 1: return new Spell("Wind Strike", 2, "Air");
|
||||
case 2: return new Spell("Water Strike", 4, "Water");
|
||||
case 3: return new Spell("Earth Strike", 6, "Earth");
|
||||
case 4: return new Spell("Fire Strike", 8, "Fire");
|
||||
case 5: return new Spell("Wind Bolt", 9, "Air");
|
||||
case 6: return new Spell("Water Bolt", 10, "Water");
|
||||
case 7: return new Spell("Earth Bolt", 11, "Earth");
|
||||
case 8: return new Spell("Fire Bolt", 12, "Fire");
|
||||
case 9: return new Spell("Wind Blast", 13, "Air");
|
||||
case 10: return new Spell("Water Blast", 14, "Water");
|
||||
case 11: return new Spell("Earth Blast", 15, "Earth");
|
||||
case 12: return new Spell("Fire Blast", 16, "Fire");
|
||||
case 13: return new Spell("Wind Wave", 17, "Air");
|
||||
case 14: return new Spell("Water Wave", 18, "Water");
|
||||
case 15: return new Spell("Earth Wave", 19, "Earth");
|
||||
case 16: return new Spell("Fire Wave", 20, "Fire");
|
||||
case 17: return new Spell("Crumble Undead", 15, null);
|
||||
case 18: return new Spell("Magic Dart", -1, null);
|
||||
case 19: return new Spell("Claws of Guthix", 20, null);
|
||||
case 20: return new Spell("Flames of Zamorak", 20, null);
|
||||
case 21: return new Spell("Saradomin Strike", 20, null);
|
||||
case 31: return new Spell("Smoke Rush", 13, null);
|
||||
case 32: return new Spell("Shadow Rush", 14, null);
|
||||
case 33: return new Spell("Blood Rush", 15, null);
|
||||
case 34: return new Spell("Ice Rush", 16, null);
|
||||
case 35: return new Spell("Smoke Burst", 17, null);
|
||||
case 36: return new Spell("Shadow Burst", 18, null);
|
||||
case 37: return new Spell("Blood Burst", 21, null);
|
||||
case 38: return new Spell("Ice Burst", 22, null);
|
||||
case 39: return new Spell("Smoke Blitz", 23, null);
|
||||
case 40: return new Spell("Shadow Blitz", 24, null);
|
||||
case 41: return new Spell("Blood Blitz", 25, null);
|
||||
case 42: return new Spell("Ice Blitz", 26, null);
|
||||
case 43: return new Spell("Smoke Barrage", 27, null);
|
||||
case 44: return new Spell("Shadow Barrage", 28, null);
|
||||
case 45: return new Spell("Blood Barrage", 29, null);
|
||||
case 46: return new Spell("Ice Barrage", 30, null);
|
||||
case 47: return new Spell("Iban Blast", 25, null);
|
||||
case 48: return new Spell("Wind Surge", 21, "Air");
|
||||
case 49: return new Spell("Water Surge", 22, "Water");
|
||||
case 50: return new Spell("Earth Surge", 23, "Earth");
|
||||
case 51: return new Spell("Fire Surge", 24, "Fire");
|
||||
default: return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Strongest fire spell of the standard spellbook at a magic level. */
|
||||
public static Spell bestStandardSpell(int magic)
|
||||
{
|
||||
if (magic >= 95) return spell(51);
|
||||
if (magic >= 75) return spell(16);
|
||||
if (magic >= 59) return spell(12);
|
||||
if (magic >= 35) return spell(8);
|
||||
if (magic >= 13) return spell(4);
|
||||
return spell(1);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString()
|
||||
{
|
||||
return label().toLowerCase(Locale.ENGLISH);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,333 @@
|
||||
package com.example.essentialsplus.loot.gear;
|
||||
|
||||
import com.example.essentialsplus.loot.gear.CombatChoice.Stance;
|
||||
import com.example.essentialsplus.loot.gear.CombatChoice.Type;
|
||||
|
||||
/**
|
||||
* Max hit, accuracy and DPS with the standard OSRS formulas. Covers prayers, stances, void, salve, slayer helm,
|
||||
* dragon hunter, arclight, twisted bow, crystal armour, powered staves and elemental weaknesses; other special
|
||||
* effects (special attacks, set effects, multi-hit weapons) are left out, so it is an estimate.
|
||||
*/
|
||||
public final class DpsCalc
|
||||
{
|
||||
private DpsCalc()
|
||||
{
|
||||
}
|
||||
|
||||
public static final class Result
|
||||
{
|
||||
public final CombatChoice choice;
|
||||
public final int maxHit;
|
||||
public final double hitChance;
|
||||
public final double dps;
|
||||
public final int speed;
|
||||
public final long attackRoll;
|
||||
public final long defenceRoll;
|
||||
|
||||
Result(CombatChoice choice, int maxHit, double hitChance, int speed, long attackRoll, long defenceRoll)
|
||||
{
|
||||
this.choice = choice;
|
||||
this.maxHit = Math.max(0, maxHit);
|
||||
this.hitChance = hitChance;
|
||||
this.speed = Math.max(1, speed);
|
||||
this.attackRoll = attackRoll;
|
||||
this.defenceRoll = defenceRoll;
|
||||
this.dps = hitChance * this.maxHit / 2.0 / (this.speed * 0.6);
|
||||
}
|
||||
|
||||
/** Expected seconds to kill {@code hp}, NaN if no damage. */
|
||||
public double secondsToKill(int hp)
|
||||
{
|
||||
return dps > 0 && hp > 0 ? hp / dps : Double.NaN;
|
||||
}
|
||||
}
|
||||
|
||||
/** Uses the selected style when the worn weapon is still worn, otherwise the weapon's likely style. */
|
||||
public static CombatChoice choiceFor(Loadout loadout, Loadout worn, PlayerSetup p)
|
||||
{
|
||||
GearItem w = loadout.weapon();
|
||||
if (loadout.id(GearItem.WEAPON) == worn.id(GearItem.WEAPON))
|
||||
{
|
||||
CombatChoice live = CombatChoice.fromVarbits(p.weaponCategory, p.comMode, p.autocastSpell, p.magic, w);
|
||||
if (live != null)
|
||||
{
|
||||
return live;
|
||||
}
|
||||
}
|
||||
return CombatChoice.infer(w, p.magic, p.autocastSpell);
|
||||
}
|
||||
|
||||
public static Result calc(Loadout l, PlayerSetup p, Monster m, CombatChoice c)
|
||||
{
|
||||
switch (c.type)
|
||||
{
|
||||
case RANGED:
|
||||
return ranged(l, p, m, c);
|
||||
case MAGIC:
|
||||
return magic(l, p, m, c);
|
||||
default:
|
||||
return melee(l, p, m, c);
|
||||
}
|
||||
}
|
||||
|
||||
static double hitChance(long att, long def)
|
||||
{
|
||||
if (att <= 0)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
if (att > def)
|
||||
{
|
||||
return 1.0 - (def + 2.0) / (2.0 * (att + 1.0));
|
||||
}
|
||||
return att / (2.0 * (def + 1.0));
|
||||
}
|
||||
|
||||
private static long defenceRoll(Monster m, int level, int bonus)
|
||||
{
|
||||
return (long) (level + 9) * (bonus + 64);
|
||||
}
|
||||
|
||||
private static int weaponSpeed(Loadout l)
|
||||
{
|
||||
GearItem w = l.weapon();
|
||||
return w == null || w.aspeed <= 0 ? 4 : w.aspeed;
|
||||
}
|
||||
|
||||
private static Result melee(Loadout l, PlayerSetup p, Monster m, CombatChoice c)
|
||||
{
|
||||
int[] b = l.bonuses();
|
||||
int attBonus = c.type == Type.STAB ? b[0] : c.type == Type.SLASH ? b[1] : b[2];
|
||||
int stanceAtt = c.stance == Stance.ACCURATE ? 3 : c.stance == Stance.CONTROLLED ? 1 : 0;
|
||||
int stanceStr = c.stance == Stance.AGGRESSIVE ? 3 : c.stance == Stance.CONTROLLED ? 1 : 0;
|
||||
|
||||
int effAtt = (int) Math.floor(p.attack * p.meleeAcc) + stanceAtt + 8;
|
||||
int effStr = (int) Math.floor(p.strength * p.meleeStr) + stanceStr + 8;
|
||||
if (hasVoid(l, "melee"))
|
||||
{
|
||||
effAtt = effAtt * 11 / 10;
|
||||
effStr = effStr * 11 / 10;
|
||||
}
|
||||
long att = (long) effAtt * (attBonus + 64);
|
||||
int max = (effStr * (b[10] + 64) + 320) / 640;
|
||||
|
||||
// salve and slayer helm do not stack; salve wins
|
||||
if (m.hasAttribute("undead") && l.slotHas(GearItem.AMULET, "salve amulet"))
|
||||
{
|
||||
boolean e = l.slotHas(GearItem.AMULET, "(e") ;
|
||||
att = e ? att * 6 / 5 : att * 7 / 6;
|
||||
max = e ? max * 6 / 5 : max * 7 / 6;
|
||||
}
|
||||
else if (p.onSlayerTask && (l.slotHas(GearItem.HEAD, "slayer helmet") || l.slotHas(GearItem.HEAD, "black mask")))
|
||||
{
|
||||
att = att * 7 / 6;
|
||||
max = max * 7 / 6;
|
||||
}
|
||||
GearItem w = l.weapon();
|
||||
if (w != null && m.hasAttribute("dragon") && w.has("dragon hunter lance"))
|
||||
{
|
||||
att = att * 6 / 5;
|
||||
max = max * 6 / 5;
|
||||
}
|
||||
if (w != null && m.hasAttribute("demon") && (w.has("arclight") || w.has("emberlight")))
|
||||
{
|
||||
att = att * 17 / 10;
|
||||
max = max * 17 / 10;
|
||||
}
|
||||
|
||||
int defBonus = c.type == Type.STAB ? m.dstab : c.type == Type.SLASH ? m.dslash : m.dcrush;
|
||||
long def = defenceRoll(m, m.defenceLevel, defBonus);
|
||||
return new Result(c, max, hitChance(att, def), weaponSpeed(l), att, def);
|
||||
}
|
||||
|
||||
/** Ammo type the weapon fires from the ammo slot: "arrow", "bolt", "javelin" or null (no ammo used). */
|
||||
static String ammoFor(GearItem w)
|
||||
{
|
||||
if (w == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
if (w.has("ballista"))
|
||||
{
|
||||
return "javelin";
|
||||
}
|
||||
if (w.has("crossbow") || w.has("karil"))
|
||||
{
|
||||
return w.has("karil") ? "bolt rack" : "bolt";
|
||||
}
|
||||
if (w.has("bow") && !w.has("crystal bow") && !w.has("bow of faerdhinen") && !w.has("blowpipe"))
|
||||
{
|
||||
return "arrow";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static Result ranged(Loadout l, PlayerSetup p, Monster m, CombatChoice c)
|
||||
{
|
||||
int[] b = l.bonuses();
|
||||
GearItem w = l.weapon();
|
||||
GearItem ammo = l.get(GearItem.AMMO);
|
||||
int rstr = b[11];
|
||||
if (ammo != null)
|
||||
{
|
||||
String wanted = ammoFor(w);
|
||||
if (wanted == null || !ammo.has(wanted))
|
||||
{
|
||||
// ammo the weapon cannot fire adds no ranged strength
|
||||
rstr -= ammo.rstr;
|
||||
}
|
||||
}
|
||||
|
||||
int stance = c.stance == Stance.ACCURATE ? 3 : 0;
|
||||
int effAtt = (int) Math.floor(p.ranged * p.rangedAcc) + stance + 8;
|
||||
int effStr = (int) Math.floor(p.ranged * p.rangedStr) + stance + 8;
|
||||
if (hasVoid(l, "ranger"))
|
||||
{
|
||||
effAtt = effAtt * 11 / 10;
|
||||
effStr = isEliteVoid(l) ? effStr * 9 / 8 : effStr * 11 / 10;
|
||||
}
|
||||
long att = (long) effAtt * (b[4] + 64);
|
||||
int max = (effStr * (rstr + 64) + 320) / 640;
|
||||
|
||||
if (w != null && (w.has("crystal bow") || w.has("bow of faerdhinen")))
|
||||
{
|
||||
int accPct = 0;
|
||||
int dmgPct = 0;
|
||||
if (l.slotHas(GearItem.HEAD, "crystal helm"))
|
||||
{
|
||||
accPct += 5;
|
||||
dmgPct += 5;
|
||||
}
|
||||
if (l.slotHas(GearItem.BODY, "crystal body"))
|
||||
{
|
||||
accPct += 15;
|
||||
dmgPct += 15;
|
||||
}
|
||||
if (l.slotHas(GearItem.LEGS, "crystal legs"))
|
||||
{
|
||||
accPct += 10;
|
||||
dmgPct += 10;
|
||||
}
|
||||
att = att * (200 + accPct) / 200;
|
||||
max = max * (200 + dmgPct) / 200;
|
||||
}
|
||||
|
||||
boolean imbued = l.slotHas(GearItem.AMULET, "(i)") || l.slotHas(GearItem.AMULET, "(ei)");
|
||||
if (m.hasAttribute("undead") && l.slotHas(GearItem.AMULET, "salve amulet") && imbued)
|
||||
{
|
||||
boolean e = l.slotHas(GearItem.AMULET, "(ei)");
|
||||
att = e ? att * 6 / 5 : att * 7 / 6;
|
||||
max = e ? max * 6 / 5 : max * 7 / 6;
|
||||
}
|
||||
else if (p.onSlayerTask && (l.slotHas(GearItem.HEAD, "slayer helmet (i)") || l.slotHas(GearItem.HEAD, "black mask (i)")))
|
||||
{
|
||||
att = att * 23 / 20;
|
||||
max = max * 23 / 20;
|
||||
}
|
||||
|
||||
if (w != null && w.has("twisted bow"))
|
||||
{
|
||||
int mg = Math.min(250, Math.max(m.magicLevel, m.magicAttack));
|
||||
int accPct = Math.min(140, 140 + (3 * mg - 10) / 100 - (int) Math.pow(3 * mg / 10 - 100, 2) / 100);
|
||||
int dmgPct = Math.min(250, 250 + (3 * mg - 14) / 100 - (int) Math.pow(3 * mg / 10 - 140, 2) / 100);
|
||||
att = att * Math.max(0, accPct) / 100;
|
||||
max = max * Math.max(0, dmgPct) / 100;
|
||||
}
|
||||
if (w != null && m.hasAttribute("dragon") && w.has("dragon hunter crossbow"))
|
||||
{
|
||||
att = att * 13 / 10;
|
||||
max = max * 5 / 4;
|
||||
}
|
||||
|
||||
int defBonus;
|
||||
if (w != null && (w.has("crossbow") || w.has("ballista") || w.has("karil")))
|
||||
{
|
||||
defBonus = m.dheavy;
|
||||
}
|
||||
else if (w != null && (w.has("dart") || w.has("knife") || w.has("thrownaxe") || w.has("blowpipe") || w.has("chinchompa")))
|
||||
{
|
||||
defBonus = m.dlight;
|
||||
}
|
||||
else
|
||||
{
|
||||
defBonus = m.dstandard;
|
||||
}
|
||||
long def = defenceRoll(m, m.defenceLevel, defBonus);
|
||||
int speed = weaponSpeed(l) - (c.stance == Stance.RAPID ? 1 : 0);
|
||||
return new Result(c, max, hitChance(att, def), speed, att, def);
|
||||
}
|
||||
|
||||
private static Result magic(Loadout l, PlayerSetup p, Monster m, CombatChoice c)
|
||||
{
|
||||
int[] b = l.bonuses();
|
||||
GearItem w = l.weapon();
|
||||
int base;
|
||||
int speed;
|
||||
String element = null;
|
||||
if (c.spell == null)
|
||||
{
|
||||
base = CombatChoice.poweredBase(w, p.magic);
|
||||
speed = weaponSpeed(l);
|
||||
}
|
||||
else
|
||||
{
|
||||
base = c.spell.base >= 0 ? c.spell.base : 10 + p.magic / 10;
|
||||
element = c.spell.element;
|
||||
speed = 5;
|
||||
}
|
||||
if (base == Integer.MIN_VALUE || base <= 0)
|
||||
{
|
||||
return new Result(c, 0, 0, speed, 0, 0);
|
||||
}
|
||||
|
||||
boolean shadow = w != null && w.has("tumeken's shadow") && c.spell == null;
|
||||
int magicAttack = shadow ? b[3] * 3 : b[3];
|
||||
double dmgPct = (shadow ? l.magicDamage() * 3 : l.magicDamage()) + p.magicDmgPct;
|
||||
|
||||
int effAtt = (int) Math.floor(p.magic * p.magicAcc) + 9;
|
||||
if (hasVoid(l, "mage"))
|
||||
{
|
||||
effAtt = effAtt * 29 / 20;
|
||||
if (isEliteVoid(l))
|
||||
{
|
||||
dmgPct += 2.5;
|
||||
}
|
||||
}
|
||||
long att = (long) effAtt * (magicAttack + 64);
|
||||
int max = (int) Math.floor(base * (1 + dmgPct / 100.0));
|
||||
|
||||
boolean imbued = l.slotHas(GearItem.AMULET, "(i)") || l.slotHas(GearItem.AMULET, "(ei)");
|
||||
if (m.hasAttribute("undead") && l.slotHas(GearItem.AMULET, "salve amulet") && imbued)
|
||||
{
|
||||
boolean e = l.slotHas(GearItem.AMULET, "(ei)");
|
||||
att = e ? att * 6 / 5 : att * 23 / 20;
|
||||
max = e ? max * 6 / 5 : max * 23 / 20;
|
||||
}
|
||||
else if (p.onSlayerTask && (l.slotHas(GearItem.HEAD, "slayer helmet (i)") || l.slotHas(GearItem.HEAD, "black mask (i)")))
|
||||
{
|
||||
att = att * 23 / 20;
|
||||
max = max * 23 / 20;
|
||||
}
|
||||
if (element != null && m.elementalWeakness != null && element.equalsIgnoreCase(m.elementalWeakness) && m.elementalWeaknessPct > 0)
|
||||
{
|
||||
att = att * (100 + m.elementalWeaknessPct) / 100;
|
||||
max = max + base * m.elementalWeaknessPct / 100;
|
||||
}
|
||||
|
||||
long def = defenceRoll(m, m.magicLevel, m.dmagic);
|
||||
return new Result(c, max, hitChance(att, def), speed, att, def);
|
||||
}
|
||||
|
||||
private static boolean hasVoid(Loadout l, String helm)
|
||||
{
|
||||
return l.slotHas(GearItem.HEAD, "void " + helm + " helm")
|
||||
&& (l.slotHas(GearItem.BODY, "void knight top") || l.slotHas(GearItem.BODY, "elite void top"))
|
||||
&& (l.slotHas(GearItem.LEGS, "void knight robe") || l.slotHas(GearItem.LEGS, "elite void robe"))
|
||||
&& l.slotHas(GearItem.GLOVES, "void knight gloves");
|
||||
}
|
||||
|
||||
private static boolean isEliteVoid(Loadout l)
|
||||
{
|
||||
return l.slotHas(GearItem.BODY, "elite void top") && l.slotHas(GearItem.LEGS, "elite void robe");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,492 @@
|
||||
package com.example.essentialsplus.loot.gear;
|
||||
|
||||
import com.example.essentialsplus.helpers.AttackStyles;
|
||||
import com.example.essentialsplus.helpers.CombatStyle;
|
||||
import com.example.essentialsplus.loot.combat.CombatTracker;
|
||||
import com.example.essentialsplus.loot.combat.FightSession;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.CopyOnWriteArrayList;
|
||||
import java.util.concurrent.Executor;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.function.Supplier;
|
||||
import net.runelite.api.Actor;
|
||||
import net.runelite.api.Client;
|
||||
import net.runelite.api.GameState;
|
||||
import net.runelite.api.Item;
|
||||
import net.runelite.api.ItemComposition;
|
||||
import net.runelite.api.ItemContainer;
|
||||
import net.runelite.api.NPC;
|
||||
import net.runelite.api.Player;
|
||||
import net.runelite.api.Prayer;
|
||||
import net.runelite.api.Skill;
|
||||
import net.runelite.api.gameval.InventoryID;
|
||||
import net.runelite.api.gameval.VarPlayerID;
|
||||
import net.runelite.api.gameval.VarbitID;
|
||||
import net.runelite.client.game.ItemManager;
|
||||
import net.runelite.client.game.ItemStats;
|
||||
import net.runelite.client.util.Text;
|
||||
|
||||
/**
|
||||
* Builds {@link FightSnapshot}s on the client thread: worn gear, owned equipment (worn, inventory, bank),
|
||||
* levels/prayers/style, the current target with its wiki stats and the running fight session.
|
||||
* The best-in-bank search runs on the background executor whenever gear or target change.
|
||||
*/
|
||||
public class FightService
|
||||
{
|
||||
private final Client client;
|
||||
private final ItemManager itemManager;
|
||||
private final CombatTracker combatTracker;
|
||||
private final AttackStyles attackStyles;
|
||||
private final MonsterService monsters;
|
||||
private final Executor executor;
|
||||
/** saved bank of the logged-in account (item id -> quantity), may be null */
|
||||
private final Supplier<Map<Integer, Long>> bank;
|
||||
private final List<Consumer<FightSnapshot>> listeners = new CopyOnWriteArrayList<>();
|
||||
|
||||
/** base item (source WORN) per canonical id, null value = not equipable */
|
||||
private final Map<Integer, GearItem> itemCache = new java.util.HashMap<>();
|
||||
|
||||
private volatile boolean slayerTask;
|
||||
private volatile boolean monsterLoaded;
|
||||
private volatile boolean clearRequested;
|
||||
private boolean containersChanged = true;
|
||||
|
||||
private PlayerSetup lastSetup;
|
||||
private int[] lastWornIds;
|
||||
private Loadout lastWorn;
|
||||
private List<GearItem> pool = Collections.emptyList();
|
||||
private long poolHash;
|
||||
private int targetNpcIndex = -1;
|
||||
private FightSnapshot.Target lastTarget;
|
||||
private Monster lastMonster;
|
||||
private volatile long gearVersion;
|
||||
private volatile List<GearOptimizer.Option> best = Collections.emptyList();
|
||||
private volatile long bestVersion = -1;
|
||||
private volatile boolean bestReady;
|
||||
private FightSnapshot last = FightSnapshot.LOGGED_OUT;
|
||||
|
||||
public FightService(Client client, ItemManager itemManager, CombatTracker combatTracker, AttackStyles attackStyles,
|
||||
MonsterService monsters, Executor executor, Supplier<Map<Integer, Long>> bank)
|
||||
{
|
||||
this.client = client;
|
||||
this.itemManager = itemManager;
|
||||
this.combatTracker = combatTracker;
|
||||
this.attackStyles = attackStyles;
|
||||
this.monsters = monsters;
|
||||
this.executor = executor;
|
||||
this.bank = bank;
|
||||
}
|
||||
|
||||
public void addListener(Consumer<FightSnapshot> l)
|
||||
{
|
||||
listeners.add(l);
|
||||
l.accept(last);
|
||||
}
|
||||
|
||||
public void removeListener(Consumer<FightSnapshot> l)
|
||||
{
|
||||
listeners.remove(l);
|
||||
}
|
||||
|
||||
public FightSnapshot last()
|
||||
{
|
||||
return last;
|
||||
}
|
||||
|
||||
public boolean isSlayerTask()
|
||||
{
|
||||
return slayerTask;
|
||||
}
|
||||
|
||||
/** Slayer helmet / black mask bonus applies (set from the panel). */
|
||||
public void setSlayerTask(boolean task)
|
||||
{
|
||||
slayerTask = task;
|
||||
lastSetup = null;
|
||||
}
|
||||
|
||||
/** Called from the wiki thread when monster stats arrive. */
|
||||
public void onMonsterLoaded()
|
||||
{
|
||||
monsterLoaded = true;
|
||||
}
|
||||
|
||||
public void onContainerChanged()
|
||||
{
|
||||
containersChanged = true;
|
||||
}
|
||||
|
||||
/** Forgets the last target (panel "clear"). Any thread. */
|
||||
public void clearTarget()
|
||||
{
|
||||
clearRequested = true;
|
||||
}
|
||||
|
||||
public void reset()
|
||||
{
|
||||
lastSetup = null;
|
||||
lastWornIds = null;
|
||||
lastWorn = null;
|
||||
lastTarget = null;
|
||||
lastMonster = null;
|
||||
targetNpcIndex = -1;
|
||||
pool = Collections.emptyList();
|
||||
poolHash = 0;
|
||||
containersChanged = true;
|
||||
itemCache.clear();
|
||||
publish(FightSnapshot.LOGGED_OUT);
|
||||
}
|
||||
|
||||
public void onGameTick()
|
||||
{
|
||||
Player me = client.getLocalPlayer();
|
||||
if (client.getGameState() != GameState.LOGGED_IN || me == null)
|
||||
{
|
||||
if (last.loggedIn)
|
||||
{
|
||||
reset();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
boolean gearChanged = false;
|
||||
|
||||
PlayerSetup setup = readSetup();
|
||||
if (lastSetup == null || !sameSetup(setup, lastSetup))
|
||||
{
|
||||
lastSetup = setup;
|
||||
gearChanged = true;
|
||||
}
|
||||
|
||||
Loadout worn = lastWorn;
|
||||
if (containersChanged || worn == null)
|
||||
{
|
||||
worn = readWorn();
|
||||
int[] ids = new int[GearItem.SLOTS];
|
||||
for (int s = 0; s < GearItem.SLOTS; s++)
|
||||
{
|
||||
ids[s] = worn.id(s);
|
||||
}
|
||||
if (lastWornIds == null || !java.util.Arrays.equals(ids, lastWornIds))
|
||||
{
|
||||
lastWornIds = ids;
|
||||
gearChanged = true;
|
||||
}
|
||||
lastWorn = worn;
|
||||
|
||||
long hash = ownedHash();
|
||||
if (hash != poolHash || gearChanged)
|
||||
{
|
||||
poolHash = hash;
|
||||
pool = readPool(worn);
|
||||
gearChanged = true;
|
||||
}
|
||||
containersChanged = false;
|
||||
}
|
||||
|
||||
if (clearRequested)
|
||||
{
|
||||
clearRequested = false;
|
||||
lastTarget = null;
|
||||
targetNpcIndex = -1;
|
||||
gearChanged = true;
|
||||
}
|
||||
|
||||
FightSnapshot.Target target = readTarget(me);
|
||||
Monster monster = target != null ? target.monster : null;
|
||||
if (monster != lastMonster || monsterLoaded)
|
||||
{
|
||||
monsterLoaded = false;
|
||||
lastMonster = monster;
|
||||
gearChanged = true;
|
||||
}
|
||||
|
||||
if (gearChanged)
|
||||
{
|
||||
gearVersion++;
|
||||
startSearch(gearVersion, worn, setup, target);
|
||||
}
|
||||
|
||||
FightSession s = combatTracker.getActiveSession();
|
||||
FightSnapshot.Session session = s != null ? new FightSnapshot.Session(s) : null;
|
||||
|
||||
boolean emit = gearChanged || bestReady || target != null && (target.live || last.target == null || last.target.live)
|
||||
|| session != null || last.session != null || target == null && last.target != null;
|
||||
bestReady = false;
|
||||
if (emit)
|
||||
{
|
||||
List<GearOptimizer.Option> b = bestVersion == gearVersion ? best : last.best;
|
||||
publish(new FightSnapshot(true, setup, worn, pool, target, session, b, gearVersion));
|
||||
}
|
||||
}
|
||||
|
||||
private void publish(FightSnapshot snap)
|
||||
{
|
||||
last = snap;
|
||||
for (Consumer<FightSnapshot> l : listeners)
|
||||
{
|
||||
l.accept(snap);
|
||||
}
|
||||
}
|
||||
|
||||
private void startSearch(long version, Loadout worn, PlayerSetup setup, FightSnapshot.Target target)
|
||||
{
|
||||
List<GearItem> items = pool;
|
||||
Monster m = target != null ? target.calcMonster() : null;
|
||||
if (m == null || items.isEmpty())
|
||||
{
|
||||
best = Collections.emptyList();
|
||||
bestVersion = version;
|
||||
return;
|
||||
}
|
||||
executor.execute(() ->
|
||||
{
|
||||
List<GearOptimizer.Option> result = GearOptimizer.bestPerStyle(items, worn, setup, m);
|
||||
if (version == gearVersion)
|
||||
{
|
||||
best = result;
|
||||
bestVersion = version;
|
||||
bestReady = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- reading game state
|
||||
|
||||
private PlayerSetup readSetup()
|
||||
{
|
||||
double meleeAcc = pick(1.20, Prayer.PIETY, 1.15, Prayer.CHIVALRY, 1.15, Prayer.INCREDIBLE_REFLEXES,
|
||||
1.10, Prayer.IMPROVED_REFLEXES, 1.05, Prayer.CLARITY_OF_THOUGHT);
|
||||
double meleeStr = pick(1.23, Prayer.PIETY, 1.18, Prayer.CHIVALRY, 1.15, Prayer.ULTIMATE_STRENGTH,
|
||||
1.10, Prayer.SUPERHUMAN_STRENGTH, 1.05, Prayer.BURST_OF_STRENGTH);
|
||||
double rangedAcc = pick(1.20, Prayer.RIGOUR, 1.18, Prayer.DEADEYE, 1.15, Prayer.EAGLE_EYE,
|
||||
1.10, Prayer.HAWK_EYE, 1.05, Prayer.SHARP_EYE);
|
||||
double rangedStr = pick(1.23, Prayer.RIGOUR, 1.18, Prayer.DEADEYE, 1.15, Prayer.EAGLE_EYE,
|
||||
1.10, Prayer.HAWK_EYE, 1.05, Prayer.SHARP_EYE);
|
||||
double magicAcc = pick(1.25, Prayer.AUGURY, 1.18, Prayer.MYSTIC_VIGOUR, 1.15, Prayer.MYSTIC_MIGHT,
|
||||
1.10, Prayer.MYSTIC_LORE, 1.05, Prayer.MYSTIC_WILL);
|
||||
int magicDmg = client.isPrayerActive(Prayer.AUGURY) ? 4 : client.isPrayerActive(Prayer.MYSTIC_VIGOUR) ? 3
|
||||
: client.isPrayerActive(Prayer.MYSTIC_MIGHT) ? 2 : client.isPrayerActive(Prayer.MYSTIC_LORE) ? 1 : 0;
|
||||
return new PlayerSetup(
|
||||
client.getBoostedSkillLevel(Skill.ATTACK),
|
||||
client.getBoostedSkillLevel(Skill.STRENGTH),
|
||||
client.getBoostedSkillLevel(Skill.DEFENCE),
|
||||
client.getBoostedSkillLevel(Skill.RANGED),
|
||||
client.getBoostedSkillLevel(Skill.MAGIC),
|
||||
client.getBoostedSkillLevel(Skill.HITPOINTS),
|
||||
meleeAcc, meleeStr, rangedAcc, rangedStr, magicAcc, magicDmg,
|
||||
client.getVarbitValue(VarbitID.COMBAT_WEAPON_CATEGORY),
|
||||
client.getVarpValue(VarPlayerID.COM_MODE),
|
||||
client.getVarbitValue(VarbitID.AUTOCAST_SPELL),
|
||||
slayerTask);
|
||||
}
|
||||
|
||||
/** Multiplier of the first active prayer in (value, prayer) pairs, 1 if none. */
|
||||
private double pick(Object... pairs)
|
||||
{
|
||||
for (int i = 0; i + 1 < pairs.length; i += 2)
|
||||
{
|
||||
if (client.isPrayerActive((Prayer) pairs[i + 1]))
|
||||
{
|
||||
return ((Number) pairs[i]).doubleValue();
|
||||
}
|
||||
}
|
||||
return 1.0;
|
||||
}
|
||||
|
||||
private static boolean sameSetup(PlayerSetup a, PlayerSetup b)
|
||||
{
|
||||
return a.attack == b.attack && a.strength == b.strength && a.defence == b.defence && a.ranged == b.ranged
|
||||
&& a.magic == b.magic && a.meleeAcc == b.meleeAcc && a.meleeStr == b.meleeStr && a.rangedAcc == b.rangedAcc
|
||||
&& a.rangedStr == b.rangedStr && a.magicAcc == b.magicAcc && a.magicDmgPct == b.magicDmgPct
|
||||
&& a.weaponCategory == b.weaponCategory && a.comMode == b.comMode && a.autocastSpell == b.autocastSpell
|
||||
&& a.onSlayerTask == b.onSlayerTask;
|
||||
}
|
||||
|
||||
private Loadout readWorn()
|
||||
{
|
||||
GearItem[] items = new GearItem[GearItem.SLOTS];
|
||||
ItemContainer c = client.getItemContainer(InventoryID.WORN);
|
||||
if (c != null)
|
||||
{
|
||||
Item[] arr = c.getItems();
|
||||
for (int s = 0; s < Math.min(arr.length, GearItem.SLOTS); s++)
|
||||
{
|
||||
if (arr[s] != null && arr[s].getId() > 0)
|
||||
{
|
||||
GearItem g = gearItem(arr[s].getId());
|
||||
if (g == null)
|
||||
{
|
||||
// no bonuses known (e.g. new or cosmetic item): still show it
|
||||
ItemComposition comp = itemManager.getItemComposition(arr[s].getId());
|
||||
g = new GearItem(arr[s].getId(), comp.getName(), s, false, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||
0, 0, 0, 0, 0, GearItem.Source.WORN);
|
||||
}
|
||||
items[s] = g;
|
||||
}
|
||||
}
|
||||
}
|
||||
return new Loadout(items);
|
||||
}
|
||||
|
||||
private long ownedHash()
|
||||
{
|
||||
long h = 17;
|
||||
ItemContainer inv = client.getItemContainer(InventoryID.INV);
|
||||
if (inv != null)
|
||||
{
|
||||
for (Item i : inv.getItems())
|
||||
{
|
||||
h = h * 31 + i.getId();
|
||||
}
|
||||
}
|
||||
Map<Integer, Long> b = bank.get();
|
||||
if (b != null)
|
||||
{
|
||||
h = h * 31 + b.size();
|
||||
for (Map.Entry<Integer, Long> e : b.entrySet())
|
||||
{
|
||||
h += e.getKey() * 1_000_003L ^ (e.getValue() > 0 ? 1 : 0);
|
||||
}
|
||||
}
|
||||
return h;
|
||||
}
|
||||
|
||||
private List<GearItem> readPool(Loadout worn)
|
||||
{
|
||||
Map<Integer, GearItem> out = new LinkedHashMap<>();
|
||||
for (int s : GearItem.ALL_SLOTS)
|
||||
{
|
||||
GearItem g = worn.get(s);
|
||||
if (g != null)
|
||||
{
|
||||
out.putIfAbsent(g.id, g.withSource(GearItem.Source.WORN));
|
||||
}
|
||||
}
|
||||
ItemContainer inv = client.getItemContainer(InventoryID.INV);
|
||||
if (inv != null)
|
||||
{
|
||||
for (Item i : inv.getItems())
|
||||
{
|
||||
addOwned(out, i.getId(), GearItem.Source.INVENTORY);
|
||||
}
|
||||
}
|
||||
Map<Integer, Long> b = bank.get();
|
||||
if (b != null)
|
||||
{
|
||||
for (Map.Entry<Integer, Long> e : b.entrySet())
|
||||
{
|
||||
if (e.getValue() != null && e.getValue() > 0)
|
||||
{
|
||||
addOwned(out, e.getKey(), GearItem.Source.BANK);
|
||||
}
|
||||
}
|
||||
}
|
||||
return Collections.unmodifiableList(new ArrayList<>(out.values()));
|
||||
}
|
||||
|
||||
private void addOwned(Map<Integer, GearItem> out, int id, GearItem.Source source)
|
||||
{
|
||||
if (id <= 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
GearItem g = gearItem(id);
|
||||
if (g != null && !out.containsKey(g.id))
|
||||
{
|
||||
out.put(g.id, g.withSource(source));
|
||||
}
|
||||
}
|
||||
|
||||
private GearItem gearItem(int rawId)
|
||||
{
|
||||
int id = itemManager.canonicalize(rawId);
|
||||
if (itemCache.containsKey(id))
|
||||
{
|
||||
return itemCache.get(id);
|
||||
}
|
||||
GearItem g = null;
|
||||
ItemStats stats = itemManager.getItemStats(id);
|
||||
if (stats != null && stats.isEquipable() && stats.getEquipment() != null && stats.getEquipment().getSlot() >= 0
|
||||
&& stats.getEquipment().getSlot() < GearItem.SLOTS)
|
||||
{
|
||||
ItemComposition comp = itemManager.getItemComposition(id);
|
||||
g = GearItem.of(id, comp.getName(), stats.getEquipment(), GearItem.Source.WORN);
|
||||
}
|
||||
itemCache.put(id, g);
|
||||
return g;
|
||||
}
|
||||
|
||||
private FightSnapshot.Target readTarget(Player me)
|
||||
{
|
||||
NPC npc = null;
|
||||
Actor interacting = me.getInteracting();
|
||||
if (interacting instanceof NPC && ((NPC) interacting).getCombatLevel() > 0)
|
||||
{
|
||||
npc = (NPC) interacting;
|
||||
}
|
||||
FightSession session = combatTracker.getActiveSession();
|
||||
if (npc == null && session != null)
|
||||
{
|
||||
npc = byIndex(session.getTargetIndex());
|
||||
}
|
||||
if (npc == null)
|
||||
{
|
||||
for (NPC n : client.getNpcs())
|
||||
{
|
||||
if (n != null && n.getInteracting() == me && n.getCombatLevel() > 0 && !n.isDead())
|
||||
{
|
||||
npc = n;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (npc == null)
|
||||
{
|
||||
if (lastTarget == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
NPC old = byIndex(targetNpcIndex);
|
||||
boolean dead = lastTarget.dead || old == null || old.isDead();
|
||||
if (lastTarget.live || lastTarget.monster == null)
|
||||
{
|
||||
Monster m = lastTarget.monster != null ? lastTarget.monster : monsters.get(lastTarget.npcId, lastTarget.combatLevel);
|
||||
lastTarget = new FightSnapshot.Target(lastTarget.npcId, lastTarget.name, lastTarget.combatLevel,
|
||||
dead ? 0 : lastTarget.healthRatio, lastTarget.healthScale, m, m == null && monsters.isLoading(lastTarget.npcId),
|
||||
false, dead, null);
|
||||
}
|
||||
return lastTarget;
|
||||
}
|
||||
|
||||
targetNpcIndex = npc.getIndex();
|
||||
Monster m = monsters.get(npc.getId(), npc.getCombatLevel());
|
||||
CombatStyle style = attackStyles.current().get(npc);
|
||||
String name = Text.removeTags(npc.getName());
|
||||
lastTarget = new FightSnapshot.Target(npc.getId(), name == null ? "Unknown" : name, npc.getCombatLevel(),
|
||||
npc.getHealthScale() > 0 ? npc.getHealthRatio() : -1, npc.getHealthScale(), m,
|
||||
m == null && monsters.isLoading(npc.getId()), true, npc.isDead(), style);
|
||||
return lastTarget;
|
||||
}
|
||||
|
||||
private NPC byIndex(int index)
|
||||
{
|
||||
if (index < 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
for (NPC n : client.getNpcs())
|
||||
{
|
||||
if (n != null && n.getIndex() == index)
|
||||
{
|
||||
return n;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package com.example.essentialsplus.loot.gear;
|
||||
|
||||
import com.example.essentialsplus.helpers.CombatStyle;
|
||||
import com.example.essentialsplus.loot.combat.FightSession;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
/** Everything the fight views show, captured on the client thread (immutable). */
|
||||
public final class FightSnapshot
|
||||
{
|
||||
public static final FightSnapshot LOGGED_OUT = new FightSnapshot(false, PlayerSetup.DEFAULT, Loadout.EMPTY,
|
||||
Collections.emptyList(), null, null, Collections.emptyList(), 0);
|
||||
|
||||
public static final class Target
|
||||
{
|
||||
public final int npcId;
|
||||
public final String name;
|
||||
public final int combatLevel;
|
||||
/** -1 when no health bar is shown */
|
||||
public final int healthRatio;
|
||||
public final int healthScale;
|
||||
/** null while loading or when the wiki has no stats */
|
||||
public final Monster monster;
|
||||
public final boolean loading;
|
||||
/** being fought right now (false: last target, kept for planning) */
|
||||
public final boolean live;
|
||||
public final boolean dead;
|
||||
/** attack style the NPC last used on the player, null if unknown */
|
||||
public final CombatStyle attackStyle;
|
||||
|
||||
public Target(int npcId, String name, int combatLevel, int healthRatio, int healthScale, Monster monster,
|
||||
boolean loading, boolean live, boolean dead, CombatStyle attackStyle)
|
||||
{
|
||||
this.npcId = npcId;
|
||||
this.name = name;
|
||||
this.combatLevel = combatLevel;
|
||||
this.healthRatio = healthRatio;
|
||||
this.healthScale = healthScale;
|
||||
this.monster = monster;
|
||||
this.loading = loading;
|
||||
this.live = live;
|
||||
this.dead = dead;
|
||||
this.attackStyle = attackStyle;
|
||||
}
|
||||
|
||||
/** Monster to calculate against: wiki stats or a dummy. */
|
||||
public Monster calcMonster()
|
||||
{
|
||||
return monster != null ? monster : Monster.DUMMY;
|
||||
}
|
||||
}
|
||||
|
||||
/** Copy of the running fight session. */
|
||||
public static final class Session
|
||||
{
|
||||
public final int durationTicks, idleTicks, attacks, damageDealt, hitsReceived, damageTaken, hpHealed;
|
||||
public final int prayerBlocked, prayerMissed, onPrayerAttacks, offPrayerAttacks, correctWeapon, incorrectWeapon;
|
||||
public final long suppliesCost;
|
||||
public final double efficiency, performance, prayerDefencePct, offPrayerPct, correctWeaponPct;
|
||||
public final String grade;
|
||||
|
||||
public Session(FightSession s)
|
||||
{
|
||||
durationTicks = s.getDurationTicks();
|
||||
idleTicks = s.getIdleTicks();
|
||||
attacks = s.getAttacksDealt();
|
||||
damageDealt = s.getDamageDealt();
|
||||
hitsReceived = s.getHitsReceived();
|
||||
damageTaken = s.getDamageTaken();
|
||||
hpHealed = s.getHpHealed();
|
||||
prayerBlocked = s.getPrayerBlockedHits();
|
||||
prayerMissed = s.getPrayerMissedHits();
|
||||
onPrayerAttacks = s.getOnPrayerAttacks();
|
||||
offPrayerAttacks = s.getOffPrayerAttacks();
|
||||
correctWeapon = s.getCorrectWeaponAttacks();
|
||||
incorrectWeapon = s.getIncorrectWeaponAttacks();
|
||||
suppliesCost = s.getSuppliesCost();
|
||||
efficiency = s.getEfficiencyPct();
|
||||
performance = s.getPerformanceScore();
|
||||
prayerDefencePct = s.getPrayerDefensePct();
|
||||
offPrayerPct = s.getOffPrayerAttackPct();
|
||||
correctWeaponPct = s.getCorrectWeaponPct();
|
||||
grade = s.getGrade();
|
||||
}
|
||||
}
|
||||
|
||||
public final boolean loggedIn;
|
||||
public final PlayerSetup setup;
|
||||
public final Loadout worn;
|
||||
/** Equipable items owned (worn, inventory, bank), one per item id */
|
||||
public final List<GearItem> pool;
|
||||
public final Target target;
|
||||
public final Session session;
|
||||
/** Best setup per style against the target, highest DPS first */
|
||||
public final List<GearOptimizer.Option> best;
|
||||
/** changes whenever gear, pool, levels or target stats change (not on hp / session updates) */
|
||||
public final long gearVersion;
|
||||
|
||||
public FightSnapshot(boolean loggedIn, PlayerSetup setup, Loadout worn, List<GearItem> pool, Target target,
|
||||
Session session, List<GearOptimizer.Option> best, long gearVersion)
|
||||
{
|
||||
this.loggedIn = loggedIn;
|
||||
this.setup = setup;
|
||||
this.worn = worn;
|
||||
this.pool = pool;
|
||||
this.target = target;
|
||||
this.session = session;
|
||||
this.best = best;
|
||||
this.gearVersion = gearVersion;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package com.example.essentialsplus.loot.gear;
|
||||
|
||||
import java.util.Locale;
|
||||
import net.runelite.client.game.ItemEquipmentStats;
|
||||
|
||||
/** An equipable item with its bonuses (immutable, safe on any thread). */
|
||||
public final class GearItem
|
||||
{
|
||||
public static final int HEAD = 0;
|
||||
public static final int CAPE = 1;
|
||||
public static final int AMULET = 2;
|
||||
public static final int WEAPON = 3;
|
||||
public static final int BODY = 4;
|
||||
public static final int SHIELD = 5;
|
||||
public static final int LEGS = 7;
|
||||
public static final int GLOVES = 9;
|
||||
public static final int BOOTS = 10;
|
||||
public static final int RING = 12;
|
||||
public static final int AMMO = 13;
|
||||
public static final int SLOTS = 14;
|
||||
|
||||
public static final int[] ALL_SLOTS = {HEAD, CAPE, AMULET, WEAPON, BODY, SHIELD, LEGS, GLOVES, BOOTS, RING, AMMO};
|
||||
|
||||
/** Where the item was found. */
|
||||
public enum Source
|
||||
{
|
||||
WORN("Worn"),
|
||||
INVENTORY("Inventory"),
|
||||
BANK("Bank");
|
||||
|
||||
public final String label;
|
||||
|
||||
Source(String label)
|
||||
{
|
||||
this.label = label;
|
||||
}
|
||||
}
|
||||
|
||||
public final int id;
|
||||
public final String name;
|
||||
public final String lower;
|
||||
public final int slot;
|
||||
public final boolean twoHanded;
|
||||
public final int astab, aslash, acrush, amagic, arange;
|
||||
public final int dstab, dslash, dcrush, dmagic, drange;
|
||||
public final int str, rstr, prayer, aspeed;
|
||||
/** magic damage in percent */
|
||||
public final double mdmg;
|
||||
public final Source source;
|
||||
|
||||
public GearItem(int id, String name, int slot, boolean twoHanded,
|
||||
int astab, int aslash, int acrush, int amagic, int arange,
|
||||
int dstab, int dslash, int dcrush, int dmagic, int drange,
|
||||
int str, int rstr, double mdmg, int prayer, int aspeed, Source source)
|
||||
{
|
||||
this.id = id;
|
||||
this.name = name == null ? "Item " + id : name;
|
||||
this.lower = this.name.toLowerCase(Locale.ENGLISH);
|
||||
this.slot = slot;
|
||||
this.twoHanded = twoHanded;
|
||||
this.astab = astab;
|
||||
this.aslash = aslash;
|
||||
this.acrush = acrush;
|
||||
this.amagic = amagic;
|
||||
this.arange = arange;
|
||||
this.dstab = dstab;
|
||||
this.dslash = dslash;
|
||||
this.dcrush = dcrush;
|
||||
this.dmagic = dmagic;
|
||||
this.drange = drange;
|
||||
this.str = str;
|
||||
this.rstr = rstr;
|
||||
this.mdmg = mdmg;
|
||||
this.prayer = prayer;
|
||||
this.aspeed = aspeed;
|
||||
this.source = source;
|
||||
}
|
||||
|
||||
public static GearItem of(int id, String name, ItemEquipmentStats e, Source source)
|
||||
{
|
||||
return new GearItem(id, name, e.getSlot(), e.isTwoHanded(),
|
||||
e.getAstab(), e.getAslash(), e.getAcrush(), e.getAmagic(), e.getArange(),
|
||||
e.getDstab(), e.getDslash(), e.getDcrush(), e.getDmagic(), e.getDrange(),
|
||||
e.getStr(), e.getRstr(), e.getMdmg(), e.getPrayer(), e.getAspeed(), source);
|
||||
}
|
||||
|
||||
public GearItem withSource(Source s)
|
||||
{
|
||||
return s == source ? this : new GearItem(id, name, slot, twoHanded, astab, aslash, acrush, amagic, arange,
|
||||
dstab, dslash, dcrush, dmagic, drange, str, rstr, mdmg, prayer, aspeed, s);
|
||||
}
|
||||
|
||||
public int totalDefence()
|
||||
{
|
||||
return dstab + dslash + dcrush + dmagic + drange;
|
||||
}
|
||||
|
||||
public boolean has(String part)
|
||||
{
|
||||
return lower.contains(part);
|
||||
}
|
||||
|
||||
public static String slotName(int slot)
|
||||
{
|
||||
switch (slot)
|
||||
{
|
||||
case HEAD: return "Head";
|
||||
case CAPE: return "Cape";
|
||||
case AMULET: return "Neck";
|
||||
case WEAPON: return "Weapon";
|
||||
case BODY: return "Body";
|
||||
case SHIELD: return "Shield";
|
||||
case LEGS: return "Legs";
|
||||
case GLOVES: return "Hands";
|
||||
case BOOTS: return "Feet";
|
||||
case RING: return "Ring";
|
||||
case AMMO: return "Ammo";
|
||||
default: return "Slot " + slot;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
package com.example.essentialsplus.loot.gear;
|
||||
|
||||
import com.example.essentialsplus.loot.gear.CombatChoice.Type;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
|
||||
/** Picks the highest DPS items per combat style from the items the player owns. */
|
||||
public final class GearOptimizer
|
||||
{
|
||||
private static final int WEAPONS_PER_STYLE = 8;
|
||||
private static final int PASSES = 2;
|
||||
|
||||
private GearOptimizer()
|
||||
{
|
||||
}
|
||||
|
||||
public static final class Option
|
||||
{
|
||||
public final String label;
|
||||
public final Loadout loadout;
|
||||
public final DpsCalc.Result result;
|
||||
|
||||
Option(String label, Loadout loadout, DpsCalc.Result result)
|
||||
{
|
||||
this.label = label;
|
||||
this.loadout = loadout;
|
||||
this.result = result;
|
||||
}
|
||||
}
|
||||
|
||||
/** Best setup of each style (melee, ranged, magic) that has a weapon, highest DPS first. */
|
||||
public static List<Option> bestPerStyle(Collection<GearItem> pool, Loadout worn, PlayerSetup p, Monster m)
|
||||
{
|
||||
List<Option> out = new ArrayList<>();
|
||||
for (Type cls : new Type[]{Type.STAB, Type.RANGED, Type.MAGIC})
|
||||
{
|
||||
Option best = bestFor(cls, pool, worn, p, m);
|
||||
if (best != null && best.result.dps > 0)
|
||||
{
|
||||
out.add(best);
|
||||
}
|
||||
}
|
||||
out.sort(Comparator.comparingDouble((Option o) -> o.result.dps).reversed());
|
||||
return out;
|
||||
}
|
||||
|
||||
private static List<CombatChoice> choices(Type cls, GearItem w, PlayerSetup p)
|
||||
{
|
||||
List<CombatChoice> out = new ArrayList<>();
|
||||
if (cls == Type.STAB)
|
||||
{
|
||||
if (w.astab > 0)
|
||||
{
|
||||
out.add(CombatChoice.melee(Type.STAB));
|
||||
}
|
||||
if (w.aslash > 0)
|
||||
{
|
||||
out.add(CombatChoice.melee(Type.SLASH));
|
||||
}
|
||||
if (w.acrush > 0)
|
||||
{
|
||||
out.add(CombatChoice.melee(Type.CRUSH));
|
||||
}
|
||||
if (out.isEmpty())
|
||||
{
|
||||
out.add(CombatChoice.melee(CombatChoice.bestMeleeType(w)));
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
out.add(CombatChoice.infer(w, p.magic, p.autocastSpell));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
static Option bestFor(Type cls, Collection<GearItem> pool, Loadout worn, PlayerSetup p, Monster m)
|
||||
{
|
||||
// rank weapons on their own first so the full search stays small
|
||||
List<Option> starts = new ArrayList<>();
|
||||
for (GearItem w : pool)
|
||||
{
|
||||
if (w.slot != GearItem.WEAPON || CombatChoice.weaponClass(w) != cls)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
Loadout l = worn.with(GearItem.WEAPON, w);
|
||||
for (CombatChoice c : choices(cls, w, p))
|
||||
{
|
||||
starts.add(new Option(label(c), l, DpsCalc.calc(l, p, m, c)));
|
||||
}
|
||||
}
|
||||
starts.sort(Comparator.comparingDouble((Option o) -> o.result.dps).reversed());
|
||||
|
||||
Option best = null;
|
||||
for (int i = 0; i < Math.min(WEAPONS_PER_STYLE, starts.size()); i++)
|
||||
{
|
||||
Option o = improve(starts.get(i), pool, p, m);
|
||||
if (best == null || o.result.dps > best.result.dps + 1e-9)
|
||||
{
|
||||
best = o;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
private static Option improve(Option start, Collection<GearItem> pool, PlayerSetup p, Monster m)
|
||||
{
|
||||
CombatChoice c = start.result.choice;
|
||||
Loadout l = start.loadout;
|
||||
DpsCalc.Result r = start.result;
|
||||
for (int pass = 0; pass < PASSES; pass++)
|
||||
{
|
||||
boolean changed = false;
|
||||
for (int slot : GearItem.ALL_SLOTS)
|
||||
{
|
||||
if (slot == GearItem.WEAPON || slot == GearItem.SHIELD && l.weapon() != null && l.weapon().twoHanded)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
for (GearItem g : pool)
|
||||
{
|
||||
if (g.slot != slot || g.id == l.id(slot))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
Loadout candidate = l.with(slot, g);
|
||||
DpsCalc.Result cr = DpsCalc.calc(candidate, p, m, c);
|
||||
if (cr.dps > r.dps + 1e-9)
|
||||
{
|
||||
l = candidate;
|
||||
r = cr;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!changed)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
return new Option(start.label, l, r);
|
||||
}
|
||||
|
||||
private static String label(CombatChoice c)
|
||||
{
|
||||
switch (c.type)
|
||||
{
|
||||
case RANGED:
|
||||
return "Ranged";
|
||||
case MAGIC:
|
||||
return "Magic";
|
||||
default:
|
||||
return "Melee (" + c.type.label.toLowerCase() + ")";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
package com.example.essentialsplus.loot.gear;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
|
||||
/** One item per equipment slot (immutable). */
|
||||
public final class Loadout
|
||||
{
|
||||
public static final Loadout EMPTY = new Loadout(new GearItem[GearItem.SLOTS]);
|
||||
|
||||
private final GearItem[] items;
|
||||
|
||||
public Loadout(GearItem[] items)
|
||||
{
|
||||
this.items = Arrays.copyOf(items, GearItem.SLOTS);
|
||||
}
|
||||
|
||||
public GearItem get(int slot)
|
||||
{
|
||||
return slot >= 0 && slot < GearItem.SLOTS ? items[slot] : null;
|
||||
}
|
||||
|
||||
public GearItem weapon()
|
||||
{
|
||||
return items[GearItem.WEAPON];
|
||||
}
|
||||
|
||||
/** Swaps one slot; a two-handed weapon removes the shield and a shield removes a two-handed weapon. */
|
||||
public Loadout with(int slot, GearItem item)
|
||||
{
|
||||
GearItem[] copy = Arrays.copyOf(items, GearItem.SLOTS);
|
||||
copy[slot] = item;
|
||||
if (item != null && slot == GearItem.WEAPON && item.twoHanded)
|
||||
{
|
||||
copy[GearItem.SHIELD] = null;
|
||||
}
|
||||
if (item != null && slot == GearItem.SHIELD && copy[GearItem.WEAPON] != null && copy[GearItem.WEAPON].twoHanded)
|
||||
{
|
||||
copy[GearItem.WEAPON] = null;
|
||||
}
|
||||
return new Loadout(copy);
|
||||
}
|
||||
|
||||
public int id(int slot)
|
||||
{
|
||||
GearItem g = get(slot);
|
||||
return g == null ? -1 : g.id;
|
||||
}
|
||||
|
||||
public boolean sameAs(Loadout o)
|
||||
{
|
||||
for (int s = 0; s < GearItem.SLOTS; s++)
|
||||
{
|
||||
if (id(s) != o.id(s))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Items of this loadout that differ from {@code base}. */
|
||||
public List<GearItem> changesFrom(Loadout base)
|
||||
{
|
||||
List<GearItem> out = new ArrayList<>();
|
||||
for (int s : GearItem.ALL_SLOTS)
|
||||
{
|
||||
if (id(s) != base.id(s) && get(s) != null)
|
||||
{
|
||||
out.add(get(s));
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
public boolean anyName(String part)
|
||||
{
|
||||
for (GearItem g : items)
|
||||
{
|
||||
if (g != null && g.has(part))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public boolean slotHas(int slot, String part)
|
||||
{
|
||||
GearItem g = get(slot);
|
||||
return g != null && g.has(part);
|
||||
}
|
||||
|
||||
/** Summed bonuses: astab, aslash, acrush, amagic, arange, dstab, dslash, dcrush, dmagic, drange, str, rstr, prayer. */
|
||||
public int[] bonuses()
|
||||
{
|
||||
int[] b = new int[13];
|
||||
for (GearItem g : items)
|
||||
{
|
||||
if (g == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
b[0] += g.astab;
|
||||
b[1] += g.aslash;
|
||||
b[2] += g.acrush;
|
||||
b[3] += g.amagic;
|
||||
b[4] += g.arange;
|
||||
b[5] += g.dstab;
|
||||
b[6] += g.dslash;
|
||||
b[7] += g.dcrush;
|
||||
b[8] += g.dmagic;
|
||||
b[9] += g.drange;
|
||||
b[10] += g.str;
|
||||
b[11] += g.rstr;
|
||||
b[12] += g.prayer;
|
||||
}
|
||||
return b;
|
||||
}
|
||||
|
||||
public double magicDamage()
|
||||
{
|
||||
double sum = 0;
|
||||
for (GearItem g : items)
|
||||
{
|
||||
if (g != null)
|
||||
{
|
||||
sum += g.mdmg;
|
||||
}
|
||||
}
|
||||
return sum;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package com.example.essentialsplus.loot.gear;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
|
||||
/** Combat stats of a monster from its wiki infobox. */
|
||||
public final class Monster
|
||||
{
|
||||
/** Target used when nobody is being fought: no defence at all. */
|
||||
public static final Monster DUMMY = new Monster(-1, "Training dummy", "", 0, 0, 0, 0, 0,
|
||||
0, 0, 0, 0, 0, 0, 0, Collections.emptyList(), Collections.emptyList(), Collections.emptyList(), 0, null, 0, 1);
|
||||
|
||||
public final int npcId;
|
||||
public final String name;
|
||||
public final String version;
|
||||
public final int combatLevel;
|
||||
public final int hitpoints;
|
||||
public final int defenceLevel;
|
||||
public final int magicLevel;
|
||||
public final int magicAttack;
|
||||
public final int dstab, dslash, dcrush, dmagic;
|
||||
public final int dlight, dstandard, dheavy;
|
||||
public final List<String> attackStyles;
|
||||
public final List<String> maxHits;
|
||||
public final List<String> attributes;
|
||||
public final int attackSpeed;
|
||||
/** e.g. "Fire", null if none */
|
||||
public final String elementalWeakness;
|
||||
public final int elementalWeaknessPct;
|
||||
public final int size;
|
||||
|
||||
public Monster(int npcId, String name, String version, int combatLevel, int hitpoints, int defenceLevel, int magicLevel,
|
||||
int magicAttack, int dstab, int dslash, int dcrush, int dmagic, int dlight, int dstandard, int dheavy,
|
||||
List<String> attackStyles, List<String> maxHits, List<String> attributes, int attackSpeed,
|
||||
String elementalWeakness, int elementalWeaknessPct, int size)
|
||||
{
|
||||
this.npcId = npcId;
|
||||
this.name = name;
|
||||
this.version = version == null ? "" : version;
|
||||
this.combatLevel = combatLevel;
|
||||
this.hitpoints = hitpoints;
|
||||
this.defenceLevel = defenceLevel;
|
||||
this.magicLevel = magicLevel;
|
||||
this.magicAttack = magicAttack;
|
||||
this.dstab = dstab;
|
||||
this.dslash = dslash;
|
||||
this.dcrush = dcrush;
|
||||
this.dmagic = dmagic;
|
||||
this.dlight = dlight;
|
||||
this.dstandard = dstandard;
|
||||
this.dheavy = dheavy;
|
||||
this.attackStyles = attackStyles;
|
||||
this.maxHits = maxHits;
|
||||
this.attributes = attributes;
|
||||
this.attackSpeed = attackSpeed;
|
||||
this.elementalWeakness = elementalWeakness;
|
||||
this.elementalWeaknessPct = elementalWeaknessPct;
|
||||
this.size = size;
|
||||
}
|
||||
|
||||
public boolean isDummy()
|
||||
{
|
||||
return npcId == -1;
|
||||
}
|
||||
|
||||
public boolean hasAttribute(String attribute)
|
||||
{
|
||||
for (String a : attributes)
|
||||
{
|
||||
if (a.toLowerCase(Locale.ENGLISH).contains(attribute))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
package com.example.essentialsplus.loot.gear;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import com.google.gson.JsonArray;
|
||||
import com.google.gson.JsonElement;
|
||||
import com.google.gson.JsonObject;
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import okhttp3.HttpUrl;
|
||||
import okhttp3.OkHttpClient;
|
||||
import okhttp3.Request;
|
||||
import okhttp3.Response;
|
||||
import okhttp3.ResponseBody;
|
||||
|
||||
/** Monster combat stats from the wiki's infobox_monster bucket, cached per NPC id. */
|
||||
@Slf4j
|
||||
public class MonsterService
|
||||
{
|
||||
private static final String API = "https://oldschool.runescape.wiki/api.php";
|
||||
private static final String FIELDS = "'page_name','name','id','version_anchor','combat_level','hitpoints','defence_level',"
|
||||
+ "'magic_level','magic_attack_bonus','stab_defence_bonus','slash_defence_bonus','crush_defence_bonus','magic_defence_bonus',"
|
||||
+ "'range_defence_bonus','light_range_defence_bonus','standard_range_defence_bonus','heavy_range_defence_bonus',"
|
||||
+ "'attack_style','max_hit','attack_speed','attribute','elemental_weakness','elemental_weakness_percent','size'";
|
||||
private static final Monster MISSING = new Monster(-2, "", "", 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||
Collections.emptyList(), Collections.emptyList(), Collections.emptyList(), 0, null, 0, 1);
|
||||
private static final Monster MISSING_LOADING = new Monster(-3, "", "", 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||
Collections.emptyList(), Collections.emptyList(), Collections.emptyList(), 0, null, 0, 1);
|
||||
private static final long RETRY_MS = 60_000;
|
||||
|
||||
private final OkHttpClient http;
|
||||
private final Gson gson;
|
||||
private final ExecutorService executor;
|
||||
private final Runnable onLoaded;
|
||||
private final Map<Integer, Monster> cache = new ConcurrentHashMap<>();
|
||||
private final Map<Integer, Long> failedAt = new ConcurrentHashMap<>();
|
||||
|
||||
public MonsterService(OkHttpClient http, Gson gson, ExecutorService executor, Runnable onLoaded)
|
||||
{
|
||||
this.http = http;
|
||||
this.gson = gson;
|
||||
this.executor = executor;
|
||||
this.onLoaded = onLoaded;
|
||||
}
|
||||
|
||||
/** Cached stats, or null while loading / when the wiki has none (see {@link #isMissing}). */
|
||||
public Monster get(int npcId, int combatLevel)
|
||||
{
|
||||
Monster m = cache.get(npcId);
|
||||
Long failed = failedAt.get(npcId);
|
||||
if (m == null && (failed == null || System.currentTimeMillis() - failed > RETRY_MS))
|
||||
{
|
||||
cache.put(npcId, MISSING_LOADING);
|
||||
executor.execute(() -> load(npcId, combatLevel));
|
||||
return null;
|
||||
}
|
||||
return m == null || m == MISSING || m == MISSING_LOADING ? null : m;
|
||||
}
|
||||
|
||||
public boolean isLoading(int npcId)
|
||||
{
|
||||
return cache.get(npcId) == MISSING_LOADING;
|
||||
}
|
||||
|
||||
public boolean isMissing(int npcId)
|
||||
{
|
||||
return cache.get(npcId) == MISSING;
|
||||
}
|
||||
|
||||
private void load(int npcId, int combatLevel)
|
||||
{
|
||||
Monster result = MISSING;
|
||||
try
|
||||
{
|
||||
HttpUrl url = HttpUrl.parse(API).newBuilder()
|
||||
.addQueryParameter("action", "bucket")
|
||||
.addQueryParameter("format", "json")
|
||||
.addQueryParameter("query", "bucket('infobox_monster').select(" + FIELDS + ").where('id','" + npcId + "').limit(20).run()")
|
||||
.build();
|
||||
Request req = new Request.Builder().url(url).header("Accept", "application/json").build();
|
||||
try (Response response = http.newCall(req).execute())
|
||||
{
|
||||
ResponseBody body = response.body();
|
||||
if (!response.isSuccessful() || body == null)
|
||||
{
|
||||
throw new IOException("HTTP " + response.code());
|
||||
}
|
||||
JsonObject root = gson.fromJson(body.charStream(), JsonObject.class);
|
||||
Monster parsed = parse(root, npcId, combatLevel);
|
||||
if (parsed != null)
|
||||
{
|
||||
result = parsed;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
log.debug("monster stats for npc {} failed", npcId, e);
|
||||
// retry after a while (offline, wiki down)
|
||||
failedAt.put(npcId, System.currentTimeMillis());
|
||||
cache.remove(npcId);
|
||||
return;
|
||||
}
|
||||
cache.put(npcId, result);
|
||||
onLoaded.run();
|
||||
}
|
||||
|
||||
static Monster parse(JsonObject root, int npcId, int combatLevel)
|
||||
{
|
||||
JsonArray rows = root == null ? null : root.getAsJsonArray("bucket");
|
||||
if (rows == null || rows.size() == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
JsonObject pick = rows.get(0).getAsJsonObject();
|
||||
for (JsonElement el : rows)
|
||||
{
|
||||
JsonObject o = el.getAsJsonObject();
|
||||
if (combatLevel > 0 && integer(o, "combat_level") == combatLevel)
|
||||
{
|
||||
pick = o;
|
||||
break;
|
||||
}
|
||||
}
|
||||
JsonObject o = pick;
|
||||
int ranged = integer(o, "range_defence_bonus");
|
||||
String weakness = string(o, "elemental_weakness");
|
||||
return new Monster(npcId,
|
||||
string(o, "name") != null ? string(o, "name") : string(o, "page_name"),
|
||||
string(o, "version_anchor"),
|
||||
integer(o, "combat_level"),
|
||||
integer(o, "hitpoints"),
|
||||
integer(o, "defence_level"),
|
||||
integer(o, "magic_level"),
|
||||
integer(o, "magic_attack_bonus"),
|
||||
integer(o, "stab_defence_bonus"),
|
||||
integer(o, "slash_defence_bonus"),
|
||||
integer(o, "crush_defence_bonus"),
|
||||
integer(o, "magic_defence_bonus"),
|
||||
o.has("light_range_defence_bonus") ? integer(o, "light_range_defence_bonus") : ranged,
|
||||
o.has("standard_range_defence_bonus") ? integer(o, "standard_range_defence_bonus") : ranged,
|
||||
o.has("heavy_range_defence_bonus") ? integer(o, "heavy_range_defence_bonus") : ranged,
|
||||
strings(o, "attack_style"),
|
||||
strings(o, "max_hit"),
|
||||
strings(o, "attribute"),
|
||||
integer(o, "attack_speed"),
|
||||
weakness == null || weakness.isEmpty() || "None".equalsIgnoreCase(weakness) ? null : weakness,
|
||||
integer(o, "elemental_weakness_percent"),
|
||||
Math.max(1, integer(o, "size")));
|
||||
}
|
||||
|
||||
private static int integer(JsonObject o, String key)
|
||||
{
|
||||
JsonElement e = o.get(key);
|
||||
if (e == null || e.isJsonNull())
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
if (e.isJsonArray())
|
||||
{
|
||||
e = e.getAsJsonArray().size() > 0 ? e.getAsJsonArray().get(0) : null;
|
||||
if (e == null)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
try
|
||||
{
|
||||
return (int) Math.round(e.getAsDouble());
|
||||
}
|
||||
catch (RuntimeException ex)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
private static String string(JsonObject o, String key)
|
||||
{
|
||||
JsonElement e = o.get(key);
|
||||
if (e == null || e.isJsonNull())
|
||||
{
|
||||
return null;
|
||||
}
|
||||
if (e.isJsonArray())
|
||||
{
|
||||
return e.getAsJsonArray().size() > 0 ? e.getAsJsonArray().get(0).getAsString() : null;
|
||||
}
|
||||
return e.getAsString();
|
||||
}
|
||||
|
||||
private static List<String> strings(JsonObject o, String key)
|
||||
{
|
||||
JsonElement e = o.get(key);
|
||||
List<String> out = new ArrayList<>();
|
||||
if (e == null || e.isJsonNull())
|
||||
{
|
||||
return out;
|
||||
}
|
||||
if (e.isJsonArray())
|
||||
{
|
||||
for (JsonElement x : e.getAsJsonArray())
|
||||
{
|
||||
if (!x.isJsonNull())
|
||||
{
|
||||
out.add(x.getAsString());
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
out.add(e.getAsString());
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package com.example.essentialsplus.loot.gear;
|
||||
|
||||
/**
|
||||
* Levels, prayers and the selected attack style of the local player. Prayer values are multipliers
|
||||
* (1.0 = no prayer); magic damage prayer is in percent.
|
||||
*/
|
||||
public final class PlayerSetup
|
||||
{
|
||||
public static final PlayerSetup DEFAULT = new PlayerSetup(99, 99, 99, 99, 99, 99, 1, 1, 1, 1, 1, 0, -1, 0, -1, false);
|
||||
|
||||
public final int attack, strength, defence, ranged, magic, hitpoints;
|
||||
public final double meleeAcc, meleeStr, rangedAcc, rangedStr, magicAcc;
|
||||
public final int magicDmgPct;
|
||||
/** varbit COMBAT_WEAPON_CATEGORY, -1 unknown */
|
||||
public final int weaponCategory;
|
||||
/** varp COM_MODE (0..3 buttons, 4 casting, 5 defensive casting when autocasting) */
|
||||
public final int comMode;
|
||||
/** varbit AUTOCAST_SPELL, -1/0 none */
|
||||
public final int autocastSpell;
|
||||
public final boolean onSlayerTask;
|
||||
|
||||
public PlayerSetup(int attack, int strength, int defence, int ranged, int magic, int hitpoints,
|
||||
double meleeAcc, double meleeStr, double rangedAcc, double rangedStr, double magicAcc, int magicDmgPct,
|
||||
int weaponCategory, int comMode, int autocastSpell, boolean onSlayerTask)
|
||||
{
|
||||
this.attack = attack;
|
||||
this.strength = strength;
|
||||
this.defence = defence;
|
||||
this.ranged = ranged;
|
||||
this.magic = magic;
|
||||
this.hitpoints = hitpoints;
|
||||
this.meleeAcc = meleeAcc;
|
||||
this.meleeStr = meleeStr;
|
||||
this.rangedAcc = rangedAcc;
|
||||
this.rangedStr = rangedStr;
|
||||
this.magicAcc = magicAcc;
|
||||
this.magicDmgPct = magicDmgPct;
|
||||
this.weaponCategory = weaponCategory;
|
||||
this.comMode = comMode;
|
||||
this.autocastSpell = autocastSpell;
|
||||
this.onSlayerTask = onSlayerTask;
|
||||
}
|
||||
|
||||
public PlayerSetup withSlayerTask(boolean task)
|
||||
{
|
||||
return task == onSlayerTask ? this : new PlayerSetup(attack, strength, defence, ranged, magic, hitpoints,
|
||||
meleeAcc, meleeStr, rangedAcc, rangedStr, magicAcc, magicDmgPct, weaponCategory, comMode, autocastSpell, task);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,377 @@
|
||||
package com.example.essentialsplus.loot.ui;
|
||||
|
||||
import com.example.essentialsplus.collectionlog.CollectionLogService;
|
||||
import com.example.essentialsplus.loot.LootRecord;
|
||||
import com.example.essentialsplus.loot.TrackedLootItem;
|
||||
import com.example.essentialsplus.loot.droptable.DropTable;
|
||||
import com.example.essentialsplus.ui.Ui;
|
||||
import com.example.essentialsplus.wiki.WikiApi;
|
||||
import java.awt.BorderLayout;
|
||||
import java.awt.Cursor;
|
||||
import java.awt.Dimension;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.function.Function;
|
||||
import javax.swing.BorderFactory;
|
||||
import javax.swing.JButton;
|
||||
import javax.swing.JComboBox;
|
||||
import javax.swing.JLabel;
|
||||
import javax.swing.JPanel;
|
||||
import javax.swing.SwingUtilities;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import net.runelite.client.game.ItemManager;
|
||||
import net.runelite.client.ui.DynamicGridLayout;
|
||||
import net.runelite.client.ui.FontManager;
|
||||
|
||||
/**
|
||||
* Collapsible list of the collection log items of a loot source that the account does not have yet,
|
||||
* with their wiki drop rate.
|
||||
*/
|
||||
@Slf4j
|
||||
class CollectionLogSection extends JPanel
|
||||
{
|
||||
/** Wiki drop tables per source name; shared by all detail panels of this session. */
|
||||
private static final Map<String, DropTable> TABLES = new ConcurrentHashMap<>();
|
||||
|
||||
private static final class Entry
|
||||
{
|
||||
final int id;
|
||||
final String name;
|
||||
final double perKill;
|
||||
final String rate;
|
||||
|
||||
Entry(int id, String name, DropTable.Rate rate)
|
||||
{
|
||||
this.id = id;
|
||||
this.name = name;
|
||||
this.perKill = rate != null && !Double.isNaN(rate.perKill) ? rate.perKill : Double.MAX_VALUE;
|
||||
this.rate = rate != null ? rate.text : "";
|
||||
}
|
||||
}
|
||||
|
||||
private final WikiApi api;
|
||||
private final ExecutorService executor;
|
||||
private final ItemManager itemManager;
|
||||
private final CollectionLogService clog;
|
||||
private final Function<String, LootRecord> allTimeRecord;
|
||||
private final JPanel body = new JPanel(new DynamicGridLayout(0, 1, 0, 3));
|
||||
private final JButton toggle = new JButton();
|
||||
private final Runnable clogListener = () -> SwingUtilities.invokeLater(this::refresh);
|
||||
|
||||
private LootRecord record;
|
||||
private boolean expanded;
|
||||
private boolean loading;
|
||||
private String loadError;
|
||||
private int variantIndex;
|
||||
|
||||
CollectionLogSection(WikiApi api, ExecutorService executor, ItemManager itemManager, CollectionLogService clog,
|
||||
Function<String, LootRecord> allTimeRecord, LootRecord record)
|
||||
{
|
||||
super(new BorderLayout(0, 4));
|
||||
this.api = api;
|
||||
this.executor = executor;
|
||||
this.itemManager = itemManager;
|
||||
this.clog = clog;
|
||||
this.allTimeRecord = allTimeRecord;
|
||||
this.record = record;
|
||||
setOpaque(false);
|
||||
|
||||
toggle.setFont(FontManager.getRunescapeSmallFont());
|
||||
toggle.setForeground(Ui.MUTED);
|
||||
toggle.setFocusPainted(false);
|
||||
toggle.setContentAreaFilled(false);
|
||||
toggle.setBorderPainted(false);
|
||||
toggle.setBorder(BorderFactory.createEmptyBorder(6, 2, 0, 2));
|
||||
toggle.addMouseListener(new java.awt.event.MouseAdapter()
|
||||
{
|
||||
@Override
|
||||
public void mouseEntered(java.awt.event.MouseEvent e)
|
||||
{
|
||||
toggle.setForeground(Ui.ACCENT);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void mouseExited(java.awt.event.MouseEvent e)
|
||||
{
|
||||
toggle.setForeground(Ui.MUTED);
|
||||
}
|
||||
});
|
||||
toggle.setHorizontalAlignment(JButton.LEFT);
|
||||
toggle.setCursor(Cursor.getPredefinedCursor(Cursor.HAND_CURSOR));
|
||||
toggle.addActionListener(e ->
|
||||
{
|
||||
expanded = !expanded;
|
||||
refresh();
|
||||
});
|
||||
add(toggle, BorderLayout.NORTH);
|
||||
|
||||
body.setOpaque(false);
|
||||
add(body, BorderLayout.CENTER);
|
||||
refresh();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void addNotify()
|
||||
{
|
||||
super.addNotify();
|
||||
if (clog != null)
|
||||
{
|
||||
clog.addListener(clogListener);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void removeNotify()
|
||||
{
|
||||
if (clog != null)
|
||||
{
|
||||
clog.removeListener(clogListener);
|
||||
}
|
||||
super.removeNotify();
|
||||
}
|
||||
|
||||
void setRecord(LootRecord record)
|
||||
{
|
||||
this.record = record;
|
||||
refresh();
|
||||
}
|
||||
|
||||
private void refresh()
|
||||
{
|
||||
toggle.setText((expanded ? "▾ " : "▸ ") + "MISSING COLLECTION LOG");
|
||||
body.removeAll();
|
||||
body.setVisible(expanded);
|
||||
if (expanded)
|
||||
{
|
||||
render();
|
||||
}
|
||||
revalidate();
|
||||
repaint();
|
||||
}
|
||||
|
||||
private void render()
|
||||
{
|
||||
if (clog == null || !clog.isLoaded())
|
||||
{
|
||||
body.add(note("Log in to load the collection log."));
|
||||
return;
|
||||
}
|
||||
String name = record.getName();
|
||||
DropTable table = TABLES.get(name);
|
||||
if (table == null)
|
||||
{
|
||||
load(name);
|
||||
}
|
||||
CollectionLogService.Page page = clog.findPage(name);
|
||||
|
||||
List<Entry> entries = new ArrayList<>();
|
||||
if (page != null)
|
||||
{
|
||||
Map<String, DropTable.Rate> rates = table == null || table.isEmpty() ? Map.of() : DropTable.rates(table.variants.get(0));
|
||||
for (int id : page.itemIds)
|
||||
{
|
||||
String itemName = clog.itemName(id);
|
||||
entries.add(new Entry(id, itemName, rates.get(DropTable.key(itemName))));
|
||||
}
|
||||
}
|
||||
else if (table == null)
|
||||
{
|
||||
body.add(loadError != null ? note("Could not load drop table: " + loadError) : note("Loading drop table from the wiki..."));
|
||||
if (loadError != null)
|
||||
{
|
||||
body.add(retryButton());
|
||||
}
|
||||
return;
|
||||
}
|
||||
else
|
||||
{
|
||||
// no own log page (e.g. a slayer monster): its wiki drops that are collection log items
|
||||
if (table.variants.size() > 1)
|
||||
{
|
||||
addVariantSelector(table);
|
||||
}
|
||||
Set<String> seen = new HashSet<>();
|
||||
for (DropTable.Rate rate : DropTable.rates(table.variants.get(Math.min(variantIndex, table.variants.size() - 1))).values())
|
||||
{
|
||||
Set<Integer> ids = clog.idsByName(rate.item);
|
||||
if (!ids.isEmpty() && seen.add(DropTable.key(rate.item)))
|
||||
{
|
||||
entries.add(new Entry(ids.iterator().next(), rate.item, rate));
|
||||
}
|
||||
}
|
||||
if (entries.isEmpty())
|
||||
{
|
||||
body.add(note("\"" + name + "\" drops no collection log items."));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
LootRecord all = allTimeRecord != null ? allTimeRecord.apply(name) : null;
|
||||
List<Entry> missing = new ArrayList<>();
|
||||
for (Entry e : entries)
|
||||
{
|
||||
if (!hasItem(e, all != null ? all : record))
|
||||
{
|
||||
missing.add(e);
|
||||
}
|
||||
}
|
||||
missing.sort(Comparator.comparingDouble(e -> e.perKill));
|
||||
|
||||
JLabel count = new JLabel(String.format("<html><font color='#a5a5a5'>%s:</font> <b>%d / %d</b> obtained</html>",
|
||||
page != null ? escape(page.name) : "Log items", entries.size() - missing.size(), entries.size()));
|
||||
count.setFont(FontManager.getRunescapeSmallFont());
|
||||
body.add(count);
|
||||
if (clog.getFullSyncAt() == 0)
|
||||
{
|
||||
body.add(note("Collection log not synced: open it in-game and click its Search button once."));
|
||||
}
|
||||
if (missing.isEmpty())
|
||||
{
|
||||
body.add(note("All collection log items obtained."));
|
||||
}
|
||||
for (Entry e : missing)
|
||||
{
|
||||
body.add(rowPanel(e));
|
||||
}
|
||||
}
|
||||
|
||||
private boolean hasItem(Entry e, LootRecord looted)
|
||||
{
|
||||
if (clog.isObtained(e.id))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
for (int id : clog.idsByName(e.name))
|
||||
{
|
||||
if (clog.isObtained(id))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
for (TrackedLootItem t : looted.getItems().values())
|
||||
{
|
||||
if (t.getQuantity() > 0 && (t.getId() == e.id || e.name.equalsIgnoreCase(t.getName())))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private void addVariantSelector(DropTable table)
|
||||
{
|
||||
JComboBox<DropTable.Variant> selector = new JComboBox<>(table.variants.toArray(new DropTable.Variant[0]));
|
||||
selector.setFont(FontManager.getRunescapeSmallFont());
|
||||
selector.setBackground(Ui.CARD);
|
||||
selector.setFocusable(false);
|
||||
selector.setSelectedIndex(Math.min(variantIndex, table.variants.size() - 1));
|
||||
selector.addActionListener(e ->
|
||||
{
|
||||
variantIndex = Math.max(0, selector.getSelectedIndex());
|
||||
refresh();
|
||||
});
|
||||
body.add(selector);
|
||||
}
|
||||
|
||||
private void load(String name)
|
||||
{
|
||||
if (loading || loadError != null || api == null || executor == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
loading = true;
|
||||
executor.execute(() ->
|
||||
{
|
||||
String error = null;
|
||||
try
|
||||
{
|
||||
TABLES.put(name, api.fetchDropTable(name));
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
log.debug("drop table for {} failed", name, e);
|
||||
error = e.getMessage() == null ? "Wiki not reachable" : e.getMessage();
|
||||
}
|
||||
String err = error;
|
||||
SwingUtilities.invokeLater(() ->
|
||||
{
|
||||
loading = false;
|
||||
loadError = err;
|
||||
if (name.equals(record.getName()) && expanded)
|
||||
{
|
||||
refresh();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private JPanel rowPanel(Entry e)
|
||||
{
|
||||
JPanel row = new JPanel(new BorderLayout(6, 0))
|
||||
{
|
||||
@Override
|
||||
protected void paintComponent(java.awt.Graphics g0)
|
||||
{
|
||||
java.awt.Graphics2D g = Ui.prepare(g0);
|
||||
g.setColor(Ui.CARD);
|
||||
g.fillRoundRect(0, 0, getWidth(), getHeight(), 6, 6);
|
||||
g.dispose();
|
||||
}
|
||||
};
|
||||
row.setOpaque(false);
|
||||
row.setBorder(BorderFactory.createEmptyBorder(2, 4, 2, 7));
|
||||
|
||||
JLabel icon = new JLabel();
|
||||
icon.setPreferredSize(new Dimension(32, 32));
|
||||
icon.setHorizontalAlignment(JLabel.CENTER);
|
||||
if (itemManager != null)
|
||||
{
|
||||
itemManager.getImage(e.id).addTo(icon);
|
||||
}
|
||||
row.add(icon, BorderLayout.WEST);
|
||||
|
||||
JLabel name = new JLabel(e.name);
|
||||
name.setFont(FontManager.getRunescapeSmallFont());
|
||||
name.setForeground(Ui.TEXT);
|
||||
name.setToolTipText(e.name);
|
||||
row.add(name, BorderLayout.CENTER);
|
||||
|
||||
JLabel rate = new JLabel(e.rate);
|
||||
rate.setFont(FontManager.getRunescapeSmallFont());
|
||||
rate.setForeground(Ui.MUTED);
|
||||
rate.setHorizontalAlignment(JLabel.RIGHT);
|
||||
row.add(rate, BorderLayout.EAST);
|
||||
return row;
|
||||
}
|
||||
|
||||
private JButton retryButton()
|
||||
{
|
||||
JButton retry = new JButton("Retry");
|
||||
retry.setFont(FontManager.getRunescapeSmallFont());
|
||||
retry.setFocusPainted(false);
|
||||
retry.addActionListener(e ->
|
||||
{
|
||||
loadError = null;
|
||||
refresh();
|
||||
});
|
||||
return retry;
|
||||
}
|
||||
|
||||
private static JLabel note(String text)
|
||||
{
|
||||
JLabel l = new JLabel("<html><font color='#888888'><i>" + escape(text) + "</i></font></html>");
|
||||
l.setFont(FontManager.getRunescapeSmallFont());
|
||||
return l;
|
||||
}
|
||||
|
||||
private static String escape(String s)
|
||||
{
|
||||
return s.replace("&", "&").replace("<", "<").replace(">", ">");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
package com.example.essentialsplus.loot.ui;
|
||||
|
||||
import com.example.essentialsplus.loot.LootRecord;
|
||||
import com.example.essentialsplus.loot.droptable.DropTable;
|
||||
import com.example.essentialsplus.ui.Ui;
|
||||
import com.example.essentialsplus.wiki.WikiApi;
|
||||
import java.awt.BorderLayout;
|
||||
import java.awt.Dimension;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.function.Function;
|
||||
import javax.swing.BorderFactory;
|
||||
import javax.swing.JComboBox;
|
||||
import javax.swing.JLabel;
|
||||
import javax.swing.JPanel;
|
||||
import javax.swing.SwingUtilities;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import net.runelite.client.game.ItemManager;
|
||||
import net.runelite.http.api.item.ItemPrice;
|
||||
|
||||
/** Drops of the current target: the player's recorded loot (opens the loot view) and the wiki drop table. */
|
||||
@Slf4j
|
||||
class DropsSection extends JPanel
|
||||
{
|
||||
private static final Map<String, DropTable> TABLES = new ConcurrentHashMap<>();
|
||||
/** item name (lower case) -> item id, -1 if not found */
|
||||
private static final Map<String, Integer> IDS = new ConcurrentHashMap<>();
|
||||
|
||||
private final WikiApi api;
|
||||
private final ExecutorService executor;
|
||||
private final ItemManager itemManager;
|
||||
private final Function<String, LootRecord> records;
|
||||
private final Consumer<LootRecord> openLoot;
|
||||
private final Consumer<String> openWiki;
|
||||
|
||||
private String name;
|
||||
private long recordStamp = -1;
|
||||
private int variant;
|
||||
private boolean loading;
|
||||
private String error;
|
||||
|
||||
DropsSection(WikiApi api, ExecutorService executor, ItemManager itemManager, Function<String, LootRecord> records,
|
||||
Consumer<LootRecord> openLoot, Consumer<String> openWiki)
|
||||
{
|
||||
this.openWiki = openWiki;
|
||||
this.api = api;
|
||||
this.executor = executor;
|
||||
this.itemManager = itemManager;
|
||||
this.records = records;
|
||||
this.openLoot = openLoot;
|
||||
setLayout(new VerticalLayout(3));
|
||||
setOpaque(false);
|
||||
render();
|
||||
}
|
||||
|
||||
/** Target name; rebuilds only when it or the recorded loot changes. */
|
||||
void setTarget(String target)
|
||||
{
|
||||
LootRecord r = target == null ? null : records.apply(target);
|
||||
long stamp = r == null ? 0 : r.getLastSeen() + r.getKills();
|
||||
if (java.util.Objects.equals(target, name) && stamp == recordStamp)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (!java.util.Objects.equals(target, name))
|
||||
{
|
||||
variant = 0;
|
||||
error = null;
|
||||
}
|
||||
name = target;
|
||||
recordStamp = stamp;
|
||||
render();
|
||||
}
|
||||
|
||||
private void render()
|
||||
{
|
||||
removeAll();
|
||||
if (name == null)
|
||||
{
|
||||
add(note("Attack a monster to see what it drops."));
|
||||
refreshLayout();
|
||||
return;
|
||||
}
|
||||
|
||||
add(LiveFightCard.sectionLabel("YOUR LOOT"));
|
||||
LootRecord record = records.apply(name);
|
||||
if (record != null && record.getKills() + record.getItems().size() > 0)
|
||||
{
|
||||
add(new LootTargetRow(record, record.getTotalValue(), itemManager, openLoot));
|
||||
}
|
||||
else
|
||||
{
|
||||
add(note("No kills recorded yet."));
|
||||
}
|
||||
|
||||
DropTable table = TABLES.get(name);
|
||||
add(LiveFightCard.sectionLabel("DROP TABLE (WIKI)"));
|
||||
if (table == null)
|
||||
{
|
||||
if (error != null)
|
||||
{
|
||||
add(note("Could not load drop table: " + error));
|
||||
}
|
||||
else
|
||||
{
|
||||
add(note("Loading..."));
|
||||
load(name);
|
||||
}
|
||||
}
|
||||
else if (table.isEmpty())
|
||||
{
|
||||
add(note("The wiki has no drop table for " + name + "."));
|
||||
}
|
||||
else
|
||||
{
|
||||
if (table.variants.size() > 1)
|
||||
{
|
||||
JComboBox<DropTable.Variant> box = new JComboBox<>(table.variants.toArray(new DropTable.Variant[0]));
|
||||
box.setSelectedIndex(Math.min(variant, table.variants.size() - 1));
|
||||
box.setFocusable(false);
|
||||
box.addActionListener(e ->
|
||||
{
|
||||
variant = box.getSelectedIndex();
|
||||
render();
|
||||
});
|
||||
add(box);
|
||||
}
|
||||
DropTable.Variant v = table.variants.get(Math.min(variant, table.variants.size() - 1));
|
||||
for (DropTable.Rate rate : DropTable.rates(v).values())
|
||||
{
|
||||
add(row(rate));
|
||||
}
|
||||
}
|
||||
refreshLayout();
|
||||
}
|
||||
|
||||
private void refreshLayout()
|
||||
{
|
||||
revalidate();
|
||||
repaint();
|
||||
}
|
||||
|
||||
private JPanel row(DropTable.Rate rate)
|
||||
{
|
||||
JPanel row = new JPanel(new BorderLayout(6, 0))
|
||||
{
|
||||
@Override
|
||||
protected void paintComponent(java.awt.Graphics g0)
|
||||
{
|
||||
java.awt.Graphics2D g = Ui.prepare(g0);
|
||||
g.setColor(Ui.CARD);
|
||||
g.fillRoundRect(0, 0, getWidth(), getHeight(), 6, 6);
|
||||
g.dispose();
|
||||
}
|
||||
};
|
||||
row.setOpaque(false);
|
||||
row.setBorder(BorderFactory.createEmptyBorder(1, 3, 1, 7));
|
||||
row.setCursor(java.awt.Cursor.getPredefinedCursor(java.awt.Cursor.HAND_CURSOR));
|
||||
row.setToolTipText(rate.item + " · click to open the wiki");
|
||||
row.addMouseListener(new java.awt.event.MouseAdapter()
|
||||
{
|
||||
@Override
|
||||
public void mousePressed(java.awt.event.MouseEvent e)
|
||||
{
|
||||
if (SwingUtilities.isLeftMouseButton(e))
|
||||
{
|
||||
openWiki.accept(rate.item);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
JLabel icon = new JLabel();
|
||||
icon.setPreferredSize(new Dimension(32, 28));
|
||||
icon.setHorizontalAlignment(JLabel.CENTER);
|
||||
Integer id = IDS.get(DropTable.key(rate.item));
|
||||
if (id != null && id > 0)
|
||||
{
|
||||
itemManager.getImage(id).addTo(icon);
|
||||
}
|
||||
row.add(icon, BorderLayout.WEST);
|
||||
|
||||
JLabel label = new JLabel(rate.item);
|
||||
label.setFont(net.runelite.client.ui.FontManager.getRunescapeSmallFont());
|
||||
label.setForeground(Ui.TEXT);
|
||||
row.add(label, BorderLayout.CENTER);
|
||||
|
||||
JLabel chance = new JLabel(rate.text);
|
||||
chance.setFont(net.runelite.client.ui.FontManager.getRunescapeSmallFont());
|
||||
chance.setForeground(rarityColor(rate.perKill));
|
||||
row.add(chance, BorderLayout.EAST);
|
||||
return row;
|
||||
}
|
||||
|
||||
private static java.awt.Color rarityColor(double perKill)
|
||||
{
|
||||
if (Double.isNaN(perKill))
|
||||
{
|
||||
return Ui.MUTED;
|
||||
}
|
||||
if (perKill >= 1)
|
||||
{
|
||||
return Ui.TEXT;
|
||||
}
|
||||
if (perKill >= 1 / 100.0)
|
||||
{
|
||||
return Ui.UP;
|
||||
}
|
||||
if (perKill >= 1 / 1000.0)
|
||||
{
|
||||
return Ui.GOLD;
|
||||
}
|
||||
return Ui.DOWN;
|
||||
}
|
||||
|
||||
private void load(String target)
|
||||
{
|
||||
if (loading || api == null || executor == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
loading = true;
|
||||
executor.execute(() ->
|
||||
{
|
||||
String err = null;
|
||||
try
|
||||
{
|
||||
DropTable table = api.fetchDropTable(target);
|
||||
// icons: item ids by name (tradeable items only)
|
||||
for (DropTable.Variant v : table.variants)
|
||||
{
|
||||
for (DropTable.Rate r : DropTable.rates(v).values())
|
||||
{
|
||||
IDS.computeIfAbsent(DropTable.key(r.item), k -> findId(r.item));
|
||||
}
|
||||
}
|
||||
TABLES.put(target, table);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
log.debug("drop table for {} failed", target, e);
|
||||
err = e.getMessage() == null ? "wiki not reachable" : e.getMessage();
|
||||
}
|
||||
String failure = err;
|
||||
SwingUtilities.invokeLater(() ->
|
||||
{
|
||||
loading = false;
|
||||
if (target.equals(name))
|
||||
{
|
||||
error = failure;
|
||||
render();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private int findId(String item)
|
||||
{
|
||||
try
|
||||
{
|
||||
for (ItemPrice p : itemManager.search(item))
|
||||
{
|
||||
if (p.getName().equalsIgnoreCase(item))
|
||||
{
|
||||
return p.getId();
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (RuntimeException e)
|
||||
{
|
||||
log.debug("item search for {} failed", item, e);
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
private static JLabel note(String text)
|
||||
{
|
||||
JLabel l = new JLabel("<html><body style='width:180px'>" + text.replace("&", "&").replace("<", "<") + "</html>");
|
||||
l.setFont(net.runelite.client.ui.FontManager.getRunescapeSmallFont());
|
||||
l.setForeground(Ui.FAINT);
|
||||
return l;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
package com.example.essentialsplus.loot.ui;
|
||||
|
||||
import com.example.essentialsplus.loot.LootRecord;
|
||||
import com.example.essentialsplus.loot.gear.FightService;
|
||||
import com.example.essentialsplus.loot.gear.FightSnapshot;
|
||||
import com.example.essentialsplus.ui.Segmented;
|
||||
import com.example.essentialsplus.ui.Ui;
|
||||
import com.example.essentialsplus.wiki.WikiApi;
|
||||
import java.awt.BorderLayout;
|
||||
import java.awt.CardLayout;
|
||||
import java.awt.Cursor;
|
||||
import java.awt.Dimension;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.function.Function;
|
||||
import javax.swing.BorderFactory;
|
||||
import javax.swing.JButton;
|
||||
import javax.swing.JLabel;
|
||||
import javax.swing.JPanel;
|
||||
import javax.swing.JScrollPane;
|
||||
import javax.swing.ScrollPaneConstants;
|
||||
import net.runelite.client.game.ItemManager;
|
||||
import net.runelite.client.ui.FontManager;
|
||||
|
||||
/** The current target in detail: tabs for its stats / this fight and the best gear against it, its drops always below. */
|
||||
class FightDetailPanel extends JPanel
|
||||
{
|
||||
private static final String[] TABS = {"Stats", "Gear"};
|
||||
|
||||
private final TargetBar targetBar;
|
||||
private final LiveFightCard stats = new LiveFightCard();
|
||||
private final GearSection gear;
|
||||
private final DropsSection drops;
|
||||
private final JLabel title = new JLabel("Fight");
|
||||
private final CardLayout tabs = new CardLayout();
|
||||
private final JPanel tabHolder = new JPanel(tabs)
|
||||
{
|
||||
@Override
|
||||
public Dimension getPreferredSize()
|
||||
{
|
||||
// only the shown tab counts (CardLayout would use the tallest one)
|
||||
for (java.awt.Component c : getComponents())
|
||||
{
|
||||
if (c.isVisible())
|
||||
{
|
||||
return c.getPreferredSize();
|
||||
}
|
||||
}
|
||||
return super.getPreferredSize();
|
||||
}
|
||||
};
|
||||
private final Segmented tabBar;
|
||||
private final JScrollPane scroll;
|
||||
|
||||
FightDetailPanel(FightService fights, ItemManager itemManager, WikiApi wikiApi, ExecutorService wikiExecutor,
|
||||
Function<String, LootRecord> records, Consumer<LootRecord> openLoot, Consumer<String> openWiki, Runnable onBack)
|
||||
{
|
||||
super(new BorderLayout());
|
||||
setOpaque(false);
|
||||
targetBar = new TargetBar(null, fights::clearTarget);
|
||||
gear = new GearSection(itemManager, fights::setSlayerTask);
|
||||
drops = new DropsSection(wikiApi, wikiExecutor, itemManager, records, openLoot, openWiki);
|
||||
|
||||
JPanel nav = new JPanel(new BorderLayout(6, 0));
|
||||
nav.setOpaque(false);
|
||||
JButton back = new JButton("← Back");
|
||||
back.setFont(FontManager.getRunescapeSmallFont());
|
||||
back.setForeground(Ui.ACCENT);
|
||||
back.setMargin(new java.awt.Insets(2, 2, 2, 2));
|
||||
back.setFocusPainted(false);
|
||||
back.setCursor(Cursor.getPredefinedCursor(Cursor.HAND_CURSOR));
|
||||
back.addActionListener(e -> onBack.run());
|
||||
nav.add(back, BorderLayout.WEST);
|
||||
title.setFont(FontManager.getRunescapeBoldFont());
|
||||
title.setForeground(Ui.ACCENT);
|
||||
title.setHorizontalAlignment(JLabel.RIGHT);
|
||||
nav.add(title, BorderLayout.CENTER);
|
||||
|
||||
tabBar = new Segmented(TABS, 0, i ->
|
||||
{
|
||||
tabs.show(tabHolder, TABS[i]);
|
||||
scrollTop();
|
||||
});
|
||||
tabBar.setPreferredSize(new Dimension(10, 24));
|
||||
|
||||
JPanel top = new JPanel(new VerticalLayout(6));
|
||||
top.setOpaque(false);
|
||||
top.setBorder(BorderFactory.createEmptyBorder(6, 6, 6, 6));
|
||||
top.add(nav);
|
||||
top.add(targetBar);
|
||||
top.add(tabBar);
|
||||
add(top, BorderLayout.NORTH);
|
||||
|
||||
tabHolder.setOpaque(false);
|
||||
tabHolder.add(tab(stats), TABS[0]);
|
||||
tabHolder.add(tab(gear), TABS[1]);
|
||||
|
||||
JPanel wrapper = new LootRecordDetailPanel.ScrollableWrapper();
|
||||
wrapper.setBorder(BorderFactory.createEmptyBorder(0, 6, 10, 6));
|
||||
JPanel body = new JPanel(new VerticalLayout(8));
|
||||
body.setOpaque(false);
|
||||
body.add(tabHolder);
|
||||
JLabel dropsHeader = new JLabel("DROPS");
|
||||
dropsHeader.setFont(FontManager.getRunescapeBoldFont());
|
||||
dropsHeader.setForeground(Ui.ACCENT);
|
||||
dropsHeader.setBorder(BorderFactory.createCompoundBorder(
|
||||
BorderFactory.createMatteBorder(1, 0, 0, 0, Ui.CARD),
|
||||
BorderFactory.createEmptyBorder(6, 0, 0, 0)));
|
||||
body.add(dropsHeader);
|
||||
body.add(drops);
|
||||
wrapper.add(body, BorderLayout.NORTH);
|
||||
scroll = new JScrollPane(wrapper);
|
||||
scroll.setBackground(Ui.BG);
|
||||
scroll.getViewport().setBackground(Ui.BG);
|
||||
scroll.setBorder(null);
|
||||
scroll.setHorizontalScrollBarPolicy(ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER);
|
||||
scroll.getVerticalScrollBar().setUnitIncrement(16);
|
||||
add(scroll, BorderLayout.CENTER);
|
||||
}
|
||||
|
||||
/** Card of the tab layout: only the visible card's height counts, so short tabs leave no gap. */
|
||||
private static JPanel tab(JPanel content)
|
||||
{
|
||||
JPanel p = new JPanel(new BorderLayout());
|
||||
p.setOpaque(false);
|
||||
p.add(content, BorderLayout.NORTH);
|
||||
return p;
|
||||
}
|
||||
|
||||
void showTab(int index)
|
||||
{
|
||||
tabBar.setSelected(index);
|
||||
tabs.show(tabHolder, TABS[index]);
|
||||
scrollTop();
|
||||
}
|
||||
|
||||
private void scrollTop()
|
||||
{
|
||||
tabHolder.revalidate();
|
||||
scroll.getVerticalScrollBar().setValue(0);
|
||||
}
|
||||
|
||||
void update(FightSnapshot s)
|
||||
{
|
||||
targetBar.update(s);
|
||||
stats.update(s);
|
||||
gear.update(s);
|
||||
String name = s.loggedIn && s.target != null ? s.target.name : null;
|
||||
title.setText(name != null ? name : "Fight");
|
||||
drops.setTarget(name);
|
||||
}
|
||||
|
||||
/** Refreshes the recorded loot shown under Drops. */
|
||||
void lootChanged(FightSnapshot s)
|
||||
{
|
||||
drops.setTarget(s.loggedIn && s.target != null ? s.target.name : null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,798 @@
|
||||
package com.example.essentialsplus.loot.ui;
|
||||
|
||||
import com.example.essentialsplus.loot.gear.DpsCalc;
|
||||
import com.example.essentialsplus.loot.gear.FightSnapshot;
|
||||
import com.example.essentialsplus.loot.gear.GearItem;
|
||||
import com.example.essentialsplus.loot.gear.Loadout;
|
||||
import com.example.essentialsplus.loot.gear.Monster;
|
||||
import com.example.essentialsplus.ui.Ui;
|
||||
import java.awt.BasicStroke;
|
||||
import java.awt.BorderLayout;
|
||||
import java.awt.Color;
|
||||
import java.awt.Cursor;
|
||||
import java.awt.Dimension;
|
||||
import java.awt.FontMetrics;
|
||||
import java.awt.Graphics;
|
||||
import java.awt.Graphics2D;
|
||||
import java.awt.Rectangle;
|
||||
import java.awt.event.MouseAdapter;
|
||||
import java.awt.event.MouseEvent;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.function.Consumer;
|
||||
import javax.swing.BorderFactory;
|
||||
import javax.swing.JCheckBox;
|
||||
import javax.swing.JComponent;
|
||||
import javax.swing.JLabel;
|
||||
import javax.swing.JPanel;
|
||||
import javax.swing.SwingUtilities;
|
||||
import net.runelite.client.game.ItemManager;
|
||||
import net.runelite.client.ui.DynamicGridLayout;
|
||||
import net.runelite.client.ui.FontManager;
|
||||
import net.runelite.client.util.AsyncBufferedImage;
|
||||
|
||||
/**
|
||||
* Worn equipment with its bonuses, max hit and DPS. Clicking a slot lists the matching items from worn gear,
|
||||
* inventory and bank ranked by DPS; picking one puts it into a planned loadout so a full setup can be
|
||||
* put together and compared with what is worn.
|
||||
*/
|
||||
public class GearSection extends JPanel
|
||||
{
|
||||
private static final int BOX = 34;
|
||||
private static final int GAP = 3;
|
||||
private static final int PICKER_ROWS = 40;
|
||||
/** grid position (column, row) per equipment slot */
|
||||
private static final int[][] LAYOUT = new int[GearItem.SLOTS][];
|
||||
|
||||
static
|
||||
{
|
||||
LAYOUT[GearItem.HEAD] = new int[]{1, 0};
|
||||
LAYOUT[GearItem.CAPE] = new int[]{0, 1};
|
||||
LAYOUT[GearItem.AMULET] = new int[]{1, 1};
|
||||
LAYOUT[GearItem.AMMO] = new int[]{2, 1};
|
||||
LAYOUT[GearItem.WEAPON] = new int[]{0, 2};
|
||||
LAYOUT[GearItem.BODY] = new int[]{1, 2};
|
||||
LAYOUT[GearItem.SHIELD] = new int[]{2, 2};
|
||||
LAYOUT[GearItem.LEGS] = new int[]{1, 3};
|
||||
LAYOUT[GearItem.GLOVES] = new int[]{0, 4};
|
||||
LAYOUT[GearItem.BOOTS] = new int[]{1, 4};
|
||||
LAYOUT[GearItem.RING] = new int[]{2, 4};
|
||||
}
|
||||
|
||||
private final ItemManager itemManager;
|
||||
private final Map<Integer, AsyncBufferedImage> icons = new HashMap<>();
|
||||
private final Consumer<Boolean> onSlayerTask;
|
||||
|
||||
private final JLabel title = new JLabel("Equipment");
|
||||
private final JLabel reset = new JLabel("Reset");
|
||||
private final JCheckBox slayer = new JCheckBox("Task");
|
||||
private final EquipmentView equipment = new EquipmentView();
|
||||
private final JPanel bestStyles = new JPanel(new java.awt.GridLayout(1, 0, 4, 0));
|
||||
private final JLabel bestHint = new JLabel();
|
||||
/** style label of the best setup shown, null = highest DPS */
|
||||
private String bestStyle;
|
||||
private final BonusView bonuses = new BonusView();
|
||||
private final JLabel pickerTitle = new JLabel();
|
||||
private final JPanel pickerList = new JPanel(new VerticalLayout(2));
|
||||
private final JPanel picker = new JPanel(new BorderLayout(0, 4));
|
||||
|
||||
private FightSnapshot snap = FightSnapshot.LOGGED_OUT;
|
||||
/** null = show the worn gear */
|
||||
private Loadout planned;
|
||||
private int selectedSlot = -1;
|
||||
private long pickerVersion = -1;
|
||||
|
||||
public GearSection(ItemManager itemManager, Consumer<Boolean> onSlayerTask)
|
||||
{
|
||||
this.itemManager = itemManager;
|
||||
this.onSlayerTask = onSlayerTask;
|
||||
setLayout(new VerticalLayout(6));
|
||||
setOpaque(false);
|
||||
|
||||
JPanel header = new JPanel(new BorderLayout(6, 0));
|
||||
header.setOpaque(false);
|
||||
title.setFont(FontManager.getRunescapeBoldFont());
|
||||
title.setForeground(Ui.TEXT);
|
||||
header.add(title, BorderLayout.WEST);
|
||||
|
||||
JPanel right = new JPanel(new BorderLayout(6, 0));
|
||||
right.setOpaque(false);
|
||||
slayer.setFont(FontManager.getRunescapeSmallFont());
|
||||
slayer.setForeground(Ui.MUTED);
|
||||
slayer.setOpaque(false);
|
||||
slayer.setFocusable(false);
|
||||
slayer.setToolTipText("On a slayer task: count the slayer helmet / black mask bonus");
|
||||
slayer.addActionListener(e -> onSlayerTask.accept(slayer.isSelected()));
|
||||
right.add(slayer, BorderLayout.WEST);
|
||||
reset.setFont(FontManager.getRunescapeSmallFont());
|
||||
reset.setForeground(Ui.ACCENT);
|
||||
reset.setCursor(Cursor.getPredefinedCursor(Cursor.HAND_CURSOR));
|
||||
reset.setToolTipText("Drop the planned items and show the worn gear again");
|
||||
reset.setVisible(false);
|
||||
reset.addMouseListener(new MouseAdapter()
|
||||
{
|
||||
@Override
|
||||
public void mousePressed(MouseEvent e)
|
||||
{
|
||||
plan(null);
|
||||
}
|
||||
});
|
||||
right.add(reset, BorderLayout.EAST);
|
||||
header.add(right, BorderLayout.EAST);
|
||||
add(header);
|
||||
|
||||
bestStyles.setOpaque(false);
|
||||
add(bestStyles);
|
||||
add(equipment);
|
||||
|
||||
picker.setOpaque(false);
|
||||
pickerTitle.setFont(FontManager.getRunescapeSmallFont());
|
||||
pickerTitle.setForeground(Ui.MUTED);
|
||||
picker.add(pickerTitle, BorderLayout.NORTH);
|
||||
pickerList.setOpaque(false);
|
||||
picker.add(pickerList, BorderLayout.CENTER);
|
||||
picker.setVisible(false);
|
||||
add(picker);
|
||||
|
||||
add(bonuses);
|
||||
bestHint.setFont(FontManager.getRunescapeSmallFont());
|
||||
bestHint.setForeground(Ui.FAINT);
|
||||
add(bestHint);
|
||||
}
|
||||
|
||||
/** Puts a loadout into the planner (null = back to worn gear). */
|
||||
public void plan(Loadout loadout)
|
||||
{
|
||||
planned = loadout == null || loadout.sameAs(snap.worn) ? null : loadout;
|
||||
pickerVersion = -1;
|
||||
refresh();
|
||||
}
|
||||
|
||||
public void update(FightSnapshot s)
|
||||
{
|
||||
boolean gear = s.gearVersion != snap.gearVersion || s.loggedIn != snap.loggedIn || s.best != snap.best;
|
||||
snap = s;
|
||||
slayer.setSelected(s.setup.onSlayerTask);
|
||||
if (!s.loggedIn)
|
||||
{
|
||||
planned = null;
|
||||
selectedSlot = -1;
|
||||
}
|
||||
if (planned != null && planned.sameAs(s.worn))
|
||||
{
|
||||
planned = null;
|
||||
}
|
||||
if (gear)
|
||||
{
|
||||
refresh();
|
||||
}
|
||||
}
|
||||
|
||||
private com.example.essentialsplus.loot.gear.GearOptimizer.Option bestOption()
|
||||
{
|
||||
if (!snap.loggedIn || snap.target == null || snap.best.isEmpty())
|
||||
{
|
||||
return null;
|
||||
}
|
||||
for (com.example.essentialsplus.loot.gear.GearOptimizer.Option o : snap.best)
|
||||
{
|
||||
if (o.label.equals(bestStyle))
|
||||
{
|
||||
return o;
|
||||
}
|
||||
}
|
||||
return snap.best.get(0);
|
||||
}
|
||||
|
||||
private void refreshBest()
|
||||
{
|
||||
com.example.essentialsplus.loot.gear.GearOptimizer.Option best = bestOption();
|
||||
bestStyles.setVisible(best != null && snap.best.size() > 1);
|
||||
if (!snap.loggedIn)
|
||||
{
|
||||
bestHint.setVisible(false);
|
||||
return;
|
||||
}
|
||||
long bankItems = snap.pool.stream().filter(g -> g.source == GearItem.Source.BANK).count();
|
||||
String text;
|
||||
if (snap.target == null)
|
||||
{
|
||||
text = "Attack a monster: gold slots then show what is already the best from your bank, click the others for better items.";
|
||||
}
|
||||
else if (best == null)
|
||||
{
|
||||
text = snap.pool.isEmpty() ? "No equipment found." : "Calculating the best gear from your bank...";
|
||||
}
|
||||
else
|
||||
{
|
||||
int swaps = best.loadout.changesFrom(shown()).size();
|
||||
text = "Gold = best vs " + snap.target.name + " (" + best.label + ", " + dps(best.result.dps) + " dps). "
|
||||
+ (swaps == 0 ? "Nothing to improve." : swaps + " slot" + (swaps == 1 ? "" : "s") + " can be better (green arrow): click them.")
|
||||
+ " " + bankItems + " bank items" + (bankItems == 0 ? " - open your bank once." : ".")
|
||||
+ (snap.target.monster == null ? " No wiki stats: calculated without defence." : "");
|
||||
}
|
||||
bestHint.setText("<html><body style='width:180px'>" + text + "</html>");
|
||||
bestHint.setVisible(true);
|
||||
|
||||
bestStyles.removeAll();
|
||||
if (best != null)
|
||||
{
|
||||
for (com.example.essentialsplus.loot.gear.GearOptimizer.Option o : snap.best)
|
||||
{
|
||||
boolean active = o == best;
|
||||
JLabel chip = new JLabel(o.label.startsWith("Melee") ? "Melee" : o.label, JLabel.CENTER);
|
||||
chip.setFont(FontManager.getRunescapeSmallFont());
|
||||
chip.setOpaque(true);
|
||||
chip.setBackground(active ? Ui.CARD_SELECTED : Ui.CARD);
|
||||
chip.setForeground(active ? Ui.GOLD : Ui.MUTED);
|
||||
chip.setBorder(BorderFactory.createEmptyBorder(3, 2, 3, 2));
|
||||
chip.setCursor(Cursor.getPredefinedCursor(Cursor.HAND_CURSOR));
|
||||
chip.setToolTipText("Best " + o.label + " setup: " + dps(o.result.dps) + " dps · max " + o.result.maxHit);
|
||||
chip.addMouseListener(new MouseAdapter()
|
||||
{
|
||||
@Override
|
||||
public void mousePressed(MouseEvent e)
|
||||
{
|
||||
bestStyle = o.label;
|
||||
pickerVersion = -1;
|
||||
refresh();
|
||||
}
|
||||
});
|
||||
bestStyles.add(chip);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private Loadout shown()
|
||||
{
|
||||
return planned != null ? planned : snap.worn;
|
||||
}
|
||||
|
||||
private Monster monster()
|
||||
{
|
||||
return snap.target != null ? snap.target.calcMonster() : Monster.DUMMY;
|
||||
}
|
||||
|
||||
private DpsCalc.Result calc(Loadout l)
|
||||
{
|
||||
return DpsCalc.calc(l, snap.setup, monster(), DpsCalc.choiceFor(l, snap.worn, snap.setup));
|
||||
}
|
||||
|
||||
private void refresh()
|
||||
{
|
||||
reset.setVisible(planned != null);
|
||||
title.setText(planned != null ? "Planned gear" : "Equipment");
|
||||
title.setForeground(planned != null ? Ui.GOLD : Ui.TEXT);
|
||||
equipment.repaint();
|
||||
bonuses.repaint();
|
||||
refreshBest();
|
||||
rebuildPicker();
|
||||
revalidate();
|
||||
repaint();
|
||||
}
|
||||
|
||||
private void select(int slot)
|
||||
{
|
||||
selectedSlot = selectedSlot == slot ? -1 : slot;
|
||||
pickerVersion = -1;
|
||||
refresh();
|
||||
}
|
||||
|
||||
private void rebuildPicker()
|
||||
{
|
||||
if (selectedSlot < 0 || !snap.loggedIn)
|
||||
{
|
||||
picker.setVisible(false);
|
||||
pickerList.removeAll();
|
||||
pickerVersion = -1;
|
||||
return;
|
||||
}
|
||||
if (pickerVersion == snap.gearVersion)
|
||||
{
|
||||
return;
|
||||
}
|
||||
pickerVersion = snap.gearVersion;
|
||||
picker.setVisible(true);
|
||||
pickerList.removeAll();
|
||||
|
||||
Loadout base = shown();
|
||||
DpsCalc.Result current = calc(base);
|
||||
List<Candidate> candidates = new ArrayList<>();
|
||||
for (GearItem g : snap.pool)
|
||||
{
|
||||
if (g.slot == selectedSlot)
|
||||
{
|
||||
Loadout l = base.with(selectedSlot, g);
|
||||
candidates.add(new Candidate(g, l, calc(l)));
|
||||
}
|
||||
}
|
||||
if (base.get(selectedSlot) != null)
|
||||
{
|
||||
Loadout l = base.with(selectedSlot, null);
|
||||
candidates.add(new Candidate(null, l, calc(l)));
|
||||
}
|
||||
candidates.sort(Comparator.<Candidate>comparingDouble(c -> c.result.dps).reversed()
|
||||
.thenComparing(Comparator.<Candidate>comparingInt(c -> c.result.maxHit).reversed())
|
||||
.thenComparing(Comparator.<Candidate>comparingInt(c -> c.item == null ? -1 : c.item.totalDefence()).reversed()));
|
||||
|
||||
Monster m = monster();
|
||||
pickerTitle.setText(GearItem.slotName(selectedSlot) + " · " + (candidates.size() - (base.get(selectedSlot) != null ? 1 : 0))
|
||||
+ " owned · DPS vs " + (m.isDummy() ? "no defence" : m.name));
|
||||
if (candidates.isEmpty())
|
||||
{
|
||||
JLabel none = new JLabel("No items for this slot in worn gear, inventory or bank");
|
||||
none.setFont(FontManager.getRunescapeSmallFont());
|
||||
none.setForeground(Ui.FAINT);
|
||||
pickerList.add(none);
|
||||
}
|
||||
com.example.essentialsplus.loot.gear.GearOptimizer.Option best = bestOption();
|
||||
int bestId = best != null ? best.loadout.id(selectedSlot) : Integer.MIN_VALUE;
|
||||
// the optimizer's pick for this slot goes first
|
||||
for (int i = 0; i < candidates.size(); i++)
|
||||
{
|
||||
Candidate c = candidates.get(i);
|
||||
if ((c.item == null ? -1 : c.item.id) == bestId)
|
||||
{
|
||||
candidates.remove(i);
|
||||
candidates.add(0, c);
|
||||
break;
|
||||
}
|
||||
}
|
||||
int shownRows = 0;
|
||||
for (Candidate c : candidates)
|
||||
{
|
||||
if (shownRows++ >= PICKER_ROWS)
|
||||
{
|
||||
break;
|
||||
}
|
||||
boolean selected = c.item == null ? base.get(selectedSlot) == null : c.item.id == base.id(selectedSlot);
|
||||
pickerList.add(new CandidateRow(c, current, selected, (c.item == null ? -1 : c.item.id) == bestId));
|
||||
}
|
||||
}
|
||||
|
||||
private AsyncBufferedImage icon(int id, JComponent target)
|
||||
{
|
||||
AsyncBufferedImage img = icons.get(id);
|
||||
if (img == null)
|
||||
{
|
||||
img = itemManager.getImage(id);
|
||||
icons.put(id, img);
|
||||
img.onLoaded(this::repaint);
|
||||
}
|
||||
return img;
|
||||
}
|
||||
|
||||
static String dps(double v)
|
||||
{
|
||||
return String.format(Locale.US, "%.2f", v);
|
||||
}
|
||||
|
||||
static String signed(double v, int decimals)
|
||||
{
|
||||
String s = String.format(Locale.US, "%." + decimals + "f", Math.abs(v));
|
||||
return (v >= 0 ? "+" : "-") + s;
|
||||
}
|
||||
|
||||
private static final class Candidate
|
||||
{
|
||||
final GearItem item;
|
||||
final Loadout loadout;
|
||||
final DpsCalc.Result result;
|
||||
|
||||
Candidate(GearItem item, Loadout loadout, DpsCalc.Result result)
|
||||
{
|
||||
this.item = item;
|
||||
this.loadout = loadout;
|
||||
this.result = result;
|
||||
}
|
||||
}
|
||||
|
||||
/** One item choice in the slot picker. */
|
||||
private final class CandidateRow extends JComponent
|
||||
{
|
||||
private static final int H = 36;
|
||||
private final Candidate c;
|
||||
private final DpsCalc.Result current;
|
||||
private final boolean selected;
|
||||
private final boolean isBest;
|
||||
private boolean hover;
|
||||
|
||||
CandidateRow(Candidate c, DpsCalc.Result current, boolean selected, boolean isBest)
|
||||
{
|
||||
this.isBest = isBest;
|
||||
this.c = c;
|
||||
this.current = current;
|
||||
this.selected = selected;
|
||||
setCursor(Cursor.getPredefinedCursor(Cursor.HAND_CURSOR));
|
||||
if (c.item != null)
|
||||
{
|
||||
GearItem g = c.item;
|
||||
setToolTipText(String.format(Locale.US, "<html><b>%s</b> (%s)<br>Attack: %d / %d / %d / %d / %d<br>Defence: %d / %d / %d / %d / %d"
|
||||
+ "<br>Str %d · R.str %d · M.dmg %.0f%% · Prayer %d%s<br>%s · max hit %d · %.1f%% hit</html>",
|
||||
g.name, g.source.label, g.astab, g.aslash, g.acrush, g.amagic, g.arange,
|
||||
g.dstab, g.dslash, g.dcrush, g.dmagic, g.drange, g.str, g.rstr, g.mdmg, g.prayer,
|
||||
g.aspeed > 0 ? " · speed " + g.aspeed : "", c.result.choice.label(), c.result.maxHit, c.result.hitChance * 100));
|
||||
}
|
||||
addMouseListener(new MouseAdapter()
|
||||
{
|
||||
@Override
|
||||
public void mouseEntered(MouseEvent e)
|
||||
{
|
||||
hover = true;
|
||||
repaint();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void mouseExited(MouseEvent e)
|
||||
{
|
||||
hover = false;
|
||||
repaint();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void mousePressed(MouseEvent e)
|
||||
{
|
||||
if (SwingUtilities.isLeftMouseButton(e))
|
||||
{
|
||||
plan(c.loadout);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public Dimension getPreferredSize()
|
||||
{
|
||||
return new Dimension(10, H);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void paintComponent(Graphics g0)
|
||||
{
|
||||
Graphics2D g = Ui.prepare(g0);
|
||||
int w = getWidth();
|
||||
g.setColor(hover ? Ui.CARD_HOVER : selected ? Ui.CARD_SELECTED : Ui.CARD);
|
||||
g.fillRoundRect(0, 0, w, H, 6, 6);
|
||||
if (selected || isBest)
|
||||
{
|
||||
g.setColor(isBest ? Ui.GOLD : Ui.alpha(Ui.ACCENT, 160));
|
||||
g.drawRoundRect(0, 0, w - 1, H - 1, 6, 6);
|
||||
}
|
||||
int x = 4;
|
||||
if (c.item != null)
|
||||
{
|
||||
g.drawImage(icon(c.item.id, this), x, (H - 32) / 2, null);
|
||||
}
|
||||
x += 36;
|
||||
|
||||
double delta = c.result.dps - current.dps;
|
||||
String right = dps(c.result.dps);
|
||||
g.setFont(FontManager.getRunescapeBoldFont());
|
||||
FontMetrics fm = g.getFontMetrics();
|
||||
int rw = fm.stringWidth(right);
|
||||
g.setColor(Ui.TEXT);
|
||||
g.drawString(right, w - 6 - rw, 4 + fm.getAscent());
|
||||
|
||||
g.setFont(FontManager.getRunescapeSmallFont());
|
||||
FontMetrics sfm = g.getFontMetrics();
|
||||
String deltaText = Math.abs(delta) < 0.005 ? "±0" : signed(delta, 2);
|
||||
g.setColor(Math.abs(delta) < 0.005 ? Ui.FAINT : delta > 0 ? Ui.UP : Ui.DOWN);
|
||||
Ui.drawRight(g, deltaText, w - 6, H - 5);
|
||||
|
||||
g.setFont(FontManager.getRunescapeSmallFont());
|
||||
g.setColor(c.item == null ? Ui.MUTED : Ui.TEXT);
|
||||
String name = c.item == null ? "(empty)" : c.item.name;
|
||||
g.drawString(Ui.ellipsize(sfm, name, w - x - rw - 12), x, 4 + sfm.getAscent());
|
||||
String sub = (isBest ? "BEST · " : "") + (c.item == null ? "" : c.item.source.label + " · ") + "max " + c.result.maxHit;
|
||||
int dMax = c.result.maxHit - current.maxHit;
|
||||
if (dMax != 0)
|
||||
{
|
||||
sub += " (" + (dMax > 0 ? "+" : "") + dMax + ")";
|
||||
}
|
||||
g.setColor(c.item != null && c.item.source == GearItem.Source.BANK ? Ui.MUTED : Ui.GOLD);
|
||||
g.drawString(Ui.ellipsize(sfm, sub, w - x - 40), x, H - 5);
|
||||
g.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/** Equipment slots on the left, key figures on the right. */
|
||||
private final class EquipmentView extends JComponent
|
||||
{
|
||||
private int hoverSlot = -1;
|
||||
EquipmentView()
|
||||
{
|
||||
setToolTipText("");
|
||||
setCursor(Cursor.getPredefinedCursor(Cursor.HAND_CURSOR));
|
||||
MouseAdapter mouse = new MouseAdapter()
|
||||
{
|
||||
@Override
|
||||
public void mouseMoved(MouseEvent e)
|
||||
{
|
||||
int s = slotAt(e.getX(), e.getY());
|
||||
if (s != hoverSlot)
|
||||
{
|
||||
hoverSlot = s;
|
||||
repaint();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void mouseExited(MouseEvent e)
|
||||
{
|
||||
hoverSlot = -1;
|
||||
repaint();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void mousePressed(MouseEvent e)
|
||||
{
|
||||
int s = slotAt(e.getX(), e.getY());
|
||||
if (s >= 0 && snap.loggedIn)
|
||||
{
|
||||
select(s);
|
||||
}
|
||||
}
|
||||
};
|
||||
addMouseListener(mouse);
|
||||
addMouseMotionListener(mouse);
|
||||
}
|
||||
|
||||
private Loadout loadout()
|
||||
{
|
||||
return shown();
|
||||
}
|
||||
|
||||
/** loadout the gold "changed" marks and the deltas refer to */
|
||||
private Loadout compare()
|
||||
{
|
||||
return planned != null ? snap.worn : null;
|
||||
}
|
||||
|
||||
private Rectangle box(int slot)
|
||||
{
|
||||
int[] p = LAYOUT[slot];
|
||||
return new Rectangle(p[0] * (BOX + GAP), p[1] * (BOX + GAP), BOX, BOX);
|
||||
}
|
||||
|
||||
private int slotAt(int x, int y)
|
||||
{
|
||||
for (int s : GearItem.ALL_SLOTS)
|
||||
{
|
||||
if (box(s).contains(x, y))
|
||||
{
|
||||
return s;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getToolTipText(MouseEvent e)
|
||||
{
|
||||
int s = slotAt(e.getX(), e.getY());
|
||||
if (s < 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
Loadout base = loadout();
|
||||
if (base == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
GearItem g = base.get(s);
|
||||
String worn = snap.worn.get(s) != null ? snap.worn.get(s).name : "nothing";
|
||||
String text = g != null ? g.name + (g.source != GearItem.Source.WORN ? " (" + g.source.label + ")" : "") : GearItem.slotName(s) + ": empty";
|
||||
if (compare() != null && base.id(s) != snap.worn.id(s))
|
||||
{
|
||||
text += " · worn: " + worn;
|
||||
}
|
||||
com.example.essentialsplus.loot.gear.GearOptimizer.Option o = bestOption();
|
||||
if (o != null && o.loadout.get(s) != null && o.loadout.id(s) != base.id(s))
|
||||
{
|
||||
text += " · best: " + o.loadout.get(s).name + " (" + o.loadout.get(s).source.label + ")";
|
||||
}
|
||||
else if (o != null && base.get(s) != null && o.loadout.id(s) == base.id(s))
|
||||
{
|
||||
text += " · already the best";
|
||||
}
|
||||
return text + " · click for the list";
|
||||
}
|
||||
|
||||
@Override
|
||||
public Dimension getPreferredSize()
|
||||
{
|
||||
return new Dimension(10, 5 * BOX + 4 * GAP);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void paintComponent(Graphics g0)
|
||||
{
|
||||
Graphics2D g = Ui.prepare(g0);
|
||||
Loadout l = loadout();
|
||||
if (l == null)
|
||||
{
|
||||
g.dispose();
|
||||
return;
|
||||
}
|
||||
Loadout cmp = compare();
|
||||
com.example.essentialsplus.loot.gear.GearOptimizer.Option bestSetup = bestOption();
|
||||
for (int s : GearItem.ALL_SLOTS)
|
||||
{
|
||||
Rectangle r = box(s);
|
||||
boolean changed = cmp != null && l.id(s) != cmp.id(s);
|
||||
boolean sel = s == selectedSlot;
|
||||
boolean isBest = bestSetup != null && l.get(s) != null && bestSetup.loadout.id(s) == l.id(s);
|
||||
boolean upgrade = bestSetup != null && bestSetup.loadout.get(s) != null && bestSetup.loadout.id(s) != l.id(s);
|
||||
g.setColor(s == hoverSlot ? Ui.CARD_HOVER : sel ? Ui.CARD_SELECTED : isBest ? Ui.alpha(Ui.GOLD, 28) : Ui.CARD);
|
||||
g.fillRoundRect(r.x, r.y, r.width, r.height, 6, 6);
|
||||
if (sel || isBest)
|
||||
{
|
||||
g.setColor(sel ? Ui.ACCENT : Ui.GOLD);
|
||||
g.setStroke(new BasicStroke(1f));
|
||||
g.drawRoundRect(r.x, r.y, r.width - 1, r.height - 1, 6, 6);
|
||||
}
|
||||
GearItem item = l.get(s);
|
||||
if (item != null)
|
||||
{
|
||||
g.drawImage(icon(item.id, this), r.x + 1, r.y + 1, null);
|
||||
}
|
||||
if (upgrade)
|
||||
{
|
||||
// a better item is in the bank: small green arrow
|
||||
g.setColor(Ui.UP);
|
||||
int ax = r.x + r.width - 9;
|
||||
int ay = r.y + 3;
|
||||
g.fillPolygon(new int[]{ax, ax + 3, ax + 6}, new int[]{ay + 5, ay, ay + 5}, 3);
|
||||
}
|
||||
if (changed)
|
||||
{
|
||||
// planned, differs from what is worn
|
||||
g.setColor(Ui.ACCENT);
|
||||
g.fillOval(r.x + 3, r.y + 3, 5, 5);
|
||||
}
|
||||
if (item == null)
|
||||
{
|
||||
g.setFont(FontManager.getRunescapeSmallFont());
|
||||
g.setColor(Ui.FAINT);
|
||||
FontMetrics fm = g.getFontMetrics();
|
||||
String label = GearItem.slotName(s);
|
||||
g.drawString(label, r.x + (BOX - fm.stringWidth(label)) / 2, r.y + (BOX + fm.getAscent()) / 2 - 1);
|
||||
}
|
||||
}
|
||||
|
||||
// key figures
|
||||
int x = 3 * (BOX + GAP) + 6;
|
||||
int right = getWidth() - 2;
|
||||
int y = 0;
|
||||
g.setFont(FontManager.getRunescapeSmallFont());
|
||||
FontMetrics fm = g.getFontMetrics();
|
||||
if (!snap.loggedIn)
|
||||
{
|
||||
g.setColor(Ui.FAINT);
|
||||
g.drawString("Log in to see", x, y + fm.getAscent() + 4);
|
||||
g.drawString("your gear", x, y + 2 * fm.getAscent() + 6);
|
||||
g.dispose();
|
||||
return;
|
||||
}
|
||||
Monster m = monster();
|
||||
DpsCalc.Result r = calc(l);
|
||||
DpsCalc.Result worn = cmp != null ? calc(snap.worn) : null;
|
||||
|
||||
g.setColor(Ui.MUTED);
|
||||
String vs = m.isDummy() ? "vs no defence" : "vs " + m.name;
|
||||
g.drawString(Ui.ellipsize(fm, vs, right - x), x, y + fm.getAscent());
|
||||
y += fm.getHeight() + 1;
|
||||
g.setColor(Ui.TEXT);
|
||||
g.drawString(Ui.ellipsize(fm, r.choice.label(), right - x), x, y + fm.getAscent());
|
||||
y += fm.getHeight() + 6;
|
||||
|
||||
y = figure(g, x, right, y, "Max hit", String.valueOf(r.maxHit), worn == null ? null : (double) (r.maxHit - worn.maxHit), 0, Ui.GOLD);
|
||||
y = figure(g, x, right, y, "Hit", String.format(Locale.US, "%.1f%%", r.hitChance * 100),
|
||||
worn == null ? null : (r.hitChance - worn.hitChance) * 100, 1, Ui.TEXT);
|
||||
y = figure(g, x, right, y, "DPS", dps(r.dps), worn == null ? null : r.dps - worn.dps, 2, Ui.UP);
|
||||
y = figure(g, x, right, y, "Speed", String.format(Locale.US, "%.1fs", r.speed * 0.6), null, 0, Ui.TEXT);
|
||||
int hp = snap.target != null && snap.target.monster != null ? snap.target.monster.hitpoints : 0;
|
||||
double ttk = r.secondsToKill(hp);
|
||||
if (bestSetup != null)
|
||||
{
|
||||
y = figure(g, x, right, y, "Best", dps(bestSetup.result.dps), null, 0, Ui.GOLD);
|
||||
}
|
||||
if (!Double.isNaN(ttk))
|
||||
{
|
||||
figure(g, x, right, y, "Kill", ttk >= 100 ? String.format(Locale.US, "%.0fs", ttk) : String.format(Locale.US, "%.1fs", ttk), null, 0, Ui.TEXT);
|
||||
}
|
||||
g.dispose();
|
||||
}
|
||||
|
||||
private int figure(Graphics2D g, int x, int right, int y, String label, String value, Double delta, int decimals, Color color)
|
||||
{
|
||||
g.setFont(FontManager.getRunescapeSmallFont());
|
||||
FontMetrics sfm = g.getFontMetrics();
|
||||
g.setColor(Ui.MUTED);
|
||||
g.drawString(label, x, y + sfm.getAscent() + 2);
|
||||
int vx = right;
|
||||
if (delta != null && Math.abs(delta) > Math.pow(10, -decimals) / 2)
|
||||
{
|
||||
String d = signed(delta, decimals);
|
||||
g.setColor(delta > 0 ? Ui.UP : Ui.DOWN);
|
||||
Ui.drawRight(g, d, right, y + sfm.getAscent() + 2);
|
||||
vx = right - sfm.stringWidth(d) - 4;
|
||||
}
|
||||
g.setFont(FontManager.getRunescapeBoldFont());
|
||||
g.setColor(color);
|
||||
Ui.drawRight(g, value, vx, y + sfm.getAscent() + 2);
|
||||
return y + 16;
|
||||
}
|
||||
}
|
||||
|
||||
/** Attack / defence bonus table and the other bonuses. */
|
||||
private final class BonusView extends JComponent
|
||||
{
|
||||
private final String[] cols = {"Stab", "Slash", "Crush", "Magic", "Range"};
|
||||
|
||||
@Override
|
||||
public Dimension getPreferredSize()
|
||||
{
|
||||
return new Dimension(10, 4 * 14 + 10);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void paintComponent(Graphics g0)
|
||||
{
|
||||
Graphics2D g = Ui.prepare(g0);
|
||||
int w = getWidth();
|
||||
g.setColor(Ui.CARD);
|
||||
g.fillRoundRect(0, 0, w, getHeight(), 6, 6);
|
||||
if (!snap.loggedIn)
|
||||
{
|
||||
g.dispose();
|
||||
return;
|
||||
}
|
||||
int[] b = shown().bonuses();
|
||||
int[] wb = planned != null ? snap.worn.bonuses() : null;
|
||||
g.setFont(FontManager.getRunescapeSmallFont());
|
||||
FontMetrics fm = g.getFontMetrics();
|
||||
int labelW = 34;
|
||||
int colW = (w - labelW - 8) / 5;
|
||||
int y = 5 + fm.getAscent();
|
||||
g.setColor(Ui.FAINT);
|
||||
for (int i = 0; i < 5; i++)
|
||||
{
|
||||
Ui.drawRight(g, cols[i], 4 + labelW + colW * (i + 1), y);
|
||||
}
|
||||
y += 14;
|
||||
row(g, "Att", b, wb, 0, labelW, colW, y);
|
||||
y += 14;
|
||||
row(g, "Def", b, wb, 5, labelW, colW, y);
|
||||
y += 14;
|
||||
String mdmg = String.format(Locale.US, "%.0f%%", shown().magicDamage());
|
||||
String[] other = {"Str " + b[10], "Rng " + b[11], "Mag " + mdmg, "Pray " + b[12]};
|
||||
int ow = (w - 8) / 4;
|
||||
for (int i = 0; i < 4; i++)
|
||||
{
|
||||
int idx = i == 0 ? 10 : i == 1 ? 11 : i == 3 ? 12 : -1;
|
||||
boolean diff = wb != null && idx >= 0 && wb[idx] != b[idx];
|
||||
g.setColor(diff ? (b[idx] > wb[idx] ? Ui.UP : Ui.DOWN) : Ui.MUTED);
|
||||
g.drawString(other[i], 4 + ow * i, y);
|
||||
}
|
||||
g.dispose();
|
||||
}
|
||||
|
||||
private void row(Graphics2D g, String label, int[] b, int[] wb, int offset, int labelW, int colW, int y)
|
||||
{
|
||||
g.setColor(Ui.MUTED);
|
||||
g.drawString(label, 4, y);
|
||||
for (int i = 0; i < 5; i++)
|
||||
{
|
||||
int v = b[offset + i];
|
||||
boolean diff = wb != null && wb[offset + i] != v;
|
||||
g.setColor(diff ? (v > wb[offset + i] ? Ui.UP : Ui.DOWN) : v < 0 ? Ui.DOWN : Ui.TEXT);
|
||||
Ui.drawRight(g, (v > 0 ? "+" : "") + v, 4 + labelW + colW * (i + 1), y);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
package com.example.essentialsplus.loot.ui;
|
||||
|
||||
import com.example.essentialsplus.loot.gear.DpsCalc;
|
||||
import com.example.essentialsplus.loot.gear.FightSnapshot;
|
||||
import com.example.essentialsplus.loot.gear.GearItem;
|
||||
import com.example.essentialsplus.loot.gear.GearOptimizer;
|
||||
import com.example.essentialsplus.loot.gear.Loadout;
|
||||
import com.example.essentialsplus.loot.gear.Monster;
|
||||
import com.example.essentialsplus.ui.StatsView;
|
||||
import com.example.essentialsplus.ui.Ui;
|
||||
import java.awt.Color;
|
||||
import java.awt.Cursor;
|
||||
import java.awt.Dimension;
|
||||
import java.awt.FontMetrics;
|
||||
import java.awt.Graphics;
|
||||
import java.awt.Graphics2D;
|
||||
import java.awt.event.MouseAdapter;
|
||||
import java.awt.event.MouseEvent;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.function.Consumer;
|
||||
import javax.swing.JComponent;
|
||||
import javax.swing.JLabel;
|
||||
import javax.swing.JPanel;
|
||||
import javax.swing.SwingUtilities;
|
||||
import net.runelite.client.game.ItemManager;
|
||||
import net.runelite.client.ui.DynamicGridLayout;
|
||||
import net.runelite.client.ui.FontManager;
|
||||
import net.runelite.client.util.AsyncBufferedImage;
|
||||
|
||||
/**
|
||||
* The monster being fought right now: its stats from the wiki, what it attacks with, how the current gear
|
||||
* does against it, the best setups from the bank, and the live fight session (uptime, prayer, supplies).
|
||||
*/
|
||||
public class LiveFightCard extends JPanel
|
||||
{
|
||||
/** wrap width of html text; the side panel is about 225px wide */
|
||||
private static final int TEXT_W = 180;
|
||||
|
||||
private final JLabel empty = new JLabel("<html><body style='width:" + TEXT_W + "px'>Not fighting. Attack a monster to see its stats and live prayer stats.</html>");
|
||||
private final StatsView monsterStats = new StatsView(3);
|
||||
private final JLabel attacks = new JLabel();
|
||||
private final JLabel youTitle = sectionLabel("YOUR GEAR VS TARGET");
|
||||
private final StatsView youStats = new StatsView(3);
|
||||
private final JLabel sessionTitle = sectionLabel("THIS FIGHT");
|
||||
private final StatsView sessionStats = new StatsView(3);
|
||||
|
||||
private FightSnapshot snap = FightSnapshot.LOGGED_OUT;
|
||||
|
||||
public LiveFightCard()
|
||||
{
|
||||
setLayout(new VerticalLayout(5));
|
||||
setOpaque(false);
|
||||
|
||||
empty.setFont(FontManager.getRunescapeSmallFont());
|
||||
empty.setForeground(Ui.FAINT);
|
||||
attacks.setFont(FontManager.getRunescapeSmallFont());
|
||||
attacks.setForeground(Ui.MUTED);
|
||||
|
||||
add(empty);
|
||||
add(monsterStats);
|
||||
add(attacks);
|
||||
add(youTitle);
|
||||
add(youStats);
|
||||
add(sessionTitle);
|
||||
add(sessionStats);
|
||||
update(snap);
|
||||
}
|
||||
|
||||
static JLabel sectionLabel(String text)
|
||||
{
|
||||
JLabel l = new JLabel(text);
|
||||
l.setFont(FontManager.getRunescapeSmallFont());
|
||||
l.setForeground(Ui.MUTED);
|
||||
return l;
|
||||
}
|
||||
|
||||
public void update(FightSnapshot s)
|
||||
{
|
||||
snap = s;
|
||||
FightSnapshot.Target t = s.target;
|
||||
boolean has = s.loggedIn && t != null;
|
||||
boolean layoutChanged = empty.isVisible() == has;
|
||||
empty.setVisible(!has);
|
||||
monsterStats.setVisible(has);
|
||||
attacks.setVisible(has);
|
||||
youTitle.setVisible(has);
|
||||
youStats.setVisible(has);
|
||||
boolean session = s.loggedIn && s.session != null;
|
||||
if (sessionTitle.isVisible() != session)
|
||||
{
|
||||
layoutChanged = true;
|
||||
}
|
||||
sessionTitle.setVisible(session);
|
||||
sessionStats.setVisible(session);
|
||||
|
||||
if (has)
|
||||
{
|
||||
updateMonster(t);
|
||||
updateYou(s, t);
|
||||
}
|
||||
if (session)
|
||||
{
|
||||
updateSession(s.session);
|
||||
}
|
||||
if (layoutChanged)
|
||||
{
|
||||
revalidate();
|
||||
}
|
||||
repaint();
|
||||
}
|
||||
|
||||
private void updateMonster(FightSnapshot.Target t)
|
||||
{
|
||||
Monster m = t.monster;
|
||||
List<StatsView.Cell> cells = new ArrayList<>();
|
||||
if (m == null)
|
||||
{
|
||||
attacks.setText(t.loading ? "Loading stats from the wiki..." : "No wiki stats for this monster");
|
||||
monsterStats.setVisible(false);
|
||||
return;
|
||||
}
|
||||
monsterStats.setVisible(true);
|
||||
int lowest = Math.min(m.dstab, Math.min(m.dslash, Math.min(m.dcrush, Math.min(m.dmagic, m.dstandard))));
|
||||
cells.add(new StatsView.Cell("Defence", String.valueOf(m.defenceLevel), null, Ui.TEXT, "Defence level"));
|
||||
cells.add(new StatsView.Cell("Magic", String.valueOf(m.magicLevel), null, Ui.TEXT, "Magic level (magic defence and twisted bow)"));
|
||||
cells.add(new StatsView.Cell("Hitpoints", String.valueOf(m.hitpoints), null, Ui.TEXT, null));
|
||||
cells.add(defCell("Stab def", m.dstab, lowest));
|
||||
cells.add(defCell("Slash def", m.dslash, lowest));
|
||||
cells.add(defCell("Crush def", m.dcrush, lowest));
|
||||
cells.add(defCell("Magic def", m.dmagic, lowest));
|
||||
cells.add(new StatsView.Cell("Range def", (m.dstandard > 0 ? "+" : "") + m.dstandard, m.dstandard == lowest ? "low" : null,
|
||||
m.dstandard == lowest ? Ui.UP : Ui.TEXT,
|
||||
"Light " + m.dlight + " · standard " + m.dstandard + " · heavy " + m.dheavy));
|
||||
cells.add(new StatsView.Cell("Atk speed", m.attackSpeed > 0 ? String.format(Locale.US, "%.1fs", m.attackSpeed * 0.6) : "-", null, Ui.TEXT, null));
|
||||
monsterStats.update(cells);
|
||||
|
||||
StringBuilder sb = new StringBuilder("<html><body style='width:" + TEXT_W + "px'>");
|
||||
if (t.attackStyle != null)
|
||||
{
|
||||
sb.append(String.format("<font color='#%06x'><b>Attacking with %s</b></font><br>", t.attackStyle.color.getRGB() & 0xFFFFFF, t.attackStyle.label));
|
||||
}
|
||||
if (!m.maxHits.isEmpty())
|
||||
{
|
||||
sb.append("<font color='#e8e8e8'>Max hit:</font> ").append(escape(String.join(", ", m.maxHits))).append("<br>");
|
||||
}
|
||||
else if (!m.attackStyles.isEmpty())
|
||||
{
|
||||
sb.append("<font color='#e8e8e8'>Attacks:</font> ").append(escape(String.join(", ", m.attackStyles))).append("<br>");
|
||||
}
|
||||
List<String> tags = new ArrayList<>();
|
||||
if (m.elementalWeakness != null)
|
||||
{
|
||||
tags.add("<font color='#ffc240'>Weak to " + escape(m.elementalWeakness) + (m.elementalWeaknessPct > 0 ? " " + m.elementalWeaknessPct + "%" : "") + "</font>");
|
||||
}
|
||||
for (String a : m.attributes)
|
||||
{
|
||||
tags.add(escape(Character.toUpperCase(a.charAt(0)) + a.substring(1)));
|
||||
}
|
||||
if (!tags.isEmpty())
|
||||
{
|
||||
sb.append(String.join(" · ", tags));
|
||||
}
|
||||
sb.append("</html>");
|
||||
attacks.setText(sb.toString());
|
||||
}
|
||||
|
||||
private static StatsView.Cell defCell(String label, int value, int lowest)
|
||||
{
|
||||
return new StatsView.Cell(label, (value > 0 ? "+" : "") + value, value == lowest ? "low" : null,
|
||||
value == lowest ? Ui.UP : value < 0 ? Ui.UP : Ui.TEXT, value == lowest ? "Weakest defence" : null);
|
||||
}
|
||||
|
||||
private void updateYou(FightSnapshot s, FightSnapshot.Target t)
|
||||
{
|
||||
Monster m = t.calcMonster();
|
||||
DpsCalc.Result r = DpsCalc.calc(s.worn, s.setup, m, DpsCalc.choiceFor(s.worn, s.worn, s.setup));
|
||||
List<StatsView.Cell> cells = new ArrayList<>();
|
||||
cells.add(new StatsView.Cell("Max hit", String.valueOf(r.maxHit), null, Ui.GOLD, r.choice.label()));
|
||||
cells.add(new StatsView.Cell("Hit", String.format(Locale.US, "%.1f%%", r.hitChance * 100), null, Ui.TEXT,
|
||||
"Attack roll " + r.attackRoll + " vs defence roll " + r.defenceRoll));
|
||||
cells.add(new StatsView.Cell("DPS", GearSection.dps(r.dps), null, Ui.UP, r.choice.label()));
|
||||
double ttk = r.secondsToKill(t.monster != null ? t.monster.hitpoints : 0);
|
||||
String best = s.best.isEmpty() ? null : s.best.get(0).label;
|
||||
cells.add(new StatsView.Cell("Kill time", Double.isNaN(ttk) ? "-" : String.format(Locale.US, "%.0fs", ttk), null, Ui.TEXT, "Average time to kill"));
|
||||
if (!s.best.isEmpty())
|
||||
{
|
||||
GearOptimizer.Option top = s.best.get(0);
|
||||
double pct = r.dps > 0 ? top.result.dps / r.dps * 100 - 100 : 0;
|
||||
cells.add(new StatsView.Cell("Best style", best, null, Ui.ACCENT, top.result.choice.label()));
|
||||
cells.add(new StatsView.Cell("Possible", pct > 0.5 ? String.format(Locale.US, "+%.0f%%", pct) : "maxed", null,
|
||||
pct > 0.5 ? Ui.UP : Ui.MUTED, "DPS gain with the best setup from your bank"));
|
||||
}
|
||||
youStats.update(cells);
|
||||
}
|
||||
|
||||
private void updateSession(FightSnapshot.Session s)
|
||||
{
|
||||
List<StatsView.Cell> cells = new ArrayList<>();
|
||||
cells.add(new StatsView.Cell("Time", duration(s.durationTicks), null, Ui.TEXT, null));
|
||||
cells.add(new StatsView.Cell("Dealt", String.valueOf(s.damageDealt), null, Ui.UP, s.attacks + " attacks"));
|
||||
cells.add(new StatsView.Cell("Taken", String.valueOf(s.damageTaken), null, s.damageTaken > 0 ? Ui.DOWN : Ui.TEXT,
|
||||
s.hitsReceived + " hits · " + s.hpHealed + " hp healed"));
|
||||
cells.add(new StatsView.Cell("Uptime", String.format(Locale.US, "%.0f%%", s.efficiency), null, pctColor(s.efficiency),
|
||||
"Ticks your weapon was ready but you did not attack: " + s.idleTicks));
|
||||
boolean prayerTested = s.prayerBlocked + s.prayerMissed > 0;
|
||||
cells.add(new StatsView.Cell("Prayer", prayerTested ? String.format(Locale.US, "%.0f%%", s.prayerDefencePct) : "-", null,
|
||||
prayerTested ? pctColor(s.prayerDefencePct) : Ui.FAINT, s.prayerBlocked + " hits prayed · " + s.prayerMissed + " missed"));
|
||||
boolean overheads = s.onPrayerAttacks + s.offPrayerAttacks > 0 && s.onPrayerAttacks > 0;
|
||||
cells.add(new StatsView.Cell("Off-prayer", overheads ? String.format(Locale.US, "%.0f%%", s.offPrayerPct) : "-", null,
|
||||
overheads ? pctColor(s.offPrayerPct) : Ui.FAINT, s.onPrayerAttacks + " attacks into its protection prayer"));
|
||||
boolean weapon = s.incorrectWeapon > 0;
|
||||
cells.add(new StatsView.Cell("Right style", weapon ? String.format(Locale.US, "%.0f%%", s.correctWeaponPct) : "-", null,
|
||||
weapon ? pctColor(s.correctWeaponPct) : Ui.FAINT, s.incorrectWeapon + " attacks with a weak style"));
|
||||
cells.add(new StatsView.Cell("Supplies", s.suppliesCost > 0 ? "-" + Ui.gp(s.suppliesCost) : "0", null,
|
||||
s.suppliesCost > 0 ? Ui.DOWN : Ui.TEXT, "Food, potions and ammo used"));
|
||||
cells.add(new StatsView.Cell("Grade", s.grade, String.format(Locale.US, "%.0f", s.performance),
|
||||
LootTargetRow.gradeColor(s.grade), "Uptime, prayer and style combined"));
|
||||
sessionStats.update(cells);
|
||||
}
|
||||
|
||||
private static Color pctColor(double pct)
|
||||
{
|
||||
return pct >= 90 ? Ui.UP : pct >= 70 ? Ui.GOLD : Ui.DOWN;
|
||||
}
|
||||
|
||||
private static String duration(int ticks)
|
||||
{
|
||||
int sec = (int) Math.round(ticks * 0.6);
|
||||
return sec >= 60 ? (sec / 60) + "m " + (sec % 60) + "s" : sec + "s";
|
||||
}
|
||||
|
||||
private static String escape(String s)
|
||||
{
|
||||
return s.replace("&", "&").replace("<", "<").replace(">", ">");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,547 @@
|
||||
package com.example.essentialsplus.loot.ui;
|
||||
|
||||
import com.example.essentialsplus.loot.LootRecord;
|
||||
import com.example.essentialsplus.loot.TrackedLootItem;
|
||||
import com.example.essentialsplus.ui.Ui;
|
||||
import java.awt.BorderLayout;
|
||||
import java.awt.Color;
|
||||
import java.awt.Cursor;
|
||||
import java.awt.Dimension;
|
||||
import java.awt.GridLayout;
|
||||
import java.awt.event.MouseAdapter;
|
||||
import java.awt.event.MouseEvent;
|
||||
import java.net.URLEncoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.function.Consumer;
|
||||
import javax.swing.BorderFactory;
|
||||
import javax.swing.JLabel;
|
||||
import javax.swing.JMenuItem;
|
||||
import javax.swing.JPanel;
|
||||
import javax.swing.JPopupMenu;
|
||||
import javax.swing.SwingUtilities;
|
||||
import javax.swing.border.Border;
|
||||
import net.runelite.client.game.ItemManager;
|
||||
import net.runelite.client.ui.ColorScheme;
|
||||
import net.runelite.client.ui.DynamicGridLayout;
|
||||
import net.runelite.client.ui.FontManager;
|
||||
import net.runelite.client.util.AsyncBufferedImage;
|
||||
import net.runelite.client.util.LinkBrowser;
|
||||
import net.runelite.client.util.QuantityFormatter;
|
||||
|
||||
/**
|
||||
* Collapsible box displaying drops for a single monster/chest/source.
|
||||
*/
|
||||
public class LootRecordBox extends JPanel
|
||||
{
|
||||
private static final Color HEADER_BG = new Color(30, 30, 30);
|
||||
private static final Color HEADER_HOVER = new Color(42, 42, 42);
|
||||
private static final Color SLOT_BG = new Color(36, 36, 36);
|
||||
private static final Color SLOT_BORDER = new Color(50, 50, 50);
|
||||
private static final Color SLOT_HOVER_BORDER = ColorScheme.BRAND_ORANGE;
|
||||
|
||||
private final LootRecord record;
|
||||
private final ItemManager itemManager;
|
||||
private final Consumer<LootRecord> onDelete;
|
||||
|
||||
private final JPanel headerPanel = new JPanel(new BorderLayout(6, 0));
|
||||
private final JLabel arrowLabel = new JLabel("▼");
|
||||
private final JLabel titleLabel = new JLabel();
|
||||
private final JLabel valueLabel = new JLabel();
|
||||
private final JPanel contentContainer = new JPanel();
|
||||
private final JPanel itemsContainer = new JPanel();
|
||||
private final JPanel combatPanel = new JPanel();
|
||||
|
||||
private boolean showTotalCombat = false;
|
||||
private boolean collapsed = false;
|
||||
|
||||
public LootRecordBox(LootRecord record, ItemManager itemManager, Consumer<LootRecord> onDelete)
|
||||
{
|
||||
this.record = record;
|
||||
this.itemManager = itemManager;
|
||||
this.onDelete = onDelete;
|
||||
|
||||
setLayout(new BorderLayout(0, 0));
|
||||
setBackground(new Color(24, 24, 24));
|
||||
setBorder(BorderFactory.createLineBorder(new Color(45, 45, 45), 1));
|
||||
|
||||
contentContainer.setLayout(new javax.swing.BoxLayout(contentContainer, javax.swing.BoxLayout.Y_AXIS));
|
||||
contentContainer.setOpaque(false);
|
||||
|
||||
buildHeader();
|
||||
buildItems();
|
||||
contentContainer.add(itemsContainer);
|
||||
|
||||
if (record.hasCombatStats())
|
||||
{
|
||||
buildCombatPanel();
|
||||
contentContainer.add(combatPanel);
|
||||
}
|
||||
|
||||
add(headerPanel, BorderLayout.NORTH);
|
||||
add(contentContainer, BorderLayout.CENTER);
|
||||
}
|
||||
|
||||
private void buildHeader()
|
||||
{
|
||||
headerPanel.setBackground(HEADER_BG);
|
||||
headerPanel.setBorder(BorderFactory.createEmptyBorder(6, 8, 6, 8));
|
||||
headerPanel.setCursor(Cursor.getPredefinedCursor(Cursor.HAND_CURSOR));
|
||||
|
||||
JPanel leftGroup = new JPanel(new BorderLayout(6, 0));
|
||||
leftGroup.setOpaque(false);
|
||||
|
||||
arrowLabel.setFont(FontManager.getRunescapeSmallFont());
|
||||
arrowLabel.setForeground(Ui.MUTED);
|
||||
leftGroup.add(arrowLabel, BorderLayout.WEST);
|
||||
|
||||
titleLabel.setFont(FontManager.getRunescapeBoldFont());
|
||||
titleLabel.setForeground(Color.WHITE);
|
||||
titleLabel.setText(record.getName() + " x " + QuantityFormatter.formatNumber(record.getKills()));
|
||||
leftGroup.add(titleLabel, BorderLayout.CENTER);
|
||||
|
||||
headerPanel.add(leftGroup, BorderLayout.WEST);
|
||||
|
||||
long totalValue = record.getTotalValue();
|
||||
valueLabel.setFont(FontManager.getRunescapeBoldFont());
|
||||
valueLabel.setForeground(Ui.GOLD);
|
||||
valueLabel.setText(Ui.gp(totalValue));
|
||||
headerPanel.add(valueLabel, BorderLayout.EAST);
|
||||
|
||||
long avg = record.getKills() > 0 ? totalValue / record.getKills() : 0;
|
||||
headerPanel.setToolTipText(String.format("<html><b>%s</b> (%s kills)<br>Total value: %s gp<br>Average: %s gp/kill</html>",
|
||||
record.getName(),
|
||||
QuantityFormatter.formatNumber(record.getKills()),
|
||||
QuantityFormatter.formatNumber(totalValue),
|
||||
QuantityFormatter.formatNumber(avg)
|
||||
));
|
||||
|
||||
headerPanel.addMouseListener(new MouseAdapter()
|
||||
{
|
||||
@Override
|
||||
public void mouseEntered(MouseEvent e)
|
||||
{
|
||||
headerPanel.setBackground(HEADER_HOVER);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void mouseExited(MouseEvent e)
|
||||
{
|
||||
headerPanel.setBackground(HEADER_BG);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void mousePressed(MouseEvent e)
|
||||
{
|
||||
if (SwingUtilities.isRightMouseButton(e) || e.isPopupTrigger())
|
||||
{
|
||||
showContextMenu(e);
|
||||
}
|
||||
else if (SwingUtilities.isLeftMouseButton(e))
|
||||
{
|
||||
toggleCollapsed();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void buildItems()
|
||||
{
|
||||
itemsContainer.removeAll();
|
||||
itemsContainer.setOpaque(false);
|
||||
itemsContainer.setBorder(BorderFactory.createEmptyBorder(4, 6, 6, 6));
|
||||
itemsContainer.setLayout(new DynamicGridLayout(0, 5, 2, 2));
|
||||
|
||||
List<TrackedLootItem> sorted = record.getSortedItems();
|
||||
for (TrackedLootItem item : sorted)
|
||||
{
|
||||
itemsContainer.add(createItemSlot(item));
|
||||
}
|
||||
}
|
||||
|
||||
private JPanel createItemSlot(TrackedLootItem item)
|
||||
{
|
||||
JPanel slot = new JPanel(new BorderLayout());
|
||||
slot.setPreferredSize(new Dimension(34, 34));
|
||||
slot.setBackground(SLOT_BG);
|
||||
Border normalBorder = BorderFactory.createLineBorder(SLOT_BORDER, 1);
|
||||
Border hoverBorder = BorderFactory.createLineBorder(SLOT_HOVER_BORDER, 1);
|
||||
slot.setBorder(normalBorder);
|
||||
slot.setCursor(Cursor.getPredefinedCursor(Cursor.HAND_CURSOR));
|
||||
|
||||
JLabel label = new JLabel();
|
||||
label.setHorizontalAlignment(JLabel.CENTER);
|
||||
label.setVerticalAlignment(JLabel.CENTER);
|
||||
|
||||
if (itemManager != null)
|
||||
{
|
||||
AsyncBufferedImage img = itemManager.getImage(item.getId(), (int) Math.min(item.getQuantity(), Integer.MAX_VALUE), item.getQuantity() > 1);
|
||||
img.addTo(label);
|
||||
}
|
||||
|
||||
long unitPrice = item.getGePrice();
|
||||
long totalItemVal = item.getTotalPrice();
|
||||
|
||||
String tooltip = String.format("<html><b><font color='white'>%s</font></b><br>"
|
||||
+ "<font color='#a5a5a5'>Quantity:</font> %s<br>"
|
||||
+ "<font color='#a5a5a5'>Price:</font> %s gp ea<br>"
|
||||
+ "<font color='#ffdc33'>Total:</font> %s gp</html>",
|
||||
item.getName(),
|
||||
QuantityFormatter.formatNumber(item.getQuantity()),
|
||||
QuantityFormatter.formatNumber(unitPrice),
|
||||
QuantityFormatter.formatNumber(totalItemVal)
|
||||
);
|
||||
slot.setToolTipText(tooltip);
|
||||
label.setToolTipText(tooltip);
|
||||
|
||||
MouseAdapter slotAdapter = new MouseAdapter()
|
||||
{
|
||||
@Override
|
||||
public void mouseEntered(MouseEvent e)
|
||||
{
|
||||
slot.setBorder(hoverBorder);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void mouseExited(MouseEvent e)
|
||||
{
|
||||
slot.setBorder(normalBorder);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void mousePressed(MouseEvent e)
|
||||
{
|
||||
if (SwingUtilities.isRightMouseButton(e) || e.isPopupTrigger())
|
||||
{
|
||||
showItemMenu(e, item);
|
||||
}
|
||||
else if (SwingUtilities.isLeftMouseButton(e))
|
||||
{
|
||||
openWiki(item.getName());
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
slot.addMouseListener(slotAdapter);
|
||||
label.addMouseListener(slotAdapter);
|
||||
slot.add(label, BorderLayout.CENTER);
|
||||
return slot;
|
||||
}
|
||||
|
||||
private void showContextMenu(MouseEvent e)
|
||||
{
|
||||
JPopupMenu menu = new JPopupMenu();
|
||||
JMenuItem deleteItem = new JMenuItem("Delete " + record.getName() + " record");
|
||||
deleteItem.addActionListener(a ->
|
||||
{
|
||||
if (onDelete != null)
|
||||
{
|
||||
onDelete.accept(record);
|
||||
}
|
||||
});
|
||||
menu.add(deleteItem);
|
||||
menu.show(headerPanel, e.getX(), e.getY());
|
||||
}
|
||||
|
||||
private void showItemMenu(MouseEvent e, TrackedLootItem item)
|
||||
{
|
||||
JPopupMenu menu = new JPopupMenu();
|
||||
JMenuItem wikiItem = new JMenuItem("Wiki: " + item.getName());
|
||||
wikiItem.addActionListener(a -> openWiki(item.getName()));
|
||||
menu.add(wikiItem);
|
||||
menu.show((java.awt.Component) e.getSource(), e.getX(), e.getY());
|
||||
}
|
||||
|
||||
private void openWiki(String itemName)
|
||||
{
|
||||
if (itemName == null || itemName.isEmpty())
|
||||
{
|
||||
return;
|
||||
}
|
||||
try
|
||||
{
|
||||
String url = "https://oldschool.runescape.wiki/w/" + URLEncoder.encode(itemName.replace(' ', '_'), StandardCharsets.UTF_8.toString());
|
||||
LinkBrowser.open(url);
|
||||
}
|
||||
catch (Exception ignored)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
public void toggleCollapsed()
|
||||
{
|
||||
setCollapsed(!collapsed);
|
||||
}
|
||||
|
||||
public void setCollapsed(boolean collapsed)
|
||||
{
|
||||
this.collapsed = collapsed;
|
||||
arrowLabel.setText(collapsed ? "▶" : "▼");
|
||||
contentContainer.setVisible(!collapsed);
|
||||
revalidate();
|
||||
repaint();
|
||||
}
|
||||
|
||||
private void buildCombatPanel()
|
||||
{
|
||||
combatPanel.removeAll();
|
||||
combatPanel.setOpaque(true);
|
||||
combatPanel.setBackground(new Color(20, 20, 20));
|
||||
combatPanel.setBorder(BorderFactory.createCompoundBorder(
|
||||
BorderFactory.createMatteBorder(1, 0, 0, 0, new Color(42, 42, 42)),
|
||||
BorderFactory.createEmptyBorder(6, 6, 6, 6)
|
||||
));
|
||||
combatPanel.setLayout(new javax.swing.BoxLayout(combatPanel, javax.swing.BoxLayout.Y_AXIS));
|
||||
|
||||
com.example.essentialsplus.loot.combat.CombatStats cs = record.getCombatStats();
|
||||
if (cs == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// 1. Title bar with mode switcher
|
||||
JPanel titleBar = new JPanel(new BorderLayout(4, 0));
|
||||
titleBar.setOpaque(false);
|
||||
|
||||
JLabel title = new JLabel("⚔ COMBAT ANALYTICS");
|
||||
title.setFont(FontManager.getRunescapeSmallFont());
|
||||
title.setForeground(new Color(0, 216, 255));
|
||||
titleBar.add(title, BorderLayout.WEST);
|
||||
|
||||
JPanel modeToggle = new JPanel(new java.awt.FlowLayout(java.awt.FlowLayout.RIGHT, 2, 0));
|
||||
modeToggle.setOpaque(false);
|
||||
|
||||
JLabel lastBtn = new JLabel(showTotalCombat ? "Last" : "[Last]");
|
||||
lastBtn.setFont(FontManager.getRunescapeSmallFont());
|
||||
lastBtn.setForeground(showTotalCombat ? Ui.MUTED : Color.WHITE);
|
||||
lastBtn.setCursor(Cursor.getPredefinedCursor(Cursor.HAND_CURSOR));
|
||||
lastBtn.addMouseListener(new MouseAdapter()
|
||||
{
|
||||
@Override
|
||||
public void mousePressed(MouseEvent e)
|
||||
{
|
||||
if (showTotalCombat)
|
||||
{
|
||||
showTotalCombat = false;
|
||||
buildCombatPanel();
|
||||
revalidate();
|
||||
repaint();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
JLabel totalBtn = new JLabel(showTotalCombat ? "[Total]" : "Total");
|
||||
totalBtn.setFont(FontManager.getRunescapeSmallFont());
|
||||
totalBtn.setForeground(showTotalCombat ? Color.WHITE : Ui.MUTED);
|
||||
totalBtn.setCursor(Cursor.getPredefinedCursor(Cursor.HAND_CURSOR));
|
||||
totalBtn.addMouseListener(new MouseAdapter()
|
||||
{
|
||||
@Override
|
||||
public void mousePressed(MouseEvent e)
|
||||
{
|
||||
if (!showTotalCombat)
|
||||
{
|
||||
showTotalCombat = true;
|
||||
buildCombatPanel();
|
||||
revalidate();
|
||||
repaint();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
modeToggle.add(lastBtn);
|
||||
modeToggle.add(new JLabel("·") {{ setForeground(Ui.MUTED); setFont(FontManager.getRunescapeSmallFont()); }});
|
||||
modeToggle.add(totalBtn);
|
||||
titleBar.add(modeToggle, BorderLayout.EAST);
|
||||
combatPanel.add(titleBar);
|
||||
combatPanel.add(javax.swing.Box.createRigidArea(new Dimension(0, 4)));
|
||||
|
||||
// 2. Data extraction based on mode
|
||||
String grade;
|
||||
double efficiency;
|
||||
long attacks;
|
||||
long damageDealt;
|
||||
long hitsTaken;
|
||||
long damageTaken;
|
||||
long hpHealed;
|
||||
long durationTicks;
|
||||
long idleTicks;
|
||||
long tilesMoved;
|
||||
long suppliesCost;
|
||||
Map<String, Integer> food;
|
||||
Map<String, Integer> pots;
|
||||
Map<String, Integer> ammo;
|
||||
long referenceLoot;
|
||||
|
||||
if (showTotalCombat)
|
||||
{
|
||||
grade = cs.getAvgGrade();
|
||||
efficiency = cs.getAvgEfficiency();
|
||||
attacks = cs.getTotalAttacksDealt();
|
||||
damageDealt = cs.getTotalDamageDealt();
|
||||
hitsTaken = cs.getTotalHitsReceived();
|
||||
damageTaken = cs.getTotalDamageTaken();
|
||||
hpHealed = cs.getTotalHpHealed();
|
||||
durationTicks = cs.getTotalDurationTicks();
|
||||
idleTicks = cs.getTotalIdleTicks();
|
||||
tilesMoved = cs.getTotalTilesMoved();
|
||||
suppliesCost = cs.getTotalSuppliesCost();
|
||||
food = cs.getTotalFoodUsed();
|
||||
pots = cs.getTotalPotionsUsed();
|
||||
ammo = cs.getTotalAmmoUsed();
|
||||
referenceLoot = record.getTotalValue();
|
||||
}
|
||||
else
|
||||
{
|
||||
grade = cs.getLastGrade();
|
||||
efficiency = cs.getLastEfficiency();
|
||||
attacks = cs.getLastAttacksDealt();
|
||||
damageDealt = cs.getLastDamageDealt();
|
||||
hitsTaken = cs.getLastHitsReceived();
|
||||
damageTaken = cs.getLastDamageTaken();
|
||||
hpHealed = cs.getLastHpHealed();
|
||||
durationTicks = cs.getLastDurationTicks();
|
||||
idleTicks = cs.getLastIdleTicks();
|
||||
tilesMoved = cs.getLastTilesMoved();
|
||||
suppliesCost = cs.getLastSuppliesCost();
|
||||
food = cs.getLastFoodUsed();
|
||||
pots = cs.getLastPotionsUsed();
|
||||
ammo = cs.getLastAmmoUsed();
|
||||
referenceLoot = record.getKills() > 0 ? record.getTotalValue() / record.getKills() : 0;
|
||||
}
|
||||
|
||||
// Grade Badge + Efficiency Row
|
||||
JPanel gradeRow = new JPanel(new BorderLayout(6, 0));
|
||||
gradeRow.setOpaque(false);
|
||||
|
||||
Color gradeColor = getGradeColor(grade);
|
||||
JLabel gradeBadge = new JLabel(" " + grade + " ");
|
||||
gradeBadge.setOpaque(true);
|
||||
gradeBadge.setBackground(new Color(gradeColor.getRed(), gradeColor.getGreen(), gradeColor.getBlue(), 35));
|
||||
gradeBadge.setForeground(gradeColor);
|
||||
gradeBadge.setFont(FontManager.getRunescapeBoldFont());
|
||||
gradeBadge.setBorder(BorderFactory.createLineBorder(gradeColor, 1));
|
||||
gradeRow.add(gradeBadge, BorderLayout.WEST);
|
||||
|
||||
double seconds = durationTicks * 0.6;
|
||||
double idleSeconds = idleTicks * 0.6;
|
||||
String efficiencySummary = String.format("<html><b><font color='%s'>%.1f%%</font></b> Uptime • <font color='#a5a5a5'>%d idle (%s)</font></html>",
|
||||
getHex(gradeColor), efficiency, idleTicks, String.format("%.1fs", idleSeconds));
|
||||
JLabel effLabel = new JLabel(efficiencySummary);
|
||||
effLabel.setFont(FontManager.getRunescapeSmallFont());
|
||||
gradeRow.add(effLabel, BorderLayout.CENTER);
|
||||
combatPanel.add(gradeRow);
|
||||
combatPanel.add(javax.swing.Box.createRigidArea(new Dimension(0, 4)));
|
||||
|
||||
// Time & Movement banner
|
||||
JLabel timeMoveLabel = new JLabel(String.format("<html><font color='#a5a5a5'>Duration:</font> %s (%dt) • <font color='#a5a5a5'>Moved:</font> %d tiles</html>",
|
||||
String.format("%.1fs", seconds), durationTicks, tilesMoved));
|
||||
timeMoveLabel.setFont(FontManager.getRunescapeSmallFont());
|
||||
combatPanel.add(timeMoveLabel);
|
||||
combatPanel.add(javax.swing.Box.createRigidArea(new Dimension(0, 3)));
|
||||
|
||||
// Combat Stats: Dealt & Taken & Healed
|
||||
JLabel combatStatsLabel = new JLabel(String.format("<html>⚔ <b>%s dmg</b> (%d atk) • 🛡 <b>%s taken</b> (%d hits)%s</html>",
|
||||
QuantityFormatter.formatNumber(damageDealt), attacks,
|
||||
QuantityFormatter.formatNumber(damageTaken), hitsTaken,
|
||||
hpHealed > 0 ? (" • 💚 <font color='#2ecc71'>+" + hpHealed + " HP</font>") : ""
|
||||
));
|
||||
combatStatsLabel.setFont(FontManager.getRunescapeSmallFont());
|
||||
combatPanel.add(combatStatsLabel);
|
||||
combatPanel.add(javax.swing.Box.createRigidArea(new Dimension(0, 4)));
|
||||
|
||||
// Supplies Breakdown: Food, Potions, Ammo
|
||||
List<String> supplyParts = new ArrayList<>();
|
||||
if (food != null && !food.isEmpty())
|
||||
{
|
||||
supplyParts.add("🍖 " + formatSuppliesMap(food));
|
||||
}
|
||||
if (pots != null && !pots.isEmpty())
|
||||
{
|
||||
supplyParts.add("🧪 " + formatSuppliesMap(pots));
|
||||
}
|
||||
if (ammo != null && !ammo.isEmpty())
|
||||
{
|
||||
supplyParts.add("🏹 " + formatSuppliesMap(ammo));
|
||||
}
|
||||
|
||||
if (!supplyParts.isEmpty())
|
||||
{
|
||||
JLabel suppliesLabel = new JLabel("<html><font color='#e67e22'>Supplies:</font> " + String.join("<br> ", supplyParts) + "</html>");
|
||||
suppliesLabel.setFont(FontManager.getRunescapeSmallFont());
|
||||
suppliesLabel.setForeground(new Color(210, 210, 210));
|
||||
combatPanel.add(suppliesLabel);
|
||||
combatPanel.add(javax.swing.Box.createRigidArea(new Dimension(0, 4)));
|
||||
}
|
||||
|
||||
// Financial Profitability (Supplies vs Loot -> Net Profit)
|
||||
long netProfit = referenceLoot - suppliesCost;
|
||||
Color netColor = netProfit >= 0 ? new Color(46, 204, 113) : new Color(231, 76, 60);
|
||||
|
||||
JPanel profitRow = new JPanel(new BorderLayout(4, 0));
|
||||
profitRow.setOpaque(true);
|
||||
profitRow.setBackground(new Color(28, 28, 28));
|
||||
profitRow.setBorder(BorderFactory.createCompoundBorder(
|
||||
BorderFactory.createLineBorder(new Color(45, 45, 45), 1),
|
||||
BorderFactory.createEmptyBorder(3, 6, 3, 6)
|
||||
));
|
||||
|
||||
String costText = suppliesCost > 0 ? ("-" + QuantityFormatter.quantityToRSDecimalStack((int) Math.min(suppliesCost, Integer.MAX_VALUE)) + " gp") : "0 gp";
|
||||
JLabel costLbl = new JLabel(String.format("<html><font color='#a5a5a5'>Cost:</font> <font color='#e74c3c'>%s</font></html>", costText));
|
||||
costLbl.setFont(FontManager.getRunescapeSmallFont());
|
||||
profitRow.add(costLbl, BorderLayout.WEST);
|
||||
|
||||
String netText = (netProfit >= 0 ? "+" : "") + QuantityFormatter.quantityToRSDecimalStack((int) Math.min(netProfit, Integer.MAX_VALUE)) + " gp";
|
||||
JLabel netLbl = new JLabel(String.format("<html><font color='#a5a5a5'>Net:</font> <b><font color='%s'>%s</font></b></html>",
|
||||
getHex(netColor), netText));
|
||||
netLbl.setFont(FontManager.getRunescapeBoldFont());
|
||||
profitRow.add(netLbl, BorderLayout.EAST);
|
||||
|
||||
profitRow.setToolTipText(String.format("<html>Loot (%s): %s gp<br>Supplies: %s gp<br><b>Net Profit: %s gp</b></html>",
|
||||
showTotalCombat ? "Total" : "Per kill",
|
||||
QuantityFormatter.formatNumber(referenceLoot),
|
||||
QuantityFormatter.formatNumber(suppliesCost),
|
||||
QuantityFormatter.formatNumber(netProfit)
|
||||
));
|
||||
|
||||
combatPanel.add(profitRow);
|
||||
}
|
||||
|
||||
private static Color getGradeColor(String grade)
|
||||
{
|
||||
if ("S+".equals(grade)) return new Color(0, 240, 255);
|
||||
if ("A+".equals(grade)) return new Color(46, 204, 113);
|
||||
if ("A".equals(grade)) return new Color(39, 174, 96);
|
||||
if ("B".equals(grade)) return new Color(241, 196, 15);
|
||||
if ("C".equals(grade)) return new Color(230, 126, 34);
|
||||
return new Color(231, 76, 60);
|
||||
}
|
||||
|
||||
private static String getHex(Color c)
|
||||
{
|
||||
return String.format("#%02x%02x%02x", c.getRed(), c.getGreen(), c.getBlue());
|
||||
}
|
||||
|
||||
private static String formatSuppliesMap(Map<String, Integer> map)
|
||||
{
|
||||
if (map == null || map.isEmpty())
|
||||
{
|
||||
return "";
|
||||
}
|
||||
List<String> list = new ArrayList<>();
|
||||
map.forEach((k, v) -> list.add(v + "x " + k));
|
||||
return String.join(", ", list);
|
||||
}
|
||||
|
||||
public boolean isCollapsed()
|
||||
{
|
||||
return collapsed;
|
||||
}
|
||||
|
||||
public LootRecord getRecord()
|
||||
{
|
||||
return record;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,827 @@
|
||||
package com.example.essentialsplus.loot.ui;
|
||||
|
||||
import com.example.essentialsplus.collectionlog.CollectionLogService;
|
||||
import com.example.essentialsplus.loot.LootRecord;
|
||||
import com.example.essentialsplus.loot.TrackedLootItem;
|
||||
import com.example.essentialsplus.loot.combat.CombatStats;
|
||||
import com.example.essentialsplus.loot.combat.CombatTracker;
|
||||
import com.example.essentialsplus.loot.combat.SingleFightRecord;
|
||||
import com.example.essentialsplus.ui.Segmented;
|
||||
import com.example.essentialsplus.ui.StatsView;
|
||||
import com.example.essentialsplus.ui.Ui;
|
||||
import com.example.essentialsplus.wiki.WikiApi;
|
||||
import java.awt.BasicStroke;
|
||||
import java.awt.BorderLayout;
|
||||
import java.awt.Color;
|
||||
import java.awt.Cursor;
|
||||
import java.awt.Dimension;
|
||||
import java.awt.FontMetrics;
|
||||
import java.awt.Graphics;
|
||||
import java.awt.Graphics2D;
|
||||
import java.awt.LayoutManager;
|
||||
import java.awt.Rectangle;
|
||||
import java.awt.event.MouseAdapter;
|
||||
import java.awt.event.MouseEvent;
|
||||
import java.net.URLEncoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.function.Function;
|
||||
import javax.swing.BorderFactory;
|
||||
import javax.swing.JButton;
|
||||
import javax.swing.JComboBox;
|
||||
import javax.swing.JComponent;
|
||||
import javax.swing.JLabel;
|
||||
import javax.swing.JMenuItem;
|
||||
import javax.swing.JOptionPane;
|
||||
import javax.swing.JPanel;
|
||||
import javax.swing.JPopupMenu;
|
||||
import javax.swing.JScrollPane;
|
||||
import javax.swing.ScrollPaneConstants;
|
||||
import javax.swing.Scrollable;
|
||||
import javax.swing.SwingUtilities;
|
||||
import net.runelite.client.game.ItemManager;
|
||||
import net.runelite.client.ui.DynamicGridLayout;
|
||||
import net.runelite.client.ui.FontManager;
|
||||
import net.runelite.client.util.AsyncBufferedImage;
|
||||
import net.runelite.client.util.LinkBrowser;
|
||||
import net.runelite.client.util.QuantityFormatter;
|
||||
|
||||
/**
|
||||
* Dedicated detail view for an individual monster or loot source.
|
||||
* Enforces scrollable tracks viewport width so it NEVER overflows horizontally.
|
||||
*/
|
||||
public class LootRecordDetailPanel extends JPanel
|
||||
{
|
||||
private LootRecord record;
|
||||
private JLabel titleLabel;
|
||||
private final ItemManager itemManager;
|
||||
private final Runnable onBack;
|
||||
/** opens a page in the in-game wiki window */
|
||||
private final Consumer<String> wikiOpener;
|
||||
private final Consumer<LootRecord> onDelete;
|
||||
|
||||
private final JPanel contentBox = new JPanel();
|
||||
private final JPanel combatSection = new JPanel();
|
||||
private final CollectionLogSection collectionLogSection;
|
||||
|
||||
// 0 = All Kills Summary, 1..N = Individual Fight index in reverse chronological order
|
||||
private int selectedFightDropdownIndex = 1;
|
||||
|
||||
/** Panel for scroll panes that always fits the viewport width (no horizontal overflow). */
|
||||
static class ScrollableWrapper extends JPanel implements Scrollable
|
||||
{
|
||||
ScrollableWrapper()
|
||||
{
|
||||
super(new BorderLayout());
|
||||
setOpaque(false);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Dimension getPreferredScrollableViewportSize()
|
||||
{
|
||||
return getPreferredSize();
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getScrollableUnitIncrement(Rectangle visibleRect, int orientation, int direction)
|
||||
{
|
||||
return 16;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getScrollableBlockIncrement(Rectangle visibleRect, int orientation, int direction)
|
||||
{
|
||||
return 40;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean getScrollableTracksViewportWidth()
|
||||
{
|
||||
return true; // Prevents any horizontal scrolling or overflowing
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean getScrollableTracksViewportHeight()
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public LootRecordDetailPanel(LootRecord record, ItemManager itemManager, WikiApi wikiApi, ExecutorService wikiExecutor,
|
||||
CollectionLogService collectionLog, Function<String, LootRecord> allTimeRecord, Consumer<String> openWiki,
|
||||
Runnable onBack, Consumer<LootRecord> onDelete)
|
||||
{
|
||||
this.record = record;
|
||||
this.wikiOpener = openWiki;
|
||||
this.itemManager = itemManager;
|
||||
this.onBack = onBack;
|
||||
this.onDelete = onDelete;
|
||||
this.collectionLogSection = new CollectionLogSection(wikiApi, wikiExecutor, itemManager, collectionLog, allTimeRecord, record);
|
||||
|
||||
setLayout(new BorderLayout());
|
||||
setBackground(Ui.BG);
|
||||
|
||||
add(buildTopNav(), BorderLayout.NORTH);
|
||||
|
||||
// DynamicGridLayout prevents any BoxLayout misalignment and stays strictly within viewport width
|
||||
contentBox.setLayout(new DynamicGridLayout(0, 1, 0, 6));
|
||||
contentBox.setOpaque(false);
|
||||
contentBox.setBorder(BorderFactory.createEmptyBorder(0, 6, 10, 6));
|
||||
|
||||
rebuildContent();
|
||||
|
||||
ScrollableWrapper contentWrapper = new ScrollableWrapper();
|
||||
contentWrapper.add(contentBox, BorderLayout.NORTH);
|
||||
|
||||
JScrollPane scrollPane = new JScrollPane(contentWrapper);
|
||||
scrollPane.setBackground(Ui.BG);
|
||||
scrollPane.getViewport().setBackground(Ui.BG);
|
||||
scrollPane.setBorder(null);
|
||||
scrollPane.setHorizontalScrollBarPolicy(ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER);
|
||||
|
||||
add(scrollPane, BorderLayout.CENTER);
|
||||
}
|
||||
|
||||
private JPanel buildTopNav()
|
||||
{
|
||||
JPanel topBar = new JPanel(new BorderLayout(6, 0));
|
||||
topBar.setOpaque(false);
|
||||
topBar.setBorder(BorderFactory.createEmptyBorder(6, 6, 8, 6));
|
||||
|
||||
JButton backBtn = new JButton("← Back");
|
||||
backBtn.setFont(FontManager.getRunescapeSmallFont());
|
||||
backBtn.setForeground(Ui.ACCENT);
|
||||
backBtn.setMargin(new java.awt.Insets(2, 2, 2, 2));
|
||||
backBtn.setFocusPainted(false);
|
||||
backBtn.setCursor(Cursor.getPredefinedCursor(Cursor.HAND_CURSOR));
|
||||
backBtn.addActionListener(e ->
|
||||
{
|
||||
if (onBack != null)
|
||||
{
|
||||
onBack.run();
|
||||
}
|
||||
});
|
||||
topBar.add(backBtn, BorderLayout.WEST);
|
||||
|
||||
titleLabel = new JLabel(record.getName());
|
||||
titleLabel.setFont(FontManager.getRunescapeBoldFont());
|
||||
titleLabel.setForeground(Ui.ACCENT);
|
||||
titleLabel.setHorizontalAlignment(JLabel.RIGHT);
|
||||
titleLabel.setToolTipText(record.getName());
|
||||
topBar.add(titleLabel, BorderLayout.CENTER);
|
||||
|
||||
JLabel delete = link("Delete", Ui.FAINT, Ui.DOWN, this::confirmDelete);
|
||||
delete.setToolTipText("Delete all loot and combat stats of this source");
|
||||
delete.setBorder(BorderFactory.createEmptyBorder(0, 6, 0, 0));
|
||||
topBar.add(delete, BorderLayout.EAST);
|
||||
return topBar;
|
||||
}
|
||||
|
||||
/** Swaps in a fresh (e.g. time-filtered) view of the record and redraws. */
|
||||
public void setRecord(LootRecord record)
|
||||
{
|
||||
this.record = record;
|
||||
collectionLogSection.setRecord(record);
|
||||
rebuildContent();
|
||||
}
|
||||
|
||||
public void rebuildContent()
|
||||
{
|
||||
contentBox.removeAll();
|
||||
|
||||
contentBox.add(buildSummary());
|
||||
buildDrops();
|
||||
|
||||
if (!"PLAYER".equals(record.getType()))
|
||||
{
|
||||
contentBox.add(collectionLogSection);
|
||||
}
|
||||
|
||||
if (record.hasCombatStats())
|
||||
{
|
||||
buildCombatSection();
|
||||
contentBox.add(combatSection);
|
||||
}
|
||||
|
||||
contentBox.revalidate();
|
||||
contentBox.repaint();
|
||||
}
|
||||
|
||||
private boolean isDoom()
|
||||
{
|
||||
return CombatTracker.DOOM_OF_MOKHAIOTL.equalsIgnoreCase(record.getName());
|
||||
}
|
||||
|
||||
private StatsView buildSummary()
|
||||
{
|
||||
long totalVal = record.getTotalValue();
|
||||
int kills = record.getKills();
|
||||
List<StatsView.Cell> cells = new ArrayList<>();
|
||||
cells.add(new StatsView.Cell("Total loot", Ui.gp(totalVal), null, totalVal > 0 ? Ui.GOLD : Ui.FAINT,
|
||||
QuantityFormatter.formatNumber(totalVal) + " gp"));
|
||||
String killsLabel = isDoom() ? "Delves" : "Kills";
|
||||
String maxDelve = isDoom() && record.hasCombatStats() && record.getCombatStats().getMaxDelve() > 0
|
||||
? "max " + record.getCombatStats().getMaxDelve() : null;
|
||||
cells.add(new StatsView.Cell(killsLabel, QuantityFormatter.formatNumber(kills), maxDelve, Ui.TEXT, null));
|
||||
cells.add(new StatsView.Cell("Avg / kill", kills > 0 ? Ui.gp(totalVal / kills) : "-", null, Ui.TEXT, null));
|
||||
cells.add(new StatsView.Cell("Last seen", Ui.ago(record.getLastSeen()), null, Ui.TEXT, null));
|
||||
StatsView view = new StatsView(2);
|
||||
view.update(cells);
|
||||
return view;
|
||||
}
|
||||
|
||||
private void buildDrops()
|
||||
{
|
||||
List<TrackedLootItem> items = record.getSortedItems();
|
||||
JLabel total = new JLabel(items.isEmpty() ? "" : Ui.gp(record.getTotalValue()) + " gp");
|
||||
total.setForeground(Ui.GOLD);
|
||||
contentBox.add(sectionHeader("DROPS (" + items.size() + ")", total));
|
||||
|
||||
if (items.isEmpty())
|
||||
{
|
||||
contentBox.add(note("No items recorded " + (record.getKills() > 0 ? "in this range" : "yet")));
|
||||
return;
|
||||
}
|
||||
JPanel grid = new JPanel(new DynamicGridLayout(0, 5, 3, 3));
|
||||
grid.setOpaque(false);
|
||||
for (TrackedLootItem item : items)
|
||||
{
|
||||
grid.add(new ItemSlot(item));
|
||||
}
|
||||
contentBox.add(grid);
|
||||
}
|
||||
|
||||
/** One drop: item icon with quantity on a rounded tile; left-click opens the wiki, right-click a menu. */
|
||||
private final class ItemSlot extends JComponent
|
||||
{
|
||||
private final TrackedLootItem item;
|
||||
private final AsyncBufferedImage img;
|
||||
private boolean hover;
|
||||
|
||||
ItemSlot(TrackedLootItem item)
|
||||
{
|
||||
this.item = item;
|
||||
this.img = itemManager == null ? null
|
||||
: itemManager.getImage(item.getId(), (int) Math.min(item.getQuantity(), Integer.MAX_VALUE), item.getQuantity() > 1);
|
||||
if (img != null)
|
||||
{
|
||||
img.onLoaded(this::repaint);
|
||||
}
|
||||
setCursor(Cursor.getPredefinedCursor(Cursor.HAND_CURSOR));
|
||||
setToolTipText(String.format("<html><b>%s</b><br>%s x %s gp<br><font color='#ffc240'>%s gp</font></html>",
|
||||
item.getName(),
|
||||
QuantityFormatter.formatNumber(item.getQuantity()),
|
||||
QuantityFormatter.formatNumber(item.getGePrice()),
|
||||
QuantityFormatter.formatNumber(item.getTotalPrice())));
|
||||
addMouseListener(new MouseAdapter()
|
||||
{
|
||||
@Override
|
||||
public void mouseEntered(MouseEvent e)
|
||||
{
|
||||
hover = true;
|
||||
repaint();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void mouseExited(MouseEvent e)
|
||||
{
|
||||
hover = false;
|
||||
repaint();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void mousePressed(MouseEvent e)
|
||||
{
|
||||
if (SwingUtilities.isRightMouseButton(e) || e.isPopupTrigger())
|
||||
{
|
||||
showItemMenu(e, item);
|
||||
}
|
||||
else if (SwingUtilities.isLeftMouseButton(e))
|
||||
{
|
||||
openWiki(item.getName());
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public Dimension getPreferredSize()
|
||||
{
|
||||
return new Dimension(38, 38);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void paintComponent(Graphics g0)
|
||||
{
|
||||
Graphics2D g = Ui.prepare(g0);
|
||||
int w = getWidth();
|
||||
int h = getHeight();
|
||||
g.setColor(hover ? Ui.CARD_HOVER : Ui.CARD);
|
||||
g.fillRoundRect(0, 0, w, h, 6, 6);
|
||||
if (hover)
|
||||
{
|
||||
g.setColor(Ui.ACCENT);
|
||||
g.setStroke(new BasicStroke(1f));
|
||||
g.drawRoundRect(0, 0, w - 1, h - 1, 6, 6);
|
||||
}
|
||||
if (img != null)
|
||||
{
|
||||
g.drawImage(img, (w - img.getWidth()) / 2, (h - img.getHeight()) / 2, null);
|
||||
}
|
||||
g.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private void buildCombatSection()
|
||||
{
|
||||
combatSection.removeAll();
|
||||
combatSection.setOpaque(false);
|
||||
combatSection.setLayout(new DynamicGridLayout(0, 1, 0, 6));
|
||||
|
||||
CombatStats cs = record.getCombatStats();
|
||||
if (cs == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
List<SingleFightRecord> reversed = new ArrayList<>(cs.getFightHistory());
|
||||
Collections.reverse(reversed);
|
||||
if (selectedFightDropdownIndex > reversed.size())
|
||||
{
|
||||
selectedFightDropdownIndex = reversed.isEmpty() ? 0 : 1;
|
||||
}
|
||||
|
||||
combatSection.add(sectionHeader("COMBAT", null));
|
||||
|
||||
int seg = selectedFightDropdownIndex <= 1 ? selectedFightDropdownIndex : -1;
|
||||
Segmented quick = new Segmented(new String[]{"Summary", "Latest"}, seg, this::selectFight);
|
||||
quick.setPreferredSize(new Dimension(10, 24));
|
||||
combatSection.add(quick);
|
||||
|
||||
JComboBox<String> selector = new JComboBox<>();
|
||||
selector.setFont(FontManager.getRunescapeSmallFont());
|
||||
selector.setBackground(Ui.CARD);
|
||||
selector.setFocusable(false);
|
||||
selector.addItem(cs.getMaxDelve() > 0
|
||||
? String.format("All %d fights (max delve %d)", cs.getFightsCount(), cs.getMaxDelve())
|
||||
: String.format("All %d fights", cs.getFightsCount()));
|
||||
for (SingleFightRecord f : reversed)
|
||||
{
|
||||
double score = f.getPerformanceScore() > 0 ? f.getPerformanceScore() : f.getEfficiency();
|
||||
selector.addItem(String.format(Locale.US, "%s • %s (%.0f%%)", fightName(f), f.getGrade(), score));
|
||||
}
|
||||
selector.setSelectedIndex(selectedFightDropdownIndex);
|
||||
selector.addActionListener(e ->
|
||||
{
|
||||
int idx = selector.getSelectedIndex();
|
||||
if (idx >= 0 && idx != selectedFightDropdownIndex)
|
||||
{
|
||||
selectFight(idx);
|
||||
}
|
||||
});
|
||||
combatSection.add(selector);
|
||||
|
||||
boolean isSummary = selectedFightDropdownIndex == 0 || reversed.isEmpty();
|
||||
SingleFightRecord f = isSummary ? null : reversed.get(selectedFightDropdownIndex - 1);
|
||||
|
||||
String grade = isSummary ? cs.getAvgGrade() : f.getGrade();
|
||||
double efficiency = isSummary ? cs.getAvgEfficiency() : f.getEfficiency();
|
||||
double score = isSummary ? cs.getAvgPerformanceScore() : (f.getPerformanceScore() > 0 ? f.getPerformanceScore() : f.getEfficiency());
|
||||
long attacks = isSummary ? cs.getTotalAttacksDealt() : f.getAttacksDealt();
|
||||
long damageDealt = isSummary ? cs.getTotalDamageDealt() : f.getDamageDealt();
|
||||
long hitsTaken = isSummary ? cs.getTotalHitsReceived() : f.getHitsReceived();
|
||||
long damageTaken = isSummary ? cs.getTotalDamageTaken() : f.getDamageTaken();
|
||||
long hpHealed = isSummary ? cs.getTotalHpHealed() : f.getHpHealed();
|
||||
long durationTicks = isSummary ? cs.getTotalDurationTicks() : f.getDurationTicks();
|
||||
long idleTicks = isSummary ? cs.getTotalIdleTicks() : f.getIdleTicks();
|
||||
long tilesMoved = isSummary ? cs.getTotalTilesMoved() : f.getTilesMoved();
|
||||
long suppliesCost = isSummary ? cs.getTotalSuppliesCost() : f.getSuppliesCost();
|
||||
Map<String, Integer> food = isSummary ? cs.getTotalFoodUsed() : f.getFoodUsed();
|
||||
Map<String, Integer> pots = isSummary ? cs.getTotalPotionsUsed() : f.getPotionsUsed();
|
||||
Map<String, Integer> ammo = isSummary ? cs.getTotalAmmoUsed() : f.getAmmoUsed();
|
||||
long loot = isSummary ? record.getTotalValue()
|
||||
: f.getLootValue() > 0 ? f.getLootValue() : (record.getKills() > 0 ? record.getTotalValue() / record.getKills() : 0);
|
||||
long netProfit = loot - suppliesCost;
|
||||
long prayerBlocked = isSummary ? cs.getTotalPrayerBlockedHits() : f.getPrayerBlockedHits();
|
||||
long prayerMissed = isSummary ? cs.getTotalPrayerMissedHits() : f.getPrayerMissedHits();
|
||||
long onPrayerAtk = isSummary ? cs.getTotalOnPrayerAttacks() : f.getOnPrayerAttacks();
|
||||
long offPrayerAtk = isSummary ? cs.getTotalOffPrayerAttacks() : f.getOffPrayerAttacks();
|
||||
long correctWep = isSummary ? cs.getTotalCorrectWeaponAttacks() : f.getCorrectWeaponAttacks();
|
||||
long incorrectWep = isSummary ? cs.getTotalIncorrectWeaponAttacks() : f.getIncorrectWeaponAttacks();
|
||||
|
||||
String heroTitle = isSummary
|
||||
? (cs.getMaxDelve() > 0 ? "Summary • max delve " + cs.getMaxDelve() : "Summary of " + cs.getFightsCount() + " fights")
|
||||
: fightName(f);
|
||||
String heroLine2 = String.format(Locale.US, "%.1f%% uptime • %d idle ticks", efficiency, idleTicks);
|
||||
String heroLine3 = String.format(Locale.US, "%s (%dt) • %d tiles moved", seconds(durationTicks), durationTicks, tilesMoved);
|
||||
combatSection.add(new FightHero(grade, score, heroTitle, heroLine2, heroLine3));
|
||||
|
||||
List<StatsView.Cell> cells = new ArrayList<>();
|
||||
cells.add(new StatsView.Cell("Damage dealt", QuantityFormatter.formatNumber(damageDealt), attacks + " atk", Ui.TEXT, null));
|
||||
cells.add(new StatsView.Cell("Damage taken", QuantityFormatter.formatNumber(damageTaken), hitsTaken + " hits",
|
||||
damageTaken > 0 ? Ui.DOWN : Ui.TEXT, null));
|
||||
long prayHits = prayerBlocked + prayerMissed;
|
||||
cells.add(prayHits > 0
|
||||
? pctCell("Prayer blocks", prayerBlocked, prayHits, prayerBlocked + " blocked, " + prayerMissed + " missed")
|
||||
: new StatsView.Cell("Prayer blocks", "-", "no hits", Ui.FAINT, null));
|
||||
long overhead = onPrayerAtk + offPrayerAtk;
|
||||
cells.add(overhead > 0
|
||||
? pctCell("Off-prayer hits", offPrayerAtk, overhead, offPrayerAtk + " of " + overhead + " attacks avoided the enemy's overhead")
|
||||
: new StatsView.Cell("Off-prayer hits", "-", "no overhead", Ui.FAINT, null));
|
||||
long wep = correctWep + incorrectWep;
|
||||
cells.add(wep > 0
|
||||
? pctCell("Right weapon", correctWep, wep, incorrectWep + " attacks with the wrong style")
|
||||
: new StatsView.Cell("Right weapon", "-", null, Ui.FAINT, null));
|
||||
cells.add(new StatsView.Cell("Healed", hpHealed > 0 ? "+" + hpHealed + " hp" : "0", null, hpHealed > 0 ? Ui.UP : Ui.TEXT, null));
|
||||
StatsView combatStats = new StatsView(2);
|
||||
combatStats.update(cells);
|
||||
combatSection.add(combatStats);
|
||||
|
||||
combatSection.add(sectionHeader("PROFIT", null));
|
||||
List<StatsView.Cell> profit = new ArrayList<>();
|
||||
profit.add(new StatsView.Cell(isSummary ? "Loot" : "Fight loot", Ui.gp(loot), null, Ui.GOLD, QuantityFormatter.formatNumber(loot) + " gp"));
|
||||
profit.add(new StatsView.Cell("Supplies", suppliesCost > 0 ? "-" + Ui.gp(suppliesCost) : "0", null, suppliesCost > 0 ? Ui.DOWN : Ui.TEXT,
|
||||
QuantityFormatter.formatNumber(suppliesCost) + " gp"));
|
||||
profit.add(new StatsView.Cell("Net", (netProfit > 0 ? "+" : "") + Ui.gp(netProfit), null, Ui.deltaColor(netProfit),
|
||||
QuantityFormatter.formatNumber(netProfit) + " gp"));
|
||||
StatsView profitStats = new StatsView(3);
|
||||
profitStats.update(profit);
|
||||
combatSection.add(profitStats);
|
||||
|
||||
RoundPanel supplies = new RoundPanel(new DynamicGridLayout(0, 1, 0, 3));
|
||||
addSupplies(supplies, "Food", food, Ui.WARN);
|
||||
addSupplies(supplies, "Potions", pots, new Color(0x5DADE2));
|
||||
addSupplies(supplies, "Ammo", ammo, new Color(0xB57EDC));
|
||||
if (supplies.getComponentCount() > 0)
|
||||
{
|
||||
combatSection.add(supplies);
|
||||
}
|
||||
|
||||
if (!reversed.isEmpty())
|
||||
{
|
||||
combatSection.add(sectionHeader((isDoom() ? "DELVE" : "KILL") + " HISTORY (" + reversed.size() + ")", null));
|
||||
JPanel historyList = new JPanel(new DynamicGridLayout(0, 1, 0, 2));
|
||||
historyList.setOpaque(false);
|
||||
for (int i = 0; i < reversed.size(); i++)
|
||||
{
|
||||
historyList.add(new FightRow(reversed.get(i), i + 1));
|
||||
}
|
||||
combatSection.add(historyList);
|
||||
}
|
||||
}
|
||||
|
||||
private void selectFight(int index)
|
||||
{
|
||||
selectedFightDropdownIndex = index;
|
||||
buildCombatSection();
|
||||
combatSection.revalidate();
|
||||
combatSection.repaint();
|
||||
}
|
||||
|
||||
private static String fightName(SingleFightRecord f)
|
||||
{
|
||||
return f.getDelve() > 0
|
||||
? String.format("Delve %d #%d", f.getDelve(), f.getFightNumber())
|
||||
: "Kill #" + f.getFightNumber();
|
||||
}
|
||||
|
||||
private static String seconds(long ticks)
|
||||
{
|
||||
double s = ticks * 0.6;
|
||||
return s >= 60 ? String.format(Locale.US, "%dm %02ds", (long) s / 60, (long) s % 60) : String.format(Locale.US, "%.1fs", s);
|
||||
}
|
||||
|
||||
private static StatsView.Cell pctCell(String label, long good, long total, String tooltip)
|
||||
{
|
||||
double pct = 100.0 * good / total;
|
||||
Color c = pct >= 90 ? Ui.UP : pct >= 70 ? Ui.GOLD : Ui.DOWN;
|
||||
return new StatsView.Cell(label, String.format(Locale.US, "%.0f%%", pct), good + "/" + total, c, tooltip);
|
||||
}
|
||||
|
||||
private static void addSupplies(JPanel box, String label, Map<String, Integer> used, Color color)
|
||||
{
|
||||
if (used == null || used.isEmpty())
|
||||
{
|
||||
return;
|
||||
}
|
||||
JLabel l = new JLabel(String.format("<html><body style='width: 165px'><font color='%s'>%s</font> %s</body></html>",
|
||||
getHex(color), label, formatSuppliesMap(used)));
|
||||
l.setFont(FontManager.getRunescapeSmallFont());
|
||||
l.setForeground(Ui.TEXT);
|
||||
box.add(l);
|
||||
}
|
||||
|
||||
/** Grade badge with score and two info lines for the selected fight or the summary. */
|
||||
private static final class FightHero extends JComponent
|
||||
{
|
||||
private final String grade;
|
||||
private final double score;
|
||||
private final String title;
|
||||
private final String line2;
|
||||
private final String line3;
|
||||
|
||||
FightHero(String grade, double score, String title, String line2, String line3)
|
||||
{
|
||||
this.grade = grade;
|
||||
this.score = score;
|
||||
this.title = title;
|
||||
this.line2 = line2;
|
||||
this.line3 = line3;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Dimension getPreferredSize()
|
||||
{
|
||||
return new Dimension(10, 54);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void paintComponent(Graphics g0)
|
||||
{
|
||||
Graphics2D g = Ui.prepare(g0);
|
||||
int w = getWidth();
|
||||
int h = getHeight();
|
||||
Color gc = LootTargetRow.gradeColor(grade);
|
||||
g.setColor(Ui.CARD);
|
||||
g.fillRoundRect(0, 0, w, h, 8, 8);
|
||||
|
||||
int bs = 38;
|
||||
int by = (h - bs) / 2;
|
||||
g.setColor(Ui.alpha(gc, 40));
|
||||
g.fillRoundRect(8, by, bs, bs, 8, 8);
|
||||
g.setColor(Ui.alpha(gc, 200));
|
||||
g.setStroke(new BasicStroke(1.2f));
|
||||
g.drawRoundRect(8, by, bs, bs, 8, 8);
|
||||
g.setFont(FontManager.getRunescapeBoldFont().deriveFont(18f));
|
||||
FontMetrics gfm = g.getFontMetrics();
|
||||
g.setColor(gc);
|
||||
g.drawString(grade, 8 + (bs - gfm.stringWidth(grade)) / 2, by + (bs + gfm.getAscent()) / 2 - 2);
|
||||
|
||||
int x = 8 + bs + 8;
|
||||
g.setFont(FontManager.getRunescapeBoldFont());
|
||||
FontMetrics bfm = g.getFontMetrics();
|
||||
int base1 = 6 + bfm.getAscent();
|
||||
String scoreText = String.format(Locale.US, "%.1f%%", score);
|
||||
g.setColor(gc);
|
||||
Ui.drawRight(g, scoreText, w - 8, base1);
|
||||
g.setColor(Ui.TEXT);
|
||||
g.drawString(Ui.ellipsize(bfm, title, w - 8 - bfm.stringWidth(scoreText) - 6 - x), x, base1);
|
||||
|
||||
g.setFont(FontManager.getRunescapeSmallFont());
|
||||
FontMetrics sfm = g.getFontMetrics();
|
||||
g.setColor(Ui.MUTED);
|
||||
g.drawString(Ui.ellipsize(sfm, line2, w - 8 - x), x, base1 + sfm.getAscent() + 3);
|
||||
g.drawString(Ui.ellipsize(sfm, line3, w - 8 - x), x, base1 + 2 * (sfm.getAscent() + 3));
|
||||
g.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/** One fight in the history list; click selects it. */
|
||||
private final class FightRow extends JComponent
|
||||
{
|
||||
private final SingleFightRecord f;
|
||||
private final int index;
|
||||
private boolean hover;
|
||||
|
||||
FightRow(SingleFightRecord f, int index)
|
||||
{
|
||||
this.f = f;
|
||||
this.index = index;
|
||||
setCursor(Cursor.getPredefinedCursor(Cursor.HAND_CURSOR));
|
||||
setToolTipText(String.format(Locale.US, "<html><b>%s</b><br>%s • %d dmg dealt<br>%d idle ticks • %d on-prayer attacks<br>Net %s gp</html>",
|
||||
fightName(f), seconds(f.getDurationTicks()), f.getDamageDealt(), f.getIdleTicks(), f.getOnPrayerAttacks(),
|
||||
QuantityFormatter.formatNumber(f.getNetProfit())));
|
||||
addMouseListener(new MouseAdapter()
|
||||
{
|
||||
@Override
|
||||
public void mouseEntered(MouseEvent e)
|
||||
{
|
||||
hover = true;
|
||||
repaint();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void mouseExited(MouseEvent e)
|
||||
{
|
||||
hover = false;
|
||||
repaint();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void mousePressed(MouseEvent e)
|
||||
{
|
||||
if (SwingUtilities.isLeftMouseButton(e))
|
||||
{
|
||||
selectFight(index);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public Dimension getPreferredSize()
|
||||
{
|
||||
return new Dimension(10, 32);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void paintComponent(Graphics g0)
|
||||
{
|
||||
Graphics2D g = Ui.prepare(g0);
|
||||
int w = getWidth();
|
||||
int h = getHeight();
|
||||
boolean selected = selectedFightDropdownIndex == index;
|
||||
g.setColor(selected ? Ui.CARD_SELECTED : hover ? Ui.CARD_HOVER : Ui.CARD);
|
||||
g.fillRoundRect(0, 0, w, h, 6, 6);
|
||||
if (selected)
|
||||
{
|
||||
g.setColor(Ui.ACCENT);
|
||||
g.fillRoundRect(0, 0, 3, h, 3, 3);
|
||||
}
|
||||
|
||||
g.setFont(FontManager.getRunescapeSmallFont());
|
||||
FontMetrics sfm = g.getFontMetrics();
|
||||
String grade = f.getGrade();
|
||||
Color gc = LootTargetRow.gradeColor(grade);
|
||||
int bs = 22;
|
||||
int by = (h - bs) / 2;
|
||||
g.setColor(Ui.alpha(gc, 40));
|
||||
g.fillRoundRect(7, by, bs, bs, 5, 5);
|
||||
g.setColor(Ui.alpha(gc, 190));
|
||||
g.setStroke(new BasicStroke(1f));
|
||||
g.drawRoundRect(7, by, bs, bs, 5, 5);
|
||||
g.setColor(gc);
|
||||
g.drawString(grade, 7 + (bs - sfm.stringWidth(grade)) / 2, by + (bs + sfm.getAscent()) / 2 - 1);
|
||||
|
||||
int x = 7 + bs + 7;
|
||||
int right = w - 7;
|
||||
int base1 = 3 + sfm.getAscent();
|
||||
int base2 = h - 4;
|
||||
|
||||
long net = f.getNetProfit();
|
||||
String netText = (net > 0 ? "+" : "") + Ui.gp(net);
|
||||
g.setColor(Ui.deltaColor(net));
|
||||
Ui.drawRight(g, netText, right, base1);
|
||||
String dmg = f.getDamageDealt() + " dmg";
|
||||
g.setColor(Ui.FAINT);
|
||||
Ui.drawRight(g, dmg, right, base2);
|
||||
|
||||
g.setColor(Ui.TEXT);
|
||||
g.drawString(Ui.ellipsize(sfm, fightName(f), right - sfm.stringWidth(netText) - 6 - x), x, base1);
|
||||
long hits = f.getPrayerBlockedHits() + f.getPrayerMissedHits();
|
||||
String sub = seconds(f.getDurationTicks()) + (hits > 0 ? " • " + f.getPrayerBlockedHits() + "/" + hits + " pray" : "")
|
||||
+ (f.getIdleTicks() > 0 ? " • " + f.getIdleTicks() + " idle" : "");
|
||||
g.setColor(Ui.MUTED);
|
||||
g.drawString(Ui.ellipsize(sfm, sub, right - sfm.stringWidth(dmg) - 6 - x), x, base2);
|
||||
g.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/** Rounded card background for Swing children. */
|
||||
private static final class RoundPanel extends JPanel
|
||||
{
|
||||
RoundPanel(LayoutManager layout)
|
||||
{
|
||||
super(layout);
|
||||
setOpaque(false);
|
||||
setBorder(BorderFactory.createEmptyBorder(6, 8, 6, 8));
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void paintComponent(Graphics g0)
|
||||
{
|
||||
Graphics2D g = Ui.prepare(g0);
|
||||
g.setColor(Ui.CARD);
|
||||
g.fillRoundRect(0, 0, getWidth(), getHeight(), 8, 8);
|
||||
g.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
static JPanel sectionHeader(String text, JLabel extra)
|
||||
{
|
||||
JPanel p = new JPanel(new BorderLayout(6, 0));
|
||||
p.setOpaque(false);
|
||||
p.setBorder(BorderFactory.createEmptyBorder(6, 2, 0, 2));
|
||||
JLabel l = new JLabel(text);
|
||||
l.setFont(FontManager.getRunescapeSmallFont());
|
||||
l.setForeground(Ui.MUTED);
|
||||
p.add(l, BorderLayout.WEST);
|
||||
if (extra != null)
|
||||
{
|
||||
extra.setFont(FontManager.getRunescapeSmallFont());
|
||||
extra.setHorizontalAlignment(JLabel.RIGHT);
|
||||
p.add(extra, BorderLayout.CENTER);
|
||||
}
|
||||
return p;
|
||||
}
|
||||
|
||||
static JLabel note(String text)
|
||||
{
|
||||
JLabel l = new JLabel(text);
|
||||
l.setFont(FontManager.getRunescapeSmallFont());
|
||||
l.setForeground(Ui.FAINT);
|
||||
l.setBorder(BorderFactory.createEmptyBorder(0, 2, 0, 2));
|
||||
return l;
|
||||
}
|
||||
|
||||
static JLabel link(String text, Color normal, Color hover, Runnable action)
|
||||
{
|
||||
JLabel l = new JLabel(text);
|
||||
l.setFont(FontManager.getRunescapeSmallFont());
|
||||
l.setForeground(normal);
|
||||
l.setCursor(Cursor.getPredefinedCursor(Cursor.HAND_CURSOR));
|
||||
l.addMouseListener(new MouseAdapter()
|
||||
{
|
||||
@Override
|
||||
public void mousePressed(MouseEvent e)
|
||||
{
|
||||
action.run();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void mouseEntered(MouseEvent e)
|
||||
{
|
||||
l.setForeground(hover);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void mouseExited(MouseEvent e)
|
||||
{
|
||||
l.setForeground(normal);
|
||||
}
|
||||
});
|
||||
return l;
|
||||
}
|
||||
|
||||
private static String getHex(Color c)
|
||||
{
|
||||
return String.format("#%02x%02x%02x", c.getRed(), c.getGreen(), c.getBlue());
|
||||
}
|
||||
|
||||
private static String formatSuppliesMap(Map<String, Integer> map)
|
||||
{
|
||||
if (map == null || map.isEmpty())
|
||||
{
|
||||
return "";
|
||||
}
|
||||
List<String> list = new ArrayList<>();
|
||||
map.forEach((k, v) -> list.add(v + "x " + k));
|
||||
return String.join(", ", list);
|
||||
}
|
||||
|
||||
private void showItemMenu(MouseEvent e, TrackedLootItem item)
|
||||
{
|
||||
JPopupMenu menu = new JPopupMenu();
|
||||
|
||||
JMenuItem wikiItem = new JMenuItem("Open Wiki");
|
||||
wikiItem.addActionListener(ev -> openWiki(item.getName()));
|
||||
menu.add(wikiItem);
|
||||
|
||||
JMenuItem copyName = new JMenuItem("Copy Name");
|
||||
copyName.addActionListener(ev ->
|
||||
{
|
||||
java.awt.datatransfer.StringSelection sel = new java.awt.datatransfer.StringSelection(item.getName());
|
||||
java.awt.Toolkit.getDefaultToolkit().getSystemClipboard().setContents(sel, sel);
|
||||
});
|
||||
menu.add(copyName);
|
||||
|
||||
JMenuItem copyVal = new JMenuItem("Copy Value (" + QuantityFormatter.formatNumber(item.getTotalPrice()) + " gp)");
|
||||
copyVal.addActionListener(ev ->
|
||||
{
|
||||
java.awt.datatransfer.StringSelection sel = new java.awt.datatransfer.StringSelection(String.valueOf(item.getTotalPrice()));
|
||||
java.awt.Toolkit.getDefaultToolkit().getSystemClipboard().setContents(sel, sel);
|
||||
});
|
||||
menu.add(copyVal);
|
||||
|
||||
menu.show(e.getComponent(), e.getX(), e.getY());
|
||||
}
|
||||
|
||||
private void openWiki(String name)
|
||||
{
|
||||
if (name != null && !name.isEmpty())
|
||||
{
|
||||
wikiOpener.accept(name);
|
||||
}
|
||||
}
|
||||
|
||||
private void confirmDelete()
|
||||
{
|
||||
int res = JOptionPane.showConfirmDialog(
|
||||
this,
|
||||
"Are you sure you want to delete all tracked loot and combat stats for " + record.getName() + "?",
|
||||
"Delete Record",
|
||||
JOptionPane.YES_NO_OPTION,
|
||||
JOptionPane.WARNING_MESSAGE
|
||||
);
|
||||
if (res == JOptionPane.YES_OPTION && onDelete != null)
|
||||
{
|
||||
onDelete.accept(record);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,386 @@
|
||||
package com.example.essentialsplus.loot.ui;
|
||||
|
||||
import com.example.essentialsplus.loot.LootRecord;
|
||||
import com.example.essentialsplus.loot.LootTrackerStore;
|
||||
import com.example.essentialsplus.loot.TrackedLootItem;
|
||||
import com.example.essentialsplus.ui.TimeFilter;
|
||||
import com.example.essentialsplus.ui.Ui;
|
||||
import java.awt.Color;
|
||||
import java.awt.Cursor;
|
||||
import java.awt.Dimension;
|
||||
import java.awt.Font;
|
||||
import java.awt.FontMetrics;
|
||||
import java.awt.Graphics;
|
||||
import java.awt.Graphics2D;
|
||||
import java.awt.event.MouseAdapter;
|
||||
import java.awt.event.MouseEvent;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import javax.swing.JComponent;
|
||||
import javax.swing.SwingUtilities;
|
||||
import net.runelite.client.game.ItemManager;
|
||||
import net.runelite.client.ui.FontManager;
|
||||
import net.runelite.client.util.QuantityFormatter;
|
||||
|
||||
/**
|
||||
* Clean entry card for Loot Tracker on the Essentials Plus Overview panel.
|
||||
* Height strictly bounded to 110-120px to prevent layout bloat.
|
||||
*/
|
||||
public class LootSummaryCard extends JComponent
|
||||
{
|
||||
private final LootTrackerStore store;
|
||||
private final ItemManager itemManager;
|
||||
private final Runnable onClick;
|
||||
private final TimeFilter timeFilter;
|
||||
private int sourceCount = 0;
|
||||
|
||||
private boolean hover = false;
|
||||
private long totalValue = 0;
|
||||
private int totalKills = 0;
|
||||
private String topSource = "";
|
||||
private long topSourceVal = 0;
|
||||
/** 0 = total value, 1 = latest drop, 2 = top source (overview right-click menu) */
|
||||
private int mode;
|
||||
private String latestSource = "";
|
||||
private long latestVal;
|
||||
private long latestAt;
|
||||
private int latestKills;
|
||||
|
||||
/** live fight shown instead of the loot figures (overview right-click options) */
|
||||
private com.example.essentialsplus.loot.gear.FightSnapshot fight = com.example.essentialsplus.loot.gear.FightSnapshot.LOGGED_OUT;
|
||||
private boolean showTarget = true;
|
||||
private boolean keepLast;
|
||||
private boolean showHp = true;
|
||||
private boolean showDps = true;
|
||||
private boolean showSession = true;
|
||||
private boolean showKills = true;
|
||||
|
||||
public LootSummaryCard(LootTrackerStore store, ItemManager itemManager, TimeFilter timeFilter, Runnable onClick)
|
||||
{
|
||||
this.store = store;
|
||||
this.timeFilter = timeFilter;
|
||||
this.itemManager = itemManager;
|
||||
this.onClick = onClick;
|
||||
|
||||
setCursor(Cursor.getPredefinedCursor(Cursor.HAND_CURSOR));
|
||||
addMouseListener(new MouseAdapter()
|
||||
{
|
||||
@Override
|
||||
public void mouseEntered(MouseEvent e)
|
||||
{
|
||||
hover = true;
|
||||
repaint();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void mouseExited(MouseEvent e)
|
||||
{
|
||||
hover = false;
|
||||
repaint();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void mousePressed(MouseEvent e)
|
||||
{
|
||||
if (SwingUtilities.isLeftMouseButton(e) && onClick != null)
|
||||
{
|
||||
onClick.run();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
store.addChangeListener(this::refresh);
|
||||
timeFilter.addListener(this::refresh);
|
||||
refresh();
|
||||
}
|
||||
|
||||
public void refresh()
|
||||
{
|
||||
List<LootRecord> records = store.getRecords(timeFilter);
|
||||
totalValue = LootTrackerStore.totalValue(records);
|
||||
totalKills = LootTrackerStore.totalKills(records);
|
||||
sourceCount = records.size();
|
||||
|
||||
LootRecord top = records.stream()
|
||||
.max(Comparator.comparingLong(LootRecord::getTotalValue))
|
||||
.orElse(null);
|
||||
|
||||
LootRecord latest = records.stream()
|
||||
.filter(r -> r.getTotalValue() > 0)
|
||||
.max(Comparator.comparingLong(LootRecord::getLastSeen))
|
||||
.orElse(null);
|
||||
latestSource = latest == null ? "" : latest.getName();
|
||||
latestVal = latest == null ? 0 : latest.getTotalValue();
|
||||
latestAt = latest == null ? 0 : latest.getLastSeen();
|
||||
latestKills = latest == null ? 0 : latest.getKills();
|
||||
|
||||
if (top != null && top.getTotalValue() > 0)
|
||||
{
|
||||
topSource = top.getName();
|
||||
topSourceVal = top.getTotalValue();
|
||||
}
|
||||
else
|
||||
{
|
||||
topSource = "";
|
||||
topSourceVal = 0;
|
||||
}
|
||||
|
||||
repaint();
|
||||
}
|
||||
|
||||
public void setFight(com.example.essentialsplus.loot.gear.FightSnapshot snap)
|
||||
{
|
||||
boolean was = fightShown();
|
||||
fight = snap;
|
||||
if (was || fightShown())
|
||||
{
|
||||
repaint();
|
||||
}
|
||||
}
|
||||
|
||||
public void setFightOptions(boolean showTarget, boolean keepLast, boolean showHp, boolean showDps, boolean showSession, boolean showKills)
|
||||
{
|
||||
this.showTarget = showTarget;
|
||||
this.keepLast = keepLast;
|
||||
this.showHp = showHp;
|
||||
this.showDps = showDps;
|
||||
this.showSession = showSession;
|
||||
this.showKills = showKills;
|
||||
repaint();
|
||||
}
|
||||
|
||||
private boolean fightShown()
|
||||
{
|
||||
com.example.essentialsplus.loot.gear.FightSnapshot.Target t = fight.loggedIn ? fight.target : null;
|
||||
return showTarget && t != null && (t.live || keepLast);
|
||||
}
|
||||
|
||||
public void setMode(int mode)
|
||||
{
|
||||
this.mode = mode;
|
||||
repaint();
|
||||
}
|
||||
|
||||
private static String ago(long t)
|
||||
{
|
||||
long d = System.currentTimeMillis() - t;
|
||||
return d < Ui.MINUTE ? "just now" : d < Ui.HOUR ? d / Ui.MINUTE + "m ago" : d < Ui.DAY ? d / Ui.HOUR + "h ago" : d / Ui.DAY + "d ago";
|
||||
}
|
||||
|
||||
@Override
|
||||
public Dimension getPreferredSize()
|
||||
{
|
||||
return new Dimension(net.runelite.client.ui.PluginPanel.PANEL_WIDTH - 16, 120);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Dimension getMaximumSize()
|
||||
{
|
||||
return new Dimension(Integer.MAX_VALUE, 130);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Dimension getMinimumSize()
|
||||
{
|
||||
return new Dimension(10, 110);
|
||||
}
|
||||
|
||||
/** Current target: name, badge, health bar, own max hit / dps, fight time and damage. */
|
||||
private void paintFight(Graphics2D g, int pad, int right, FontMetrics headerFm)
|
||||
{
|
||||
com.example.essentialsplus.loot.gear.FightSnapshot.Target t = fight.target;
|
||||
Font bold = FontManager.getRunescapeBoldFont();
|
||||
g.setFont(FontManager.getRunescapeSmallFont());
|
||||
FontMetrics sfm = g.getFontMetrics();
|
||||
String badge = t.dead ? "DEAD" : t.live ? "FIGHTING" : "LAST TARGET";
|
||||
Color bc = t.dead ? Ui.MUTED : t.live ? Ui.DOWN : Ui.FAINT;
|
||||
int bw = sfm.stringWidth(badge) + 6;
|
||||
int bx = right - 14 - bw;
|
||||
g.setColor(Ui.alpha(bc, 50));
|
||||
g.fillRoundRect(bx, 6, bw, sfm.getAscent() + 3, 4, 4);
|
||||
g.setColor(bc);
|
||||
g.drawString(badge, bx + 3, 7 + sfm.getAscent());
|
||||
|
||||
int y = 24;
|
||||
g.setFont(bold.deriveFont(18f));
|
||||
FontMetrics nfm = g.getFontMetrics();
|
||||
g.setColor(Color.WHITE);
|
||||
String name = t.name + " (" + t.combatLevel + ")";
|
||||
g.drawString(Ui.ellipsize(nfm, name, right - pad), pad, y + nfm.getAscent());
|
||||
y += nfm.getHeight() + 2;
|
||||
|
||||
if (showHp)
|
||||
{
|
||||
int barW = right - pad;
|
||||
g.setColor(new Color(60, 20, 20));
|
||||
g.fillRoundRect(pad, y, barW, 12, 4, 4);
|
||||
double ratio = t.healthRatio >= 0 && t.healthScale > 0 ? (double) t.healthRatio / t.healthScale : t.dead ? 0 : 1;
|
||||
g.setColor(t.live ? new Color(40, 170, 70) : new Color(60, 110, 70));
|
||||
g.fillRoundRect(pad, y, (int) Math.round(barW * ratio), 12, 4, 4);
|
||||
String hp = t.monster != null && t.monster.hitpoints > 0
|
||||
? (int) Math.round(t.monster.hitpoints * ratio) + " / " + t.monster.hitpoints
|
||||
: String.format(java.util.Locale.US, "%.0f%%", ratio * 100);
|
||||
g.setFont(FontManager.getRunescapeSmallFont());
|
||||
g.setColor(Color.WHITE);
|
||||
g.drawString(hp, pad + (barW - sfm.stringWidth(hp)) / 2, y + 10);
|
||||
y += 16;
|
||||
}
|
||||
|
||||
g.setFont(FontManager.getRunescapeSmallFont());
|
||||
if (showDps)
|
||||
{
|
||||
com.example.essentialsplus.loot.gear.DpsCalc.Result r = com.example.essentialsplus.loot.gear.DpsCalc.calc(fight.worn, fight.setup,
|
||||
t.calcMonster(), com.example.essentialsplus.loot.gear.DpsCalc.choiceFor(fight.worn, fight.worn, fight.setup));
|
||||
y += sfm.getAscent() + 2;
|
||||
int x = pad;
|
||||
x = pair(g, sfm, x, y, "Max ", String.valueOf(r.maxHit), Ui.GOLD);
|
||||
x = pair(g, sfm, x, y, " DPS ", String.format(java.util.Locale.US, "%.2f", r.dps), Ui.UP);
|
||||
pair(g, sfm, x, y, " Hit ", String.format(java.util.Locale.US, "%.0f%%", r.hitChance * 100), Ui.TEXT);
|
||||
}
|
||||
if (showSession && fight.session != null)
|
||||
{
|
||||
com.example.essentialsplus.loot.gear.FightSnapshot.Session se = fight.session;
|
||||
y += sfm.getAscent() + 4;
|
||||
int sec = (int) Math.round(se.durationTicks * 0.6);
|
||||
int x = pad;
|
||||
x = pair(g, sfm, x, y, "", sec >= 60 ? sec / 60 + "m " + sec % 60 + "s" : sec + "s", Ui.TEXT);
|
||||
x = pair(g, sfm, x, y, " Dealt ", String.valueOf(se.damageDealt), Ui.UP);
|
||||
pair(g, sfm, x, y, " Taken ", String.valueOf(se.damageTaken), se.damageTaken > 0 ? Ui.DOWN : Ui.TEXT);
|
||||
}
|
||||
else if (showKills && totalKills > 0)
|
||||
{
|
||||
y += sfm.getAscent() + 4;
|
||||
g.setColor(Ui.FAINT);
|
||||
g.drawString(Ui.gp(totalValue) + " loot • " + QuantityFormatter.formatNumber(totalKills) + " kills • " + timeFilter.getLabel(), pad, y);
|
||||
}
|
||||
}
|
||||
|
||||
private static int pair(Graphics2D g, FontMetrics fm, int x, int y, String label, String value, Color color)
|
||||
{
|
||||
g.setColor(Ui.MUTED);
|
||||
g.drawString(label, x, y);
|
||||
x += fm.stringWidth(label);
|
||||
g.setColor(color);
|
||||
g.drawString(value, x, y);
|
||||
return x + fm.stringWidth(value);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void paintComponent(Graphics g0)
|
||||
{
|
||||
Graphics2D g = Ui.prepare(g0);
|
||||
int w = getWidth();
|
||||
int h = getHeight();
|
||||
|
||||
g.setColor(hover ? Ui.CARD_HOVER : Ui.CARD);
|
||||
g.fillRoundRect(0, 0, w, h, 8, 8);
|
||||
|
||||
int pad = 10;
|
||||
int right = w - pad;
|
||||
Font bold = FontManager.getRunescapeBoldFont();
|
||||
Font small = FontManager.getRunescapeSmallFont();
|
||||
|
||||
// Header: "Loot Tracker" + chevron ›
|
||||
g.setFont(bold);
|
||||
FontMetrics fm = g.getFontMetrics();
|
||||
g.setColor(Ui.ACCENT);
|
||||
g.drawString("Fights & Loot", pad, 6 + fm.getAscent());
|
||||
|
||||
g.setFont(bold.deriveFont(14f));
|
||||
g.setColor(hover ? Ui.ACCENT : Ui.FAINT);
|
||||
Ui.drawRight(g, "›", right, 6 + fm.getAscent());
|
||||
|
||||
if (fightShown())
|
||||
{
|
||||
paintFight(g, pad, right, fm);
|
||||
Ui.paintDragOutline(g, this);
|
||||
g.dispose();
|
||||
return;
|
||||
}
|
||||
|
||||
if (totalKills > 0 && showKills)
|
||||
{
|
||||
g.setFont(small);
|
||||
g.setColor(Ui.MUTED);
|
||||
String killsText = QuantityFormatter.formatNumber(totalKills) + " kills • " + timeFilter.getLabel();
|
||||
FontMetrics sfm = g.getFontMetrics();
|
||||
int titleW = fm.stringWidth("Fights & Loot");
|
||||
int killsW = sfm.stringWidth(killsText);
|
||||
int maxKillsX = right - 14;
|
||||
if (maxKillsX - killsW > pad + titleW + 8)
|
||||
{
|
||||
Ui.drawRight(g, killsText, maxKillsX, 6 + fm.getAscent());
|
||||
}
|
||||
}
|
||||
|
||||
if (mode != 0 && totalKills > 0)
|
||||
{
|
||||
boolean latestMode = mode == 1;
|
||||
String src = latestMode ? latestSource : topSource;
|
||||
long val = latestMode ? latestVal : topSourceVal;
|
||||
g.setFont(bold.deriveFont(26f));
|
||||
FontMetrics bfm = g.getFontMetrics();
|
||||
int valueBase = 22 + bfm.getAscent();
|
||||
g.setColor(val > 0 ? Ui.GOLD : Ui.FAINT);
|
||||
g.drawString(Ui.gp(val), pad, valueBase);
|
||||
g.setFont(small);
|
||||
FontMetrics sfm = g.getFontMetrics();
|
||||
int line1 = valueBase + sfm.getAscent() + 4;
|
||||
g.setColor(Ui.MUTED);
|
||||
String prefix = latestMode ? "Latest: " : "Top: ";
|
||||
g.drawString(prefix, pad, line1);
|
||||
g.setColor(Color.WHITE);
|
||||
g.drawString(src.isEmpty() ? "-" : src, pad + sfm.stringWidth(prefix), line1);
|
||||
int line2 = line1 + sfm.getAscent() + 4;
|
||||
g.setColor(Ui.FAINT);
|
||||
g.drawString(latestMode && latestAt > 0 ? ago(latestAt) + " • " + latestKills + " kills" : "Total " + Ui.gp(totalValue) + " • " + timeFilter.getLabel(),
|
||||
pad, line2);
|
||||
Ui.paintDragOutline(g, this);
|
||||
g.dispose();
|
||||
return;
|
||||
}
|
||||
|
||||
// Big Value
|
||||
Font big = bold.deriveFont(26f);
|
||||
g.setFont(big);
|
||||
FontMetrics bfm = g.getFontMetrics();
|
||||
int valueBase = 22 + bfm.getAscent();
|
||||
g.setColor(totalValue > 0 ? Ui.GOLD : Ui.FAINT);
|
||||
g.drawString(totalValue > 0 ? Ui.gp(totalValue) : "0 gp", pad, valueBase);
|
||||
|
||||
// Subtitle & highlight
|
||||
g.setFont(small);
|
||||
FontMetrics sfm = g.getFontMetrics();
|
||||
int line1 = valueBase + sfm.getAscent() + 4;
|
||||
|
||||
if (totalKills == 0)
|
||||
{
|
||||
g.setColor(Ui.FAINT);
|
||||
g.drawString((timeFilter.start() > 0 ? "No loot " + timeFilter.inLabel() : "No loot recorded yet") + " • Click to open", pad, line1);
|
||||
}
|
||||
else
|
||||
{
|
||||
g.setColor(Ui.TEXT);
|
||||
String summaryDesc = "Tracked drops from " + sourceCount + " sources";
|
||||
g.drawString(summaryDesc, pad, line1);
|
||||
|
||||
if (!topSource.isEmpty())
|
||||
{
|
||||
int line2 = line1 + sfm.getAscent() + 4;
|
||||
g.setColor(Ui.MUTED);
|
||||
g.drawString("Top: ", pad, line2);
|
||||
|
||||
int topW = sfm.stringWidth("Top: ");
|
||||
g.setColor(Color.WHITE);
|
||||
g.drawString(topSource, pad + topW, line2);
|
||||
|
||||
int nameW = sfm.stringWidth(topSource);
|
||||
g.setColor(Ui.GOLD);
|
||||
g.drawString(" (" + Ui.gp(topSourceVal) + ")", pad + topW + nameW, line2);
|
||||
}
|
||||
}
|
||||
|
||||
Ui.paintDragOutline(g, this);
|
||||
g.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
package com.example.essentialsplus.loot.ui;
|
||||
|
||||
import com.example.essentialsplus.loot.LootRecord;
|
||||
import com.example.essentialsplus.loot.TrackedLootItem;
|
||||
import com.example.essentialsplus.loot.combat.CombatStats;
|
||||
import com.example.essentialsplus.loot.combat.CombatTracker;
|
||||
import com.example.essentialsplus.ui.Ui;
|
||||
import java.awt.BasicStroke;
|
||||
import java.awt.Color;
|
||||
import java.awt.Cursor;
|
||||
import java.awt.Dimension;
|
||||
import java.awt.Font;
|
||||
import java.awt.FontMetrics;
|
||||
import java.awt.Graphics;
|
||||
import java.awt.Graphics2D;
|
||||
import java.awt.event.MouseAdapter;
|
||||
import java.awt.event.MouseEvent;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.util.List;
|
||||
import java.util.function.Consumer;
|
||||
import javax.swing.JComponent;
|
||||
import javax.swing.SwingUtilities;
|
||||
import net.runelite.client.game.ItemManager;
|
||||
import net.runelite.client.ui.FontManager;
|
||||
import net.runelite.client.util.AsyncBufferedImage;
|
||||
import net.runelite.client.util.QuantityFormatter;
|
||||
|
||||
/**
|
||||
* One loot source in the Loot Tracker overview: icon of its best drop, name, kills and last seen,
|
||||
* total value, and grade / net profit for tracked fights. A faint bar shows its share of the top source.
|
||||
*/
|
||||
public class LootTargetRow extends JComponent
|
||||
{
|
||||
private static final int HEIGHT = 38;
|
||||
private static final int ICON = 32;
|
||||
|
||||
private final LootRecord record;
|
||||
private final long value;
|
||||
private final long maxValue;
|
||||
private final BufferedImage icon;
|
||||
private boolean hover;
|
||||
|
||||
public LootTargetRow(LootRecord record, long maxValue, ItemManager itemManager, Consumer<LootRecord> onSelect)
|
||||
{
|
||||
this.record = record;
|
||||
this.value = record.getTotalValue();
|
||||
this.maxValue = maxValue;
|
||||
|
||||
List<TrackedLootItem> items = record.getSortedItems();
|
||||
if (!items.isEmpty() && itemManager != null)
|
||||
{
|
||||
TrackedLootItem best = items.get(0);
|
||||
AsyncBufferedImage img = itemManager.getImage(best.getId(), (int) Math.min(Integer.MAX_VALUE, best.getQuantity()), best.getQuantity() > 1);
|
||||
img.onLoaded(this::repaint);
|
||||
icon = img;
|
||||
}
|
||||
else
|
||||
{
|
||||
icon = null;
|
||||
}
|
||||
|
||||
setCursor(Cursor.getPredefinedCursor(Cursor.HAND_CURSOR));
|
||||
long avg = record.getKills() > 0 ? value / record.getKills() : 0;
|
||||
setToolTipText(String.format("<html><b>%s</b> (%s kills)<br>Total loot: %s gp<br>Average: %s gp/kill<br>Last: %s<br><i>Click for loot & combat stats</i></html>",
|
||||
record.getName(),
|
||||
QuantityFormatter.formatNumber(record.getKills()),
|
||||
QuantityFormatter.formatNumber(value),
|
||||
QuantityFormatter.formatNumber(avg),
|
||||
Ui.ago(record.getLastSeen())));
|
||||
|
||||
addMouseListener(new MouseAdapter()
|
||||
{
|
||||
@Override
|
||||
public void mouseEntered(MouseEvent e)
|
||||
{
|
||||
hover = true;
|
||||
repaint();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void mouseExited(MouseEvent e)
|
||||
{
|
||||
hover = false;
|
||||
repaint();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void mousePressed(MouseEvent e)
|
||||
{
|
||||
if (SwingUtilities.isLeftMouseButton(e) && onSelect != null)
|
||||
{
|
||||
onSelect.accept(record);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public Dimension getPreferredSize()
|
||||
{
|
||||
return new Dimension(10, HEIGHT);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void paintComponent(Graphics g0)
|
||||
{
|
||||
Graphics2D g = Ui.prepare(g0);
|
||||
int w = getWidth();
|
||||
int h = getHeight();
|
||||
|
||||
g.setColor(hover ? Ui.CARD_HOVER : Ui.CARD);
|
||||
g.fillRoundRect(0, 0, w, h, 6, 6);
|
||||
if (value > 0 && maxValue > 0)
|
||||
{
|
||||
g.setColor(Ui.alpha(Ui.GOLD, 18));
|
||||
g.fillRoundRect(0, 0, Math.max(6, (int) (w * (double) value / maxValue)), h, 6, 6);
|
||||
}
|
||||
if (hover)
|
||||
{
|
||||
g.setColor(Ui.ACCENT);
|
||||
g.fillRoundRect(0, 0, 3, h, 3, 3);
|
||||
}
|
||||
|
||||
int x = 6;
|
||||
if (icon != null)
|
||||
{
|
||||
g.drawImage(icon, x, (h - ICON) / 2, null);
|
||||
}
|
||||
x += ICON + 6;
|
||||
|
||||
Font small = FontManager.getRunescapeSmallFont();
|
||||
Font bold = FontManager.getRunescapeBoldFont();
|
||||
g.setFont(small);
|
||||
FontMetrics sfm = g.getFontMetrics();
|
||||
g.setFont(bold);
|
||||
FontMetrics bfm = g.getFontMetrics();
|
||||
int base1 = 4 + bfm.getAscent();
|
||||
int base2 = h - 6;
|
||||
int right = w - 8;
|
||||
|
||||
// right column: value on top, net profit + grade below for tracked fights
|
||||
String valueText = value > 0 ? Ui.gp(value) : "-";
|
||||
int valueW = bfm.stringWidth(valueText);
|
||||
g.setColor(value > 0 ? Ui.GOLD : Ui.FAINT);
|
||||
Ui.drawRight(g, valueText, right, base1);
|
||||
|
||||
int subRight = right;
|
||||
g.setFont(small);
|
||||
if (record.hasCombatStats())
|
||||
{
|
||||
CombatStats cs = record.getCombatStats();
|
||||
long net = value - cs.getTotalSuppliesCost();
|
||||
String netText = (net > 0 ? "+" : "") + Ui.gp(net);
|
||||
g.setColor(Ui.deltaColor(net));
|
||||
Ui.drawRight(g, netText, subRight, base2);
|
||||
subRight -= sfm.stringWidth(netText) + 5;
|
||||
|
||||
String grade = cs.getAvgGrade();
|
||||
Color gc = gradeColor(grade);
|
||||
int bw = sfm.stringWidth(grade) + 6;
|
||||
int bh = sfm.getAscent() + 2;
|
||||
int bx = subRight - bw;
|
||||
int by = base2 - sfm.getAscent() - 1;
|
||||
g.setColor(Ui.alpha(gc, 40));
|
||||
g.fillRoundRect(bx, by, bw, bh, 4, 4);
|
||||
g.setColor(Ui.alpha(gc, 200));
|
||||
g.setStroke(new BasicStroke(1f));
|
||||
g.drawRoundRect(bx, by, bw, bh, 4, 4);
|
||||
g.setColor(gc);
|
||||
g.drawString(grade, bx + 3, base2);
|
||||
subRight = bx - 5;
|
||||
}
|
||||
|
||||
// left column: name, then kills and last seen
|
||||
g.setFont(bold);
|
||||
g.setColor(Ui.TEXT);
|
||||
g.drawString(Ui.ellipsize(bfm, record.getName(), right - valueW - 8 - x), x, base1);
|
||||
|
||||
g.setFont(small);
|
||||
String unit = CombatTracker.DOOM_OF_MOKHAIOTL.equalsIgnoreCase(record.getName()) ? " delves" : " kills";
|
||||
String sub = QuantityFormatter.formatNumber(record.getKills()) + unit + " • " + Ui.ago(record.getLastSeen());
|
||||
g.setColor(Ui.MUTED);
|
||||
g.drawString(Ui.ellipsize(sfm, sub, subRight - x), x, base2);
|
||||
|
||||
g.dispose();
|
||||
}
|
||||
|
||||
static Color gradeColor(String grade)
|
||||
{
|
||||
if ("S+".equals(grade)) return new Color(0, 220, 255);
|
||||
if ("A+".equals(grade)) return Ui.UP;
|
||||
if ("A".equals(grade)) return new Color(39, 174, 96);
|
||||
if ("B".equals(grade)) return new Color(241, 196, 15);
|
||||
if ("C".equals(grade)) return new Color(230, 126, 34);
|
||||
return Ui.DOWN;
|
||||
}
|
||||
|
||||
public LootRecord getRecord()
|
||||
{
|
||||
return record;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,477 @@
|
||||
package com.example.essentialsplus.loot.ui;
|
||||
|
||||
import com.example.essentialsplus.collectionlog.CollectionLogService;
|
||||
import com.example.essentialsplus.wiki.WikiApi;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import com.example.essentialsplus.loot.LootRecord;
|
||||
import com.example.essentialsplus.loot.LootTrackerStore;
|
||||
import com.example.essentialsplus.loot.combat.CombatStats;
|
||||
import com.example.essentialsplus.loot.gear.FightService;
|
||||
import com.example.essentialsplus.ui.StatsView;
|
||||
import com.example.essentialsplus.ui.TimeFilter;
|
||||
import com.example.essentialsplus.ui.Ui;
|
||||
import java.awt.BorderLayout;
|
||||
import java.awt.CardLayout;
|
||||
import java.awt.Cursor;
|
||||
import java.awt.Dimension;
|
||||
import java.awt.event.KeyAdapter;
|
||||
import java.awt.event.KeyEvent;
|
||||
import java.awt.event.MouseAdapter;
|
||||
import java.awt.event.MouseEvent;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.Locale;
|
||||
import javax.swing.BorderFactory;
|
||||
import javax.swing.JButton;
|
||||
import javax.swing.JComboBox;
|
||||
import javax.swing.JLabel;
|
||||
import javax.swing.JOptionPane;
|
||||
import javax.swing.JPanel;
|
||||
import javax.swing.JScrollPane;
|
||||
import javax.swing.ScrollPaneConstants;
|
||||
import javax.swing.SwingUtilities;
|
||||
import net.runelite.client.game.ItemManager;
|
||||
import net.runelite.client.ui.DynamicGridLayout;
|
||||
import net.runelite.client.ui.FontManager;
|
||||
import net.runelite.client.ui.PluginPanel;
|
||||
import net.runelite.client.ui.components.IconTextField;
|
||||
import net.runelite.client.util.QuantityFormatter;
|
||||
|
||||
/**
|
||||
* Main Loot Tracker Panel for Essentials Plus.
|
||||
* Provides high-level dashboard metrics ("Grosse Statistiken") and lists tracked monsters.
|
||||
* Clicking any monster opens its individual, spacious detail view (loot items + combat analytics).
|
||||
*/
|
||||
public class LootTrackerPanel extends PluginPanel
|
||||
{
|
||||
private static final String VIEW_OVERVIEW = "OVERVIEW";
|
||||
private static final String VIEW_DETAIL = "DETAIL";
|
||||
private static final String VIEW_FIGHT = "FIGHT";
|
||||
|
||||
private enum SortOrder
|
||||
{
|
||||
RECENT("Sort: Most recent"),
|
||||
VALUE_DESC("Sort: Value"),
|
||||
PROFIT_DESC("Sort: Net profit"),
|
||||
KILLS_DESC("Sort: Kills"),
|
||||
NAME_ASC("Sort: Name (A-Z)");
|
||||
|
||||
private final String label;
|
||||
|
||||
SortOrder(String label)
|
||||
{
|
||||
this.label = label;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString()
|
||||
{
|
||||
return label;
|
||||
}
|
||||
}
|
||||
|
||||
private final LootTrackerStore store;
|
||||
private final ItemManager itemManager;
|
||||
private final WikiApi wikiApi;
|
||||
private final ExecutorService wikiExecutor;
|
||||
private final CollectionLogService collectionLog;
|
||||
private final Runnable onBack;
|
||||
private final Consumer<String> openWiki;
|
||||
private final TimeFilter timeFilter;
|
||||
private final CardLayout cardLayout = new CardLayout();
|
||||
private final JPanel mainContainer = new JPanel(cardLayout);
|
||||
|
||||
// Overview Components
|
||||
private final StatsView stats = new StatsView(2);
|
||||
private final JLabel countLabel = new JLabel();
|
||||
private final IconTextField searchField = new IconTextField();
|
||||
private final JComboBox<SortOrder> sortBox = new JComboBox<>(SortOrder.values());
|
||||
|
||||
private final JPanel listBox = new JPanel(new DynamicGridLayout(0, 1, 0, 4));
|
||||
|
||||
// Detail View State
|
||||
private final JPanel detailHolder = new JPanel(new BorderLayout());
|
||||
private LootRecord currentDetailRecord = null;
|
||||
private LootRecordDetailPanel currentDetailPanel = null;
|
||||
private String currentView = VIEW_OVERVIEW;
|
||||
|
||||
private final TargetBar targetBar;
|
||||
private final FightDetailPanel fightDetail;
|
||||
private com.example.essentialsplus.loot.gear.FightSnapshot lastSnap = com.example.essentialsplus.loot.gear.FightSnapshot.LOGGED_OUT;
|
||||
|
||||
public LootTrackerPanel(LootTrackerStore store, ItemManager itemManager, WikiApi wikiApi, ExecutorService wikiExecutor,
|
||||
CollectionLogService collectionLog, TimeFilter timeFilter, FightService fights, Consumer<String> openWiki, Runnable onBack)
|
||||
{
|
||||
super(false);
|
||||
this.store = store;
|
||||
this.itemManager = itemManager;
|
||||
this.wikiApi = wikiApi;
|
||||
this.wikiExecutor = wikiExecutor;
|
||||
this.collectionLog = collectionLog;
|
||||
this.onBack = onBack;
|
||||
this.openWiki = openWiki;
|
||||
this.timeFilter = timeFilter;
|
||||
targetBar = new TargetBar(this::openFight, null);
|
||||
fightDetail = new FightDetailPanel(fights, itemManager, wikiApi, wikiExecutor, store::getRecord, this::openDetail, openWiki, this::closeDetail);
|
||||
|
||||
setLayout(new BorderLayout());
|
||||
setBackground(Ui.BG);
|
||||
|
||||
mainContainer.setOpaque(false);
|
||||
mainContainer.add(buildOverviewPanel(), VIEW_OVERVIEW);
|
||||
mainContainer.add(detailHolder, VIEW_DETAIL);
|
||||
mainContainer.add(fightDetail, VIEW_FIGHT);
|
||||
|
||||
add(mainContainer, BorderLayout.CENTER);
|
||||
|
||||
store.addChangeListener(this::rebuild);
|
||||
timeFilter.addListener(this::rebuild);
|
||||
fights.addListener(snap -> SwingUtilities.invokeLater(() ->
|
||||
{
|
||||
lastSnap = snap;
|
||||
targetBar.update(snap);
|
||||
fightDetail.update(snap);
|
||||
}));
|
||||
rebuild();
|
||||
}
|
||||
|
||||
private JPanel buildOverviewPanel()
|
||||
{
|
||||
JPanel panel = new JPanel(new BorderLayout());
|
||||
panel.setOpaque(false);
|
||||
|
||||
listBox.setOpaque(false);
|
||||
listBox.setBorder(BorderFactory.createEmptyBorder(0, 6, 8, 6));
|
||||
|
||||
// gear, live fight and loot scroll together
|
||||
JPanel content = new JPanel(new BorderLayout());
|
||||
content.setOpaque(false);
|
||||
content.add(buildTopBar(), BorderLayout.NORTH);
|
||||
content.add(listBox, BorderLayout.CENTER);
|
||||
|
||||
JPanel listWrapper = new LootRecordDetailPanel.ScrollableWrapper();
|
||||
listWrapper.add(content, BorderLayout.NORTH);
|
||||
|
||||
JScrollPane scrollPane = new JScrollPane(listWrapper);
|
||||
scrollPane.setBackground(Ui.BG);
|
||||
scrollPane.getViewport().setBackground(Ui.BG);
|
||||
scrollPane.setBorder(null);
|
||||
scrollPane.setHorizontalScrollBarPolicy(ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER);
|
||||
|
||||
panel.add(scrollPane, BorderLayout.CENTER);
|
||||
return panel;
|
||||
}
|
||||
|
||||
private JPanel buildTopBar()
|
||||
{
|
||||
JPanel container = new JPanel(new VerticalLayout(6));
|
||||
container.setOpaque(false);
|
||||
container.setBorder(BorderFactory.createEmptyBorder(6, 6, 6, 6));
|
||||
|
||||
JPanel backRow = new JPanel(new BorderLayout(6, 0));
|
||||
backRow.setOpaque(false);
|
||||
backRow.setBorder(BorderFactory.createEmptyBorder(0, 0, 4, 0));
|
||||
if (onBack != null)
|
||||
{
|
||||
JButton backBtn = button("← Overview", onBack);
|
||||
backBtn.setForeground(Ui.ACCENT);
|
||||
backRow.add(backBtn, BorderLayout.WEST);
|
||||
}
|
||||
JLabel titleLabel = new JLabel("Fights & Loot");
|
||||
titleLabel.setFont(FontManager.getRunescapeBoldFont());
|
||||
titleLabel.setForeground(Ui.ACCENT);
|
||||
titleLabel.setHorizontalAlignment(JLabel.RIGHT);
|
||||
backRow.add(titleLabel, BorderLayout.CENTER);
|
||||
container.add(backRow);
|
||||
|
||||
container.add(sectionHeader("TARGET"));
|
||||
container.add(targetBar);
|
||||
container.add(sectionHeader("LOOT"));
|
||||
container.add(stats);
|
||||
|
||||
searchField.setIcon(IconTextField.Icon.SEARCH);
|
||||
searchField.setPreferredSize(new Dimension(100, 28));
|
||||
searchField.setBackground(Ui.CARD);
|
||||
searchField.setHoverBackgroundColor(Ui.CARD_HOVER);
|
||||
searchField.addClearListener(this::rebuild);
|
||||
searchField.addKeyListener(new KeyAdapter()
|
||||
{
|
||||
@Override
|
||||
public void keyReleased(KeyEvent e)
|
||||
{
|
||||
rebuild();
|
||||
}
|
||||
});
|
||||
container.add(searchField);
|
||||
|
||||
sortBox.setFont(FontManager.getRunescapeSmallFont());
|
||||
sortBox.setBackground(Ui.CARD);
|
||||
sortBox.setFocusable(false);
|
||||
sortBox.setSelectedItem(SortOrder.RECENT);
|
||||
sortBox.addActionListener(e -> rebuild());
|
||||
container.add(sortBox);
|
||||
|
||||
// section header: source count, clear all
|
||||
JPanel header = new JPanel(new BorderLayout(6, 0));
|
||||
header.setOpaque(false);
|
||||
header.setBorder(BorderFactory.createEmptyBorder(4, 2, 0, 2));
|
||||
countLabel.setFont(FontManager.getRunescapeSmallFont());
|
||||
countLabel.setForeground(Ui.MUTED);
|
||||
header.add(countLabel, BorderLayout.WEST);
|
||||
JLabel clear = new JLabel("Clear all");
|
||||
clear.setFont(FontManager.getRunescapeSmallFont());
|
||||
clear.setForeground(Ui.FAINT);
|
||||
clear.setCursor(Cursor.getPredefinedCursor(Cursor.HAND_CURSOR));
|
||||
clear.setToolTipText("Delete all tracked loot records");
|
||||
clear.addMouseListener(new MouseAdapter()
|
||||
{
|
||||
@Override
|
||||
public void mousePressed(MouseEvent e)
|
||||
{
|
||||
confirmClearAll();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void mouseEntered(MouseEvent e)
|
||||
{
|
||||
clear.setForeground(Ui.DOWN);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void mouseExited(MouseEvent e)
|
||||
{
|
||||
clear.setForeground(Ui.FAINT);
|
||||
}
|
||||
});
|
||||
header.add(clear, BorderLayout.EAST);
|
||||
container.add(header);
|
||||
return container;
|
||||
}
|
||||
|
||||
private static JLabel sectionHeader(String text)
|
||||
{
|
||||
JLabel l = new JLabel(text);
|
||||
l.setFont(FontManager.getRunescapeBoldFont());
|
||||
l.setForeground(Ui.ACCENT);
|
||||
l.setBorder(BorderFactory.createCompoundBorder(
|
||||
BorderFactory.createMatteBorder(1, 0, 0, 0, Ui.CARD),
|
||||
BorderFactory.createEmptyBorder(6, 0, 0, 0)));
|
||||
return l;
|
||||
}
|
||||
|
||||
private static JButton button(String text, Runnable action)
|
||||
{
|
||||
JButton b = new JButton(text);
|
||||
b.setFont(FontManager.getRunescapeSmallFont());
|
||||
b.setFocusPainted(false);
|
||||
b.setMargin(new java.awt.Insets(2, 2, 2, 2));
|
||||
b.setCursor(Cursor.getPredefinedCursor(Cursor.HAND_CURSOR));
|
||||
b.addActionListener(e -> action.run());
|
||||
return b;
|
||||
}
|
||||
|
||||
public void rebuild()
|
||||
{
|
||||
SwingUtilities.invokeLater(() ->
|
||||
{
|
||||
long start = timeFilter.start();
|
||||
List<LootRecord> records = store.getRecords(timeFilter);
|
||||
long totalVal = LootTrackerStore.totalValue(records);
|
||||
int totalKills = LootTrackerStore.totalKills(records);
|
||||
long suppliesCost = LootTrackerStore.totalSuppliesCost(records);
|
||||
long netProfit = totalVal - suppliesCost;
|
||||
double efficiency = LootTrackerStore.overallEfficiency(records);
|
||||
String grade = CombatStats.getGrade(efficiency);
|
||||
String label = timeFilter.getLabel();
|
||||
List<StatsView.Cell> cells = new ArrayList<>();
|
||||
cells.add(new StatsView.Cell("Total loot", Ui.gp(totalVal), label, Ui.GOLD,
|
||||
QuantityFormatter.formatNumber(totalVal) + " gp " + timeFilter.inLabel()));
|
||||
cells.add(new StatsView.Cell("Net profit", (netProfit > 0 ? "+" : "") + Ui.gp(netProfit), null, Ui.deltaColor(netProfit),
|
||||
"Loot minus supplies used in tracked fights"));
|
||||
cells.add(new StatsView.Cell("Kills", QuantityFormatter.formatNumber(totalKills), null, Ui.TEXT, null));
|
||||
cells.add(new StatsView.Cell("Supplies", suppliesCost > 0 ? "-" + Ui.gp(suppliesCost) : "0", null,
|
||||
suppliesCost > 0 ? Ui.DOWN : Ui.TEXT, "Food, potions and ammo used in tracked fights"));
|
||||
boolean anyFights = records.stream().anyMatch(LootRecord::hasCombatStats);
|
||||
cells.add(new StatsView.Cell("Avg performance", anyFights ? String.format(Locale.US, "%.1f%%", efficiency) : "-",
|
||||
anyFights ? grade : null, anyFights ? LootTargetRow.gradeColor(grade) : Ui.FAINT, null));
|
||||
cells.add(new StatsView.Cell("Avg / kill", totalKills > 0 ? Ui.gp(totalVal / totalKills) : "-", null, Ui.TEXT, null));
|
||||
stats.update(cells);
|
||||
fightDetail.lootChanged(lastSnap);
|
||||
|
||||
// Rebuild Detail View if currently active
|
||||
if (currentDetailRecord != null && currentDetailPanel != null && VIEW_DETAIL.equals(currentView))
|
||||
{
|
||||
LootRecord fresh = store.getRecord(currentDetailRecord.getName());
|
||||
if (fresh != null)
|
||||
{
|
||||
currentDetailRecord = fresh;
|
||||
currentDetailPanel.setRecord(fresh.view(timeFilter));
|
||||
}
|
||||
else
|
||||
{
|
||||
closeDetail();
|
||||
}
|
||||
}
|
||||
|
||||
// Rebuild Overview List
|
||||
listBox.removeAll();
|
||||
|
||||
if (records.isEmpty())
|
||||
{
|
||||
JPanel emptyPanel = new JPanel(new DynamicGridLayout(0, 1, 0, 4));
|
||||
emptyPanel.setOpaque(false);
|
||||
emptyPanel.setBorder(BorderFactory.createEmptyBorder(24, 8, 24, 8));
|
||||
|
||||
JLabel emptyLabel = new JLabel(start > 0 && !store.getRecords().isEmpty()
|
||||
? "No loot " + timeFilter.inLabel() : "No loot recorded yet");
|
||||
emptyLabel.setFont(FontManager.getRunescapeBoldFont());
|
||||
emptyLabel.setForeground(Ui.MUTED);
|
||||
emptyLabel.setHorizontalAlignment(JLabel.CENTER);
|
||||
emptyPanel.add(emptyLabel);
|
||||
|
||||
JLabel emptySub = new JLabel("Defeat monsters, open chests, or solve clues!");
|
||||
emptySub.setFont(FontManager.getRunescapeSmallFont());
|
||||
emptySub.setForeground(Ui.FAINT);
|
||||
emptySub.setHorizontalAlignment(JLabel.CENTER);
|
||||
emptyPanel.add(emptySub);
|
||||
|
||||
countLabel.setText("SOURCES (0)");
|
||||
listBox.add(emptyPanel);
|
||||
listBox.revalidate();
|
||||
listBox.repaint();
|
||||
return;
|
||||
}
|
||||
|
||||
// Filter
|
||||
String search = searchField.getText() == null ? "" : searchField.getText().trim().toLowerCase(Locale.ENGLISH);
|
||||
if (!search.isEmpty())
|
||||
{
|
||||
records.removeIf(r ->
|
||||
{
|
||||
if (r.getName() != null && r.getName().toLowerCase(Locale.ENGLISH).contains(search))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
for (var item : r.getItems().values())
|
||||
{
|
||||
if (item.getName() != null && item.getName().toLowerCase(Locale.ENGLISH).contains(search))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
// Sort
|
||||
SortOrder sort = (SortOrder) sortBox.getSelectedItem();
|
||||
if (sort == null)
|
||||
{
|
||||
sort = SortOrder.RECENT;
|
||||
}
|
||||
|
||||
switch (sort)
|
||||
{
|
||||
case VALUE_DESC:
|
||||
records.sort((a, b) -> Long.compare(b.getTotalValue(), a.getTotalValue()));
|
||||
break;
|
||||
case PROFIT_DESC:
|
||||
records.sort((a, b) ->
|
||||
{
|
||||
long profitA = a.getTotalValue() - (a.hasCombatStats() ? a.getCombatStats().getTotalSuppliesCost() : 0);
|
||||
long profitB = b.getTotalValue() - (b.hasCombatStats() ? b.getCombatStats().getTotalSuppliesCost() : 0);
|
||||
return Long.compare(profitB, profitA);
|
||||
});
|
||||
break;
|
||||
case KILLS_DESC:
|
||||
records.sort((a, b) -> Integer.compare(b.getKills(), a.getKills()));
|
||||
break;
|
||||
case NAME_ASC:
|
||||
records.sort((a, b) -> a.getName().compareToIgnoreCase(b.getName()));
|
||||
break;
|
||||
case RECENT:
|
||||
records.sort((a, b) -> Long.compare(b.getLastSeen(), a.getLastSeen()));
|
||||
break;
|
||||
}
|
||||
|
||||
long maxValue = 0;
|
||||
for (LootRecord r : records)
|
||||
{
|
||||
maxValue = Math.max(maxValue, r.getTotalValue());
|
||||
}
|
||||
countLabel.setText("SOURCES (" + records.size() + ")");
|
||||
for (LootRecord r : records)
|
||||
{
|
||||
listBox.add(new LootTargetRow(r, maxValue, itemManager, this::openDetail));
|
||||
}
|
||||
|
||||
listBox.revalidate();
|
||||
listBox.repaint();
|
||||
});
|
||||
}
|
||||
|
||||
/** Stats, gear and drops of the current target. */
|
||||
public void openFight()
|
||||
{
|
||||
currentView = VIEW_FIGHT;
|
||||
fightDetail.showTab(0);
|
||||
cardLayout.show(mainContainer, VIEW_FIGHT);
|
||||
}
|
||||
|
||||
public void openDetail(LootRecord view)
|
||||
{
|
||||
LootRecord base = store.getRecord(view.getName());
|
||||
currentDetailRecord = base != null ? base : view;
|
||||
LootRecord record = currentDetailRecord.view(timeFilter);
|
||||
currentView = VIEW_DETAIL;
|
||||
detailHolder.removeAll();
|
||||
|
||||
currentDetailPanel = new LootRecordDetailPanel(
|
||||
record,
|
||||
itemManager,
|
||||
wikiApi,
|
||||
wikiExecutor,
|
||||
collectionLog,
|
||||
store::getRecord,
|
||||
openWiki,
|
||||
this::closeDetail,
|
||||
rec ->
|
||||
{
|
||||
store.deleteRecord(currentDetailRecord != null ? currentDetailRecord.getName() : rec.getName());
|
||||
closeDetail();
|
||||
}
|
||||
);
|
||||
|
||||
detailHolder.add(currentDetailPanel, BorderLayout.CENTER);
|
||||
detailHolder.revalidate();
|
||||
detailHolder.repaint();
|
||||
|
||||
cardLayout.show(mainContainer, VIEW_DETAIL);
|
||||
}
|
||||
|
||||
public void closeDetail()
|
||||
{
|
||||
currentDetailRecord = null;
|
||||
currentDetailPanel = null;
|
||||
currentView = VIEW_OVERVIEW;
|
||||
cardLayout.show(mainContainer, VIEW_OVERVIEW);
|
||||
rebuild();
|
||||
}
|
||||
|
||||
private void confirmClearAll()
|
||||
{
|
||||
int res = JOptionPane.showConfirmDialog(
|
||||
this,
|
||||
"Are you sure you want to delete ALL tracked loot records?",
|
||||
"Clear Loot Tracker",
|
||||
JOptionPane.YES_NO_OPTION,
|
||||
JOptionPane.WARNING_MESSAGE
|
||||
);
|
||||
if (res == JOptionPane.YES_OPTION)
|
||||
{
|
||||
store.clearAll();
|
||||
closeDetail();
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user