commit 15463c2e3cca9385edf2a551050c3d60537672a6 Author: Hikyu Date: Fri Sep 18 17:18:53 2026 +0200 chore: init diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a28bff2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +.gradle +build +.idea/ +.project +.settings/ +.classpath +nbactions.xml +nb-configuration.xml +nbproject/ \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..ddd407b --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,40 @@ +# EssentialsPlus - Entwicklungsrichtlinien & Build-Workflow + +## ⚠️ EXTREM WICHTIGER DEPLOYMENT- & RELEASE-WORKFLOW + +Bei **JEDEM** Update / Build müssen immer folgende Schritte ausgeführt werden: + +### 1. Lokales Bolt Launcher Deployment +- `./gradlew deploy` baut die Fat-Jar (`shadowJar`) und installiert sie atomar nach: + `~/.local/share/bolt-launcher/custom-runelite.jar` +- Zusätzlich wird die Jar immer nach `/home/hikyu/Downloads/RuneLite.jar` kopiert. + +### 2. Git Release Upload (`Runelite-EssentialsPlus`) +- **Remote**: `ssh://git@git.gapspace.de:2222/Hikyu/Runelite-EssentialsPlus.git` +- **Lokaler Release-Ordner**: `/home/hikyu/Documents/projects/Runelite-EssentialsPlus` +- **Direkter Download-Link**: `https://gapspace.de/Hikyu/Runelite-EssentialsPlus/raw/branch/master/RuneLite.jar` +- **WICHTIGE REGELN**: + 1. In diesem Repository darf **NUR** die einzelne Datei `RuneLite.jar` liegen (keine Ordner, keine Skripte, keine anderen Dateien). + 2. Es darf **keine Versions-Historie** auf dem Server akkumulieren: Alte Versionen werden bei jedem Update überschrieben. + 3. Der Befehl zum Aktualisieren des Release-Repos lautet: + ```bash + cp build/libs/bank-highlighter-1.0-SNAPSHOT-all.jar /home/hikyu/Documents/projects/Runelite-EssentialsPlus/RuneLite.jar + cd /home/hikyu/Documents/projects/Runelite-EssentialsPlus + git add RuneLite.jar + git commit --amend -m "RuneLite EssentialsPlus (neuestes Update)" --date=now + git push -f origin master + ``` + +--- + +## 🛠️ Plugin-Module Übersicht +- **Deterministischer Attack Timer** (`com.example.essentialsplus.helpers.combat.*`): + - 100% deterministische Berechnung von Waffengeschwindigkeit, Ranged Rapid-Modus (-1 Tick), Powered Staves (4 Ticks), Manual/Autocast Spells (5 Ticks, Harmonised 4 Ticks, Twinflame 6 Ticks) inkl. Casting-Sound-Erkennung. + - Spezielle Boss-Mechaniken: Tormented Demons, Blood Moon Set, Red Keris, Scurrius, Amoxliatl, ToA Warden Skulls, Eye of Ayak, Doom of Mokhaiotl, Royal Titans, Maggot King, Yama Shadow Crash. + - Essens-/Trankverzögerungen (Fast Foods 2 Ticks, Standard 3 Ticks, Vampyrium Tartare 4 Ticks). + - Design und Einstellungen in `AttackTimerOverlay.java` und `EssentialsPlusConfig.java` bleiben optisch immer unverändert. +- **Item Charges Helper** (`com.example.essentialsplus.helpers.ItemCharges*`): + - Lückenlose Ladungsanzeige & Persistenz für Barrows (Dharok etc. in %), Blowpipe (Darts & Scales getrennt/kombiniert), Coal Bag, Gem Bag, Herb Sack, Fish Barrel, Arclight/Emberlight, Ringe, Jewelry, Crystal Tools/Armor. +- **Wealth & Currency Store**: Vermögen, GE-Buy-Limits und Minigame-Währungen. +- **Loot & Combat Tracker**: Drop Tables, Sessions, DPS-Rechner. +- **Bank Highlighter**: Farbhervorhebung in Bank-Tabs. diff --git a/README.md b/README.md new file mode 100644 index 0000000..fc7d115 --- /dev/null +++ b/README.md @@ -0,0 +1,182 @@ +# RuneLite Example Plugin ("Hello World") + +Ein einsatzbereites RuneLite-Plugin-Beispielprojekt. Das Plugin sendet eine **"Hello World!"**-Nachricht in den Ingame-Chat, sobald du dich einloggst oder das Plugin aktivierst. + +--- + +## 📁 Projektstruktur + +``` +. +├── build.gradle # Gradle Build-Konfiguration +├── settings.gradle # Projektname +├── gradlew / gradlew.bat # Gradle Wrapper (keine manuelle Gradle-Installation nötig) +└── src + ├── main/java/com/example + │ ├── ExamplePlugin.java # Hauptklasse des Plugins + │ └── ExampleConfig.java # Einstellungs-Menü im RuneLite Config-Panel + └── test/java/com/example + └── ExamplePluginTest.java # Startet RuneLite mit dem geladenen Plugin +``` + +--- + +## 🚀 Wie starte / teste ich das Plugin lokal? + +Der Standard- und einfachste Weg in der RuneLite-Entwicklung ist der **integrierte Test-Client**. Dadurch musst du keine JAR-Dateien manuell kopieren oder den offiziellen Client patchen. + +### Option 1: Über das Terminal / CLI (am schnellsten) + +Führe einfach folgenden Befehl im Projektordner aus: + +```bash +./gradlew run +``` + +*Unter Windows:* +```cmd +gradlew.bat run +``` + +RuneLite startet im Entwicklermodus mit deinem geladenen Plugin. Sobald du dich mit deinem Account einloggst, erscheint im Chat: +> `Example Plugin: Hello World!` + +--- + +### Option 2: Über eine IDE (IntelliJ IDEA / VS Code / Eclipse) + +1. **IntelliJ IDEA öffnen** $\rightarrow$ `Open` $\rightarrow$ diesen Projektordner auswählen $\rightarrow$ als Gradle-Projekt importieren. +2. Zu [`ExamplePluginTest.java`](file:///home/hikyu/Documents/projects/orsrs-plugin-bank-highlighter/src/test/java/com/example/ExamplePluginTest.java) navigieren. +3. Den grünen **Play-Button ▶** neben `main()` anklicken (oder `Shift + F10`). +4. RuneLite startet sofort mit Hot-Reloading & Debugging-Möglichkeiten. + +--- + +## ⚙️ Wie funktioniert das "Hello World"? + +In [`ExamplePlugin.java`](file:///home/hikyu/Documents/projects/orsrs-plugin-bank-highlighter/src/main/java/com/example/ExamplePlugin.java): +1. **Beim Login:** Das Event `@Subscribe public void onGameStateChanged(GameStateChanged gameStateChanged)` fängt `GameState.LOGGED_IN` ab und sendet die Nachricht. +2. **Bei Plugin-Aktivierung:** In `startUp()` wird geprüft, ob du bereits eingeloggt bist (`client.getGameState() == GameState.LOGGED_IN`), und sendet ebenfalls die Nachricht. +3. **Anpassbar:** Die Nachricht kann im RuneLite Einstellungsmenü (unter **Example Plugin** in der Seitenleiste) konfiguriert werden. + +--- + +## 📦 Standalone-JAR bauen (ShadowJar) + +Falls du eine eigenständige JAR-Datei benötigst: + +```bash +./gradlew shadowJar +``` + +Die fertige JAR befindet sich anschließend in: +`build/libs/bank-highlighter-1.0-SNAPSHOT-all.jar` + +--- + +## ⚡ Installation in Bolt (lokal) + +Die Plugins (ExamplePlugin, LiveDashboardPlugin, EssentialsPlusPlugin) werden über +den Einstiegspunkt `ExamplePluginTest.main` direkt als Builtin-Externals in den +RuneLite-Client injiziert — es ist **kein** Bolt-Plugin-Manifest (RS3) nötig. + +### 1. Einmalige Bolt-Konfiguration + +In `~/.config/bolt-launcher/launcher.json` muss gesetzt sein: + +```json +"runelite_use_custom_jar": true, +"runelite_custom_jar": "/home/hikyu/.local/share/bolt-launcher/custom-runelite.jar", +"osrs_launch_command": "java -jar /home/hikyu/.local/share/bolt-launcher/custom-runelite.jar" +``` + +### 2. Bauen & installieren + +```bash +./gradlew deploy +``` + +Der Befehl baut den Shadow-Jar und kopiert ihn **atomar** nach +`~/.local/share/bolt-launcher/custom-runelite.jar`, so dass er auch bei laufendem +Bolt/RuneLite sicher ersetzt werden kann (build.gradle `deploy`). + +### 3. Automatische Neuinstallation bei jeder Änderung + +Lasse den Continuous-Build im Projektordner laufen — er überwacht die Quelldateien +und installiert automatisch neu, sobald etwas geändert wird: + +```bash +./gradlew deploy -t +``` + +*(Beenden mit `Ctrl+C`. Der Watcher kompiliert und kopiert die JAR bei jeder +Quellcode-Änderung selbstständig.)* + +### 4. Start in Bolt + +1. Bolt starten → **RuneLite** wählen → einloggen. +2. „Essentials Plus" erscheint mit einem Icon in der Seitenleiste. +3. Falls nicht sichtbar: RuneLite-Hamburger → **Configuration** → **External Plugins** → „Essentials Plus" aktivieren. + +--- + +## 🪟 Windows / Jagex Launcher (offizielles RuneLite) + +Auf Windows wird RuneLite über den **Jagex Launcher** gestartet. Dort lässt sich der +Bolt-Trick (eigenes Client-JAR mit `ExamplePluginTest.main`) **nicht** verwenden, weil +der Jagex Launcher immer die offizielle RuneLite-EXE startet. Stattdessen nutzt man das +offizielle **Side-Loading** von RuneLite: Beim Client-Start lädt RuneLite alle `.jar` +Dateien aus `~\.runelite\sideloaded-plugins\` nach – sofern der **Developer Mode** +aktiviert ist. `runelite-plugin.properties` wird dabei nicht gebraucht. + +### 1. Plugin-JAR bauen (klein, ohne Client) + +```cmd +gradlew.bat jar +``` + +Ergebnis: `build\libs\bank-highlighter-1.0-SNAPSHOT.jar` (enthält nur `com.example.*` +Klassen, keine `net.runelite.*` – der richtige Jar-Typ zum Side-Loaden). + +### 2. JAR in den Side-Load-Ordner kopieren + +Ordner anlegen (falls nicht vorhanden) und das JAR hineinkopieren: + +``` +%USERPROFILE%\.runelite\sideloaded-plugins\bank-highlighter.jar +``` + +*Beispiel via Explorer: `Start` → `%USERPROFILE%\.runelite\` eingeben → Ordner +`sideloaded-plugins` erstellen → JAR hineinlegen.* + +### 3. Developer Mode in RuneLite aktivieren + +1. RuneLite über den **Jagex Launcher** starten. +2. Schraubenschlüssel (Configuration) → ganz nach unten scrollen → **RuneLite**-Reiter. +3. **„Enable Developer Mode"** aktivieren (Hinweis bestätigen). +4. RuneLite vollständig schließen und erneut starten. + +### 4. Plugin aktivieren + +Nach dem Neustart erscheinen die Plugins in der Plugin-Liste (Plug-Icon) unter +**External Plugins** (unten). + +- **Essentials Plus** aktivieren. +- Weitere: **Example Plugin**, **Live Dashboard**. + +### Automatisch aktualisieren + +Sobald sich etwas im Quellcode ändert, im Projektordner laufen lassen: + +```cmd +gradlew.bat jar -t +``` + +Der Watcher kompiliert das schlanke JAR automatisch neu. Danach **RuneLite einmal +neu starten**, damit das neue JAR geladen wird (Side-Loaded-Plugins werden nur beim +Client-Start eingelesen – nicht live). + +> ⚠️ **Hinweis:** Nach einem OSRS-Game-Update muss das JAR frisch gebaut werden +> (`gradlew.bat jar` bzw. der Watcher), damit die API (`net.runelite.api.gameval`, …) +> zum laufenden Client-Rev passt. Läuft ein älteres JAR, können Plugins mit Fehlern +> abstürzen. diff --git a/Update-EssentialsPlus.bat b/Update-EssentialsPlus.bat new file mode 100644 index 0000000..6717b13 --- /dev/null +++ b/Update-EssentialsPlus.bat @@ -0,0 +1,94 @@ +@echo off +setlocal enabledelayedexpansion +title RuneLite EssentialsPlus Updater +color 0B + +echo. +echo ======================================================== +echo RuneLite EssentialsPlus Updater +echo ======================================================== +echo. + +set "SCRIPT_DIR=%~dp0" +set "TARGET_JAR=%SCRIPT_DIR%RuneLite.jar" +set "OLD_JAR=%SCRIPT_DIR%RuneLite.jar.old" +set "TMP_JAR=%SCRIPT_DIR%RuneLite.jar.tmp" +set "DOWNLOAD_URL=https://gapspace.de/Hikyu/Runelite-EssentialsPlus/raw/branch/master/RuneLite.jar" + +:: ----------------------------------------------------------- +:: Schritt 1: Bestehende Version sichern +:: ----------------------------------------------------------- +echo [1/3] Sichere bestehende Version... +if exist "%TARGET_JAR%" ( + if exist "%OLD_JAR%" del /f /q "%OLD_JAR%" 2>nul + move /y "%TARGET_JAR%" "%OLD_JAR%" >nul + if !errorlevel! equ 0 ( + echo - Alte Version gesichert als: RuneLite.jar.old + ) else ( + echo [!] FEHLER: Konnte RuneLite.jar nicht verschieben. + echo Bitte schliesse RuneLite / Bolt Launcher und versuche es erneut. + echo. + pause + exit /b 1 + ) +) else ( + echo - Keine vorherige RuneLite.jar gefunden (Neuinstallation). +) + +echo. + +:: ----------------------------------------------------------- +:: Schritt 2: Neueste Version herunterladen +:: ----------------------------------------------------------- +echo [2/3] Lade neueste Version herunter... +echo Quelle: %DOWNLOAD_URL% +echo. + +if exist "%TMP_JAR%" del /f /q "%TMP_JAR%" 2>nul + +where curl >nul 2>nul +if !errorlevel! equ 0 ( + curl -# -L -f -o "%TMP_JAR%" "%DOWNLOAD_URL%" +) else ( + echo (Verwende PowerShell fuer Download...) + powershell -Command "[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12; (New-Object Net.WebClient).DownloadFile('%DOWNLOAD_URL%', '%TMP_JAR%')" +) + +if not exist "%TMP_JAR%" ( + echo. + echo [!] FEHLER: Download fehlgeschlagen! + if exist "%OLD_JAR%" ( + echo Stelle alte Version wieder her... + move /y "%OLD_JAR%" "%TARGET_JAR%" >nul + ) + echo. + pause + exit /b 1 +) + +:: Datei fertigstellen +move /y "%TMP_JAR%" "%TARGET_JAR%" >nul + +echo. + +:: ----------------------------------------------------------- +:: Schritt 3: Fertigstellung +:: ----------------------------------------------------------- +echo [3/3] Aktualisierung erfolgreich abgeschlossen! +echo - Datei: RuneLite.jar +echo. + +:: Optional: Falls im Bolt Launcher Verzeichnis, auch custom-runelite.jar synchronisieren +if exist "%LOCALAPPDATA%\bolt-launcher" ( + copy /y "%TARGET_JAR%" "%LOCALAPPDATA%\bolt-launcher\custom-runelite.jar" >nul 2>nul +) +if exist "%APPDATA%\bolt-launcher" ( + copy /y "%TARGET_JAR%" "%APPDATA%\bolt-launcher\custom-runelite.jar" >nul 2>nul +) + +color 0A +echo ======================================================== +echo Bereit! Du kannst RuneLite jetzt starten. +echo ======================================================== +echo. +pause diff --git a/Update-EssentialsPlus.sh b/Update-EssentialsPlus.sh new file mode 100755 index 0000000..0b51e75 --- /dev/null +++ b/Update-EssentialsPlus.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash +set -e + +# Farben für die Ausgabe +CYAN='\033[0;36m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +RED='\033[0;31m' +NC='\033[0m' # No Color + +echo -e "\n${CYAN}========================================================${NC}" +echo -e "${CYAN} RuneLite EssentialsPlus Linux Updater ${NC}" +echo -e "${CYAN}========================================================${NC}\n" + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +TARGET_JAR="$SCRIPT_DIR/RuneLite.jar" +OLD_JAR="$SCRIPT_DIR/RuneLite.jar.old" +TMP_JAR="$SCRIPT_DIR/RuneLite.jar.tmp" +DOWNLOAD_URL="https://gapspace.de/Hikyu/Runelite-EssentialsPlus/raw/branch/master/RuneLite.jar" + +# 1. Bestehende Version sichern +echo -e "${YELLOW}[1/3] Sichere bestehende Version...${NC}" +if [ -f "$TARGET_JAR" ]; then + rm -f "$OLD_JAR" + mv "$TARGET_JAR" "$OLD_JAR" + echo -e " -> Alte Version gesichert als: ${CYAN}RuneLite.jar.old${NC}" +else + echo -e " -> Keine vorherige RuneLite.jar gefunden (Neuinstallation)." +fi +echo "" + +# 2. Neueste Version herunterladen +echo -e "${YELLOW}[2/3] Lade neueste Version herunter...${NC}" +echo -e " Quelle: ${CYAN}$DOWNLOAD_URL${NC}\n" + +rm -f "$TMP_JAR" +if ! curl -# -L -f -o "$TMP_JAR" "$DOWNLOAD_URL"; then + echo -e "\n${RED}[!] FEHLER: Download fehlgeschlagen!${NC}" + if [ -f "$OLD_JAR" ]; then + echo -e " Stelle alte Version wieder her..." + mv "$OLD_JAR" "$TARGET_JAR" + fi + exit 1 +fi + +mv "$TMP_JAR" "$TARGET_JAR" +echo "" + +# 3. Fertigstellung & optionaler Bolt-Launcher-Sync +echo -e "${YELLOW}[3/3] Aktualisierung erfolgreich abgeschlossen!${NC}" +echo -e " -> Datei: ${GREEN}$TARGET_JAR${NC}" + +BOLT_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/bolt-launcher" +if [ -d "$BOLT_DIR" ]; then + cp "$TARGET_JAR" "$BOLT_DIR/custom-runelite.jar" + echo -e " -> Bolt Launcher synchronisiert: ${GREEN}$BOLT_DIR/custom-runelite.jar${NC}" +fi + +echo -e "\n${GREEN}========================================================${NC}" +echo -e "${GREEN} Bereit! Du kannst RuneLite jetzt starten. ${NC}" +echo -e "${GREEN}========================================================${NC}\n" diff --git a/build.gradle b/build.gradle new file mode 100644 index 0000000..849f068 --- /dev/null +++ b/build.gradle @@ -0,0 +1,102 @@ +plugins { + id 'java' +} + +repositories { + mavenLocal() + maven { + url = 'https://repo.runelite.net' + content { + includeGroupByRegex("net\\.runelite.*") + } + } + mavenCentral() +} + +def runeLiteVersion = 'latest.release' +def pluginMainClass = 'com.example.ExamplePluginTest' + +dependencies { + compileOnly group: 'net.runelite', name: 'client', version: runeLiteVersion + + compileOnly 'org.projectlombok:lombok:1.18.30' + annotationProcessor 'org.projectlombok:lombok:1.18.30' + + testImplementation 'junit:junit:4.12' + testImplementation group: 'net.runelite', name: 'client', version: runeLiteVersion + testImplementation group: 'net.runelite', name: 'jshell', version: runeLiteVersion + testCompileOnly 'org.projectlombok:lombok:1.18.30' + testAnnotationProcessor 'org.projectlombok:lombok:1.18.30' +} + +group = 'com.example' +version = '1.0-SNAPSHOT' + +tasks.withType(JavaCompile).configureEach { + options.encoding = 'UTF-8' + options.release.set(11) +} + +tasks.register('run', JavaExec) { + classpath = sourceSets.test.runtimeClasspath + mainClass = pluginMainClass + + jvmArgs "-ea" + args "--developer-mode", "--debug" +} + +tasks.register('shadowJar', Jar) { + dependsOn configurations.testRuntimeClasspath + manifest { + attributes('Main-Class': pluginMainClass, 'Multi-Release': true) + } + + duplicatesStrategy = DuplicatesStrategy.EXCLUDE + from sourceSets.main.output + from sourceSets.test.output + from { + configurations.testRuntimeClasspath.collect { file -> + file.isDirectory() ? file : zipTree(file) + } + } + + exclude 'META-INF/INDEX.LIST' + exclude 'META-INF/*.SF' + exclude 'META-INF/*.DSA' + exclude 'META-INF/*.RSA' + exclude '**/module-info.class' + + group = BasePlugin.BUILD_GROUP + archiveClassifier.set('shadow') + archiveFileName.set("${rootProject.name}-${project.version}-all.jar") +} + +tasks.register('deploy') { + dependsOn 'shadowJar' + doLast { + def destDir = new File("${System.getProperty('user.home')}/.local/share/bolt-launcher") + destDir.mkdirs() + def srcFile = file("build/libs/${rootProject.name}-${project.version}-all.jar") + def targetFile = new File(destDir, "custom-runelite.jar") + def tmpFile = new File(destDir, "custom-runelite.jar.tmp") + + // Copy to temporary file first + tmpFile.bytes = srcFile.bytes + + // Atomic move (keeps open file descriptors valid on running Linux processes) + java.nio.file.Files.move( + tmpFile.toPath(), + targetFile.toPath(), + java.nio.file.StandardCopyOption.ATOMIC_MOVE, + java.nio.file.StandardCopyOption.REPLACE_EXISTING + ) + // Also copy to Downloads as documented in CLAUDE.md + def downloadsDir = new File("${System.getProperty('user.home')}/Downloads") + if (downloadsDir.exists()) { + new File(downloadsDir, "RuneLite.jar").bytes = srcFile.bytes + } + println "Plugin sicher und atomar nach ~/.local/share/bolt-launcher/custom-runelite.jar installiert!" + } +} + + diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..a4b76b9 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..9036682 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,8 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionSha256Sum=682b4df7fe5accdca84a4d1ef6a3a6ab096b3efd5edf7de2bd8c758d95a93703 +distributionUrl=https\://services.gradle.org/distributions/gradle-8.10-all.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..f5feea6 --- /dev/null +++ b/gradlew @@ -0,0 +1,252 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s +' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..9b42019 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/item-charges.md b/item-charges.md new file mode 100644 index 0000000..957ab6d --- /dev/null +++ b/item-charges.md @@ -0,0 +1,136 @@ +# Item charges – Taskbeschreibung + +Implementiere einen **„Item charges"**-Helper in **Essentials Plus** (`com.example.essentialsplus.helpers`): Auf dem **Icon jedes Items** mit bekanntem Charge/Füllstand wird eine **Zahl** gerendert. Manche Items zeigen **mehrere Zahlen** (z. B. Toxic Blowpipe: Scales + Darts). Togglebar per Checkbox, anzeige in Inventar, Ausrüstung und (optional) Bank. + +**Beispiele:** +- Toxic Blowpipe (mit Scales) → `scales`-Zahl; mit geladenen Darts → zweiter Wert (Darts-Marker, s. Hinweise). +- Dizan's Quiver → zwei Zahlen: Ammo (`CHARGES_DIZANAS_QUIVER_QUANTITY`) + Blessing (`CHARGES_DIZANAS_QUIVER_BLESSING`). +- Trident/Shadow/Tome/Bowfa etc. → eine Zahl. +- **Lootingsack**: Geldwert (Coins) als Zahl auf dem Sack-Icon. +- **Coal Bag**: Füllstand via Chat-Tracking + Persistenz (kein Varbit!). + +**Format:** Ganzzahlen / Stack-Format (Geld: `QuantityFormatter.quantityToStackSize`, z. B. `1.2M`). Unbekannter Wert → `?`. + +## Datenquellen & Mechanik + +### 1. `CHARGES_*`-Varbits (Hauptquelle, charakterweit) + +Werte via `client.getVarbitValue(id)` (`net.runelite.api.gameval.VarbitID`). Mapping Item-Gruppe → Varbit (verifiziert, Release 1.12.39). Varbit aus der **ID-Variante** des Items ableiten (s. Quelle 2), nicht aus einem Katalog — zwei identische Waffen haben nur EINEN Varbit-Wert (OSRS-Grenze!). + +| Item-Gruppe (ItemID-Konstanten) | VarbitID-Konstante | +|---|---| +| `TOXIC_BLOWPIPE`, `TOXIC_BLOWPIPE_LOADED`, `TOXIC_BLOWPIPE_ORNAMENT`, `TOXIC_BLOWPIPE_LOADED_ORNAMENT` | `CHARGES_TOXIC_BLOWPIPE_QUANTITY` | +| `SERPENTINE_HELM`, `SERPENTINE_HELM_CHARGED`, `_CYAN`, `_CYAN_CHARGED`, `_RED`, `_RED_CHARGED` | `CHARGES_SERPENTINE_HELM_QUANTITY` | +| `WARPED_SCEPTRE`, `WARPED_SCEPTRE_UNCHARGED` | `CHARGES_WARPED_SCEPTRE_QUANTITY` | +| `TUMEKENS_SHADOW`, `TUMEKENS_SHADOW_UNCHARGED`, `TUMEKENS_HEKA`, `TUMEKENS_HEKA_UNCHARGED` | `CHARGES_TUMEKENS_SHADOW_QUANTITY` | +| `SANGUINESTI_STAFF`, `SANGUINESTI_STAFF_UNCHARGED`, `_OR`, `_UNCHARGED_OR` | `CHARGES_SANGUINESTI_STAFF_QUANTITY` | +| `TRIDENT_OF_THE_SEAS` (+ `_E`), jew. gecharge/un-gecharge-Varianten | `CHARGES_TRIDENT_OF_THE_SEAS_QUANTITY` bzw. `..._E_QUANTITY` | +| `TOXIC_STAFF_OF_THE_DEAD` (+ `_UNCHARGED`, `_OR`-Varianten) | `CHARGES_TOXIC_STAFF_OF_THE_DEAD_QUANTITY` | +| `BOW_OF_FAERDHINEN`, `BOW_OF_FAERDHINEN_INACTIVE`, `_INFINITE*` | `CHARGES_BOW_OF_FAERDHINEN_QUANTITY` (+ `CHARGES_BOW_OF_FAERDHINEN_CORRUPTION` für korrupte Variante) | +| `BLADE_OF_SAELDOR`, `BLADE_OF_SAELDOR_INACTIVE`, `_INFINITE` | `CHARGES_BLADE_OF_SAELDOR_QUANTITY` (+ `_CORRUPTION`) | +| `VENATOR_BOW`, `VENATOR_BOW_UNCHARGED`, `VENATOR_BOW_ORNAMENT(_UNCHARGED)` | `CHARGES_VENATOR_BOW_QUANTITY` | +| `TONALZTICS_OF_RALOS_CHARGED`, `_UNCHARGED` | `CHARGES_TONALZTICS_OF_RALOS_QUANTITY` | +| `ARCLIGHT` | `CHARGES_ARCLIGHT_QUANTITY` | +| `DIZANAS_QUIVER_CHARGED`, `_UNCHARGED`, `_INFINITE`, `_BROKEN`, Trouver-Varianten | `CHARGES_DIZANAS_QUIVER_QUANTITY` (Ammo) + `CHARGES_DIZANAS_QUIVER_BLESSING` (Blessing) — zwei Zahlen | +| `TOME_OF_FIRE`, `TOME_OF_FIRE_UNCHARGED` | `CHARGES_TOME_OF_FIRE_QUANTITY` | +| `TOME_OF_WATER`, `TOME_OF_WATER_UNCHARGED` | `CHARGES_TOME_OF_WATER_QUANTITY` | +| `TOME_OF_EARTH`, `TOME_OF_EARTH_UNCHARGED` | `CHARGES_TOME_OF_EARTH_QUANTITY` | +| `ASH_SANCTIFIER` | `CHARGES_ASH_SANCTIFIER_QUANTITY` | +| `BONECRUSHER` | `CHARGES_BONECRUSHER_QUANTITY` | +| `BRYOPHYTAS_STAFF` | `CHARGES_BRYOPHYTAS_STAFF_QUANTITY` | +| `CIRCLET_OF_WATER` | `CHARGES_CIRCLET_OF_WATER_QUANTITY` | +| `SOUL_BEARER` | `CHARGES_SOUL_BEARER_QUANTITY` | +| `CELESTIAL_RING`, `CELESTIAL_RING_CHARGED` | `CHARGES_CELESTIAL_RING_QUANTITY` | +| `WILD_CAVE_SCEPTRE_CHARGED`, `WILD_CAVE_SCEPTRE_UNCHARGED` | `CHARGES_WILDERNESS_WEAPON_QUANTITY` | +| `BRACELET_OF_ETHEREUM` | `CHARGES_BRACELET_OF_ETHEREUM_QUANTITY` | +| `RING_OF_SUFFERING` | `CHARGES_RING_OF_SUFFERING_QUANTITY` | +| `XERIC_TALISMAN`, `XERIC_TALISMAN_EMPTY` | `CHARGES_XERICS_TALISMAN_QUANTITY` | +| `RING_OF_SHADOWS`, `RING_OF_SHADOWS_UNCHARGED` | `CHARGES_RING_OF_SHADOWS_QUANTITY` | +| `ECHO_BOOTS` | `CHARGES_ECHO_BOOTS_QUANTITY` | +| `AMULET_OF_BLOOD_FURY` | `CHARGES_AMULET_OF_BLOOD_FURY_QUANTITY` | +| `GIANTSOUL_AMULET_CHARGED`, `_UNCHARGED` | `CHARGES_GIANTSOUL_AMULET_QUANTITY` | +| `EYE_OF_AYAK`, `EYE_OF_AYAK_UNCHARGED` | `CHARGES_EYE_OF_AYAK_QUANTITY` | +| `HORN_OF_PLENTY`, `_UNCHARGED`, `SAILORS_AMULET`, `_EMPTY`, `COWBELL_AMULET`, `_EMPTY`, `PENDANT_OF_ATES`, `_EMPTY` | jew. `CHARGES_*_QUANTITY`-Varbit | +| Crystal Armour/Tools (Crystal, `CRYSTAL_*` piece IDs) | `CHARGES_CRYSTAL_ARMOUR_QUANTITY`, `CHARGES_CRYSTAL_TOOLS_QUANTITY`, `CHARGES_CRYSTAL_WEAK_QUANTITY` | +| `ALCHEMIST_RING` | `CHARGES_ALCHEMIST_AMULET_QUANTITY` | + +Vollständige Liste ergänzen per `javap -constants net.runelite.api.gameval.VarbitID | grep 'CHARGES_.*_QUANTITY'` gegen das runelite-api-Jar (release 1.12.39; Pfad s. **Referenzen**). Das sind ~55 Konstanten; es kommen auch Solo-/Sailing-Charges vor (`CHARGES_PLANK_SACK*`, `CHARGES_BOWSTRING_SPOOL*`), nur relevante übernehmen. Stichproben-Hinweis: `DBTableID$Charges$Row` (in runelite-api `net.runelite.api.gameval.DBTableID`) listet dieselben Charge-Typen als DB-Rows (Referenz/Querschnitt, nicht zwingend zur Laufzeit nötig). + +Varianten-Ableitung für die Tabelle: Einträge in `net.runelite.api.gameval.ItemID` mit typischen Suffixen (`_CHARGED`, `_UNCHARGED`, `_INACTIVE`, `_EMPTY`, `_LOADED`, `_OR`, `_CYAN/_RED`, `_TROUVER`, `_INFINITE`, `_BROKEN`) — per `javap -constants ... ItemID | grep` verifizieren. + +### 2. Item-ID-Statusvarianten (Ladungs-Status) + +Viele Items wechseln die **Item-ID** bei leer/laden — mehrere exakte IDs auf dieselbe Charge abbilden bzw. „0"-Semantik: + +- `TOXIC_BLOWPIPE` (12924) vs `TOXIC_BLOWPIPE_LOADED` (12926): **Darts geladen: ja/nein** (zweiter Wert, Marker; die exakte **Dart-Anzahl ist NICHT in der API** — kein Varbit/Container). `DRYGORE_BLOWPIPE` vs `DRYGORE_BLOWPIPE_LOADED` analog. +- `BOW_OF_FAERDHINEN_INACTIVE` → 0; `BLADE_OF_SAELDOR_INACTIVE` → 0; `*_INFINITE` → „∞". +- Alle `*_UNCHARGED`/`*_EMPTY` → 0 bzw. kein Varbit abfragen. + +### 3. Container (Echtzeit; per GameTick gelesen) + +- **Lootingsack**: `client.getItemContainer(InventoryID.LOOTING_BAG)` (auch `LOOTING_BAG_OPEN`). Geldwert = Σ qty von `ItemID.COINS` im Container. (Plugin hat bereits `readContainer()`/WealthStore für Loot — hier eigenes Lesen im Overlay-Tick, da das Overlay nicht an den Container-Event gebunden ist.) +- **Dizan's Quiver Ammo**: `InventoryID.DIZANAS_QUIVER_AMMO` als Crosscheck/Geld-Anzeige zum Varbit-Wert möglich. +- **Coal/Gem/Herb sack**: kein Container, kein Varbit (verifiziert) → Quelle 4. + +### 4. Coal Bag — Chat-Tracking + Persistenz + +- Kein Varbit/Container in `VarbitID`/`InventoryID` (verifiziert; nur `ItemID.COAL_BAG`/`COAL_BAG_OPEN`). +- Füllstand (max **27**) ableiten aus **Chat-Nachrichten** + **letzten bekannten Wert pro Account** in `configManager` persistieren (key `coalBag.`, Wert int 0–27; `accountKey`-Konvention wie bestehende Persistenz im Plugin). +- Zu verarbeitende Muster (ChatMessage-Filter `Game`): Check-/Fill-Feedback „[…]`X` coal in your coal bag]" o. Ä. *exakt in-game verifizieren* (2024 wurde Feedback von Popups auf Chat umgestellt); zusätzlich Empty → 0; bei unbekannter Nachricht/Login ohne Check → **`?`** anzeigen statt falscher Zahl. +- `ChatMessage`-Events: Fehler-tolerant parsen (Regex), bei Update sofort speichern. Beim `LOGOUT` aktuellen Wert speichern. Optional Rechtsklick „Coal bag vergessen" → Wert nicht aus Chat-Nachrichten ableiten. + +## Neue Dateien + +### `helpers/ItemCharges.java` (`@Singleton`, Logik) + +- **Datenmodell**: `ChargeValue { String text; Color color; }` (+ optional 2. Wert). Lookup-API `List chargesFor(int itemId)`: + 1. exakte `itemId` in Tabelle (Load/Empty/Inactive-Varianten); dann + 2. `itemManager.canonicalize(itemId)` (Noten/Placeholder) erneut prüfen. +- **`CHARGE_MAP`**: `Map` statisch aufgebaut aus den ItemID-Konstanten (Quelle 1+2); `ChargeSource` = Varbit-Konstante bzw. `COAL_BAG`-Typ bzw. `LOOTING_BAG`-Container-Typ. +- **State**: Coal-Bag-Count pro Account + `Map`-Cache nur so weit nötig (Varbit-Lesen ist billig; kein harter Cache zwingend). +- **Events** (im Plugin registriert, Muster `TreeCooldowns`): + - `GameTick` → die zwei Bag-Varbits/Container aktualisieren. + - `VarbitChanged` → relevante Varbit-Id → Value merken. + - `ChatMessage` → Coal-Bag-Messages parsen (s. o.), speichern. + - `GameStateChanged` → `!= LOGGED_IN` → `reset()`, Coal-Bag-Count aus `configManager` laden bzw. auf „?" zurücksetzen. +- **Reset**: Coal-Bag-Cache auf `?` beim Logout (Persistenz bleibt). + +### `helpers/ItemChargesOverlay.java` (`WidgetItemOverlay`) + +- `showOnInventory(); showOnEquipment(); showOnBank();` — Bank über Config-Sub-Key steuerbar (s. unten). +- `renderItemOverlay(g, itemId, WidgetItem)`: + - `if (!config.itemCharges()) return;` + - `List` aus `chargesFor(itemId)`; leer → nichts rendern. + - Zahl unten-links am Icon (offizielles RuneLite-Muster): `bounds.x - 1, bounds.y + 15`, mit Schatten (`Fonts.RUNESCAPE_SMALL` bzw. `TimerTextSize`), Farbe aus `itemChargesColor`; **2. Wert** (Blowpipe-Darts, Quiver-Blessing) darüber (`bounds.y - 3`) in `itemChargesSecondaryColor`. + - Geld (Lootingsack): Stack-Size-Format; Low-Charge-Färbung ab `itemChargesLowAt` → `itemChargesLowColor`. +- Coins/Loot-Bag-Wert: aus `chargesFor(LOOTING_BAG_OPEN/LOOTING_BAG)`-Container-Lesung im Overlay-Tick (Falls Container null → kein Wert). + +## Zu ändernde Dateien + +- **`EssentialsPlusConfig.java`**: `@ConfigItem itemCharges` (bool, default `false`, position ~37). Optionen als `configManager`-Keys via `setHelperOption` (Muster wie treeCooldowns): + - `itemChargesWeapons` (bool, true) — Varbit-Charges an/aus + - `itemChargesBags` (bool, true) — Loot-Bag-Geld + Coal-Bag-Füllstand an/aus + - `itemChargesBank` (bool, false) — Overlay auch in Bank (Achtung Hinweise!) + - `itemChargesSize` (`TimerTextSize`, SMALL), `itemChargesColor` (Color, weiß), `itemChargesSecondaryColor` (Color, hellgrau `0xC0C0C0`), `itemChargesLowColor` (Color, rot `0xFF3333`), `itemChargesLowAt` (int, 0 = aus) +- **`EssentialsPlusPlugin.java`**: `@Inject ItemCharges` + `ItemChargesOverlay`; `startUp()`: `eventBus.register(itemCharges)`, `overlayManager.add(itemChargesOverlay)`; `shutDown()`: spiegeln + `itemCharges.reset()`. `isHelperOption()`: `case "itemCharges"` → `config.itemCharges()`. `setHelperOption`/`onConfigChanged`: Bestände für `itemcharges*`-Keys refresh (Muster solcher Bestands-Abschnitte). +- **`HelpersCard.java`**: neues `JCheckBox "Item charges"` (Tooltip: „Charge-Zahl auf Item-Icons (Blowpipe, Trident, Tome, …) + Loot-Bag-Geld & Coal-Bag-Füllstand. Rechtsklick für Optionen"). Rechtsklick-Menü analog `styleMenu`/`treeMenu`: Toggles `itemChargesWeapons`/`itemChargesBags`/`itemChargesBank`, Größe, Farben, Schwellenwert, Reset. `refresh()` erweitern. + +## Verifikation + +- `./gradlew compileJava` (bzw. `./gradlew deploy` für Bolt). +- Manuell: Blowpipe mit Scales (nur Scales-Zahl) → mit Darts laden (2. Zahl erscheint); Trident/Shadow/Sang/Tome (Zahl); Lootingsack mit Coins betreten (Geld auf Sack-Icon); Coal Bag füllen → Zahl steigt, Empty → 0, Relog → Wert bleibt; Bank öffnen mit `itemChargesBank` → Zahlen auch dort; beide Blowpipes (leer + geladen) im Inventar → korrekte per-ID-Darstellung. + +## Hinweise / Einschränkungen + +- `CHARGES_*`-Varbits sind **charakterweit, nicht pro Item-Exemplar**: zwei unterschiedlich geladene Tridents zeigen denselben Wert (OSRS-Systemgrenze, deckungsgleich mit dem In-Game-Charge-Overlay). +- **Bank-Anzeige** dementsprechend unzuverlässig für „unbenutzte" Kopien; default daher `itemChargesBank = false`. +- Coal Bag: nur Heuristik (Chat + Persistenz), kein API-Wert; nach Login unbekannt → `?`. +- Blowpipe: Dart-**Anzahl** nicht in der API → 2. Wert ist Darts-geladen-Marker, keine Zahl. +- Keine Chat-Popup-abhängigkeit (Popup = ist abgeschaltet hat), ausschließlich ChatMessage-Events verwenden. + +## Referenzen + +- `tree-cooldown.md` (gleiche Struktur), `reflect.txt`/Cachierte Jars für Konstanten: + - runelite-api: `/home/hikyu/.gradle/caches/modules-2/files-2.1/net.runelite/runelite-api/1.12.39//runelite-api-1.12.39.jar` → `net/runelite/api/gameval/{VarbitID,ItemID,InventoryID,DBTableID$Charges$Row}` + - client: `/home/hikyu/.gradle/caches/modules-2/files-2.1/net.runelite/client/1.12.39//client-1.12.39.jar` → `net.runelite.client.ui.overlay.WidgetItemOverlay`, `components.TextComponent`, `ui.FontManager` +- Konventionen: `helpers/TreeCooldowns.java` (Logik+Events), `helpers/TreeCooldownOverlay.java`/`ItemPriceOverlay.java` (Overlay-Zeichnung), `HelpersCard.java` (Checkbox+Menü), `EssentialsPlusPlugin.isHelperOption/setHelperOption/onConfigChanged`. \ No newline at end of file diff --git a/runelite-plugin.properties b/runelite-plugin.properties new file mode 100644 index 0000000..771ff8b --- /dev/null +++ b/runelite-plugin.properties @@ -0,0 +1,7 @@ +displayName=Essentials Plus +author=hikyu +description=Essentials Plus Plugin Suite +tags=essentials,plus,wealth +version=1.0.0 +plugins=com.example.essentialsplus.EssentialsPlusPlugin +build=standard \ No newline at end of file diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 0000000..f7c8fa2 --- /dev/null +++ b/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'bank-highlighter' diff --git a/skill-reset-hourly.md b/skill-reset-hourly.md new file mode 100644 index 0000000..f72fe26 --- /dev/null +++ b/skill-reset-hourly.md @@ -0,0 +1,94 @@ +# Skill "Reset Hourly" – Taskbeschreibung + +Implementiere ein **Rechtsklick-Feature „Reset Hourly"** in **Essentials Plus**. Es erscheint an zwei Stellen: + +1. **In-Game-XP-HUD** (`skills/SkillsHudOverlay.java`): Rechtsklick auf das Overlay zeigt den Menüeintrag „Reset Hourly". +2. **Skills-Karte** im Seitenpanel: auf der **Overview-Karte** als Eintrag im bestehenden Karten-Rechtsklick-Menü und im **Skills-Tab** als eigenes Rechtsklick-Popup auf der Karte. + +Klick setzt die **aktuell angezeigte XP-Zählung** zurück. Es wirkt **nur**, wenn der global aktive Zeitraum `Session` oder `Now` ist. Alle anderen Zeiträume (`1H`, `1D`, `2W`, `3M`, `All`) bleiben unberührt – insbesondere bleibt der **Gesamt-XP-Wert (`All`) erhalten**, weil er aus der ersten Sample-Spur abgeleitet wird. + +**Display-Sprache:** Deutsch (Menütext trotzdem exakt `Reset Hourly`). + +## Hintergrund / Datenmodell + +- `skills/SkillHistory.java` speichert **absolute** XP-Samples pro Skill mit Zeitstempel (`XpSample`, `List samples`), ein File pro Charakter (JSON in `essentials-plus/skills`). +- `ui/TimeFilter.java` + `ui/TimeRange.java` liefern den global geteilten Zeitraum: `CURRENT("Now")`, `SESSION("Sess")`, `HOUR("1H")`, `DAY`, `TWO_WEEKS`, `QUARTER`, `ALL`. +- Gewinne werden als `latest - at(start)` berechnet; der Start kommt aus `SkillHistory.start(filter)`, `starts(filter)` und `gained(filter)`. +- Es gibt **noch kein** Reset-/Baseline-Feld. Der Reset wird als **Zeitmarker** umgesetzt, nicht durch Löschen von Samples. + +## Neue Dateien + +### `skills/SkillHistory.java` (erweitern, keine neue Datei) + +- Zwei persistente Marker ergänzen (0 = nie zurückgesetzt): + - `public long resetSessionAt;` + - `public long resetNowAt;` +- `public synchronized void reset(TimeRange range)`: + - `SESSION` → `resetSessionAt = System.currentTimeMillis()` + - `CURRENT` → `resetNowAt = System.currentTimeMillis()` + - sonst → no-op + - `dirty = true` (Persistenz-Flag, existiert bereits) +- Startlogik **nur** für die betroffenen Ranges clampen: + - `starts(TimeFilter)`: bei `SESSION` für jeden Skill `Math.max(filter.start(), resetSessionAt)`; bei `CURRENT` pro Skill `Math.max(currentStart(i, now), resetNowAt)`, idle (`Long.MAX_VALUE`) bleibt idle. + - `start(TimeFilter)`: bei `SESSION` `Math.max(filter.start(), resetSessionAt)`; bei `CURRENT` nach `currentStart(-1, now)` clampen (Fallback `now` wenn idle). + - `gained(TimeFilter)`: Nicht-Current-Zweig von `gained(filter.start())` auf `gained(start(filter))` umstellen, damit der Session-Clamp greift. Current-Zweig nutzt bereits `starts(filter)`. +- Da `range(long)`, `levelUps(long)` und `activeMillis(long, int)` ihren Start aus `start(filter)` bekommen, ziehen Charts, Stats/„XP per hour" und Level-Ups automatisch mit. +- `normalize()` prüft nur `skills`/`samples`; die neuen Felder brauchen keine Migration (fehlend = 0 = nie zurückgesetzt). + +### `skills/SkillStore.java` (erweitern) + +- `public void resetHourly(String key, TimeRange range)`: History per `histories.get(key)` holen; wenn vorhanden `h.reset(range)` und `save(h)` aufrufen. +- Wird vom Plugin auf dem Executor aufgerufen (File-I/O), nicht auf dem Swing-/Client-Thread. + +## Zu ändernde Dateien + +### `skills/SkillsHudOverlay.java` + +- Im Konstruktor: `addMenuEntry(net.runelite.api.MenuAction.RUNELITE_OVERLAY, "Reset Hourly", "Skills")`. +- Die Render-Logik nutzt bereits `history.starts(filter)`/`gained(filter)` → greift den Reset automatisch, **kein** Render-Umbau nötig. +- Verifikation der API (RuneLite 1.12.39): `Overlay.addMenuEntry(MenuAction, option, target)` und die 4-arg-Variante mit `Consumer` existieren; `OverlayRenderer` injiziert den Callback in `MenuEntry.onClick(...)` **und** feuert `OverlayMenuClicked`. + +### `EssentialsPlusPlugin.java` + +- Import `net.runelite.client.events.OverlayMenuClicked` (+ `net.runelite.client.ui.overlay.Overlay` falls nötig). +- `public void resetSkillsHourly(String key)`: + - `if (key == null) return;` + - `executor.execute(() -> skillStore.resetHourly(key, timeFilter.getRange()));` + - danach Panel-Refresh: `EssentialsPlusPanel p = panel; if (p != null) p.refreshSkillsLater();` +- `@Subscribe public void onOverlayMenuClicked(OverlayMenuClicked event)`: + - wenn `event.getOverlay() == skillsHudOverlay` und `"Reset Hourly".equals(event.getEntry().getOption())` → `resetSkillsHourly(skillKey)`. +- `getSkillStore()`, `getSkillKey()`, `getTimeFilter()` sind vorhandene Lombok-Getter; `skillsHudOverlay` ist injiziert (`EssentialsPlusPlugin.java:232`). +- Alternativ den `Consumer` der 4-arg-`addMenuEntry` nutzen; dann ist kein `OverlayMenuClicked`-Subscriber nötig (beides funktioniert). Empfehlung: Subscriber, damit die Aktion im Plugin kapselt bleibt. + +### `ui/EssentialsPlusPanel.java` (Overview-Karte) + +- In `menu(String id)` im `case SKILLS` (ca. Zeile 560) einen Eintrag ergänzen: + - `JMenuItem reset = new JMenuItem("Reset Hourly");` + - Action: Character über `SkillsPanel.resolve(plugin.getSkillStore(), null, plugin.getSkillKey())` auflösen; wenn nicht null → `plugin.resetSkillsHourly(h.key);` und danach `refreshSkills();`. +- **Kein** zweites Popup auf der Overview-Karte installieren: `CardDrag` (`ui/CardDrag.java`) konsumiert dort bereits Rechtsklick und öffnet `menu(id)` – sonst gäbe es zwei Menüs. + +### `ui/SkillsPanel.java` (Skills-Tab) + +- Nach `private final SkillsCard card = new SkillsCard(null);` (Zeile 61) im Konstruktor einen `MouseAdapter` auf `card` installieren: + - bei `SwingUtilities.isRightMouseButton(e)` (oder `e.isPopupTrigger()`) ein `JPopupMenu` mit `JMenuItem "Reset Hourly"` bauen und `menu.show(card, e.getX(), e.getY())` zeigen. + - Action: `plugin.resetSkillsHourly(shownKey);` danach `refresh();` (bzw. `refreshLater()`). +- Im Skills-Tab ist **keine** `CardDrag` installiert, daher kollidiert das Popup nicht. + +## Verifikation + +- `./gradlew test` (bestehende Tests + neuer Test unten) und `./gradlew build`. +- Neuer Test in `src/test/java/com/example/essentialsplus/SkillHistoryTest.java` (Muster: `record` per Reflection, package-private): + - Samples über ca. eine Stunde anlegen, dann `h.reset(TimeRange.CURRENT)` bzw. `h.reset(TimeRange.SESSION)`. + - Erwartung: `gained(filter)` für den betroffenen Range startet neu (Baseline = Sample bei `resetAt`); `gained(TimeRange.ALL)` bleibt unverändert; `1H`/`1D` bleiben unverändert. +- Manuell: + - Filter auf **Sess** oder **Now** stellen → Rechtsklick aufs XP-HUD → „Reset Hourly" → `XP gained` zählt ab 0 neu; Gesamt-XP (`All`) unverändert. + - Filter auf `1H`/`1D`/`All` stellen → „Reset Hourly" ändert die Anzeige nicht. + - Overview-Skills-Karte rechtsklicken → „Reset Hourly"; Skills-Tab-Karte rechtsklicken → „Reset Hourly". + - Nach Reset Datei `essentials-plus/skills/.json` prüfen (`resetSessionAt`/`resetNowAt` gesetzt); Relog → Session wird ohnehin vom neuen Login übersteuert, `Now`-Marker bleibt gespeichert. + +## Hinweise / Einschränkungen + +- „Reset Hourly" wirkt bewusst **nur** auf `Session` und `Now`; bei `1H`/`1D`/`2W`/`3M`/`All` passiert nichts (`TimeRange`-Vergleich im `reset(...)`). +- Der Gesamtwert (`All`) bleibt in jedem Fall erhalten, da er aus der ersten Sample-Spur (`samples.get(0)`) berechnet wird und der Reset nur einen Start-Clamp setzt. +- Marker sind pro Charakter im `SkillHistory`-JSON persistiert; ein fehlendes Feld nach älterem Stand bedeutet 0 (nie zurückgesetzt) und ist damit abwärtskompatibel. +- `record(...)` bleibt unverändert: Es fügt weiterhin Samples hinzu; der Reset löscht keine Historie. diff --git a/src/main/java/com/example/ExampleConfig.java b/src/main/java/com/example/ExampleConfig.java new file mode 100644 index 0000000..146f1b9 --- /dev/null +++ b/src/main/java/com/example/ExampleConfig.java @@ -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; + } +} diff --git a/src/main/java/com/example/ExamplePlugin.java b/src/main/java/com/example/ExamplePlugin.java new file mode 100644 index 0000000..0790b09 --- /dev/null +++ b/src/main/java/com/example/ExamplePlugin.java @@ -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, "", "[Profile] " + message, null); + } + + @Provides + ExampleConfig provideConfig(ConfigManager configManager) + { + return configManager.getConfig(ExampleConfig.class); + } +} diff --git a/src/main/java/com/example/ProfileManager.java b/src/main/java/com/example/ProfileManager.java new file mode 100644 index 0000000..2c0c48a --- /dev/null +++ b/src/main/java/com/example/ProfileManager.java @@ -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 folders = new ArrayList<>(); + + @Getter + private final List 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 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>() {}.getType(); + List 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>() {}.getType(); + List 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(); + } + } +} diff --git a/src/main/java/com/example/essentialsplus/EssentialsPlusConfig.java b/src/main/java/com/example/essentialsplus/EssentialsPlusConfig.java new file mode 100644 index 0000000..01b8e3f --- /dev/null +++ b/src/main/java/com/example/essentialsplus/EssentialsPlusConfig.java @@ -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; + } +} + diff --git a/src/main/java/com/example/essentialsplus/EssentialsPlusPlugin.java b/src/main/java/com/example/essentialsplus/EssentialsPlusPlugin.java new file mode 100644 index 0000000..2ac98b6 --- /dev/null +++ b/src/main/java/com/example/essentialsplus/EssentialsPlusPlugin.java @@ -0,0 +1,3526 @@ +package com.example.essentialsplus; + +import com.example.essentialsplus.data.AccountData; +import com.example.essentialsplus.data.ItemLedger; +import com.example.essentialsplus.data.ItemSnapshot; +import com.example.essentialsplus.data.Snapshot; +import com.example.essentialsplus.data.SourceValue; +import com.example.essentialsplus.data.TopItem; +import com.example.essentialsplus.skills.SkillHistory; +import com.example.essentialsplus.skills.SkillStore; +import com.example.essentialsplus.skills.SkillsHudOverlay; +import com.example.essentialsplus.ui.EssentialsPlusPanel; +import com.example.essentialsplus.ui.TimeFilter; +import com.example.essentialsplus.ui.WealthPanel; +import com.example.essentialsplus.wiki.WikiApi; +import com.example.essentialsplus.wiki.WikiOverlay; +import com.example.essentialsplus.wiki.WikiWindow; +import com.example.essentialsplus.wiki.WikiWindows; +import com.google.inject.Provides; +import java.awt.BasicStroke; +import java.awt.Color; +import java.awt.Graphics2D; +import java.awt.RenderingHints; +import java.awt.image.BufferedImage; +import net.runelite.client.util.ImageUtil; +import java.io.File; +import java.io.IOException; +import java.io.PrintWriter; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.time.Instant; +import java.time.LocalDate; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeMap; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; +import java.util.function.Consumer; +import javax.inject.Inject; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; +import net.runelite.api.Client; +import net.runelite.api.EnumComposition; +import net.runelite.api.GameState; +import net.runelite.api.Item; +import net.runelite.api.ItemComposition; +import net.runelite.api.ItemContainer; +import net.runelite.api.Player; +import net.runelite.api.events.GameStateChanged; +import net.runelite.api.events.GameTick; +import net.runelite.api.events.GrandExchangeOfferChanged; +import net.runelite.api.events.ItemContainerChanged; +import net.runelite.api.events.VarbitChanged; +import net.runelite.api.gameval.InterfaceID; +import net.runelite.api.gameval.InventoryID; +import net.runelite.api.gameval.ItemID; +import net.runelite.api.gameval.VarbitID; +import net.runelite.api.widgets.ComponentID; +import java.util.Iterator; +import net.runelite.client.callback.ClientThread; +import net.runelite.client.config.ConfigManager; +import net.runelite.client.config.RuneScapeProfileType; +import net.runelite.client.eventbus.Subscribe; +import net.runelite.client.events.ConfigChanged; +import net.runelite.client.events.OverlayMenuClicked; +import net.runelite.client.game.ItemManager; +import net.runelite.client.plugins.Plugin; +import net.runelite.client.plugins.PluginDescriptor; +import net.runelite.client.ui.ClientToolbar; +import net.runelite.client.ui.NavigationButton; +import com.example.essentialsplus.data.GeBuyLimit; +import com.example.essentialsplus.data.GeLimitInfo; +import com.example.essentialsplus.data.GeSlot; +import java.time.Duration; +import java.util.Locale; +import net.runelite.api.GrandExchangeOffer; +import net.runelite.api.GrandExchangeOfferState; +import net.runelite.api.VarPlayer; +import net.runelite.api.Varbits; +import net.runelite.api.events.ClientTick; +import net.runelite.api.events.ScriptCallbackEvent; +import net.runelite.api.gameval.InterfaceID; +import net.runelite.api.widgets.Widget; +import net.runelite.client.game.ItemStats; + +@Slf4j +@PluginDescriptor( + name = "Essentials Plus", + description = "Moderne Plugin-Suite & Übersicht (Wealth Tracker, Net Worth, Bank, GE limits und mehr)", + tags = {"essentials", "plus", "wiki", "wealth", "net worth", "bank", "gp", "graph", "coffer", "value", "history", "analysis"} +) +public class EssentialsPlusPlugin extends Plugin +{ + private static final int DOOM_REWARD_CONTAINER = 923; + private static final int RUNEPOUCH_RUNE_ENUM = 982; + private static final int[] RUNE_POUCH_TYPES = { + VarbitID.RUNE_POUCH_TYPE_1, VarbitID.RUNE_POUCH_TYPE_2, VarbitID.RUNE_POUCH_TYPE_3, + VarbitID.RUNE_POUCH_TYPE_4, VarbitID.RUNE_POUCH_TYPE_5, VarbitID.RUNE_POUCH_TYPE_6 + }; + private static final int[] RUNE_POUCH_QUANTITIES = { + VarbitID.RUNE_POUCH_QUANTITY_1, VarbitID.RUNE_POUCH_QUANTITY_2, VarbitID.RUNE_POUCH_QUANTITY_3, + VarbitID.RUNE_POUCH_QUANTITY_4, VarbitID.RUNE_POUCH_QUANTITY_5, VarbitID.RUNE_POUCH_QUANTITY_6 + }; + + /** Item containers the game only sends while/after they are opened; last contents are persisted. */ + private static final ContainerSource[] CONTAINERS = { + new ContainerSource(InventoryID.BANK, "bank", "Bank", WealthCategory.BANK), + new ContainerSource(InventoryID.INV, "inventory", "Inventory", WealthCategory.INVENTORY), + new ContainerSource(InventoryID.WORN, "equipment", "Equipment", WealthCategory.EQUIPMENT), + new ContainerSource(InventoryID.LOOTING_BAG, "lootingBag", "Looting bag", WealthCategory.STORAGE), + new ContainerSource(InventoryID.SEED_VAULT, "seedVault", "Seed vault", WealthCategory.STORAGE), + }; + + private static final Set WATCHED_VARBITS = new java.util.HashSet<>(Arrays.asList( + VarbitID.MISC_COFFERS, VarbitID.BLAST_FURNACE_COFFER, VarbitID.NZONE_CASH, + VarbitID.RUNE_POUCH_TYPE_1, VarbitID.RUNE_POUCH_TYPE_2, VarbitID.RUNE_POUCH_TYPE_3, + VarbitID.RUNE_POUCH_TYPE_4, VarbitID.RUNE_POUCH_TYPE_5, VarbitID.RUNE_POUCH_TYPE_6, + VarbitID.RUNE_POUCH_QUANTITY_1, VarbitID.RUNE_POUCH_QUANTITY_2, VarbitID.RUNE_POUCH_QUANTITY_3, + VarbitID.RUNE_POUCH_QUANTITY_4, VarbitID.RUNE_POUCH_QUANTITY_5, VarbitID.RUNE_POUCH_QUANTITY_6 + )); + + /** Blast Furnace: ores inside the furnace and bars waiting in the dispenser (varbit, item id) */ + private static final int[][] BLAST_FURNACE_ITEMS = { + {VarbitID.BLAST_FURNACE_TIN_ORE, ItemID.TIN_ORE}, + {VarbitID.BLAST_FURNACE_COPPER_ORE, ItemID.COPPER_ORE}, + {VarbitID.BLAST_FURNACE_IRON_ORE, ItemID.IRON_ORE}, + {VarbitID.BLAST_FURNACE_MITHRIL_ORE, ItemID.MITHRIL_ORE}, + {VarbitID.BLAST_FURNACE_ADAMANTITE_ORE, ItemID.ADAMANTITE_ORE}, + {VarbitID.BLAST_FURNACE_RUNITE_ORE, ItemID.RUNITE_ORE}, + {VarbitID.BLAST_FURNACE_GOLD_ORE, ItemID.GOLD_ORE}, + {VarbitID.BLAST_FURNACE_SILVER_ORE, ItemID.SILVER_ORE}, + {VarbitID.BLAST_FURNACE_COAL, ItemID.COAL}, + {VarbitID.BLAST_FURNACE_LEAD_ORE, ItemID.LEAD_ORE}, + {VarbitID.BLAST_FURNACE_NICKEL_ORE, ItemID.NICKEL_ORE}, + {VarbitID.BLAST_FURNACE_BRONZE_BARS, ItemID.BRONZE_BAR}, + {VarbitID.BLAST_FURNACE_IRON_BARS, ItemID.IRON_BAR}, + {VarbitID.BLAST_FURNACE_STEEL_BARS, ItemID.STEEL_BAR}, + {VarbitID.BLAST_FURNACE_MITHRIL_BARS, ItemID.MITHRIL_BAR}, + {VarbitID.BLAST_FURNACE_ADAMANTITE_BARS, ItemID.ADAMANTITE_BAR}, + {VarbitID.BLAST_FURNACE_RUNITE_BARS, ItemID.RUNITE_BAR}, + {VarbitID.BLAST_FURNACE_GOLD_BARS, ItemID.GOLD_BAR}, + {VarbitID.BLAST_FURNACE_SILVER_BARS, ItemID.SILVER_BAR}, + {VarbitID.BLAST_FURNACE_LEAD_BARS, ItemID.LEAD_BAR}, + {VarbitID.BLAST_FURNACE_CUPRONICKEL_BARS, ItemID.CUPRONICKEL_BAR} + }; + + private static final Set COFFER_VARBITS = Set.of(VarbitID.MISC_COFFERS, VarbitID.BLAST_FURNACE_COFFER, VarbitID.NZONE_CASH); + private static final Set BLAST_FURNACE_VARBITS = new java.util.HashSet<>(); + private static final int BLAST_FURNACE_REGION = 7757; + + static + { + for (int[] bf : BLAST_FURNACE_ITEMS) + { + WATCHED_VARBITS.add(bf[0]); + BLAST_FURNACE_VARBITS.add(bf[0]); + } + } + + /** quiet ticks before a normal change (inventory while skilling, GE collect) is valued */ + private static final int COMPUTE_MIN_TICKS = 2; + /** a burst of changes is valued after this many ticks even if it keeps changing */ + private static final int COMPUTE_MAX_DELAY_TICKS = 5; + /** periodic re-valuation (prices) when nothing happens */ + private static final int COMPUTE_IDLE_TICKS = 100; + private static final long SAVE_INTERVAL_MS = 60_000; + private static final long LEDGER_SAVE_INTERVAL_MS = 5 * 60_000; + private static final long HOUR_MS = 3_600_000; + private static final long OFFLINE_GRACE_MS = 10 * 60_000; + private static final int TOP_ITEMS = 60; + + @Inject + private Client client; + + @Inject + private ClientThread clientThread; + + @Inject + private ClientToolbar clientToolbar; + + @Inject + @Getter + private ItemManager itemManager; + + @Inject + private EssentialsPlusConfig config; + + @Inject + @Getter + private WealthStore store; + + @Inject + @Getter + private com.example.essentialsplus.loot.LootTrackerStore lootTrackerStore; + + @Inject + @Getter + private com.example.essentialsplus.loot.combat.CombatTracker combatTracker; + + @Inject + private net.runelite.client.game.LootManager lootManager; + + @Inject + @Getter + private SkillStore skillStore; + + /** Level goals per skill, set from the skills tab. */ + @Getter + private com.example.essentialsplus.skills.SkillGoals skillGoals; + + @Inject + private com.example.essentialsplus.currency.CurrencyStore currencyStore; + + /** Minigame points, marks of grace and other tokens of the logged-in character. */ + @Getter + private com.example.essentialsplus.currency.CurrencyTracker currencyTracker; + + @Inject + @Getter + private net.runelite.client.game.SkillIconManager skillIconManager; + + @Inject + private net.runelite.client.ui.overlay.OverlayManager overlayManager; + + @Inject + private SkillsHudOverlay skillsHudOverlay; + + @Inject + private com.example.essentialsplus.helpers.AttackTimers attackTimers; + + @Inject + private com.example.essentialsplus.helpers.AttackStyles attackStyles; + + @Inject + private com.example.essentialsplus.helpers.AttackTimerOverlay attackTimerOverlay; + + @Inject + private com.example.essentialsplus.helpers.ItemPriceOverlay itemPriceOverlay; + + @Inject + private com.example.essentialsplus.helpers.TreeCooldowns treeCooldowns; + + @Inject + private com.example.essentialsplus.helpers.TreeCooldownOverlay treeCooldownOverlay; + + @Inject + private com.example.essentialsplus.helpers.ItemCharges itemCharges; + + @Inject + private com.example.essentialsplus.helpers.ItemChargesOverlay itemChargesOverlay; + + @Inject + private net.runelite.client.eventbus.EventBus eventBus; + + @Inject + private net.runelite.client.ui.components.colorpicker.ColorPickerManager colorPickerManager; + + @Inject + private okhttp3.OkHttpClient okHttpClient; + + @Inject + private com.google.gson.Gson gson; + + @Inject + private net.runelite.client.input.MouseManager mouseManager; + + @Inject + @Getter + private net.runelite.client.game.SpriteManager spriteManager; + + @Getter + private WikiApi wikiApi; + + /** Worn gear, bank gear planner and the live target of the fights view. */ + @Getter + private com.example.essentialsplus.loot.gear.FightService fightService; + + /** Collection log pages and the items the logged-in account has obtained. */ + @Getter + private com.example.essentialsplus.collectionlog.CollectionLogService collectionLog; + + /** Achievement diary and combat achievement progress of the logged-in account. */ + @Getter + private com.example.essentialsplus.progress.ProgressService progress; + + /** Wiki page loads, searches and thumbnails; kept off the shared executor because image downloads can take seconds. */ + @Getter + private java.util.concurrent.ExecutorService wikiExecutor; + + private WikiWindows wikiWindows; + private WikiOverlay wikiOverlay; + + /** Global time range of all views; persisted in the config. */ + @Getter + private TimeFilter timeFilter; + + /** Account key of the logged-in character for skill tracking (null when logged out). */ + @Getter + private volatile String skillKey; + private boolean xpDirty; + private ScheduledFuture skillSaveTask; + + private String pendingCrateName; + private int pendingCrateTick; + private Map invSnapshotBeforeOpen; + + @Inject + private ScheduledExecutorService executor; + + @Inject + private ConfigManager configManager; + + /** timestamp for a pinned item snapshot (session start), 0 = none */ + private volatile long pinLedgerAt; + /** true until the next LOGGED_IN is a fresh login (hops and reconnects keep the session) */ + private boolean awaitingLogin = true; + /** the real bank was not visible yet this session - the saved one may be outdated (played elsewhere, closed client) */ + private boolean bankSeenThisSession; + + private EssentialsPlusPanel panel; + private NavigationButton navButton; + private ScheduledFuture reloadTask; + private ScheduledFuture revalueTask; + + @Getter + private volatile String activeKey; + private boolean dirty = true; + private int ticksSinceCompute; + private int quietTicks; + /** ticks since the oldest change that is not valued yet */ + private int dirtyTicks; + /** value on the next tick (bank opened/closed, gear changed) */ + private boolean urgent; + /** sources that only change on specific events are read then and cached in between */ + private boolean pouchDirty = true; + private boolean coffersDirty = true; + private boolean furnaceDirty = true; + private final long[] cofferCache = new long[3]; + private Map furnaceCache = new HashMap<>(); + private int ticksSinceLogin = -1; + private boolean bankUpdated; + private volatile boolean bankOpen; + private volatile boolean justOpenedBank; + private Map lastInvItems; + /** + * Container contents captured when they change. The bank container is gone once the bank is closed, so a + * withdrawal right before closing would otherwise be valued with the old bank (items counted twice). + */ + private final Map> seenContainers = new java.util.concurrent.ConcurrentHashMap<>(); + private Map lastWornItems; + private final GrandExchangeTracker geTracker = new GrandExchangeTracker(); + private long lastSave; + private long lastLedgerSave; + private boolean unsaved; + private final Map lastRevalue = new HashMap<>(); + + @Provides + EssentialsPlusConfig provideConfig(ConfigManager configManager) + { + return configManager.getConfig(EssentialsPlusConfig.class); + } + + @Override + protected void startUp() + { + timeFilter = new TimeFilter(TimeFilter.parse(configManager.getConfiguration(EssentialsPlusConfig.GROUP, "timeRange")), + r -> configManager.setConfiguration(EssentialsPlusConfig.GROUP, "timeRange", r.name())); + awaitingLogin = client.getGameState() != GameState.LOGGED_IN; + if (!awaitingLogin) + { + pinLedgerAt = timeFilter.getSessionStart(); + } + skillStore.load(); + skillGoals = new com.example.essentialsplus.skills.SkillGoals(configManager); + overlayManager.add(skillsHudOverlay); + eventBus.register(attackTimers); + eventBus.register(attackStyles); + eventBus.register(treeCooldowns); + eventBus.register(itemCharges); + overlayManager.add(attackTimerOverlay); + overlayManager.add(itemPriceOverlay); + overlayManager.add(treeCooldownOverlay); + overlayManager.add(itemChargesOverlay); + currencyStore.load(); + currencyTracker = new com.example.essentialsplus.currency.CurrencyTracker(client, currencyStore); + skillSaveTask = executor.scheduleWithFixedDelay(() -> + { + skillStore.saveDirty(); + currencyStore.saveDirty(); + }, 60, 60, TimeUnit.SECONDS); + startWiki(); + collectionLog = new com.example.essentialsplus.collectionlog.CollectionLogService(client, configManager); + progress = new com.example.essentialsplus.progress.ProgressService(client, okHttpClient, wikiExecutor); + + com.example.essentialsplus.loot.gear.MonsterService monsters = new com.example.essentialsplus.loot.gear.MonsterService( + okHttpClient, gson, wikiExecutor, () -> fightService.onMonsterLoaded()); + fightService = new com.example.essentialsplus.loot.gear.FightService(client, itemManager, combatTracker, attackStyles, + monsters, wikiExecutor, () -> + { + AccountData acc = activeKey != null ? store.get(activeKey) : null; + return acc != null ? acc.containers.get("bank") : null; + }); + + panel = new EssentialsPlusPanel(this); + BufferedImage icon = loadIcon(); + navButton = NavigationButton.builder() + .tooltip("Essentials Plus") + .icon(icon) + .priority(7) + .panel(panel) + .build(); + clientToolbar.addNavigation(navButton); + + executor.execute(() -> + { + store.reloadExternal(activeKey); + refreshPanel(); + }); + + reloadTask = executor.scheduleWithFixedDelay(() -> + { + if (store.reloadExternal(activeKey)) + { + refreshPanel(); + } + }, 30, 30, TimeUnit.SECONDS); + + revalueTask = executor.scheduleWithFixedDelay(() -> clientThread.invokeLater(this::revalueOffline), 20, 600, TimeUnit.SECONDS); + + lootTrackerStore.load(); + lootTrackerStore.addChangeListener(this::refreshPanel); + + dirty = true; + if (client.getGameState() == GameState.LOGGED_IN) + { + if (ticksSinceLogin < 0) + { + ticksSinceLogin = 0; + } + clientThread.invokeLater(this::syncGeOffers); + } + } + + @Override + protected void shutDown() + { + stopWiki(); + fightService = null; + lootTrackerStore.save(); + overlayManager.remove(skillsHudOverlay); + overlayManager.remove(attackTimerOverlay); + overlayManager.remove(itemPriceOverlay); + overlayManager.remove(treeCooldownOverlay); + overlayManager.remove(itemChargesOverlay); + eventBus.unregister(attackTimers); + eventBus.unregister(attackStyles); + eventBus.unregister(treeCooldowns); + eventBus.unregister(itemCharges); + attackTimers.reset(); + attackStyles.reset(); + treeCooldowns.reset(); + itemCharges.reset(); + if (skillSaveTask != null) + { + skillSaveTask.cancel(false); + skillSaveTask = null; + } + skillStore.saveDirty(); + currencyStore.saveDirty(); + skillKey = null; + if (reloadTask != null) + { + reloadTask.cancel(false); + reloadTask = null; + } + if (revalueTask != null) + { + revalueTask.cancel(false); + revalueTask = null; + } + AccountData acc = store.get(activeKey); + if (acc != null) + { + recordFinal(acc); + WealthStore.compact(acc, config.rawDays(), config.hourlyDays(), System.currentTimeMillis()); + store.save(acc); + ItemLedger ledger = store.ledger(acc.key, true); + if (ledger.dirty) + { + store.saveLedger(acc.key, ledger); + } + } + activeKey = null; + bankOpen = false; + lastInvItems = null; + lastWornItems = null; + clientToolbar.removeNavigation(navButton); + if (panel != null) + { + panel.dispose(); + } + panel = null; + navButton = null; + } + + private void refreshPanel() + { + EssentialsPlusPanel p = panel; + if (p != null) + { + p.refreshLater(); + } + } + + // ---------------------------------------------------------------- wiki + + private static final String WIKI_BOUNDS = "wikiBounds"; + private static final String WIKI_RECENT = "wikiRecent"; + private static final int WIKI_RECENT_MAX = 10; + + private void startWiki() + { + java.util.concurrent.atomic.AtomicInteger threads = new java.util.concurrent.atomic.AtomicInteger(); + wikiExecutor = java.util.concurrent.Executors.newFixedThreadPool(3, r -> + { + Thread t = new Thread(r, "essentials-wiki-" + threads.incrementAndGet()); + t.setDaemon(true); + return t; + }); + wikiApi = new WikiApi(okHttpClient, gson); + wikiWindows = new WikiWindows(wikiApi, wikiExecutor, client::getCanvasWidth, client::getCanvasHeight, + loadWikiBounds(), this::saveWikiBounds, this::addWikiRecent); + wikiOverlay = new WikiOverlay(spriteManager, wikiWindows); + overlayManager.add(wikiOverlay); + mouseManager.registerMouseListener(wikiWindows); + mouseManager.registerMouseWheelListener(wikiWindows); + } + + private void stopWiki() + { + if (wikiWindows != null) + { + mouseManager.unregisterMouseListener(wikiWindows); + mouseManager.unregisterMouseWheelListener(wikiWindows); + wikiWindows.closeAll(); + wikiWindows = null; + } + if (wikiOverlay != null) + { + overlayManager.remove(wikiOverlay); + wikiOverlay = null; + } + if (wikiExecutor != null) + { + wikiExecutor.shutdownNow(); + wikiExecutor = null; + } + } + + /** Opens a wiki page in the front in-game wiki window (a new one if none is open). */ + public void openWikiPage(String title) + { + WikiWindows w = wikiWindows; + if (w != null) + { + w.open(title, null); + } + } + + /** Opens a wiki page in an additional in-game window. */ + public void openWikiPageNewWindow(String title) + { + WikiWindows w = wikiWindows; + if (w != null) + { + w.openNew(title, null); + } + } + + /** Layout and display options of the overview cards ("overview."), null when unset. */ + public String getOverviewSetting(String name) + { + return configManager.getConfiguration(EssentialsPlusConfig.GROUP, "overview." + name); + } + + public void setOverviewSetting(String name, String value) + { + if (value == null || value.isEmpty()) + { + configManager.unsetConfiguration(EssentialsPlusConfig.GROUP, "overview." + name); + } + else + { + configManager.setConfiguration(EssentialsPlusConfig.GROUP, "overview." + name, value); + } + } + + /** In-game helper switches (attack timers) by config key. */ + public boolean isHelperOption(String key) + { + switch (key) + { + case "attackTimer": + return config.attackTimer(); + case "enemyAttackTimer": + return config.enemyAttackTimer(); + case "enemyAttackStyle": + return config.enemyAttackStyle(); + case "itemPrices": + return config.itemPrices(); + case "treeCooldowns": + return config.treeCooldowns(); + case "itemCharges": + return config.itemCharges(); + default: + return false; + } + } + + /** Sets a helper config value; null restores the default. */ + public void setHelperOption(String key, Object value) + { + if (value == null) + { + configManager.unsetConfiguration(EssentialsPlusConfig.GROUP, key); + } + else + { + configManager.setConfiguration(EssentialsPlusConfig.GROUP, key, value); + } + } + + /** Typed config value with the interface default applied (for the helper option menus). */ + public T getHelperValue(String key, Class type, T def) + { + T v = configManager.getConfiguration(EssentialsPlusConfig.GROUP, key, type); + return v == null ? def : v; + } + + public com.example.essentialsplus.helpers.ItemCharges getItemCharges() + { + return itemCharges; + } + + public net.runelite.client.ui.components.colorpicker.ColorPickerManager getColorPickerManager() + { + return colorPickerManager; + } + + public boolean isWikiMenuEnabled() + { + return config.wikiMenuOption(); + } + + public void setWikiMenuEnabled(boolean enabled) + { + configManager.setConfiguration(EssentialsPlusConfig.GROUP, "wikiMenuOption", enabled); + } + + /** Adds a "Wiki" entry for every examinable thing (npc, object, ground or inventory item) at the bottom of the menu. */ + @Subscribe + public void onMenuOpened(net.runelite.api.events.MenuOpened event) + { + if (!config.wikiMenuOption() || wikiWindows == null) + { + return; + } + net.runelite.api.Menu menu = client.getMenu(); + net.runelite.api.MenuEntry[] entries = menu.getMenuEntries(); + List targets = new ArrayList<>(); + Set seen = new java.util.HashSet<>(); + for (int i = entries.length - 1; i >= 0; i--) + { + net.runelite.api.MenuEntry entry = entries[i]; + WikiTarget target = widgetWikiTarget(entry); + if (target == null && "Examine".equalsIgnoreCase(net.runelite.client.util.Text.removeTags(entry.getOption()))) + { + target = wikiTarget(entry); + } + if (target != null && seen.add(target.type == null ? "page:" + target.name : target.type + ":" + target.id)) + { + targets.add(target); + } + } + // insert directly above "Cancel"; each insert lands below the previous one, keeping the menu order + int index = Math.min(1, entries.length); + for (WikiTarget target : targets) + { + menu.createMenuEntry(index) + .setOption("Wiki") + .setTarget(target.menuTarget) + .setType(net.runelite.api.MenuAction.RUNELITE) + .onClick(e -> lookupWiki(target)); + } + } + + private static final class WikiTarget + { + final String type; + final int id; + final String name; + final String menuTarget; + + WikiTarget(String type, int id, String name, String menuTarget) + { + this.type = type; + this.id = id; + this.name = name; + this.menuTarget = menuTarget; + } + } + + private WikiTarget wikiTarget(net.runelite.api.MenuEntry entry) + { + String name = null; + String type = null; + int id = -1; + switch (entry.getType()) + { + case EXAMINE_NPC: + { + net.runelite.api.NPC npc = entry.getNpc(); + if (npc != null) + { + net.runelite.api.NPCComposition comp = npc.getTransformedComposition(); + if (comp == null) + { + comp = npc.getComposition(); + } + if (comp != null) + { + type = "npc"; + id = comp.getId(); + name = comp.getName(); + } + } + break; + } + case EXAMINE_OBJECT: + { + net.runelite.api.ObjectComposition comp = client.getObjectDefinition(entry.getIdentifier()); + if (comp != null && comp.getImpostorIds() != null && comp.getImpostor() != null) + { + comp = comp.getImpostor(); + } + if (comp != null) + { + type = "object"; + id = comp.getId(); + name = comp.getName(); + } + break; + } + case EXAMINE_ITEM_GROUND: + type = "item"; + id = entry.getIdentifier(); + break; + default: + if (entry.getItemId() > 0) + { + type = "item"; + id = entry.getItemId(); + } + break; + } + if (type == null || id < 0) + { + return null; + } + if ("item".equals(type)) + { + id = itemManager.canonicalize(id); + ItemComposition comp = itemManager.getItemComposition(id); + name = comp == null ? null : comp.getName(); + } + name = name == null ? null : net.runelite.client.util.Text.removeTags(name).trim(); + if (name == null || name.isEmpty() || name.equalsIgnoreCase("null")) + { + return null; + } + return new WikiTarget(type, id, name, entry.getTarget()); + } + + /** Stats tab children 1..24, in widget order. */ + private static final String[] STAT_SKILLS = { + "Attack", "Strength", "Defence", "Ranged", "Prayer", "Magic", "Runecraft", "Construction", + "Hitpoints", "Agility", "Herblore", "Thieving", "Crafting", "Fletching", "Slayer", "Hunter", + "Mining", "Smithing", "Fishing", "Cooking", "Firemaking", "Woodcutting", "Farming", "Sailing" + }; + + /** Equipment tab slot widgets mapped to their worn container slot. */ + private static final Map WORN_SLOT_WIDGETS = new HashMap<>(); + + static + { + int[][] slots = { + {InterfaceID.Wornitems.SLOT0, 0}, {InterfaceID.Wornitems.SLOT1, 1}, {InterfaceID.Wornitems.SLOT2, 2}, + {InterfaceID.Wornitems.SLOT3, 3}, {InterfaceID.Wornitems.SLOT4, 4}, {InterfaceID.Wornitems.SLOT5, 5}, + {InterfaceID.Wornitems.SLOT7, 7}, {InterfaceID.Wornitems.SLOT9, 9}, {InterfaceID.Wornitems.SLOT10, 10}, + {InterfaceID.Wornitems.SLOT12, 12}, {InterfaceID.Wornitems.SLOT13, 13} + }; + for (int[] s : slots) + { + WORN_SLOT_WIDGETS.put(s[0], s[1]); + } + } + + /** Wiki targets for side-panel interfaces without an "Examine" option: prayers, equipment, combat options, skills. */ + private WikiTarget widgetWikiTarget(net.runelite.api.MenuEntry entry) + { + if (entry.getWidget() == null) + { + return null; + } + int component = entry.getParam1(); + int group = net.runelite.api.widgets.WidgetUtil.componentToInterface(component); + String option = net.runelite.client.util.Text.removeTags(entry.getOption()).trim(); + String target = net.runelite.client.util.Text.removeTags(entry.getTarget()).trim(); + switch (group) + { + case InterfaceID.PRAYERBOOK: + { + if (component < InterfaceID.Prayerbook.PRAYER1 || component > InterfaceID.Prayerbook.PRAYER30) + { + return null; + } + String name = target.isEmpty() ? net.runelite.client.util.Text.removeTags(entry.getWidget().getName()).trim() : target; + return name.isEmpty() ? null : new WikiTarget(null, -1, name, "" + name + ""); + } + case InterfaceID.WORNITEMS: + { + int itemId = entry.getItemId(); + Integer slot = WORN_SLOT_WIDGETS.get(component); + if (itemId <= 0 && slot != null) + { + ItemContainer worn = client.getItemContainer(InventoryID.WORN); + Item item = worn == null ? null : worn.getItem(slot); + itemId = item == null ? -1 : item.getId(); + } + if (itemId <= 0) + { + return null; + } + itemId = itemManager.canonicalize(itemId); + ItemComposition comp = itemManager.getItemComposition(itemId); + String name = comp == null ? null : net.runelite.client.util.Text.removeTags(comp.getName()).trim(); + if (name == null || name.isEmpty() || name.equalsIgnoreCase("null")) + { + return null; + } + return new WikiTarget("item", itemId, name, "" + name + ""); + } + case InterfaceID.COMBAT_INTERFACE: + { + String page; + if (component == InterfaceID.CombatInterface.RETALIATE) + { + page = "Auto Retaliate"; + } + else if (component == InterfaceID.CombatInterface.SP_ATTACKBAR || component == InterfaceID.CombatInterface.SPECIAL_ATTACK) + { + page = "Special attacks"; + } + else + { + page = "Combat Options"; + } + String label = target.isEmpty() ? option : target; + return new WikiTarget(null, -1, page, "" + (label.isEmpty() ? page : label) + ""); + } + case InterfaceID.STATS: + { + int child = net.runelite.api.widgets.WidgetUtil.componentToId(component); + String page = child >= 1 && child <= STAT_SKILLS.length ? STAT_SKILLS[child - 1] + : component == InterfaceID.Stats.TOTAL ? "Skills" : null; + return page == null ? null : new WikiTarget(null, -1, page, "" + page + ""); + } + default: + return null; + } + } + + private void lookupWiki(WikiTarget target) + { + if (target.type == null) + { + WikiWindows w = wikiWindows; + if (w != null) + { + w.open(target.name, null); + } + return; + } + java.util.concurrent.ExecutorService ex = wikiExecutor; + if (ex == null) + { + return; + } + ex.execute(() -> + { + String title = target.name; + String anchor = null; + try + { + WikiWindow.Target resolved = WikiWindow.parseWikiUrl(wikiApi.lookup(target.type, target.id, target.name)); + if (resolved != null) + { + title = resolved.title; + anchor = resolved.anchor; + } + } + catch (Exception e) + { + log.debug("wiki lookup for {} failed", target.name, e); + } + WikiWindows w = wikiWindows; + if (w != null) + { + w.open(title, anchor); + } + }); + } + + /** Recently opened wiki pages, newest first. */ + public List getWikiRecent() + { + String raw = configManager.getConfiguration(EssentialsPlusConfig.GROUP, WIKI_RECENT); + List out = new ArrayList<>(); + if (raw != null) + { + for (String t : raw.split("\\|")) + { + if (!t.trim().isEmpty()) + { + out.add(t.trim()); + } + } + } + return out; + } + + private void addWikiRecent(String title) + { + List recent = getWikiRecent(); + recent.removeIf(t -> t.equalsIgnoreCase(title)); + recent.add(0, title); + while (recent.size() > WIKI_RECENT_MAX) + { + recent.remove(recent.size() - 1); + } + configManager.setConfiguration(EssentialsPlusConfig.GROUP, WIKI_RECENT, String.join("|", recent)); + EssentialsPlusPanel p = panel; + if (p != null) + { + p.refreshWikiLater(); + } + } + + private java.awt.Rectangle loadWikiBounds() + { + String raw = configManager.getConfiguration(EssentialsPlusConfig.GROUP, WIKI_BOUNDS); + if (raw != null) + { + String[] parts = raw.split(","); + try + { + if (parts.length == 4) + { + return new java.awt.Rectangle(Integer.parseInt(parts[0]), Integer.parseInt(parts[1]), + Integer.parseInt(parts[2]), Integer.parseInt(parts[3])); + } + } + catch (NumberFormatException ignored) + { + // fall back to the default position + } + } + return null; + } + + private void saveWikiBounds(java.awt.Rectangle r) + { + configManager.setConfiguration(EssentialsPlusConfig.GROUP, WIKI_BOUNDS, r.x + "," + r.y + "," + r.width + "," + r.height); + } + + public static String accountKey(long accountHash, RuneScapeProfileType type) + { + String base = String.valueOf(accountHash); + return type == null || type == RuneScapeProfileType.STANDARD ? base : base + "-" + type.name().toLowerCase(); + } + + // ---------------------------------------------------------------- events + + @Subscribe + public void onItemContainerChanged(ItemContainerChanged event) + { + combatTracker.onItemContainerChanged(event); + if (fightService != null) + { + fightService.onContainerChanged(); + } + int containerId = event.getContainerId(); + + // Check if a reward crate / casket was just opened from inventory + if (containerId == net.runelite.api.InventoryID.INVENTORY.getId() && pendingCrateName != null) + { + if (client.getTickCount() - pendingCrateTick <= 4) + { + ItemContainer inv = event.getItemContainer(); + Map newInv = new HashMap<>(); + if (inv != null) + { + for (Item item : inv.getItems()) + { + if (item != null && item.getId() > 0 && item.getQuantity() > 0) + { + newInv.merge(item.getId(), item.getQuantity(), Integer::sum); + } + } + } + + List gained = new ArrayList<>(); + if (invSnapshotBeforeOpen != null) + { + for (Map.Entry entry : newInv.entrySet()) + { + int id = entry.getKey(); + int currentQty = entry.getValue(); + int prevQty = invSnapshotBeforeOpen.getOrDefault(id, 0); + if (currentQty > prevQty) + { + gained.add(new net.runelite.client.game.ItemStack(id, currentQty - prevQty)); + } + } + } + + if (!gained.isEmpty()) + { + lootTrackerStore.addLoot(pendingCrateName, "EVENT", gained, 1, itemManager, client.getTickCount()); + } + } + pendingCrateName = null; + invSnapshotBeforeOpen = null; + } + + checkChestContainers(event); + trackBankAndContainers(containerId, event.getItemContainer()); + + for (ContainerSource c : CONTAINERS) + { + if (c.id == containerId) + { + ItemContainer changed = event.getItemContainer(); + if (changed != null) + { + Map items = readContainer(changed); + seenContainers.put(c.key, items); + AccountData acc = activeAccount(); + if (acc != null) + { + acc.containers.put(c.key, items); + if (c.category == WealthCategory.BANK) + { + acc.bankKnown = true; + bankUpdated = true; + bankSeenThisSession = true; + if (acc.bankPending != null && !acc.bankPending.isEmpty()) + { + acc.bankPending = new HashMap<>(); + } + } + } + } + if (c.category == WealthCategory.BANK) + { + bankUpdated = true; + bankSeenThisSession = true; + } + if (c.category == WealthCategory.EQUIPMENT) + { + markUrgent(); + } + else + { + markDirty(); + } + return; + } + } + } + + private AccountData activeAccount() + { + if (activeKey != null) + { + AccountData acc = store.get(activeKey); + if (acc != null) + { + return acc; + } + } + if (client.getGameState() != GameState.LOGGED_IN) + { + return null; + } + Player player = client.getLocalPlayer(); + long hash = client.getAccountHash(); + if (player == null || player.getName() == null || hash == -1) + { + return null; + } + String name = player.getName(); + RuneScapeProfileType type = RuneScapeProfileType.getCurrent(client); + String key = accountKey(hash, type); + return store.getOrCreate(key, name); + } + + private boolean isBankOpen() + { + if (bankOpen) + { + return true; + } + net.runelite.api.widgets.Widget w = client.getWidget(ComponentID.BANK_CONTAINER); + if (w != null && !w.isHidden()) + { + bankOpen = true; + return true; + } + net.runelite.api.widgets.Widget itemContainer = client.getWidget(ComponentID.BANK_ITEM_CONTAINER); + if (itemContainer != null && !itemContainer.isHidden()) + { + bankOpen = true; + return true; + } + return isWidgetOpen(InterfaceID.BANKMAIN) + || isWidgetOpen(InterfaceID.BANKSIDE); + } + + void deductFromBank(Map bank, int itemId, long quantity) + { + deductFromBank(bank, itemId, quantity, itemManager != null ? itemManager::canonicalize : id -> id); + } + + static void deductFromBank(Map bank, int itemId, long quantity, java.util.function.IntUnaryOperator canonicalize) + { + if (bank == null || quantity <= 0) + { + return; + } + int canonical = canonicalize.applyAsInt(itemId); + + Long current = bank.get(itemId); + if (current != null) + { + if (current <= quantity) + { + bank.remove(itemId); + quantity -= current; + } + else + { + bank.put(itemId, current - quantity); + return; + } + } + + if (quantity <= 0) + { + return; + } + + for (Iterator> it = bank.entrySet().iterator(); it.hasNext(); ) + { + Map.Entry entry = it.next(); + if (canonicalize.applyAsInt(entry.getKey()) == canonical) + { + long available = entry.getValue(); + if (available <= quantity) + { + it.remove(); + quantity -= available; + } + else + { + entry.setValue(available - quantity); + quantity = 0; + break; + } + if (quantity <= 0) + { + break; + } + } + } + } + + void addToBank(Map bank, int itemId, long quantity) + { + addToBank(bank, itemId, quantity, itemManager != null ? itemManager::canonicalize : id -> id); + } + + static void addToBank(Map bank, int itemId, long quantity, java.util.function.IntUnaryOperator canonicalize) + { + if (bank == null || quantity <= 0) + { + return; + } + int canonical = canonicalize.applyAsInt(itemId); + + if (bank.containsKey(itemId)) + { + bank.merge(itemId, quantity, Long::sum); + return; + } + + for (Map.Entry entry : bank.entrySet()) + { + if (canonicalize.applyAsInt(entry.getKey()) == canonical) + { + entry.setValue(entry.getValue() + quantity); + return; + } + } + + bank.merge(canonical, quantity, Long::sum); + } + + /** + * Tracks transfers between Inventory/Equipment and Bank while the bank is open, + * as well as deposits into deposit boxes, ensuring bank and inventory remain strictly symmetrical. + */ + private void trackBankAndContainers(int containerId, ItemContainer container) + { + boolean inv = containerId == InventoryID.INV; + if (!inv && containerId != InventoryID.WORN) + { + return; + } + Map now = container == null ? Map.of() : readContainer(container); + Map before = inv ? lastInvItems : lastWornItems; + if (inv) + { + lastInvItems = now; + } + else + { + lastWornItems = now; + } + AccountData acc = activeAccount(); + if (acc == null) + { + return; + } + if (before == null) + { + Map saved = acc.containers.get(inv ? "inventory" : "equipment"); + before = saved != null ? saved : Map.of(); + } + + // 1. Transfers between inventory/equipment and main bank while bank is open + if (isBankOpen()) + { + ItemContainer liveBank = client.getItemContainer(InventoryID.BANK); + if (liveBank != null) + { + // the live bank is authoritative; booking the difference as well would count transfers twice + // when the bank update arrives before the inventory update + Map bank = readContainer(liveBank); + acc.containers.put("bank", bank); + acc.bankKnown = true; + seenContainers.put("bank", bank); + unsaved = true; + markUrgent(); + return; + } + Map bank = acc.containers.get("bank"); + if (bank == null) + { + bank = new HashMap<>(); + acc.containers.put("bank", bank); + } + boolean bankChanged = false; + + // Gained in inventory/equipment => withdrawn from bank + for (Map.Entry e : now.entrySet()) + { + int id = e.getKey(); + long currentQty = e.getValue(); + long prevQty = before.getOrDefault(id, 0L); + if (currentQty > prevQty) + { + long withdrawn = currentQty - prevQty; + deductFromBank(bank, id, withdrawn); + bankChanged = true; + log.info("Wealth tracker: Withdrew item {} x{} from bank (into {})", id, withdrawn, inv ? "INV" : "WORN"); + } + } + + // Lost in inventory/equipment => deposited into bank + for (Map.Entry e : before.entrySet()) + { + int id = e.getKey(); + long prevQty = e.getValue(); + long currentQty = now.getOrDefault(id, 0L); + if (prevQty > currentQty) + { + long deposited = prevQty - currentQty; + addToBank(bank, id, deposited); + bankChanged = true; + log.info("Wealth tracker: Deposited item {} x{} into bank (from {})", id, deposited, inv ? "INV" : "WORN"); + } + } + + if (bankChanged) + { + acc.containers.put("bank", bank); + seenContainers.put("bank", new HashMap<>(bank)); + unsaved = true; + markUrgent(); + } + return; + } + + // 2. Deposit boxes (bank interface not open) + if (isWidgetOpen(InterfaceID.BANK_DEPOSITBOX) + || isWidgetOpen(InterfaceID.BANK_DEPOSIT_IMP) + || isWidgetOpen(InterfaceID.SEED_VAULT_DEPOSIT) + || isWidgetOpen(InterfaceID.SHARED_BANK) + || isWidgetOpen(InterfaceID.SHARED_BANK_SIDE)) + { + Map pending = new HashMap<>(acc.bankPending); + boolean changed = false; + for (Map.Entry e : before.entrySet()) + { + long gone = e.getValue() - now.getOrDefault(e.getKey(), 0L); + if (gone > 0) + { + pending.merge(itemManager.canonicalize(e.getKey()), gone, Long::sum); + changed = true; + } + } + if (changed) + { + acc.bankPending = pending; + unsaved = true; + markUrgent(); + } + } + } + + private boolean isWidgetOpen(int group) + { + net.runelite.api.widgets.Widget w = client.getWidget(group, 0); + return w != null && !w.isHidden(); + } + + private void checkChestContainers(ItemContainerChanged event) + { + int cid = event.getContainerId(); + String chestName = null; + if (cid == net.runelite.api.InventoryID.BARROWS_REWARD.getId()) + { + chestName = "Barrows Chest"; + } + else if (cid == net.runelite.api.InventoryID.CHAMBERS_OF_XERIC_CHEST.getId()) + { + chestName = "Chambers of Xeric"; + } + else if (cid == net.runelite.api.InventoryID.THEATRE_OF_BLOOD_CHEST.getId()) + { + chestName = "Theatre of Blood"; + } + else if (cid == net.runelite.api.InventoryID.TOA_REWARD_CHEST.getId()) + { + chestName = "Tombs of Amascut"; + } + else if (cid == net.runelite.api.InventoryID.LUNAR_CHEST.getId()) + { + chestName = "Lunar Chest"; + } + else if (cid == net.runelite.api.InventoryID.FORTIS_COLOSSEUM_REWARD_CHEST.getId()) + { + chestName = "Fortis Colosseum"; + } + else if (cid == net.runelite.api.InventoryID.WILDERNESS_LOOT_CHEST.getId()) + { + chestName = "Wilderness Loot Chest"; + } + + if (chestName != null) + { + ItemContainer cont = event.getItemContainer(); + if (cont != null) + { + List items = new ArrayList<>(); + for (Item item : cont.getItems()) + { + if (item != null && item.getId() > 0 && item.getQuantity() > 0) + { + items.add(new net.runelite.client.game.ItemStack(item.getId(), item.getQuantity())); + } + } + if (!items.isEmpty()) + { + lootTrackerStore.addLoot(chestName, "CHEST", items, 1, itemManager, client.getTickCount()); + } + } + } + } + + @Subscribe + public void onMenuOptionClicked(net.runelite.api.events.MenuOptionClicked event) + { + String option = event.getMenuOption(); + if (option != null && (option.equalsIgnoreCase("Open") || option.equalsIgnoreCase("Search") || option.equalsIgnoreCase("Loot"))) + { + int itemId = event.getItemId(); + if (itemId > 0) + { + ItemComposition comp = itemManager.getItemComposition(itemId); + if (comp != null && comp.getName() != null) + { + String name = comp.getName(); + if (isRewardCrateOrChest(name)) + { + pendingCrateName = name; + pendingCrateTick = client.getTickCount(); + ItemContainer inv = client.getItemContainer(net.runelite.api.InventoryID.INVENTORY); + invSnapshotBeforeOpen = new HashMap<>(); + if (inv != null) + { + for (Item item : inv.getItems()) + { + if (item != null && item.getId() > 0 && item.getQuantity() > 0) + { + invSnapshotBeforeOpen.merge(item.getId(), item.getQuantity(), Integer::sum); + } + } + } + } + } + } + } + } + + private static boolean isRewardCrateOrChest(String name) + { + if (name == null) + { + return false; + } + String lower = name.toLowerCase(Locale.ENGLISH); + return lower.contains("supply crate") + || lower.contains("casket") + || lower.contains("spoils of war") + || lower.contains("bag full of gems") + || lower.contains("seed pack") + || lower.contains("bird nest") + || lower.contains("hallowed sack") + || lower.contains("ore pack") + || lower.contains("lockbox") + || lower.contains("reward pool"); + } + + @Subscribe + public void onHitsplatApplied(net.runelite.api.events.HitsplatApplied event) + { + combatTracker.onHitsplatApplied(event); + } + + @Subscribe + public void onAnimationChanged(net.runelite.api.events.AnimationChanged event) + { + combatTracker.onAnimationChanged(event); + } + + @Subscribe + public void onProjectileMoved(net.runelite.api.events.ProjectileMoved event) + { + combatTracker.onProjectileMoved(event); + } + + @Subscribe + public void onActorDeath(net.runelite.api.events.ActorDeath event) + { + combatTracker.onActorDeath(event); + } + + @Subscribe + public void onNpcDespawned(net.runelite.api.events.NpcDespawned event) + { + combatTracker.onNpcDespawned(event); + } + + @Subscribe + public void onServerNpcLoot(net.runelite.client.events.ServerNpcLoot event) + { + net.runelite.api.NPCComposition comp = event.getComposition(); + if (comp != null && comp.getName() != null && event.getItems() != null && !event.getItems().isEmpty()) + { + String rawName = net.runelite.client.util.Text.removeTags(comp.getName()); + String name = com.example.essentialsplus.loot.combat.CombatTracker.getCanonicalEncounterName(rawName); + long dropVal = calculateDropValue(event.getItems()); + lootTrackerStore.addLoot(name, "NPC", event.getItems(), 1, itemManager, client.getTickCount()); + combatTracker.onLootReceivedForEnemy(name, dropVal); + } + } + + @Subscribe + public void onVarbitChanged(VarbitChanged event) + { + int id = event.getVarbitId(); + if (!WATCHED_VARBITS.contains(id)) + { + return; + } + if (COFFER_VARBITS.contains(id)) + { + coffersDirty = true; + } + else if (BLAST_FURNACE_VARBITS.contains(id)) + { + // outside the Blast Furnace these read 0 - keep the last known contents + if (!atBlastFurnace()) + { + return; + } + furnaceDirty = true; + } + else + { + pouchDirty = true; + } + markDirty(); + } + + @Subscribe + public void onGrandExchangeOfferChanged(GrandExchangeOfferChanged event) + { + markDirty(); + checkGeOfferChanged(event.getSlot(), event.getOffer()); + } + + @Subscribe + public void onNpcLootReceived(net.runelite.client.events.NpcLootReceived event) + { + net.runelite.api.NPC npc = event.getNpc(); + if (npc != null && npc.getName() != null && event.getItems() != null && !event.getItems().isEmpty()) + { + String rawName = net.runelite.client.util.Text.removeTags(npc.getName()); + String name = com.example.essentialsplus.loot.combat.CombatTracker.getCanonicalEncounterName(rawName); + long dropVal = calculateDropValue(event.getItems()); + lootTrackerStore.addLoot(name, "NPC", event.getItems(), 1, itemManager, client.getTickCount()); + combatTracker.onLootReceivedForEnemy(name, dropVal); + } + } + + @Subscribe + public void onPlayerLootReceived(net.runelite.client.events.PlayerLootReceived event) + { + net.runelite.api.Player player = event.getPlayer(); + if (player != null && player.getName() != null && event.getItems() != null && !event.getItems().isEmpty()) + { + String name = net.runelite.client.util.Text.removeTags(player.getName()); + long dropVal = calculateDropValue(event.getItems()); + lootTrackerStore.addLoot(player.getName(), "PLAYER", event.getItems(), 1, itemManager, client.getTickCount()); + combatTracker.onLootReceivedForEnemy(name, dropVal); + } + } + + @Subscribe + public void onScriptPreFired(net.runelite.api.events.ScriptPreFired event) + { + collectionLog.onScriptPreFired(event); + if (event.getScriptId() == 7928) + { + int delve = -1; + try + { + net.runelite.api.widgets.Widget widget = client.getWidget(60227586); + if (widget != null) + { + net.runelite.api.widgets.Widget child = widget.getChild(1); + if (child != null && child.getText() != null) + { + String text = child.getText(); + // e.g. "Delve 4" or "Level 4" + String[] parts = text.split(" "); + if (parts.length > 1) + { + delve = Integer.parseInt(parts[1].trim()); + combatTracker.setDoomDelve(delve); + } + } + } + } + catch (Exception e) + { + log.debug("Failed parsing delve from script 7928", e); + } + recordDoomReward(delve); + } + } + + /** + * Doom of Mokhaiotl rewards come from a reward interface, not an NPC drop. RuneLite's own + * loot tracker only posts LootReceived for it when that plugin is enabled, so read the + * reward container ourselves. If both fire, LootTrackerStore dedups by tick + items. + */ + private void recordDoomReward(int delve) + { + net.runelite.api.ItemContainer container = client.getItemContainer(DOOM_REWARD_CONTAINER); + if (container == null) + { + return; + } + // mirror LootTrackerPlugin#toItemStacks so the dedup hash matches its LootReceived + java.util.List items = new java.util.ArrayList<>(); + for (net.runelite.api.Item item : container.getItems()) + { + if (item.getId() > -1) + { + items.add(new net.runelite.client.game.ItemStack(item.getId(), item.getQuantity())); + } + } + if (items.isEmpty()) + { + return; + } + String name = com.example.essentialsplus.loot.combat.CombatTracker.DOOM_OF_MOKHAIOTL; + lootTrackerStore.addLoot(name, "EVENT", items, -1, itemManager, client.getTickCount()); + combatTracker.onLootReceivedForEnemy(name, calculateDropValue(items), Math.max(0, delve)); + } + + @Subscribe + public void onChatMessage(net.runelite.api.events.ChatMessage event) + { + collectionLog.onChatMessage(event); + String msg = event.getMessage(); + if (msg != null && (msg.contains("Delve") || msg.contains("delve"))) + { + java.util.regex.Matcher m = java.util.regex.Pattern.compile("(?i)delve\\s*(\\d+)").matcher(msg); + if (m.find()) + { + try + { + int delve = Integer.parseInt(m.group(1)); + combatTracker.setDoomDelve(delve); + } + catch (Exception ignored) + { + } + } + } + } + + @Subscribe + public void onLootReceived(net.runelite.client.plugins.loottracker.LootReceived event) + { + if (event.getName() != null && event.getItems() != null && !event.getItems().isEmpty()) + { + String name = com.example.essentialsplus.loot.combat.CombatTracker.getCanonicalEncounterName(event.getName()); + String typeName = event.getType() != null ? event.getType().name() : "EVENT"; + int amount = event.getAmount(); + long dropVal = calculateDropValue(event.getItems()); + int delve = 0; + if (event.getMetadata() instanceof Number) + { + delve = ((Number) event.getMetadata()).intValue(); + } + lootTrackerStore.addLoot(name, typeName, event.getItems(), amount, itemManager, client.getTickCount()); + combatTracker.onLootReceivedForEnemy(name, dropVal, delve); + } + } + + private long calculateDropValue(java.util.Collection items) + { + if (items == null) + { + return 0; + } + long total = 0; + for (net.runelite.client.game.ItemStack item : items) + { + if (item != null && item.getId() > 0 && item.getQuantity() > 0) + { + total += (long) itemManager.getItemPrice(item.getId()) * item.getQuantity(); + } + } + return total; + } + + /** Changes often arrive split over a few ticks (e.g. GE collect) - wait until things are quiet. */ + private void markDirty() + { + if (!dirty) + { + dirtyTicks = 0; + } + dirty = true; + quietTicks = 0; + } + + /** Changes the player expects to see right away (bank closed, item equipped). */ + private void markUrgent() + { + markDirty(); + urgent = true; + } + + /** Re-reads every cached source on the next valuation (login, character switch). */ + private void invalidateSources() + { + pouchDirty = true; + coffersDirty = true; + furnaceDirty = true; + } + + @Subscribe + public void onWidgetLoaded(net.runelite.api.events.WidgetLoaded event) + { + if (event.getGroupId() == InterfaceID.BANKMAIN) + { + bankOpen = true; + justOpenedBank = true; + markUrgent(); + log.info("Wealth tracker: Bank opened (WidgetLoaded 12)"); + } + else if (event.getGroupId() == InterfaceID.BANK_DEPOSITBOX) + { + markUrgent(); + } + } + + @Subscribe + public void onWidgetClosed(net.runelite.api.events.WidgetClosed event) + { + if (event.getGroupId() == InterfaceID.BANKMAIN) + { + bankOpen = false; + justOpenedBank = false; + // save right away: closing the client does not always save, and an outdated bank breaks the next session + lastSave = 0; + markUrgent(); + log.info("Wealth tracker: Bank closed (WidgetClosed 12)"); + } + else if (event.getGroupId() == InterfaceID.BANK_DEPOSITBOX) + { + lastSave = 0; + markUrgent(); + } + } + + private boolean atBlastFurnace() + { + Player player = client.getLocalPlayer(); + return player != null && player.getWorldLocation() != null && player.getWorldLocation().getRegionID() == BLAST_FURNACE_REGION; + } + + @Subscribe + public void onConfigChanged(ConfigChanged event) + { + if (EssentialsPlusConfig.GROUP.equals(event.getGroup())) + { + dirty = true; + EssentialsPlusPanel p = panel; + if (p != null && "showSkillsHud".equals(event.getKey())) + { + p.refreshSkillsLater(); + } + if (p != null && (event.getKey().toLowerCase().contains("attacktimer") || event.getKey().toLowerCase().contains("attackstyle") || event.getKey().toLowerCase().contains("treecooldown") || event.getKey().toLowerCase().contains("itemcharge"))) + { + p.refreshHelpersLater(); + } + } + } + + @Subscribe + public void onGameStateChanged(GameStateChanged event) + { + GameState state = event.getGameState(); + if (state == GameState.LOGIN_SCREEN || state == GameState.LOGIN_SCREEN_AUTHENTICATOR) + { + awaitingLogin = true; + seenContainers.clear(); + bankOpen = false; + lastInvItems = null; + lastWornItems = null; + } + if (state == GameState.LOGGED_IN && awaitingLogin) + { + awaitingLogin = false; + bankSeenThisSession = false; + // a fresh login (not a hop or loading screen) starts the "Sess" range, with the exact item state pinned + long now = System.currentTimeMillis(); + timeFilter.setSessionStart(now); + pinLedgerAt = now; + } + if (state == GameState.LOGGED_IN) + { + invalidateSources(); + markUrgent(); + if (ticksSinceLogin < 0) + { + ticksSinceLogin = 0; + } + } + else if (state == GameState.LOGGING_IN || state == GameState.HOPPING || state == GameState.LOGIN_SCREEN + || state == GameState.CONNECTION_LOST) + { + ticksSinceLogin = -1; + geTracker.reset(); + } + if (state == GameState.LOGGED_IN) + { + xpDirty = true; + } + if (state == GameState.LOGIN_SCREEN) + { + collectionLog.reset(); + progress.reset(); + currencyTracker.reset(); + executor.execute(currencyStore::saveDirty); + } + if (state == GameState.LOGIN_SCREEN && skillKey != null) + { + skillKey = null; + executor.execute(skillStore::saveDirty); + } + if (state == GameState.LOGIN_SCREEN && activeKey != null) + { + AccountData acc = store.get(activeKey); + String key = activeKey; + activeKey = null; + if (acc != null) + { + recordFinal(acc); + saveAsync(acc); + saveLedgerAsync(key); + } + refreshPanel(); + } + } + + @Subscribe + public void onGameTick(GameTick event) + { + collectionLog.onGameTick(); + progress.onGameTick(); + combatTracker.onGameTick(event); + if (fightService != null) + { + fightService.onGameTick(); + } + trackCurrencies(); + if (xpDirty && ticksSinceLogin >= 3) + { + recordXp(); + } + ticksSinceCompute++; + quietTicks++; + dirtyTicks++; + if (ticksSinceLogin >= 0) + { + ticksSinceLogin++; + if (ticksSinceLogin == 5) + { + syncGeOffers(); + } + } + boolean due = dirty && (urgent || quietTicks >= COMPUTE_MIN_TICKS || dirtyTicks >= COMPUTE_MAX_DELAY_TICKS); + if (due || ticksSinceCompute >= COMPUTE_IDLE_TICKS) + { + dirty = false; + urgent = false; + ticksSinceCompute = 0; + compute(); + } + } + + private void trackCurrencies() + { + Player player = client.getLocalPlayer(); + long hash = client.getAccountHash(); + if (client.getGameState() != GameState.LOGGED_IN || player == null || player.getName() == null || hash == -1) + { + return; + } + currencyTracker.onGameTick(accountKey(hash, RuneScapeProfileType.getCurrent(client)), player.getName(), ticksSinceLogin); + } + + @Subscribe + public void onStatChanged(net.runelite.api.events.StatChanged event) + { + xpDirty = true; + } + + private void recordXp() + { + Player player = client.getLocalPlayer(); + long hash = client.getAccountHash(); + if (client.getGameState() != GameState.LOGGED_IN || player == null || player.getName() == null || hash == -1) + { + return; + } + // hitpoints start at level 10: anything less means stats are not loaded yet + if (client.getSkillExperience(net.runelite.api.Skill.HITPOINTS) < 1154) + { + return; + } + xpDirty = false; + int[] xp = new int[SkillHistory.SKILLS.length]; + for (int i = 0; i < xp.length; i++) + { + xp[i] = client.getSkillExperience(SkillHistory.SKILLS[i]); + } + String key = accountKey(hash, RuneScapeProfileType.getCurrent(client)); + boolean switched = !key.equals(skillKey); + skillKey = key; + if (skillStore.record(key, player.getName(), xp, System.currentTimeMillis()) || switched) + { + EssentialsPlusPanel p = panel; + if (p != null) + { + p.refreshSkillsLater(); + } + } + } + + public void resetSkillsHourly(String key) + { + if (key == null) + { + return; + } + executor.execute(() -> skillStore.resetHourly(key, timeFilter.getRange())); + EssentialsPlusPanel p = panel; + if (p != null) + { + p.refreshSkillsLater(); + } + } + + public void resetWealthHourly(String key) + { + long now = System.currentTimeMillis(); + com.example.essentialsplus.ui.TimeRange range = timeFilter.getRange(); + EssentialsPlusPanel p = panel; + com.example.essentialsplus.ui.WealthPanel wp = p != null ? p.getWealthPanel() : null; + boolean isSession = (wp != null && wp.isSessionView()) || range == com.example.essentialsplus.ui.TimeRange.SESSION; + boolean isCurrent = timeFilter.isCurrent() && !isSession; + if (!isSession && !isCurrent) + { + return; + } + if (wp != null) + { + wp.resetHourly(isSession, now); + } + if (key == null || "ALL".equals(key)) + { + for (AccountData a : store.all()) + { + a.resetHourly(isSession); + } + } + else + { + AccountData a = store.get(key); + if (a != null) + { + a.resetHourly(isSession); + } + } + AccountData cur = store.get(activeKey); + if (cur != null) + { + saveAsync(cur); + } + if (p != null) + { + p.refreshWealthLater(); + } + } + + @Subscribe + public void onOverlayMenuClicked(OverlayMenuClicked event) + { + if (event.getOverlay() == skillsHudOverlay && "Reset Hourly".equals(event.getEntry().getOption())) + { + resetSkillsHourly(skillKey); + } + } + + public boolean isSkillsHudEnabled() + { + return config.showSkillsHud(); + } + + public void setSkillsHudEnabled(boolean enabled) + { + configManager.setConfiguration(EssentialsPlusConfig.GROUP, "showSkillsHud", enabled); + } + + /** Boolean HUD options by config key, read with the config defaults applied. */ + public boolean isHudOption(String key) + { + switch (key) + { + case "skillsHudLevel": + return config.skillsHudLevel(); + case "skillsHudPercent": + return config.skillsHudPercent(); + case "skillsHudXpLeft": + return config.skillsHudXpLeft(); + case "skillsHudTimeToLevel": + return config.skillsHudTimeToLevel(); + case "skillsHudXpHour": + return config.skillsHudXpHour(); + case "skillsHudProgressBar": + return config.skillsHudProgressBar(); + default: + return false; + } + } + + public void setHudOption(String key, boolean enabled) + { + configManager.setConfiguration(EssentialsPlusConfig.GROUP, key, enabled); + } + + /** Start of the current session (0 = none). */ + public long getSessionStart() + { + String v = configManager.getConfiguration(EssentialsPlusConfig.GROUP, "sessionStart"); + try + { + return v == null ? 0 : Long.parseLong(v); + } + catch (NumberFormatException e) + { + return 0; + } + } + + /** Starts a new session now: views "since session" compare against this moment. */ + public void startSession() + { + long now = System.currentTimeMillis(); + configManager.setConfiguration(EssentialsPlusConfig.GROUP, "sessionStart", String.valueOf(now)); + pinLedgerAt = now; + markDirty(); + } + + /** Called from the panel's refresh button. */ + public void requestUpdate() + { + dirty = true; + executor.execute(() -> + { + store.reloadExternal(activeKey); + refreshPanel(); + }); + } + + public void deleteAccount(String key) + { + clientThread.invokeLater(() -> + { + store.delete(key); + if (key.equals(activeKey)) + { + dirty = true; + } + refreshPanel(); + }); + } + + // ---------------------------------------------------------------- calculation + + private boolean pricesLoaded() + { + return itemManager.getItemPrice(ItemID.ABYSSAL_WHIP) > 0; + } + + private void compute() + { + if (client.getGameState() != GameState.LOGGED_IN) + { + return; + } + Player player = client.getLocalPlayer(); + long hash = client.getAccountHash(); + if (player == null || player.getName() == null || hash == -1 || !pricesLoaded()) + { + dirty = true; + return; + } + + String name = player.getName(); + RuneScapeProfileType type = RuneScapeProfileType.getCurrent(client); + String key = accountKey(hash, type); + + if (!key.equals(activeKey)) + { + AccountData previous = store.get(activeKey); + if (previous != null) + { + recordFinal(previous); + saveAsync(previous); + saveLedgerAsync(previous.key); + } + activeKey = key; + invalidateSources(); + furnaceCache = new HashMap<>(); + store.refreshFromDisk(key); + geTracker.reset(); + } + + AccountData acc = store.getOrCreate(key, name); + acc.name = name; + acc.accountHash = hash; + acc.profile = type == null ? null : type.name(); + long now = System.currentTimeMillis(); + + // Synchronize bankOpen with actual widget visibility + if (bankOpen) + { + net.runelite.api.widgets.Widget w = client.getWidget(ComponentID.BANK_CONTAINER); + net.runelite.api.widgets.Widget ic = client.getWidget(ComponentID.BANK_ITEM_CONTAINER); + boolean widgetVisible = (w != null && !w.isHidden()) || (ic != null && !ic.isHidden()); + if (!widgetVisible && !isWidgetOpen(InterfaceID.BANKMAIN)) + { + bankOpen = false; + justOpenedBank = false; + } + } + + for (ContainerSource c : CONTAINERS) + { + ItemContainer container = client.getItemContainer(c.id); + // the contents from the last change event are the newest; the client may keep an outdated bank after closing + Map items = seenContainers.remove(c.key); + if (items == null && container != null) + { + if (c.category == WealthCategory.BANK) + { + // For bank, only read from client container if bank is not in memory yet, + // or if the bank interface was just opened this frame to sync the initial live state + if (!acc.containers.containsKey("bank") || acc.containers.get("bank").isEmpty() || justOpenedBank) + { + Map candidate = readContainer(container); + Map existingBank = acc.containers.get("bank"); + if (existingBank != null && !existingBank.isEmpty() && candidate.size() < existingBank.size() / 2) + { + log.debug("Wealth tracker: Bank container streaming ({} items vs {} known), deferring sync", + candidate.size(), existingBank.size()); + } + else + { + items = candidate; + justOpenedBank = false; + log.info("Wealth tracker: Synced live bank container ({} items)", items.size()); + } + } + } + else + { + items = readContainer(container); + } + } + if (items == null && container == null && ticksSinceLogin >= 10 + && (c.category == WealthCategory.INVENTORY || c.category == WealthCategory.EQUIPMENT)) + { + // the game sends nothing for an empty inventory / no gear - don't keep an old saved state + items = new HashMap<>(); + } + if (items != null) + { + if (c.category == WealthCategory.BANK) + { + bankSeenThisSession = true; + acc.bankKnown = true; + } + acc.containers.put(c.key, items); + } + } + if (bankUpdated && client.getItemContainer(InventoryID.BANK) != null) + { + // the real bank was seen again, items booked while it was not visible are included now + acc.bankPending = new HashMap<>(); + } + bankUpdated = false; + + Map ge = Map.of(); + boolean offersLive = ticksSinceLogin >= 5; + if (config.includeGe()) + { + if (!offersLive) + { + // GE offers of this login are not known yet - keep showing the last value + dirty = true; + return; + } + ge = geTracker.update(acc, client, itemManager::canonicalize, true); + } + + Valuation val = new Valuation(); + valueContainers(acc, val); + + // Rune pouch contents live in varbits, not in a container + Map pouch = acc.containers.get("runePouch"); + if (pouchDirty || pouch == null) + { + pouchDirty = false; + pouch = readRunePouch(); + acc.containers.put("runePouch", pouch); + } + if (!pouch.isEmpty()) + { + val.add("Rune pouch", WealthCategory.STORAGE, pouch); + } + + // read at login and while at the Blast Furnace only + if (furnaceDirty && (atBlastFurnace() || ticksSinceLogin <= 10)) + { + furnaceDirty = false; + Map read = new HashMap<>(); + for (int[] bf : BLAST_FURNACE_ITEMS) + { + int qty = client.getVarbitValue(bf[0]); + if (qty > 0) + { + read.merge(bf[1], (long) qty, Long::sum); + } + } + furnaceCache = read; + } + Map furnace = furnaceCache; + if (!furnace.isEmpty()) + { + val.add("Blast Furnace", WealthCategory.STORAGE, furnace); + } + + if (coffersDirty) + { + coffersDirty = false; + cofferCache[0] = client.getVarbitValue(VarbitID.MISC_COFFERS); + cofferCache[1] = client.getVarbitValue(VarbitID.BLAST_FURNACE_COFFER); + cofferCache[2] = client.getVarbitValue(VarbitID.NZONE_CASH); + } + val.addGp("Miscellania coffer", WealthCategory.COFFERS, cofferCache[0]); + val.addGp("Blast Furnace coffer", WealthCategory.COFFERS, cofferCache[1]); + val.addGp("Nightmare Zone coffer", WealthCategory.COFFERS, cofferCache[2]); + + if (!ge.isEmpty()) + { + val.add("GE offers", WealthCategory.GRAND_EXCHANGE, ge); + } + + if (!Arrays.equals(acc.current, val.values)) + { + unsaved = true; + } + // no timeline points until the GE offers of this login are known + apply(acc, val, now, config.snapshotSeconds() * 1000L, true); + // moving tokens between bank and inventory is not earning them + boolean itemsSync = ticksSinceLogin < 10 || isBankOpen() || isWidgetOpen(InterfaceID.BANK_DEPOSITBOX) + || isWidgetOpen(InterfaceID.BANK_DEPOSIT_IMP) || isWidgetOpen(InterfaceID.SEED_VAULT_DEPOSIT); + currencyTracker.onItems(key, name, acc.containers, acc.bankPending, itemsSync, itemManager::canonicalize); + + if (unsaved && now - lastSave >= SAVE_INTERVAL_MS) + { + saveAsync(acc); + } + if (now - lastLedgerSave >= LEDGER_SAVE_INTERVAL_MS) + { + saveLedgerAsync(key); + } + + refreshPanel(); + } + + /** Stores a valuation into the account: current state, top items, timeline point and item snapshot. */ + private void apply(AccountData acc, Valuation val, long now, long minGap, boolean record) + { + acc.current = val.values; + acc.sources = val.sources; + acc.topItems = topItems(val); + acc.lastUpdated = now; + + if (!acc.bankKnown || !record) + { + return; + } + if (acc.key.equals(activeKey) && ticksSinceLogin < 10) + { + // inventory / equipment may still hold the last session's state: pinning the session start on them + // would show the difference to the live state as activity + return; + } + + ItemLedger ledger = store.ledger(acc.key, true); + acc.market += marketEffect(ledger.last(), val); + + Snapshot last = acc.lastSnapshot(); + long valTotal = val.total(); + + // Check if the previous snapshot(s) in history were an isolated transient spike that now reverted + if (last != null && acc.history.size() >= 2) + { + Snapshot prev = acc.history.get(acc.history.size() - 2); + if (AccountData.isTransientSpike(prev.t, prev.total(), last.t, last.total(), now, valTotal)) + { + acc.history.remove(acc.history.size() - 1); + last = acc.lastSnapshot(); + unsaved = true; + } + else if (acc.history.size() >= 3) + { + Snapshot prev2 = acc.history.get(acc.history.size() - 3); + if (AccountData.isDoubleTransientSpike(prev2.t, prev2.total(), prev.t, prev.total(), last.t, last.total(), now, valTotal)) + { + acc.history.remove(acc.history.size() - 1); + acc.history.remove(acc.history.size() - 1); + last = acc.lastSnapshot(); + unsaved = true; + } + else if (acc.history.size() >= 4) + { + Snapshot prev3 = acc.history.get(acc.history.size() - 4); + if (AccountData.isTripleTransientSpike(prev3.t, prev3.total(), prev2.t, prev2.total(), prev.t, prev.total(), last.t, last.total(), now, valTotal)) + { + acc.history.remove(acc.history.size() - 1); + acc.history.remove(acc.history.size() - 1); + acc.history.remove(acc.history.size() - 1); + last = acc.lastSnapshot(); + unsaved = true; + } + } + } + } + + boolean differs = last == null || !last.sameValues(val.values); + if (differs && (last == null || now - last.t >= minGap)) + { + Snapshot point = new Snapshot(now, val.values.clone()); + point.m = acc.market; + acc.history.add(point); + unsaved = true; + } + + long pin = pinLedgerAt; + if (pin > 0 && acc.key.equals(activeKey)) + { + // exact item state at the session start so "what changed" can compare against it + pinLedgerAt = 0; + ItemSnapshot pinned = val.itemSnapshot(pin); + pinned.k = true; + ledger.snapshots.add(pinned); + ledger.dirty = true; + return; + } + ItemSnapshot snap = val.itemSnapshot(now); + ItemSnapshot prev = ledger.last(); + if (prev == null || !prev.sameAs(snap)) + { + boolean sameHour = prev != null && !Boolean.TRUE.equals(prev.k) && now / HOUR_MS == prev.t / HOUR_MS; + if (sameHour && ledger.snapshots.size() > 1) + { + ledger.snapshots.set(ledger.snapshots.size() - 1, snap); + } + else + { + ledger.snapshots.add(snap); + } + ledger.dirty = true; + } + } + + /** Value change of the items held at the previous valuation that comes only from their price changing. */ + private static long marketEffect(ItemSnapshot prev, Valuation val) + { + if (prev == null || prev.ids == null) + { + return 0; + } + long effect = 0; + for (int i = 0; i < prev.ids.length; i++) + { + Integer price = val.prices.get(prev.ids[i]); + if (price != null && price > 0 && prev.p[i] > 0) + { + effect += prev.q[i] * (long) (price - prev.p[i]); + } + } + return effect; + } + + private void valueContainers(AccountData acc, Valuation val) + { + for (ContainerSource c : CONTAINERS) + { + Map items = acc.containers.get(c.key); + if (c.category == WealthCategory.BANK && items != null && !acc.bankPending.isEmpty()) + { + Map withPending = new HashMap<>(items); + acc.bankPending.forEach((id, q) -> withPending.merge(id, q, Long::sum)); + items = withPending; + } + if (items != null) + { + val.add(c.label, c.category, items); + } + } + } + + /** + * Re-prices characters that are not logged in (in any client) with current GE prices, + * so market movements show up in their timeline too. + */ + private void revalueOffline() + { + if (!config.revalueOffline() || client.getGameState().ordinal() < GameState.LOGIN_SCREEN.ordinal() || !pricesLoaded()) + { + return; + } + long now = System.currentTimeMillis(); + boolean any = false; + for (AccountData acc : store.all()) + { + if (acc.key.equals(activeKey) || acc.containers.isEmpty() + || now - acc.lastUpdated < OFFLINE_GRACE_MS + || now - lastRevalue.getOrDefault(acc.key, 0L) < HOUR_MS) + { + continue; + } + lastRevalue.put(acc.key, now); + + Valuation val = new Valuation(); + valueContainers(acc, val); + // keep non-container sources (rune pouch, coffers, GE) at their last known value + Set containerLabels = new java.util.HashSet<>(); + for (ContainerSource c : CONTAINERS) + { + containerLabels.add(c.label); + } + for (SourceValue s : acc.sources) + { + if (!containerLabels.contains(s.name) && s.category < WealthCategory.COUNT) + { + val.addGp(s.name, WealthCategory.values()[s.category], s.value); + } + } + + long lastUpdated = acc.lastUpdated; + apply(acc, val, now, HOUR_MS, true); + acc.lastUpdated = lastUpdated; + WealthStore.compact(acc, config.rawDays(), config.hourlyDays(), now); + AccountData ref = acc; + executor.execute(() -> + { + store.save(ref); + ItemLedger ledger = store.ledger(ref.key, false); + WealthStore.compact(ledger, now); + store.saveLedger(ref.key, ledger); + }); + any = true; + } + if (any) + { + refreshPanel(); + } + } + + /** Writes the latest known value so the timeline ends exactly where the session ended. */ + private void recordFinal(AccountData acc) + { + if (!acc.bankKnown || acc.current == null) + { + return; + } + Snapshot last = acc.lastSnapshot(); + if (last == null || !last.sameValues(acc.current)) + { + Snapshot point = new Snapshot(System.currentTimeMillis(), acc.current.clone()); + point.m = acc.market; + acc.history.add(point); + } + } + + private void saveAsync(AccountData acc) + { + long now = System.currentTimeMillis(); + WealthStore.compact(acc, config.rawDays(), config.hourlyDays(), now); + lastSave = now; + unsaved = false; + executor.execute(() -> store.save(acc)); + } + + private void saveLedgerAsync(String key) + { + long now = System.currentTimeMillis(); + lastLedgerSave = now; + ItemLedger ledger = store.ledger(key, true); + WealthStore.compact(ledger, now); + if (ledger.dirty) + { + executor.execute(() -> store.saveLedger(key, ledger)); + } + } + + + + private Map readContainer(ItemContainer container) + { + Map items = new HashMap<>(); + for (Item item : container.getItems()) + { + if (item == null || item.getId() <= 0 || item.getQuantity() <= 0 || isPlaceholder(item.getId())) + { + continue; + } + items.merge(item.getId(), (long) item.getQuantity(), Long::sum); + } + return items; + } + + /** Bank placeholders canonicalize to the real item, so they would be valued as if the item was still banked. */ + private boolean isPlaceholder(int itemId) + { + ItemComposition comp = itemManager.getItemComposition(itemId); + return comp != null && comp.getPlaceholderTemplateId() != -1; + } + + private Map readRunePouch() + { + Map items = new HashMap<>(); + EnumComposition runes = client.getEnum(RUNEPOUCH_RUNE_ENUM); + for (int i = 0; i < RUNE_POUCH_TYPES.length; i++) + { + int type = client.getVarbitValue(RUNE_POUCH_TYPES[i]); + int qty = client.getVarbitValue(RUNE_POUCH_QUANTITIES[i]); + if (type <= 0 || qty <= 0 || runes == null) + { + continue; + } + int itemId = runes.getIntValue(type); + if (itemId > 0) + { + items.merge(itemId, (long) qty, Long::sum); + } + } + return items; + } + + private int price(int canonicalId, Map priceCache) + { + return priceCache.computeIfAbsent(canonicalId, id -> + { + if (id == ItemID.COINS) + { + return 1; + } + if (id == ItemID.PLATINUM) + { + return 1000; + } + return Math.max(0, itemManager.getItemPrice(id)); + }); + } + + private List topItems(Valuation val) + { + List list = new ArrayList<>(); + for (Map.Entry e : val.merged.entrySet()) + { + long value = price(e.getKey(), val.prices) * e.getValue(); + if (value > 0) + { + list.add(new TopItem(e.getKey(), null, e.getValue(), value)); + } + } + list.sort((a, b) -> Long.compare(b.value, a.value)); + if (list.size() > TOP_ITEMS) + { + list = new ArrayList<>(list.subList(0, TOP_ITEMS)); + } + for (TopItem item : list) + { + item.name = itemName(item.id); + } + return list; + } + + private String itemName(int id) + { + ItemComposition comp = itemManager.getItemComposition(id); + return comp != null ? comp.getName() : "Item " + id; + } + + /** + * Collects the priced items of one view ("bank", "inventory", "equipment", "lootingBag", "seedVault", + * "runePouch", "storage", "ge", "coffers", "all") over the given characters, sorted by value. + * Runs on the client thread, calls back on the Swing thread. + */ + public void loadItems(List accounts, String view, Consumer> callback) + { + clientThread.invokeLater(() -> + { + Map merged = new HashMap<>(); + List rows = new ArrayList<>(); + for (AccountData acc : accounts) + { + for (String key : viewContainers(view)) + { + if (key.equals("ge")) + { + if (acc.geSlots != null) + { + for (com.example.essentialsplus.data.GeSlot slot : acc.geSlots) + { + if (slot != null) + { + merged.merge(itemManager.canonicalize(slot.item), slot.escrowItems + slot.collectItems, Long::sum); + merged.merge(ItemID.COINS, slot.escrowCoins + slot.collectCoins, Long::sum); + } + } + } + continue; + } + if (key.equals("coffers")) + { + for (SourceValue sv : acc.sources) + { + if (sv.category == WealthCategory.COFFERS.ordinal() && sv.value > 0) + { + rows.add(new com.example.essentialsplus.data.ItemRow(ItemID.COINS, (accounts.size() > 1 ? acc.name + ": " : "") + sv.name, sv.value, 1)); + } + } + continue; + } + Map items = acc.containers.get(key); + if (items != null) + { + items.forEach((id, q) -> + { + if (!isPlaceholder(id)) + { + merged.merge(itemManager.canonicalize(id), q, Long::sum); + } + }); + } + if (key.equals("bank")) + { + acc.bankPending.forEach((id, q) -> merged.merge(itemManager.canonicalize(id), q, Long::sum)); + } + } + } + Map prices = new HashMap<>(); + merged.forEach((id, q) -> + { + if (q > 0) + { + rows.add(new com.example.essentialsplus.data.ItemRow(id, itemName(id), q, price(id, prices))); + } + }); + rows.sort((a, b) -> b.value != a.value ? Long.compare(b.value, a.value) : Long.compare(b.qty, a.qty)); + javax.swing.SwingUtilities.invokeLater(() -> callback.accept(rows)); + }); + } + + /** Grand Exchange slots of the given characters with item names and current GE prices. */ + public void loadGe(List accounts, Consumer> callback) + { + clientThread.invokeLater(() -> + { + List rows = new ArrayList<>(); + Map prices = new HashMap<>(); + for (AccountData acc : accounts) + { + com.example.essentialsplus.data.GeSlot[] slots = acc.geSlots; + for (int i = 0; i < 8; i++) + { + com.example.essentialsplus.data.GeSlot s = slots != null && i < slots.length ? slots[i] : null; + if (s == null) + { + rows.add(new com.example.essentialsplus.data.GeRow(acc.name, i, null, null, 0)); + } + else + { + int id = itemManager.canonicalize(s.item); + GeLimitInfo limitInfo = null; + if (s.buy) + { + ItemStats stats = itemManager.getItemStats(id); + int geLimit = stats != null ? stats.getGeLimit() : 0; + limitInfo = getBuyLimitInfo(acc, id, geLimit); + } + rows.add(new com.example.essentialsplus.data.GeRow(acc.name, i, s, itemName(id), price(id, prices), limitInfo)); + } + } + } + javax.swing.SwingUtilities.invokeLater(() -> callback.accept(rows)); + }); + } + + private static List viewContainers(String view) + { + switch (view) + { + case "storage": + return List.of("lootingBag", "seedVault", "runePouch"); + case "all": + return List.of("bank", "inventory", "equipment", "lootingBag", "seedVault", "runePouch", "ge"); + default: + return List.of(view); + } + } + + /** Resolves item names on the client thread, then calls back on the Swing thread. */ + public void resolveItemNames(java.util.Collection ids, Consumer> callback) + { + clientThread.invokeLater(() -> + { + Map names = new HashMap<>(); + for (int id : ids) + { + names.put(id, itemName(id)); + } + javax.swing.SwingUtilities.invokeLater(() -> callback.accept(names)); + }); + } + + // ---------------------------------------------------------------- GE buy limit tracking + + private void syncGeOffers() + { + if (client.getGameState() != GameState.LOGGED_IN || activeKey == null) + { + return; + } + GrandExchangeOffer[] offers = client.getGrandExchangeOffers(); + if (offers == null) + { + return; + } + for (int i = 0; i < offers.length; i++) + { + checkGeOfferChanged(i, offers[i]); + } + } + + private void checkGeOfferChanged(int slot, GrandExchangeOffer offer) + { + if (slot < 0 || slot >= 8) + { + return; + } + AccountData acc = store.get(activeKey); + if (acc == null) + { + return; + } + if (acc.geBuyLimitSlots == null || acc.geBuyLimitSlots.length != 8) + { + acc.geBuyLimitSlots = new AccountData.GeBuyLimitSlot[8]; + } + + if (offer == null || offer.getItemId() <= 0 || offer.getState() == GrandExchangeOfferState.EMPTY) + { + if (client.getGameState() == GameState.LOGGED_IN) + { + acc.geBuyLimitSlots[slot] = null; + } + return; + } + + GrandExchangeOfferState state = offer.getState(); + boolean buy = state == GrandExchangeOfferState.BUYING || state == GrandExchangeOfferState.BOUGHT + || state == GrandExchangeOfferState.CANCELLED_BUY; + if (!buy) + { + acc.geBuyLimitSlots[slot] = null; + return; + } + + int canonicalId = itemManager.canonicalize(offer.getItemId()); + AccountData.GeBuyLimitSlot prev = acc.geBuyLimitSlots[slot]; + AccountData.GeBuyLimitSlot current = new AccountData.GeBuyLimitSlot( + offer.getItemId(), + offer.getPrice(), + offer.getTotalQuantity(), + offer.getQuantitySold(), + offer.getSpent() + ); + + boolean sameOffer = current.isSameOffer(prev) && current.quantity >= prev.quantity; + int quantityDelta = sameOffer ? (current.quantity - prev.quantity) : current.quantity; + + if (quantityDelta > 0) + { + long now = System.currentTimeMillis(); + acc.recordBuy(canonicalId, quantityDelta, now); + GeBuyLimit limit = acc.geBuyLimits != null ? acc.geBuyLimits.get(canonicalId) : null; + if (limit != null && limit.windowStart > 0) + { + try + { + configManager.setRSProfileConfiguration("grandexchange", String.valueOf(canonicalId), + Instant.ofEpochMilli(limit.windowStart + AccountData.FOUR_HOURS_MS)); + } + catch (Exception ignored) + { + } + } + } + acc.geBuyLimitSlots[slot] = current; + } + + @Subscribe + public void onClientTick(ClientTick event) + { + updateGeBuyLimitWidgets(); + } + + @Subscribe + public void onScriptCallbackEvent(ScriptCallbackEvent event) + { + if (!config.showGeBuyLimits()) + { + return; + } + if ("geBuyExamineText".equals(event.getEventName())) + { + Object[] objectStack = client.getObjectStack(); + int objectStackSize = client.getObjectStackSize(); + if (objectStack == null || objectStackSize < 1) + { + return; + } + int itemId = client.getVarpValue(VarPlayer.CURRENT_GE_ITEM); + if (itemId <= 0) + { + return; + } + itemId = itemManager.canonicalize(itemId); + ItemStats stats = itemManager.getItemStats(itemId); + int geLimit = stats != null ? stats.getGeLimit() : 0; + AccountData acc = store.get(activeKey); + GeLimitInfo info = getBuyLimitInfo(acc, itemId, geLimit); + if (info != null && (info.limit > 0 || info.bought > 0)) + { + String current = (String) objectStack[objectStackSize - 1]; + if (current == null) + { + current = ""; + } + String limitLine = formatBuyLimitLine(info); + String base = stripBuyLimitLine(current); + String updated = base.isEmpty() ? limitLine : base + "
" + limitLine; + objectStack[objectStackSize - 1] = updated; + } + } + } + + private void updateGeBuyLimitWidgets() + { + if (!config.showGeBuyLimits() || client.getGameState() != GameState.LOGGED_IN) + { + return; + } + Widget geUniverse = client.getWidget(InterfaceID.GE_OFFERS, 0); + if (geUniverse == null || geUniverse.isHidden()) + { + return; + } + + AccountData acc = store.get(activeKey); + + // 1. DETAILS screen (child 15: DETAILS container, child 16: DETAILS_DESC) + Widget details = client.getWidget(InterfaceID.GE_OFFERS, 15); + Widget detailsDesc = client.getWidget(InterfaceID.GE_OFFERS, 16); + if (details != null && !details.isHidden() && detailsDesc != null && !detailsDesc.isHidden()) + { + updateDetailsWidget(acc, details, detailsDesc); + } + + // 2. SETUP screen (child 26: SETUP container, child 27: SETUP_DESC) + Widget setup = client.getWidget(InterfaceID.GE_OFFERS, 26); + Widget setupDesc = client.getWidget(InterfaceID.GE_OFFERS, 27); + if (setup != null && !setup.isHidden() && setupDesc != null && !setupDesc.isHidden()) + { + updateSetupWidget(acc, setup, setupDesc); + } + } + + private void updateDetailsWidget(AccountData acc, Widget details, Widget detailsDesc) + { + if (!isGeDetailsBuyOffer(details)) + { + return; + } + int itemId = getGeDetailsItemId(details); + if (itemId <= 0) + { + return; + } + itemId = itemManager.canonicalize(itemId); + ItemStats stats = itemManager.getItemStats(itemId); + int geLimit = stats != null ? stats.getGeLimit() : 0; + GeLimitInfo info = getBuyLimitInfo(acc, itemId, geLimit); + if (info == null || (info.limit <= 0 && info.bought <= 0)) + { + return; + } + + String limitLine = formatBuyLimitLine(info); + String current = detailsDesc.getText(); + if (current == null) + { + current = ""; + } + String base = stripBuyLimitLine(current); + String updated = base.isEmpty() ? limitLine : base + "
" + limitLine; + if (!updated.equals(current)) + { + detailsDesc.setText(updated); + if (detailsDesc.getOriginalHeight() < 38) + { + detailsDesc.setOriginalHeight(38); + detailsDesc.revalidate(); + } + } + } + + private void updateSetupWidget(AccountData acc, Widget setup, Widget setupDesc) + { + int offerType = client.getVarbitValue(Varbits.GE_OFFER_CREATION_TYPE); + if (offerType != 0 && !hasWidgetWithText(setup, "Buy")) + { + return; + } + int varpItem = client.getVarpValue(VarPlayer.CURRENT_GE_ITEM); + int itemId = varpItem > 0 ? varpItem : findItemIdInWidget(setup); + if (itemId <= 0) + { + return; + } + itemId = itemManager.canonicalize(itemId); + ItemStats stats = itemManager.getItemStats(itemId); + int geLimit = stats != null ? stats.getGeLimit() : 0; + GeLimitInfo info = getBuyLimitInfo(acc, itemId, geLimit); + if (info == null || (info.limit <= 0 && info.bought <= 0)) + { + return; + } + + String limitLine = formatBuyLimitLine(info); + String current = setupDesc.getText(); + if (current == null) + { + current = ""; + } + String base = stripBuyLimitLine(current); + String updated = base.isEmpty() ? limitLine : base + "
" + limitLine; + if (!updated.equals(current)) + { + setupDesc.setText(updated); + if (setupDesc.getOriginalHeight() < 38) + { + setupDesc.setOriginalHeight(38); + setupDesc.revalidate(); + } + } + } + + private boolean isGeDetailsBuyOffer(Widget details) + { + if (details == null) + { + return true; + } + if (hasWidgetWithText(details, "Buy offer")) + { + return true; + } + if (hasWidgetWithText(details, "Sell offer")) + { + return false; + } + int id = getGeDetailsItemId(details); + if (id > 0) + { + GrandExchangeOffer[] offers = client.getGrandExchangeOffers(); + if (offers != null) + { + for (GrandExchangeOffer o : offers) + { + if (o != null && itemManager.canonicalize(o.getItemId()) == id) + { + GrandExchangeOfferState state = o.getState(); + return state == GrandExchangeOfferState.BUYING || state == GrandExchangeOfferState.BOUGHT + || state == GrandExchangeOfferState.CANCELLED_BUY; + } + } + } + } + return true; + } + + private int getGeDetailsItemId(Widget details) + { + int varp = client.getVarpValue(VarPlayer.CURRENT_GE_ITEM); + if (varp > 0) + { + return varp; + } + for (int c = 15; c <= 25; c++) + { + Widget w = client.getWidget(InterfaceID.GE_OFFERS, c); + if (w != null) + { + int id = findItemIdInWidget(w); + if (id > 0) + { + return id; + } + } + } + if (details != null) + { + int id = findItemIdInWidget(details); + if (id > 0) + { + return id; + } + GrandExchangeOffer[] offers = client.getGrandExchangeOffers(); + if (offers != null) + { + for (GrandExchangeOffer o : offers) + { + if (o != null && o.getItemId() > 0) + { + ItemComposition comp = itemManager.getItemComposition(o.getItemId()); + if (comp != null && hasWidgetWithText(details, comp.getName())) + { + return o.getItemId(); + } + } + } + } + } + return -1; + } + + private static int findItemIdInWidget(Widget root) + { + if (root == null) + { + return -1; + } + if (root.getItemId() > 0) + { + return root.getItemId(); + } + Widget[] children = root.getChildren(); + if (children != null) + { + for (Widget c : children) + { + int id = findItemIdInWidget(c); + if (id > 0) + { + return id; + } + } + } + Widget[] dynamic = root.getDynamicChildren(); + if (dynamic != null) + { + for (Widget c : dynamic) + { + int id = findItemIdInWidget(c); + if (id > 0) + { + return id; + } + } + } + Widget[] nested = root.getNestedChildren(); + if (nested != null) + { + for (Widget c : nested) + { + int id = findItemIdInWidget(c); + if (id > 0) + { + return id; + } + } + } + Widget[] stat = root.getStaticChildren(); + if (stat != null) + { + for (Widget c : stat) + { + int id = findItemIdInWidget(c); + if (id > 0) + { + return id; + } + } + } + return -1; + } + + private static boolean hasWidgetWithText(Widget root, String textToFind) + { + if (root == null || textToFind == null) + { + return false; + } + String txt = root.getText(); + if (txt != null && txt.toLowerCase().contains(textToFind.toLowerCase())) + { + return true; + } + Widget[] children = root.getChildren(); + if (children != null) + { + for (Widget c : children) + { + if (hasWidgetWithText(c, textToFind)) + { + return true; + } + } + } + Widget[] dynamic = root.getDynamicChildren(); + if (dynamic != null) + { + for (Widget c : dynamic) + { + if (hasWidgetWithText(c, textToFind)) + { + return true; + } + } + } + Widget[] nested = root.getNestedChildren(); + if (nested != null) + { + for (Widget c : nested) + { + if (hasWidgetWithText(c, textToFind)) + { + return true; + } + } + } + Widget[] stat = root.getStaticChildren(); + if (stat != null) + { + for (Widget c : stat) + { + if (hasWidgetWithText(c, textToFind)) + { + return true; + } + } + } + return false; + } + + public GeLimitInfo getBuyLimitInfo(AccountData acc, int itemId, int itemGeLimit) + { + long now = System.currentTimeMillis(); + if (acc != null) + { + GeLimitInfo info = acc.getBuyLimitInfo(itemId, itemGeLimit, now); + if (info.windowActive) + { + return info; + } + } + try + { + Instant rlReset = configManager.getRSProfileConfiguration("grandexchange", String.valueOf(itemId), Instant.class); + if (rlReset != null && rlReset.isAfter(Instant.now())) + { + long resetInMs = Duration.between(Instant.now(), rlReset).toMillis(); + int bought = acc != null && acc.geBuyLimits != null && acc.geBuyLimits.containsKey(itemId) + ? acc.geBuyLimits.get(itemId).bought : 0; + if (bought > 0) + { + int remaining = Math.max(0, itemGeLimit > 0 ? itemGeLimit - bought : 0); + return new GeLimitInfo(itemId, itemGeLimit, bought, remaining, resetInMs, true); + } + } + } + catch (Exception ignored) + { + } + return new GeLimitInfo(itemId, itemGeLimit, 0, itemGeLimit, 0, false); + } + + public static String formatBuyLimitLine(GeLimitInfo info) + { + if (info == null) + { + return ""; + } + if (info.limit > 0) + { + String formattedBought = String.format(Locale.US, "%,d", info.bought); + String formattedLimit = String.format(Locale.US, "%,d", info.limit); + String formattedRemaining = String.format(Locale.US, "%,d", info.remaining); + + if (info.remaining <= 0 && info.windowActive) + { + String time = info.resetInMs > 0 ? " - reset in " + formatDuration(info.resetInMs) + "" : ""; + return "Buy limit (4h): " + formattedLimit + " / " + formattedLimit + " (0 left)" + time; + } + else if (info.windowActive && info.resetInMs > 0) + { + return "Buy limit (4h): " + formattedBought + " / " + + formattedLimit + " (" + formattedRemaining + " left) - reset in " + + formatDuration(info.resetInMs) + ""; + } + else + { + return "Buy limit (4h): 0 / " + + formattedLimit + " (" + formattedLimit + " left)"; + } + } + else if (info.bought > 0) + { + String formattedBought = String.format(Locale.US, "%,d", info.bought); + String time = info.resetInMs > 0 ? " - reset in " + formatDuration(info.resetInMs) + "" : ""; + return "Bought in 4h: " + formattedBought + "" + time; + } + return ""; + } + + public static String formatDuration(long ms) + { + long m = Math.max(0, ms) / 60_000; + if (m < 60) + { + return m + "m"; + } + long h = m / 60; + if (h < 24) + { + return h + "h " + (m % 60) + "m"; + } + return (h / 24) + "d " + (h % 24) + "h"; + } + + public static String stripBuyLimitLine(String text) + { + if (text == null || text.isEmpty()) + { + return ""; + } + String[] lines = text.split("
"); + StringBuilder sb = new StringBuilder(); + for (String line : lines) + { + String lower = line.toLowerCase(); + if (lower.contains("buy limit") || lower.contains("bought in 4h")) + { + continue; + } + if (sb.length() > 0) + { + sb.append("
"); + } + sb.append(line); + } + return sb.toString(); + } + + // ---------------------------------------------------------------- export + + /** Writes timeline and item history CSVs of the given characters to essentials-plus/export. */ + public void exportCsv(List accounts, Runnable done) + { + clientThread.invokeLater(() -> + { + java.util.Set ids = new java.util.TreeSet<>(); + Map ledgers = new HashMap<>(); + for (AccountData a : accounts) + { + ItemLedger l = store.ledger(a.key, a.key.equals(activeKey)); + ledgers.put(a.key, l); + for (ItemSnapshot s : l.snapshots) + { + for (int id : s.ids) + { + ids.add(id); + } + } + } + Map names = new HashMap<>(); + for (int id : ids) + { + names.put(id, itemName(id)); + } + executor.execute(() -> + { + try + { + writeExport(accounts, ledgers, names); + } + catch (IOException e) + { + log.warn("Wealth tracker: export failed", e); + } + if (done != null) + { + javax.swing.SwingUtilities.invokeLater(done); + } + }); + }); + } + + private static void writeExport(List accounts, Map ledgers, Map names) throws IOException + { + File dir = new File(WealthStore.DIR, "export"); + Files.createDirectories(dir.toPath()); + DateTimeFormatter iso = DateTimeFormatter.ISO_OFFSET_DATE_TIME; + ZoneId zone = ZoneId.systemDefault(); + String date = LocalDate.now().toString(); + + for (AccountData a : accounts) + { + String base = WealthStore.fileBase(a.name == null ? a.key : a.name) + "-" + date; + + try (PrintWriter w = new PrintWriter(Files.newBufferedWriter(new File(dir, base + "-timeline.csv").toPath(), StandardCharsets.UTF_8))) + { + StringBuilder header = new StringBuilder("time,epoch_ms,resolution"); + for (WealthCategory c : WealthCategory.values()) + { + header.append(',').append(c.name().toLowerCase()); + } + header.append(",total,open,close,low,high"); + w.println(header); + for (Snapshot s : a.history) + { + StringBuilder row = new StringBuilder(); + row.append(Instant.ofEpochMilli(s.t).atZone(zone).format(iso)).append(',').append(s.t).append(',') + .append(s.g == null ? "raw" : s.g.equals(Snapshot.HOUR) ? "hour" : "day"); + for (WealthCategory c : WealthCategory.values()) + { + row.append(',').append(s.get(c)); + } + row.append(',').append(s.total()).append(',').append(s.open()).append(',').append(s.close()) + .append(',').append(s.low()).append(',').append(s.high()); + w.println(row); + } + } + + ItemLedger ledger = ledgers.get(a.key); + if (ledger != null && !ledger.snapshots.isEmpty()) + { + try (PrintWriter w = new PrintWriter(Files.newBufferedWriter(new File(dir, base + "-items.csv").toPath(), StandardCharsets.UTF_8))) + { + w.println("time,epoch_ms,item_id,item_name,quantity,price,value"); + for (ItemSnapshot s : ledger.snapshots) + { + String time = Instant.ofEpochMilli(s.t).atZone(zone).format(iso); + for (int i = 0; i < s.ids.length; i++) + { + String n = names.getOrDefault(s.ids[i], "").replace("\"", "\"\""); + w.println(time + ',' + s.t + ',' + s.ids[i] + ",\"" + n + "\"," + s.q[i] + ',' + s.p[i] + ',' + (s.q[i] * s.p[i])); + } + } + } + } + } + } + + // ---------------------------------------------------------------- helpers + + /** Accumulates values per category/source plus merged canonical holdings with prices. */ + private final class Valuation + { + final long[] values = new long[WealthCategory.COUNT]; + final List sources = new ArrayList<>(); + final Map merged = new HashMap<>(); + final Map prices = new HashMap<>(); + + long total() + { + long sum = 0; + for (long v : values) + { + sum += v; + } + return sum; + } + + void add(String label, WealthCategory category, Map items) + { + long total = 0; + for (Map.Entry e : items.entrySet()) + { + if (isPlaceholder(e.getKey())) + { + // saved banks from older versions may still contain placeholders + continue; + } + int id = itemManager.canonicalize(e.getKey()); + long qty = e.getValue(); + total += price(id, prices) * qty; + merged.merge(id, qty, Long::sum); + } + values[category.ordinal()] += total; + sources.add(new SourceValue(label, category.ordinal(), total)); + } + + void addGp(String label, WealthCategory category, long gp) + { + if (gp > 0) + { + values[category.ordinal()] += gp; + sources.add(new SourceValue(label, category.ordinal(), gp)); + } + } + + ItemSnapshot itemSnapshot(long t) + { + TreeMap sorted = new TreeMap<>(merged); + int[] ids = new int[sorted.size()]; + long[] q = new long[sorted.size()]; + int[] p = new int[sorted.size()]; + int i = 0; + for (Map.Entry e : sorted.entrySet()) + { + ids[i] = e.getKey(); + q[i] = e.getValue(); + p[i] = price(e.getKey(), prices); + i++; + } + return new ItemSnapshot(t, ids, q, p); + } + } + + private static BufferedImage loadIcon() + { + try + { + BufferedImage img = ImageUtil.loadImageResource(EssentialsPlusPlugin.class, "/com/example/essentialsplus/icon.png"); + if (img != null) + { + return img; + } + } + catch (Exception ignored) + { + } + return createIcon(); + } + + private static BufferedImage createIcon() + { + BufferedImage img = new BufferedImage(16, 16, BufferedImage.TYPE_INT_ARGB); + Graphics2D g = img.createGraphics(); + g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); + + // Background dark rounded rect + g.setColor(new Color(35, 35, 42)); + g.fillRoundRect(0, 0, 16, 16, 4, 4); + + // Border in warm orange + g.setColor(new Color(225, 145, 20)); + g.drawRoundRect(0, 0, 15, 15, 4, 4); + + // E letter in white + g.setColor(Color.WHITE); + // Vertical bar + g.fillRect(3, 4, 2, 8); + // Top bar + g.fillRect(5, 4, 3, 2); + // Middle bar + g.fillRect(5, 7, 2, 2); + // Bottom bar + g.fillRect(5, 10, 3, 2); + + // + symbol in orange + g.setColor(new Color(255, 175, 40)); + // Vertical plus + g.fillRect(11, 5, 2, 6); + // Horizontal plus + g.fillRect(9, 7, 6, 2); + + g.dispose(); + return img; + } + + private static final class ContainerSource + { + final int id; + final String key; + final String label; + final WealthCategory category; + + ContainerSource(int id, String key, String label, WealthCategory category) + { + this.id = id; + this.key = key; + this.label = label; + this.category = category; + } + } +} diff --git a/src/main/java/com/example/essentialsplus/GrandExchangeTracker.java b/src/main/java/com/example/essentialsplus/GrandExchangeTracker.java new file mode 100644 index 0000000..ddec402 --- /dev/null +++ b/src/main/java/com/example/essentialsplus/GrandExchangeTracker.java @@ -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. + *

+ * 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 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 update(AccountData acc, Client client, IntUnaryOperator canonicalize, boolean live) + { + boolean migrate = acc.geSlots == null || acc.geSlots.length != SLOTS; + + Map holdings = new HashMap<>(); + addCanonical(holdings, acc.containers.get("inventory"), canonicalize); + addCanonical(holdings, acc.containers.get("bank"), canonicalize); + Map 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 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 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 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 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 pending = new HashMap<>(acc.bankPending); + pending.merge(item, qty, Long::sum); + acc.bankPending = pending; + } + + private static void addCanonical(Map into, Map items, IntUnaryOperator canonicalize) + { + if (items == null) + { + return; + } + for (Map.Entry 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(); + } +} diff --git a/src/main/java/com/example/essentialsplus/WealthCategory.java b/src/main/java/com/example/essentialsplus/WealthCategory.java new file mode 100644 index 0000000..318f13d --- /dev/null +++ b/src/main/java/com/example/essentialsplus/WealthCategory.java @@ -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; +} diff --git a/src/main/java/com/example/essentialsplus/WealthStore.java b/src/main/java/com/example/essentialsplus/WealthStore.java new file mode 100644 index 0000000..c10cdba --- /dev/null +++ b/src/main/java/com/example/essentialsplus/WealthStore.java @@ -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: + *

    + *
  • {@code .json} - state + wealth timeline
  • + *
  • {@code .items.json} - item holdings over time
  • + *
+ * 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 accounts = new ConcurrentHashMap<>(); + private final Map ledgers = new ConcurrentHashMap<>(); + + @Inject + WealthStore(Gson gson) + { + this.gson = gson.newBuilder().disableHtmlEscaping().create(); + } + + // ------------------------------------------------------------------ accounts + + public List all() + { + List 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 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 read(File f, Class 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: + *
    + *
  • younger than {@code rawDays}: every change (identical consecutive points dropped)
  • + *
  • younger than {@code hourlyDays}: one point per hour
  • + *
  • older: one point per day
  • + *
+ * 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 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 out = new ArrayList<>(src.size()); + List 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 bucket, long start, String g, ZoneId zone, List 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 src = ledger.snapshots; + if (src.size() < 2) + { + return; + } + ZoneId zone = ZoneId.systemDefault(); + List 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; + } + } +} diff --git a/src/main/java/com/example/essentialsplus/collectionlog/CollectionLogService.java b/src/main/java/com/example/essentialsplus/collectionlog/CollectionLogService.java new file mode 100644 index 0000000..907585a --- /dev/null +++ b/src/main/java/com/example/essentialsplus/collectionlog/CollectionLogService.java @@ -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 itemIds; + + Page(String name, List 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 pages; + + Tab(String name, List pages) + { + this.name = name; + this.pages = pages; + } + } + + private final Client client; + private final ConfigManager configManager; + private final List listeners = new CopyOnWriteArrayList<>(); + + private volatile List pages = Collections.emptyList(); + private volatile List tabs = Collections.emptyList(); + private volatile Map itemNames = Collections.emptyMap(); + private final Set 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 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 seen = new java.util.HashMap<>(searchItems); + searchItems.clear(); + EnumComposition remap = client.getEnum(ENUM_ITEM_REMAP); + Set 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( + "Essentials Plus: 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 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 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 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 idsByName(String name) + { + Set out = new HashSet<>(); + for (Map.Entry 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 out = new ArrayList<>(); + List tabList = new ArrayList<>(); + Map 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 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 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 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(); + } + } +} diff --git a/src/main/java/com/example/essentialsplus/currency/Currency.java b/src/main/java/com/example/essentialsplus/currency/Currency.java new file mode 100644 index 0000000..7e9634f --- /dev/null +++ b/src/main/java/com/example/essentialsplus/currency/Currency.java @@ -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; + } +} diff --git a/src/main/java/com/example/essentialsplus/currency/CurrencyHistory.java b/src/main/java/com/example/essentialsplus/currency/CurrencyHistory.java new file mode 100644 index 0000000..a651141 --- /dev/null +++ b/src/main/java/com/example/essentialsplus/currency/CurrencyHistory.java @@ -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> 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 samples(Currency c) + { + List s = series.get(c.name()); + return s == null ? new ArrayList<>() : new ArrayList<>(s); + } + + public synchronized boolean has(Currency c) + { + List 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 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 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 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 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 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; + } +} diff --git a/src/main/java/com/example/essentialsplus/currency/CurrencyStore.java b/src/main/java/com/example/essentialsplus/currency/CurrencyStore.java new file mode 100644 index 0000000..385f866 --- /dev/null +++ b/src/main/java/com/example/essentialsplus/currency/CurrencyStore.java @@ -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 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> 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 compact(List in, long now) + { + if (in.size() < 3) + { + return in; + } + List 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; + } +} diff --git a/src/main/java/com/example/essentialsplus/currency/CurrencyTracker.java b/src/main/java/com/example/essentialsplus/currency/CurrencyTracker.java new file mode 100644 index 0000000..e0a8959 --- /dev/null +++ b/src/main/java/com/example/essentialsplus/currency/CurrencyTracker.java @@ -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 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 listeners = new CopyOnWriteArrayList<>(); + /** currencies read at least once since login; the first reading is a sync */ + private final Set 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> containers, Map bankPending, + boolean sync, IntUnaryOperator canonicalize) + { + if (accountKey == null || containers == null || !accountKey.equals(key)) + { + return; + } + Map 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 items, IntUnaryOperator canonicalize, Map totals) + { + if (items == null) + { + return; + } + for (Map.Entry 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(); + } + } +} diff --git a/src/main/java/com/example/essentialsplus/currency/ui/CurrencyCard.java b/src/main/java/com/example/essentialsplus/currency/ui/CurrencyCard.java new file mode 100644 index 0000000..557a229 --- /dev/null +++ b/src/main/java/com/example/essentialsplus/currency/ui/CurrencyCard.java @@ -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(); + } +} diff --git a/src/main/java/com/example/essentialsplus/currency/ui/CurrencyIcons.java b/src/main/java/com/example/essentialsplus/currency/ui/CurrencyIcons.java new file mode 100644 index 0000000..7e3d9a5 --- /dev/null +++ b/src/main/java/com/example/essentialsplus/currency/ui/CurrencyIcons.java @@ -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 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); + } +} diff --git a/src/main/java/com/example/essentialsplus/currency/ui/CurrencyPanel.java b/src/main/java/com/example/essentialsplus/currency/ui/CurrencyPanel.java new file mode 100644 index 0000000..bde16e9 --- /dev/null +++ b/src/main/java/com/example/essentialsplus/currency/ui/CurrencyPanel.java @@ -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 tracked() + { + List 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 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 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 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("" + text + ""); + 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); + } +} diff --git a/src/main/java/com/example/essentialsplus/data/AccountData.java b/src/main/java/com/example/essentialsplus/data/AccountData.java new file mode 100644 index 0000000..6592052 --- /dev/null +++ b/src/main/java/com/example/essentialsplus/data/AccountData.java @@ -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> containers = new ConcurrentHashMap<>(); + public List sources = new ArrayList<>(); + public List topItems = new ArrayList<>(); + public List 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 geSeedCollected; + /** Items that went to the bank while it was not visible; dropped when the bank is seen again. */ + public Map bankPending = new java.util.HashMap<>(); + /** 4-hour Grand Exchange buy limit tracker: canonical item id -> GeBuyLimit */ + public Map 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 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 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 sanitizeHistory(List snapshots) + { + if (snapshots == null || snapshots.size() < 3) + { + return snapshots == null ? new ArrayList<>() : new ArrayList<>(snapshots); + } + + List 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; + } + } +} + diff --git a/src/main/java/com/example/essentialsplus/data/GeBuyLimit.java b/src/main/java/com/example/essentialsplus/data/GeBuyLimit.java new file mode 100644 index 0000000..21c35e0 --- /dev/null +++ b/src/main/java/com/example/essentialsplus/data/GeBuyLimit.java @@ -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; + } +} diff --git a/src/main/java/com/example/essentialsplus/data/GeLimitInfo.java b/src/main/java/com/example/essentialsplus/data/GeLimitInfo.java new file mode 100644 index 0000000..e738b0d --- /dev/null +++ b/src/main/java/com/example/essentialsplus/data/GeLimitInfo.java @@ -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; + } +} diff --git a/src/main/java/com/example/essentialsplus/data/GeRow.java b/src/main/java/com/example/essentialsplus/data/GeRow.java new file mode 100644 index 0000000..da8fe28 --- /dev/null +++ b/src/main/java/com/example/essentialsplus/data/GeRow.java @@ -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; + } +} diff --git a/src/main/java/com/example/essentialsplus/data/GeSlot.java b/src/main/java/com/example/essentialsplus/data/GeSlot.java new file mode 100644 index 0000000..587e433 --- /dev/null +++ b/src/main/java/com/example/essentialsplus/data/GeSlot.java @@ -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; +} diff --git a/src/main/java/com/example/essentialsplus/data/ItemLedger.java b/src/main/java/com/example/essentialsplus/data/ItemLedger.java new file mode 100644 index 0000000..c050605 --- /dev/null +++ b/src/main/java/com/example/essentialsplus/data/ItemLedger.java @@ -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 snapshots = new CopyOnWriteArrayList<>(); + + public transient long fileModified; + public transient boolean dirty; + + public ItemSnapshot last() + { + List 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 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 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); + } +} diff --git a/src/main/java/com/example/essentialsplus/data/ItemRow.java b/src/main/java/com/example/essentialsplus/data/ItemRow.java new file mode 100644 index 0000000..87208a2 --- /dev/null +++ b/src/main/java/com/example/essentialsplus/data/ItemRow.java @@ -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; + } +} diff --git a/src/main/java/com/example/essentialsplus/data/ItemSnapshot.java b/src/main/java/com/example/essentialsplus/data/ItemSnapshot.java new file mode 100644 index 0000000..4a96152 --- /dev/null +++ b/src/main/java/com/example/essentialsplus/data/ItemSnapshot.java @@ -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); + } +} diff --git a/src/main/java/com/example/essentialsplus/data/Snapshot.java b/src/main/java/com/example/essentialsplus/data/Snapshot.java new file mode 100644 index 0000000..c8e62d3 --- /dev/null +++ b/src/main/java/com/example/essentialsplus/data/Snapshot.java @@ -0,0 +1,148 @@ +package com.example.essentialsplus.data; + +import com.example.essentialsplus.WealthCategory; +import java.util.Arrays; + +/** + * One point on the wealth timeline. + *

+ * 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); + } +} diff --git a/src/main/java/com/example/essentialsplus/data/SourceValue.java b/src/main/java/com/example/essentialsplus/data/SourceValue.java new file mode 100644 index 0000000..a1430ae --- /dev/null +++ b/src/main/java/com/example/essentialsplus/data/SourceValue.java @@ -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; + } +} diff --git a/src/main/java/com/example/essentialsplus/data/TopItem.java b/src/main/java/com/example/essentialsplus/data/TopItem.java new file mode 100644 index 0000000..2c097e3 --- /dev/null +++ b/src/main/java/com/example/essentialsplus/data/TopItem.java @@ -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; + } +} diff --git a/src/main/java/com/example/essentialsplus/helpers/AttackStyles.java b/src/main/java/com/example/essentialsplus/helpers/AttackStyles.java new file mode 100644 index 0000000..2c5ecb2 --- /dev/null +++ b/src/main/java/com/example/essentialsplus/helpers/AttackStyles.java @@ -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 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 projectileStyles; + private Map animationStyles; + + private final Map styles = new HashMap<>(); + /** Attack animations without a known style: melee if no projectile follows by the next tick. */ + private final Map unresolved = new HashMap<>(); + private final Set 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 current() + { + Map out = new HashMap<>(); + Player me = client.getLocalPlayer(); + int tick = client.getTickCount(); + for (Map.Entry 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 projectileStyles() + { + if (projectileStyles == null) + { + projectileStyles = stylesByName(SpotanimID.class); + } + return projectileStyles; + } + + private Map 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 stylesByName(Class gameval) + { + Map out = new HashMap<>(); + Set 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; + } +} diff --git a/src/main/java/com/example/essentialsplus/helpers/AttackTimerOverlay.java b/src/main/java/com/example/essentialsplus/helpers/AttackTimerOverlay.java new file mode 100644 index 0000000..ead6023 --- /dev/null +++ b/src/main/java/com/example/essentialsplus/helpers/AttackTimerOverlay.java @@ -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> 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 npcTimers = config.enemyAttackTimer() ? timers.npcTimers() : Map.of(); + Map 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 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 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; + } +} diff --git a/src/main/java/com/example/essentialsplus/helpers/AttackTimers.java b/src/main/java/com/example/essentialsplus/helpers/AttackTimers.java new file mode 100644 index 0000000..aff32c8 --- /dev/null +++ b/src/main/java/com/example/essentialsplus/helpers/AttackTimers.java @@ -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 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 npcAttackTicks = new HashMap<>(); + private final Map npcPrevTicks = new HashMap<>(); + private final Map npcSpeeds = new HashMap<>(); + private final Map pendingNpcAnims = new HashMap<>(); + private final Map animOutcomes = new HashMap<>(); + private final java.util.List 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 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 npcTimers() + { + Map out = new HashMap<>(); + Player me = client.getLocalPlayer(); + for (Map.Entry 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; + } + } +} diff --git a/src/main/java/com/example/essentialsplus/helpers/CombatStyle.java b/src/main/java/com/example/essentialsplus/helpers/CombatStyle.java new file mode 100644 index 0000000..8d5449e --- /dev/null +++ b/src/main/java/com/example/essentialsplus/helpers/CombatStyle.java @@ -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; + } +} diff --git a/src/main/java/com/example/essentialsplus/helpers/ItemCharges.java b/src/main/java/com/example/essentialsplus/helpers/ItemCharges.java new file mode 100644 index 0000000..2e397c8 --- /dev/null +++ b/src/main/java/com/example/essentialsplus/helpers/ItemCharges.java @@ -0,0 +1,3187 @@ +package com.example.essentialsplus.helpers; + +import com.example.essentialsplus.EssentialsPlusConfig; +import com.example.essentialsplus.EssentialsPlusPlugin; +import java.awt.Color; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import javax.inject.Inject; +import javax.inject.Singleton; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; +import net.runelite.api.ChatMessageType; +import net.runelite.api.Client; +import net.runelite.api.EquipmentInventorySlot; +import net.runelite.api.GameState; +import net.runelite.api.Item; +import net.runelite.api.ItemContainer; +import net.runelite.api.Player; +import net.runelite.api.events.AnimationChanged; +import net.runelite.api.events.ChatMessage; +import net.runelite.api.events.GameStateChanged; +import net.runelite.api.events.GameTick; +import net.runelite.api.events.ItemContainerChanged; +import net.runelite.api.events.MenuOptionClicked; +import net.runelite.api.gameval.InventoryID; +import net.runelite.api.gameval.ItemID; +import net.runelite.api.gameval.VarbitID; +import net.runelite.client.config.ConfigManager; +import net.runelite.client.config.RuneScapeProfileType; +import net.runelite.client.eventbus.Subscribe; +import net.runelite.client.game.ItemManager; +import net.runelite.client.util.QuantityFormatter; +import net.runelite.client.util.Text; + +/** + * Comprehensive Item Charges Helper: + * 1. CHARGES_* varbits for weapon/armour charges + * 2. Degradable Barrows armor & weapons (chat-tracked + percentage) + * 3. Moons of Peril armor sets (Blood Moon, Blue Moon, Eclipse Moon) + * 4. Toxic blowpipe (dual count: darts + scales) + * 5. Dragonfire shields (Dragonfire shield, Dragonfire ward, Ancient wyvern shield) + * 6. Master scroll book (varbits) & Ring of the Elements (varbit) + * 7. Multi-charge jewelry & Potions & mixes (1-4 doses with color codes) + * 8. Utility containers, bags, buckets, cans, pouches (Bottomless compost/milk buckets, Gricoller's can, Colossal & Essence pouches, Tackle box, Huntsman's kit, etc.) + * 9. Diary items & Capes & Specialized weapons + */ +@Slf4j +@Singleton +public class ItemCharges +{ + public static final String CONFIG_PREFIX = "itemCharges."; + + public enum BarrowsPiece + { + DHAROK_HELM("dharoks_helm"), + DHAROK_BODY("dharoks_platebody"), + DHAROK_LEGS("dharoks_platelegs"), + DHAROK_WEAPON("dharoks_greataxe"), + + AHRIM_HOOD("ahrims_hood"), + AHRIM_BODY("ahrims_robetop"), + AHRIM_LEGS("ahrims_robeskirt"), + AHRIM_WEAPON("ahrims_staff"), + + GUTHAN_HELM("guthans_helm"), + GUTHAN_BODY("guthans_platebody"), + GUTHAN_LEGS("guthans_chainskirt"), + GUTHAN_WEAPON("guthans_warspear"), + + KARIL_COIF("karils_coif"), + KARIL_BODY("karils_leathertop"), + KARIL_LEGS("karils_leatherskirt"), + KARIL_WEAPON("karils_crossbow"), + + TORAG_HELM("torags_helm"), + TORAG_BODY("torags_platebody"), + TORAG_LEGS("torags_platelegs"), + TORAG_WEAPON("torags_hammers"), + + VERAC_HELM("veracs_helm"), + VERAC_BODY("veracs_brassard"), + VERAC_LEGS("veracs_plateskirt"), + VERAC_WEAPON("veracs_flail"); + + @Getter + private final String configKey; + + BarrowsPiece(String configKey) + { + this.configKey = configKey; + } + } + + public enum MoonPiece + { + BLOOD_CHEST("blood_moon_chestplate"), + BLOOD_HELM("blood_moon_helm"), + BLOOD_LEGS("blood_moon_tassets"), + + BLUE_CHEST("blue_moon_chestplate"), + BLUE_HELM("blue_moon_helm"), + BLUE_LEGS("blue_moon_tassets"), + + ECLIPSE_CHEST("eclipse_moon_chestplate"), + ECLIPSE_HELM("eclipse_moon_helm"), + ECLIPSE_LEGS("eclipse_moon_tassets"); + + @Getter + private final String configKey; + + MoonPiece(String configKey) + { + this.configKey = configKey; + } + } + + public static class ChargeValue + { + private final String text; + private final Color color; + + public ChargeValue(String text, Color color) + { + this.text = text; + this.color = color; + } + + public String getText() + { + return text; + } + + public Color getColor() + { + return color; + } + } + + private enum SourceType + { + VARBIT, + BOWFA_BLADE, + DIZANAS_QUIVER, + BLOWPIPE, + BARROWS, + MOONS, + CONFIG_COUNT, + BAG, + LOOTING_BAG, + MASTER_SCROLL_BOOK, + GHOMMALS_HILT, + FIXED_VALUE + } + + private static class ItemChargeDef + { + final SourceType type; + final int primaryVarbit; + final int secondaryVarbit; + final boolean isUncharged; + final boolean isInfinite; + final boolean hasDarts; + final BarrowsPiece barrowsPiece; + final int barrowsTier; // 100, 75, 50, 25, 0 + final MoonPiece moonPiece; + final int moonTier; // 3000, -1, 0 + final String configKey; + final Integer defaultCharges; + final int fixedAmount; + + ItemChargeDef(SourceType type, int primaryVarbit, int secondaryVarbit, boolean isUncharged, + boolean isInfinite, boolean hasDarts, BarrowsPiece barrowsPiece, int barrowsTier, + MoonPiece moonPiece, int moonTier, String configKey, Integer defaultCharges, int fixedAmount) + { + this.type = type; + this.primaryVarbit = primaryVarbit; + this.secondaryVarbit = secondaryVarbit; + this.isUncharged = isUncharged; + this.isInfinite = isInfinite; + this.hasDarts = hasDarts; + this.barrowsPiece = barrowsPiece; + this.barrowsTier = barrowsTier; + this.moonPiece = moonPiece; + this.moonTier = moonTier; + this.configKey = configKey; + this.defaultCharges = defaultCharges; + this.fixedAmount = fixedAmount; + } + + static ItemChargeDef varbit(int varbitId) + { + return new ItemChargeDef(SourceType.VARBIT, varbitId, -1, false, false, false, null, 0, null, 0, null, null, 0); + } + + static ItemChargeDef uncharged() + { + return new ItemChargeDef(SourceType.VARBIT, -1, -1, true, false, false, null, 0, null, 0, null, null, 0); + } + + static ItemChargeDef infinite() + { + return new ItemChargeDef(SourceType.VARBIT, -1, -1, false, true, false, null, 0, null, 0, null, null, 0); + } + + static ItemChargeDef fixed(int amount) + { + return new ItemChargeDef(SourceType.FIXED_VALUE, -1, -1, false, false, false, null, 0, null, 0, null, null, amount); + } + + static ItemChargeDef bowfaBlade(int chargesVarbit, int corruptionVarbit, boolean uncharged, boolean infinite) + { + return new ItemChargeDef(SourceType.BOWFA_BLADE, chargesVarbit, corruptionVarbit, uncharged, infinite, false, null, 0, null, 0, null, null, 0); + } + + static ItemChargeDef quiver(boolean uncharged, boolean infinite) + { + return new ItemChargeDef(SourceType.DIZANAS_QUIVER, VarbitID.CHARGES_DIZANAS_QUIVER_QUANTITY, VarbitID.CHARGES_DIZANAS_QUIVER_BLESSING, uncharged, infinite, false, null, 0, null, 0, null, null, 0); + } + + static ItemChargeDef blowpipe(boolean isUncharged, boolean hasDarts) + { + return new ItemChargeDef(SourceType.BLOWPIPE, VarbitID.CHARGES_TOXIC_BLOWPIPE_QUANTITY, -1, isUncharged, false, hasDarts, null, 0, null, 0, null, null, 0); + } + + static ItemChargeDef barrows(BarrowsPiece piece, int tier) + { + return new ItemChargeDef(SourceType.BARROWS, -1, -1, tier == 0, false, false, piece, tier, null, 0, piece.getConfigKey(), tier, 0); + } + + static ItemChargeDef moons(MoonPiece piece, int tier) + { + return new ItemChargeDef(SourceType.MOONS, -1, -1, tier == 0, false, false, null, 0, piece, tier, piece.getConfigKey(), tier > 0 ? tier : null, 0); + } + + static ItemChargeDef ghommalsHilt(int maxCharges, int varbitId) + { + return new ItemChargeDef(SourceType.GHOMMALS_HILT, varbitId, -1, false, false, false, null, 0, null, 0, null, null, maxCharges); + } + + static ItemChargeDef masterScrollBook() + { + return new ItemChargeDef(SourceType.MASTER_SCROLL_BOOK, -1, -1, false, false, false, null, 0, null, 0, null, null, 0); + } + + static ItemChargeDef configCount(String configKey, Integer defaultCharges, boolean isUncharged) + { + return new ItemChargeDef(SourceType.CONFIG_COUNT, -1, -1, isUncharged, false, false, null, 0, null, 0, configKey, defaultCharges, 0); + } + + static ItemChargeDef bag(String configKey) + { + return new ItemChargeDef(SourceType.BAG, -1, -1, false, false, false, null, 0, null, 0, configKey, null, 0); + } + + static ItemChargeDef lootingBag() + { + return new ItemChargeDef(SourceType.LOOTING_BAG, -1, -1, false, false, false, null, 0, null, 0, null, null, 0); + } + } + + private static final int[] MASTER_SCROLL_BOOK_VARBITS = { + 5672, 5673, 5674, 5675, 5676, 5677, 5678, 5679, 5680, 5681, 5682, 5683, 5684, + 6056, 8253, 10967, 10995, 11029, 16238 + }; + + private static final Pattern NUMBER_IN_PARENS_PATTERN = Pattern.compile("\\(([a-zA-Z\\s]*?)(\\d+)\\)$"); + + private static final Map CHARGE_MAP = new HashMap<>(); + + static + { + // 1. Toxic Blowpipe & Variants + CHARGE_MAP.put(ItemID.TOXIC_BLOWPIPE, ItemChargeDef.blowpipe(true, false)); + CHARGE_MAP.put(ItemID.TOXIC_BLOWPIPE_LOADED, ItemChargeDef.blowpipe(false, true)); + CHARGE_MAP.put(ItemID.TOXIC_BLOWPIPE_ORNAMENT, ItemChargeDef.blowpipe(true, false)); + CHARGE_MAP.put(ItemID.TOXIC_BLOWPIPE_LOADED_ORNAMENT, ItemChargeDef.blowpipe(false, true)); + CHARGE_MAP.put(ItemID.DRYGORE_BLOWPIPE, ItemChargeDef.blowpipe(true, false)); + CHARGE_MAP.put(ItemID.DRYGORE_BLOWPIPE_LOADED, ItemChargeDef.blowpipe(false, true)); + CHARGE_MAP.put(ItemID.CAMPHOR_BLOWPIPE_EMPTY, ItemChargeDef.blowpipe(true, false)); + CHARGE_MAP.put(ItemID.CAMPHOR_BLOWPIPE, ItemChargeDef.blowpipe(false, true)); + CHARGE_MAP.put(ItemID.IRONWOOD_BLOWPIPE_EMPTY, ItemChargeDef.blowpipe(true, false)); + CHARGE_MAP.put(ItemID.IRONWOOD_BLOWPIPE, ItemChargeDef.blowpipe(false, true)); + CHARGE_MAP.put(ItemID.ROSEWOOD_BLOWPIPE_EMPTY, ItemChargeDef.blowpipe(true, false)); + CHARGE_MAP.put(ItemID.ROSEWOOD_BLOWPIPE, ItemChargeDef.blowpipe(false, true)); + + // 2. Barrows Equipment + registerBarrows(BarrowsPiece.DHAROK_HELM, ItemID.BARROWS_DHAROK_HEAD, ItemID.BARROWS_DHAROK_HEAD_100, ItemID.BARROWS_DHAROK_HEAD_75, ItemID.BARROWS_DHAROK_HEAD_50, ItemID.BARROWS_DHAROK_HEAD_25, ItemID.BARROWS_DHAROK_HEAD_BROKEN); + registerBarrows(BarrowsPiece.DHAROK_BODY, ItemID.BARROWS_DHAROK_BODY, ItemID.BARROWS_DHAROK_BODY_100, ItemID.BARROWS_DHAROK_BODY_75, ItemID.BARROWS_DHAROK_BODY_50, ItemID.BARROWS_DHAROK_BODY_25, ItemID.BARROWS_DHAROK_BODY_BROKEN); + registerBarrows(BarrowsPiece.DHAROK_LEGS, ItemID.BARROWS_DHAROK_LEGS, ItemID.BARROWS_DHAROK_LEGS_100, ItemID.BARROWS_DHAROK_LEGS_75, ItemID.BARROWS_DHAROK_LEGS_50, ItemID.BARROWS_DHAROK_LEGS_25, ItemID.BARROWS_DHAROK_LEGS_BROKEN); + registerBarrows(BarrowsPiece.DHAROK_WEAPON, ItemID.BARROWS_DHAROK_WEAPON, ItemID.BARROWS_DHAROK_WEAPON_100, ItemID.BARROWS_DHAROK_WEAPON_75, ItemID.BARROWS_DHAROK_WEAPON_50, ItemID.BARROWS_DHAROK_WEAPON_25, ItemID.BARROWS_DHAROK_WEAPON_BROKEN); + + registerBarrows(BarrowsPiece.AHRIM_HOOD, ItemID.BARROWS_AHRIM_HEAD, ItemID.BARROWS_AHRIM_HEAD_100, ItemID.BARROWS_AHRIM_HEAD_75, ItemID.BARROWS_AHRIM_HEAD_50, ItemID.BARROWS_AHRIM_HEAD_25, ItemID.BARROWS_AHRIM_HEAD_BROKEN); + registerBarrows(BarrowsPiece.AHRIM_BODY, ItemID.BARROWS_AHRIM_BODY, ItemID.BARROWS_AHRIM_BODY_100, ItemID.BARROWS_AHRIM_BODY_75, ItemID.BARROWS_AHRIM_BODY_50, ItemID.BARROWS_AHRIM_BODY_25, ItemID.BARROWS_AHRIM_BODY_BROKEN); + registerBarrows(BarrowsPiece.AHRIM_LEGS, ItemID.BARROWS_AHRIM_LEGS, ItemID.BARROWS_AHRIM_LEGS_100, ItemID.BARROWS_AHRIM_LEGS_75, ItemID.BARROWS_AHRIM_LEGS_50, ItemID.BARROWS_AHRIM_LEGS_25, ItemID.BARROWS_AHRIM_LEGS_BROKEN); + registerBarrows(BarrowsPiece.AHRIM_WEAPON, ItemID.BARROWS_AHRIM_WEAPON, ItemID.BARROWS_AHRIM_WEAPON_100, ItemID.BARROWS_AHRIM_WEAPON_75, ItemID.BARROWS_AHRIM_WEAPON_50, ItemID.BARROWS_AHRIM_WEAPON_25, ItemID.BARROWS_AHRIM_WEAPON_BROKEN); + + registerBarrows(BarrowsPiece.AHRIM_HOOD, ItemID.BARROWS_AHRIM_HEAD_ORNAMENT, ItemID.BARROWS_AHRIM_HEAD_ORNAMENT_100, ItemID.BARROWS_AHRIM_HEAD_ORNAMENT_75, ItemID.BARROWS_AHRIM_HEAD_ORNAMENT_50, ItemID.BARROWS_AHRIM_HEAD_ORNAMENT_25, ItemID.BARROWS_AHRIM_HEAD_ORNAMENT_BROKEN); + registerBarrows(BarrowsPiece.AHRIM_BODY, ItemID.BARROWS_AHRIM_BODY_ORNAMENT, ItemID.BARROWS_AHRIM_BODY_ORNAMENT_100, ItemID.BARROWS_AHRIM_BODY_ORNAMENT_75, ItemID.BARROWS_AHRIM_BODY_ORNAMENT_50, ItemID.BARROWS_AHRIM_BODY_ORNAMENT_25, ItemID.BARROWS_AHRIM_BODY_ORNAMENT_BROKEN); + registerBarrows(BarrowsPiece.AHRIM_LEGS, ItemID.BARROWS_AHRIM_LEGS_ORNAMENT, ItemID.BARROWS_AHRIM_LEGS_ORNAMENT_100, ItemID.BARROWS_AHRIM_LEGS_ORNAMENT_75, ItemID.BARROWS_AHRIM_LEGS_ORNAMENT_50, ItemID.BARROWS_AHRIM_LEGS_ORNAMENT_25, ItemID.BARROWS_AHRIM_LEGS_ORNAMENT_BROKEN); + registerBarrows(BarrowsPiece.AHRIM_WEAPON, ItemID.BARROWS_AHRIM_WEAPON_ORNAMENT, ItemID.BARROWS_AHRIM_WEAPON_ORNAMENT_100, ItemID.BARROWS_AHRIM_WEAPON_ORNAMENT_75, ItemID.BARROWS_AHRIM_WEAPON_ORNAMENT_50, ItemID.BARROWS_AHRIM_WEAPON_ORNAMENT_25, ItemID.BARROWS_AHRIM_WEAPON_ORNAMENT_BROKEN); + + registerBarrows(BarrowsPiece.GUTHAN_HELM, ItemID.BARROWS_GUTHAN_HEAD, ItemID.BARROWS_GUTHAN_HEAD_100, ItemID.BARROWS_GUTHAN_HEAD_75, ItemID.BARROWS_GUTHAN_HEAD_50, ItemID.BARROWS_GUTHAN_HEAD_25, ItemID.BARROWS_GUTHAN_HEAD_BROKEN); + registerBarrows(BarrowsPiece.GUTHAN_BODY, ItemID.BARROWS_GUTHAN_BODY, ItemID.BARROWS_GUTHAN_BODY_100, ItemID.BARROWS_GUTHAN_BODY_75, ItemID.BARROWS_GUTHAN_BODY_50, ItemID.BARROWS_GUTHAN_BODY_25, ItemID.BARROWS_GUTHAN_BODY_BROKEN); + registerBarrows(BarrowsPiece.GUTHAN_LEGS, ItemID.BARROWS_GUTHAN_LEGS, ItemID.BARROWS_GUTHAN_LEGS_100, ItemID.BARROWS_GUTHAN_LEGS_75, ItemID.BARROWS_GUTHAN_LEGS_50, ItemID.BARROWS_GUTHAN_LEGS_25, ItemID.BARROWS_GUTHAN_LEGS_BROKEN); + registerBarrows(BarrowsPiece.GUTHAN_WEAPON, ItemID.BARROWS_GUTHAN_WEAPON, ItemID.BARROWS_GUTHAN_WEAPON_100, ItemID.BARROWS_GUTHAN_WEAPON_75, ItemID.BARROWS_GUTHAN_WEAPON_50, ItemID.BARROWS_GUTHAN_WEAPON_25, ItemID.BARROWS_GUTHAN_WEAPON_BROKEN); + + registerBarrows(BarrowsPiece.KARIL_COIF, ItemID.BARROWS_KARIL_HEAD, ItemID.BARROWS_KARIL_HEAD_100, ItemID.BARROWS_KARIL_HEAD_75, ItemID.BARROWS_KARIL_HEAD_50, ItemID.BARROWS_KARIL_HEAD_25, ItemID.BARROWS_KARIL_HEAD_BROKEN); + registerBarrows(BarrowsPiece.KARIL_BODY, ItemID.BARROWS_KARIL_BODY, ItemID.BARROWS_KARIL_BODY_100, ItemID.BARROWS_KARIL_BODY_75, ItemID.BARROWS_KARIL_BODY_50, ItemID.BARROWS_KARIL_BODY_25, ItemID.BARROWS_KARIL_BODY_BROKEN); + registerBarrows(BarrowsPiece.KARIL_LEGS, ItemID.BARROWS_KARIL_LEGS, ItemID.BARROWS_KARIL_LEGS_100, ItemID.BARROWS_KARIL_LEGS_75, ItemID.BARROWS_KARIL_LEGS_50, ItemID.BARROWS_KARIL_LEGS_25, ItemID.BARROWS_KARIL_LEGS_BROKEN); + registerBarrows(BarrowsPiece.KARIL_WEAPON, ItemID.BARROWS_KARIL_WEAPON, ItemID.BARROWS_KARIL_WEAPON_100, ItemID.BARROWS_KARIL_WEAPON_75, ItemID.BARROWS_KARIL_WEAPON_50, ItemID.BARROWS_KARIL_WEAPON_25, ItemID.BARROWS_KARIL_WEAPON_BROKEN); + + registerBarrows(BarrowsPiece.TORAG_HELM, ItemID.BARROWS_TORAG_HEAD, ItemID.BARROWS_TORAG_HEAD_100, ItemID.BARROWS_TORAG_HEAD_75, ItemID.BARROWS_TORAG_HEAD_50, ItemID.BARROWS_TORAG_HEAD_25, ItemID.BARROWS_TORAG_HEAD_BROKEN); + registerBarrows(BarrowsPiece.TORAG_BODY, ItemID.BARROWS_TORAG_BODY, ItemID.BARROWS_TORAG_BODY_100, ItemID.BARROWS_TORAG_BODY_75, ItemID.BARROWS_TORAG_BODY_50, ItemID.BARROWS_TORAG_BODY_25, ItemID.BARROWS_TORAG_BODY_BROKEN); + registerBarrows(BarrowsPiece.TORAG_LEGS, ItemID.BARROWS_TORAG_LEGS, ItemID.BARROWS_TORAG_LEGS_100, ItemID.BARROWS_TORAG_LEGS_75, ItemID.BARROWS_TORAG_LEGS_50, ItemID.BARROWS_TORAG_LEGS_25, ItemID.BARROWS_TORAG_LEGS_BROKEN); + registerBarrows(BarrowsPiece.TORAG_WEAPON, ItemID.BARROWS_TORAG_WEAPON, ItemID.BARROWS_TORAG_WEAPON_100, ItemID.BARROWS_TORAG_WEAPON_75, ItemID.BARROWS_TORAG_WEAPON_50, ItemID.BARROWS_TORAG_WEAPON_25, ItemID.BARROWS_TORAG_WEAPON_BROKEN); + + registerBarrows(BarrowsPiece.VERAC_HELM, ItemID.BARROWS_VERAC_HEAD, ItemID.BARROWS_VERAC_HEAD_100, ItemID.BARROWS_VERAC_HEAD_75, ItemID.BARROWS_VERAC_HEAD_50, ItemID.BARROWS_VERAC_HEAD_25, ItemID.BARROWS_VERAC_HEAD_BROKEN); + registerBarrows(BarrowsPiece.VERAC_BODY, ItemID.BARROWS_VERAC_BODY, ItemID.BARROWS_VERAC_BODY_100, ItemID.BARROWS_VERAC_BODY_75, ItemID.BARROWS_VERAC_BODY_50, ItemID.BARROWS_VERAC_BODY_25, ItemID.BARROWS_VERAC_BODY_BROKEN); + registerBarrows(BarrowsPiece.VERAC_LEGS, ItemID.BARROWS_VERAC_LEGS, ItemID.BARROWS_VERAC_LEGS_100, ItemID.BARROWS_VERAC_LEGS_75, ItemID.BARROWS_VERAC_LEGS_50, ItemID.BARROWS_VERAC_LEGS_25, ItemID.BARROWS_VERAC_LEGS_BROKEN); + registerBarrows(BarrowsPiece.VERAC_WEAPON, ItemID.BARROWS_VERAC_WEAPON, ItemID.BARROWS_VERAC_WEAPON_100, ItemID.BARROWS_VERAC_WEAPON_75, ItemID.BARROWS_VERAC_WEAPON_50, ItemID.BARROWS_VERAC_WEAPON_25, ItemID.BARROWS_VERAC_WEAPON_BROKEN); + + // 3. Moons of Peril Armor + registerMoons(MoonPiece.BLOOD_CHEST, ItemID.BLOOD_MOON_CHESTPLATE, ItemID.BLOOD_MOON_CHESTPLATE_DEGRADED, 0, ItemID.BLOOD_MOON_CHESTPLATE_BROKEN); + registerMoons(MoonPiece.BLOOD_HELM, ItemID.BLOOD_MOON_HELM, ItemID.BLOOD_MOON_HELM_DEGRADED, 0, ItemID.BLOOD_MOON_HELM_BROKEN); + registerMoons(MoonPiece.BLOOD_LEGS, ItemID.BLOOD_MOON_TASSETS, ItemID.BLOOD_MOON_TASSETS_DEGRADED, 0, ItemID.BLOOD_MOON_TASSETS_BROKEN); + + registerMoons(MoonPiece.BLUE_CHEST, ItemID.FROST_MOON_CHESTPLATE, ItemID.FROST_MOON_CHESTPLATE_DEGRADED, 0, ItemID.FROST_MOON_CHESTPLATE_BROKEN); + registerMoons(MoonPiece.BLUE_HELM, ItemID.FROST_MOON_HELM, ItemID.FROST_MOON_HELM_DEGRADED, 0, ItemID.FROST_MOON_HELM_BROKEN); + registerMoons(MoonPiece.BLUE_LEGS, ItemID.FROST_MOON_TASSETS, ItemID.FROST_MOON_TASSETS_DEGRADED, 0, ItemID.FROST_MOON_TASSETS_BROKEN); + + registerMoons(MoonPiece.ECLIPSE_CHEST, ItemID.ECLIPSE_MOON_CHESTPLATE, ItemID.ECLIPSE_MOON_CHESTPLATE_DEGRADED, 0, ItemID.ECLIPSE_MOON_CHESTPLATE_BROKEN); + registerMoons(MoonPiece.ECLIPSE_HELM, ItemID.ECLIPSE_MOON_HELM, ItemID.ECLIPSE_MOON_HELM_DEGRADED, 0, ItemID.ECLIPSE_MOON_HELM_BROKEN); + registerMoons(MoonPiece.ECLIPSE_LEGS, ItemID.ECLIPSE_MOON_TASSETS, ItemID.ECLIPSE_MOON_TASSETS_DEGRADED, 0, ItemID.ECLIPSE_MOON_TASSETS_BROKEN); + + // 4. Dragonfire Shields + CHARGE_MAP.put(ItemID.DRAGONFIRE_SHIELD, ItemChargeDef.configCount("dragonfire_shield", null, false)); + CHARGE_MAP.put(ItemID.DRAGONFIRE_SHIELD_UNCHARGED, ItemChargeDef.uncharged()); + CHARGE_MAP.put(ItemID.DRAGONFIRE_WARD, ItemChargeDef.configCount("dragonfire_ward", null, false)); + CHARGE_MAP.put(ItemID.DRAGONFIRE_WARD_UNCHARGED, ItemChargeDef.uncharged()); + CHARGE_MAP.put(net.runelite.api.ItemID.ANCIENT_WYVERN_SHIELD, ItemChargeDef.configCount("ancient_wyvern_shield", null, false)); + CHARGE_MAP.put(net.runelite.api.ItemID.ANCIENT_WYVERN_SHIELD_21634, ItemChargeDef.uncharged()); + + // 5. Master Scroll Book & Ring of the Elements + CHARGE_MAP.put(ItemID.BOOKOFSCROLLS_CHARGED, ItemChargeDef.masterScrollBook()); + CHARGE_MAP.put(ItemID.BOOKOFSCROLLS_EMPTY, ItemChargeDef.uncharged()); + CHARGE_MAP.put(ItemID.RING_OF_ELEMENTS, ItemChargeDef.uncharged()); + CHARGE_MAP.put(ItemID.RING_OF_ELEMENTS_CHARGED, ItemChargeDef.varbit(VarbitID.RING_OF_THE_ELEMENTS_CHARGES)); + + // 6. Ghommal's Hilt + CHARGE_MAP.put(ItemID.CA_OFFHAND_EASY, ItemChargeDef.ghommalsHilt(3, VarbitID.CA_TELEPORT_COUNT_TROLLHEIM)); + CHARGE_MAP.put(ItemID.CA_OFFHAND_MEDIUM, ItemChargeDef.ghommalsHilt(5, VarbitID.CA_TELEPORT_COUNT_TROLLHEIM)); + CHARGE_MAP.put(ItemID.CA_OFFHAND_HARD, ItemChargeDef.infinite()); + CHARGE_MAP.put(ItemID.CA_OFFHAND_ELITE, ItemChargeDef.ghommalsHilt(3, VarbitID.CA_TELEPORT_COUNT_MORULREK)); + CHARGE_MAP.put(ItemID.CA_OFFHAND_MASTER, ItemChargeDef.ghommalsHilt(5, VarbitID.CA_TELEPORT_COUNT_MORULREK)); + CHARGE_MAP.put(ItemID.CA_OFFHAND_GRANDMASTER, ItemChargeDef.infinite()); + + // 7. Buckets, Cans, Pouches & Bags + CHARGE_MAP.put(ItemID.BOTTOMLESS_COMPOST_BUCKET_FILLED, ItemChargeDef.configCount("bottomless_compost_bucket", null, false)); + CHARGE_MAP.put(ItemID.BOTTOMLESS_COMPOST_BUCKET, ItemChargeDef.uncharged()); + CHARGE_MAP.put(ItemID.BOTTOMLESS_MILK_BUCKET, ItemChargeDef.configCount("bottomless_milk_bucket", null, false)); + CHARGE_MAP.put(net.runelite.api.ItemID.BOTTOMLESS_MILK_BUCKET_EMPTY, ItemChargeDef.uncharged()); + CHARGE_MAP.put(ItemID.ZEAH_WATERINGCAN, ItemChargeDef.configCount("gricollers_can", 1000, false)); + + CHARGE_MAP.put(ItemID.HUNTSMANS_KIT, ItemChargeDef.bag("huntsmans_kit")); + CHARGE_MAP.put(ItemID.TACKLE_BOX, ItemChargeDef.bag("tackle_box")); + + CHARGE_MAP.put(ItemID.RCU_POUCH_COLOSSAL, ItemChargeDef.configCount("colossal_pouch", 40, false)); + CHARGE_MAP.put(ItemID.RCU_POUCH_COLOSSAL_DEGRADE, ItemChargeDef.configCount("colossal_pouch", 40, false)); + CHARGE_MAP.put(ItemID.RCU_POUCH_SMALL, ItemChargeDef.fixed(3)); + CHARGE_MAP.put(ItemID.RCU_POUCH_MEDIUM, ItemChargeDef.configCount("essence_pouch_medium", 6, false)); + CHARGE_MAP.put(ItemID.RCU_POUCH_MEDIUM_DEGRADE, ItemChargeDef.configCount("essence_pouch_medium", 6, false)); + CHARGE_MAP.put(ItemID.RCU_POUCH_LARGE, ItemChargeDef.configCount("essence_pouch_large", 9, false)); + CHARGE_MAP.put(ItemID.RCU_POUCH_LARGE_DEGRADE, ItemChargeDef.configCount("essence_pouch_large", 9, false)); + CHARGE_MAP.put(ItemID.RCU_POUCH_GIANT, ItemChargeDef.configCount("essence_pouch_giant", 12, false)); + CHARGE_MAP.put(ItemID.RCU_POUCH_GIANT_DEGRADE, ItemChargeDef.configCount("essence_pouch_giant", 12, false)); + + CHARGE_MAP.put(ItemID.HG_MEATPOUCH_SMALL, ItemChargeDef.bag("meat_pouch")); + CHARGE_MAP.put(ItemID.HG_MEATPOUCH_SMALL_OPEN, ItemChargeDef.bag("meat_pouch")); + CHARGE_MAP.put(ItemID.HG_MEATPOUCH_LARGE, ItemChargeDef.bag("meat_pouch")); + CHARGE_MAP.put(ItemID.HG_MEATPOUCH_LARGE_OPEN, ItemChargeDef.bag("meat_pouch")); + + CHARGE_MAP.put(ItemID.HG_FURPOUCH_SMALL, ItemChargeDef.bag("fur_pouch")); + CHARGE_MAP.put(ItemID.HG_FURPOUCH_SMALL_OPEN, ItemChargeDef.bag("fur_pouch")); + CHARGE_MAP.put(ItemID.HG_FURPOUCH_LARGE, ItemChargeDef.bag("fur_pouch")); + CHARGE_MAP.put(ItemID.HG_FURPOUCH_LARGE_OPEN, ItemChargeDef.bag("fur_pouch")); + + CHARGE_MAP.put(ItemID.FLAMTAER_BAG, ItemChargeDef.bag("flamtaer_bag")); + CHARGE_MAP.put(net.runelite.api.ItemID.LOG_BASKET, ItemChargeDef.bag("log_basket")); + CHARGE_MAP.put(net.runelite.api.ItemID.FORESTRY_BASKET, ItemChargeDef.bag("log_basket")); + + CHARGE_MAP.put(ItemID.HG_QUETZALWHISTLE_BASIC, ItemChargeDef.configCount("quetzal_whistle", 5, false)); + CHARGE_MAP.put(ItemID.HG_QUETZALWHISTLE_ENHANCED, ItemChargeDef.configCount("quetzal_whistle", 20, false)); + CHARGE_MAP.put(ItemID.HG_QUETZALWHISTLE_PERFECTED, ItemChargeDef.configCount("quetzal_whistle", 50, false)); + + CHARGE_MAP.put(ItemID.MM_PREPOT_DEVICE, ItemChargeDef.configCount("chugging_barrel", null, false)); + CHARGE_MAP.put(ItemID.MM_PREPOT_DEVICE_DISASSEMBLED, ItemChargeDef.uncharged()); + + // 8. Diary & Cape Items + CHARGE_MAP.put(ItemID.FALADOR_SHIELD_EASY, ItemChargeDef.configCount("falador_shield", 1, false)); + CHARGE_MAP.put(ItemID.FALADOR_SHIELD_MEDIUM, ItemChargeDef.configCount("falador_shield", 1, false)); + CHARGE_MAP.put(ItemID.FALADOR_SHIELD_HARD, ItemChargeDef.configCount("falador_shield", 1, false)); + CHARGE_MAP.put(ItemID.FALADOR_SHIELD_ELITE, ItemChargeDef.configCount("falador_shield", 2, false)); + + CHARGE_MAP.put(ItemID.VEOS_KHAREDSTS_MEMOIRS, ItemChargeDef.configCount("kharedsts_memoirs", null, false)); + CHARGE_MAP.put(ItemID.BOOK_OF_THE_DEAD, ItemChargeDef.configCount("kharedsts_memoirs", null, false)); + + CHARGE_MAP.put(net.runelite.api.ItemID.ARDOUGNE_CLOAK_1, ItemChargeDef.configCount("ardougne_cloak", 1, false)); + CHARGE_MAP.put(net.runelite.api.ItemID.ARDOUGNE_CLOAK_2, ItemChargeDef.configCount("ardougne_cloak", 3, false)); + CHARGE_MAP.put(net.runelite.api.ItemID.ARDOUGNE_CLOAK_3, ItemChargeDef.configCount("ardougne_cloak", 5, false)); + CHARGE_MAP.put(net.runelite.api.ItemID.ARDOUGNE_CLOAK_4, ItemChargeDef.infinite()); + + CHARGE_MAP.put(net.runelite.api.ItemID.EXPLORERS_RING_1, ItemChargeDef.configCount("explorers_ring", null, false)); + CHARGE_MAP.put(net.runelite.api.ItemID.EXPLORERS_RING_2, ItemChargeDef.configCount("explorers_ring", null, false)); + CHARGE_MAP.put(net.runelite.api.ItemID.EXPLORERS_RING_3, ItemChargeDef.configCount("explorers_ring", null, false)); + CHARGE_MAP.put(net.runelite.api.ItemID.EXPLORERS_RING_4, ItemChargeDef.configCount("explorers_ring", null, false)); + + CHARGE_MAP.put(net.runelite.api.ItemID.FREMENNIK_SEA_BOOTS_1, ItemChargeDef.configCount("fremennik_sea_boots", 1, false)); + CHARGE_MAP.put(net.runelite.api.ItemID.FREMENNIK_SEA_BOOTS_2, ItemChargeDef.configCount("fremennik_sea_boots", 1, false)); + CHARGE_MAP.put(net.runelite.api.ItemID.FREMENNIK_SEA_BOOTS_3, ItemChargeDef.configCount("fremennik_sea_boots", 1, false)); + CHARGE_MAP.put(net.runelite.api.ItemID.FREMENNIK_SEA_BOOTS_4, ItemChargeDef.infinite()); + + CHARGE_MAP.put(net.runelite.api.ItemID.KANDARIN_HEADGEAR_1, ItemChargeDef.configCount("kandarin_headgear", 1, false)); + CHARGE_MAP.put(net.runelite.api.ItemID.KANDARIN_HEADGEAR_2, ItemChargeDef.configCount("kandarin_headgear", 1, false)); + CHARGE_MAP.put(net.runelite.api.ItemID.KANDARIN_HEADGEAR_3, ItemChargeDef.configCount("kandarin_headgear", 1, false)); + CHARGE_MAP.put(net.runelite.api.ItemID.KANDARIN_HEADGEAR_4, ItemChargeDef.infinite()); + + CHARGE_MAP.put(net.runelite.api.ItemID.MORYTANIA_LEGS_1, ItemChargeDef.configCount("morytania_legs", 2, false)); + CHARGE_MAP.put(net.runelite.api.ItemID.MORYTANIA_LEGS_2, ItemChargeDef.configCount("morytania_legs", 5, false)); + CHARGE_MAP.put(net.runelite.api.ItemID.MORYTANIA_LEGS_3, ItemChargeDef.infinite()); + CHARGE_MAP.put(net.runelite.api.ItemID.MORYTANIA_LEGS_4, ItemChargeDef.infinite()); + + CHARGE_MAP.put(net.runelite.api.ItemID.WESTERN_BANNER_1, ItemChargeDef.configCount("western_banner", 1, false)); + CHARGE_MAP.put(net.runelite.api.ItemID.WESTERN_BANNER_2, ItemChargeDef.configCount("western_banner", 1, false)); + CHARGE_MAP.put(net.runelite.api.ItemID.WESTERN_BANNER_3, ItemChargeDef.configCount("western_banner", 1, false)); + CHARGE_MAP.put(net.runelite.api.ItemID.WESTERN_BANNER_4, ItemChargeDef.infinite()); + + CHARGE_MAP.put(ItemID.SKILLCAPE_MAGIC, ItemChargeDef.configCount("magic_cape", 5, false)); + CHARGE_MAP.put(ItemID.SKILLCAPE_MAGIC_TRIMMED, ItemChargeDef.configCount("magic_cape", 5, false)); + + // 9. Weapons & Tools + CHARGE_MAP.put(ItemID.ABYSSAL_TENTACLE, ItemChargeDef.configCount("abyssal_tentacle", null, false)); + CHARGE_MAP.put(ItemID.LEAGUE_3_WHIP_TENTACLE, ItemChargeDef.configCount("abyssal_tentacle", null, false)); + + CHARGE_MAP.put(ItemID.SCYTHE_OF_VITUR, ItemChargeDef.configCount("scythe_of_vitur", null, false)); + CHARGE_MAP.put(ItemID.SCYTHE_OF_VITUR_OR, ItemChargeDef.configCount("scythe_of_vitur", null, false)); + CHARGE_MAP.put(ItemID.SCYTHE_OF_VITUR_BL, ItemChargeDef.configCount("scythe_of_vitur", null, false)); + CHARGE_MAP.put(ItemID.DEADMAN_BLIGHTED_SCYTHE_OF_VITUR, ItemChargeDef.configCount("scythe_of_vitur", null, false)); + CHARGE_MAP.put(ItemID.SCYTHE_OF_VITUR_UNCHARGED, ItemChargeDef.uncharged()); + CHARGE_MAP.put(ItemID.SCYTHE_OF_VITUR_UNCHARGED_OR, ItemChargeDef.uncharged()); + CHARGE_MAP.put(ItemID.SCYTHE_OF_VITUR_UNCHARGED_BL, ItemChargeDef.uncharged()); + CHARGE_MAP.put(ItemID.DEADMAN_BLIGHTED_SCYTHE_OF_VITUR_UNCHARGED, ItemChargeDef.uncharged()); + + CHARGE_MAP.put(ItemID.IBANSTAFF, ItemChargeDef.configCount("ibans_staff", 120, false)); + CHARGE_MAP.put(ItemID.IBANSTAFF_UPGRADED, ItemChargeDef.configCount("ibans_staff", 2500, false)); + CHARGE_MAP.put(ItemID.IBAN_STAFF_ORN, ItemChargeDef.configCount("ibans_staff", 120, false)); + CHARGE_MAP.put(ItemID.IBAN_STAFF_UPGRADED_ORN, ItemChargeDef.configCount("ibans_staff", 2500, false)); + + CHARGE_MAP.put(net.runelite.api.ItemID.SLAYERS_STAFF_E, ItemChargeDef.configCount("slayers_staff_e", null, false)); + + CHARGE_MAP.put(net.runelite.api.ItemID.INFERNAL_AXE, ItemChargeDef.configCount("infernal_tools", 5000, false)); + CHARGE_MAP.put(net.runelite.api.ItemID.INFERNAL_AXE_UNCHARGED, ItemChargeDef.uncharged()); + CHARGE_MAP.put(net.runelite.api.ItemID.INFERNAL_PICKAXE, ItemChargeDef.configCount("infernal_tools", 5000, false)); + CHARGE_MAP.put(net.runelite.api.ItemID.INFERNAL_PICKAXE_UNCHARGED, ItemChargeDef.uncharged()); + CHARGE_MAP.put(net.runelite.api.ItemID.INFERNAL_HARPOON, ItemChargeDef.configCount("infernal_tools", 5000, false)); + CHARGE_MAP.put(net.runelite.api.ItemID.INFERNAL_HARPOON_UNCHARGED, ItemChargeDef.uncharged()); + + CHARGE_MAP.put(net.runelite.api.ItemID.SKULL_SCEPTRE, ItemChargeDef.configCount("skull_sceptre", null, false)); + CHARGE_MAP.put(net.runelite.api.ItemID.SKULL_SCEPTRE_I, ItemChargeDef.infinite()); + + CHARGE_MAP.put(net.runelite.api.ItemID.CRYSTAL_SAW, ItemChargeDef.configCount("crystal_saw", 28, false)); + CHARGE_MAP.put(net.runelite.api.ItemID.JAR_GENERATOR, ItemChargeDef.configCount("jar_generator", 100, false)); + CHARGE_MAP.put(ItemID.MAGIC_IMP_BOX_HALF, ItemChargeDef.fixed(1)); + CHARGE_MAP.put(ItemID.MAGIC_IMP_BOX_FULL, ItemChargeDef.fixed(2)); + CHARGE_MAP.put(ItemID.ECTOPHIAL, ItemChargeDef.fixed(1)); + CHARGE_MAP.put(ItemID.ECTOPHIAL_EMPTY, ItemChargeDef.uncharged()); + CHARGE_MAP.put(net.runelite.api.ItemID.ROYAL_SEED_POD, ItemChargeDef.infinite()); + + // 10. Jewelry & Amulets + CHARGE_MAP.put(ItemID.BLOOD_ESSENCE_ACTIVE, ItemChargeDef.configCount("blood_essence", 1000, false)); + CHARGE_MAP.put(ItemID.BLOOD_ESSENCE_INACTIVE, ItemChargeDef.uncharged()); + + CHARGE_MAP.put(ItemID.DODGY_NECKLACE, ItemChargeDef.configCount("dodgy_necklace", 10, false)); + CHARGE_MAP.put(ItemID.RING_OF_FORGING, ItemChargeDef.configCount("ring_of_forging", 140, false)); + CHARGE_MAP.put(ItemID.RING_OF_RECOIL, ItemChargeDef.configCount("ring_of_recoil", 40, false)); + CHARGE_MAP.put(ItemID.MAGIC_EMERALD_NECKLACE, ItemChargeDef.configCount("binding_necklace", 16, false)); + CHARGE_MAP.put(ItemID.AMULET_OF_CHEMISTRY, ItemChargeDef.configCount("amulet_of_chemistry", 5, false)); + CHARGE_MAP.put(ItemID.AMULET_OF_BOUNTY, ItemChargeDef.configCount("amulet_of_bounty", 10, false)); + CHARGE_MAP.put(ItemID.STRANGE_OLD_LOCKPICK, ItemChargeDef.configCount("strange_old_lockpick", 50, false)); + CHARGE_MAP.put(ItemID.STRANGE_OLD_LOCKPICK_FULL, ItemChargeDef.configCount("strange_old_lockpick", 50, false)); + CHARGE_MAP.put(ItemID.CHRONICLE, ItemChargeDef.configCount("chronicle", 1000, false)); + + CHARGE_MAP.put(net.runelite.api.ItemID.BRACELET_OF_SLAUGHTER, ItemChargeDef.configCount("bracelet_of_slaughter", 30, false)); + CHARGE_MAP.put(net.runelite.api.ItemID.EXPEDITIOUS_BRACELET, ItemChargeDef.configCount("expeditious_bracelet", 30, false)); + CHARGE_MAP.put(net.runelite.api.ItemID.BRACELET_OF_CLAY, ItemChargeDef.configCount("bracelet_of_clay", 28, false)); + CHARGE_MAP.put(net.runelite.api.ItemID.FLAMTAER_BRACELET, ItemChargeDef.configCount("flamtaer_bracelet", 80, false)); + CHARGE_MAP.put(net.runelite.api.ItemID.PHOENIX_NECKLACE, ItemChargeDef.fixed(1)); + CHARGE_MAP.put(net.runelite.api.ItemID.INOCULATION_BRACELET, ItemChargeDef.configCount("inoculation_bracelet", 100, false)); + CHARGE_MAP.put(net.runelite.api.ItemID.RING_OF_ENDURANCE, ItemChargeDef.configCount("ring_of_endurance", null, false)); + CHARGE_MAP.put(net.runelite.api.ItemID.RING_OF_ENDURANCE_UNCHARGED, ItemChargeDef.uncharged()); + CHARGE_MAP.put(net.runelite.api.ItemID.AMULET_OF_ETERNAL_GLORY, ItemChargeDef.infinite()); + CHARGE_MAP.put(net.runelite.api.ItemID.ETERNAL_TELEPORT_CRYSTAL, ItemChargeDef.infinite()); + + // 11. Bags & Sacks + CHARGE_MAP.put(ItemID.COAL_BAG, ItemChargeDef.bag("coal_bag")); + CHARGE_MAP.put(ItemID.COAL_BAG_OPEN, ItemChargeDef.bag("coal_bag")); + CHARGE_MAP.put(ItemID.GEM_BAG, ItemChargeDef.bag("gem_bag")); + CHARGE_MAP.put(ItemID.GEM_BAG_OPEN, ItemChargeDef.bag("gem_bag")); + CHARGE_MAP.put(ItemID.REWARD_GEM_BAG, ItemChargeDef.bag("gem_bag")); + CHARGE_MAP.put(ItemID.REWARD_GEM_BAG_GUILD, ItemChargeDef.bag("gem_bag")); + CHARGE_MAP.put(ItemID.STAR_REWARD_GEM_BAG, ItemChargeDef.bag("gem_bag")); + CHARGE_MAP.put(ItemID.SLAYER_HERB_SACK, ItemChargeDef.bag("herb_sack")); + CHARGE_MAP.put(ItemID.SLAYER_HERB_SACK_OPEN, ItemChargeDef.bag("herb_sack")); + CHARGE_MAP.put(ItemID.SLAYER_HERB_SACK_SILK, ItemChargeDef.bag("herb_sack")); + CHARGE_MAP.put(ItemID.SLAYER_HERB_SACK_SILK_OPEN, ItemChargeDef.bag("herb_sack")); + CHARGE_MAP.put(ItemID.FISH_BARREL_CLOSED, ItemChargeDef.bag("fish_barrel")); + CHARGE_MAP.put(ItemID.FISH_BARREL_OPEN, ItemChargeDef.bag("fish_barrel")); + CHARGE_MAP.put(ItemID.PLANK_SACK, ItemChargeDef.bag("plank_sack")); + CHARGE_MAP.put(ItemID.SEED_BOX, ItemChargeDef.bag("seed_box")); + CHARGE_MAP.put(ItemID.SEED_BOX_OPEN, ItemChargeDef.bag("seed_box")); + CHARGE_MAP.put(ItemID.LOOTING_BAG, ItemChargeDef.lootingBag()); + CHARGE_MAP.put(ItemID.LOOTING_BAG_OPEN, ItemChargeDef.lootingBag()); + + // 12. Helms & Staffs & Tridents + CHARGE_MAP.put(ItemID.SERPENTINE_HELM, ItemChargeDef.uncharged()); + CHARGE_MAP.put(ItemID.SERPENTINE_HELM_CHARGED, ItemChargeDef.varbit(VarbitID.CHARGES_SERPENTINE_HELM_QUANTITY)); + CHARGE_MAP.put(ItemID.SERPENTINE_HELM_CYAN, ItemChargeDef.uncharged()); + CHARGE_MAP.put(ItemID.SERPENTINE_HELM_CHARGED_CYAN, ItemChargeDef.varbit(VarbitID.CHARGES_SERPENTINE_HELM_QUANTITY)); + CHARGE_MAP.put(ItemID.SERPENTINE_HELM_RED, ItemChargeDef.uncharged()); + CHARGE_MAP.put(ItemID.SERPENTINE_HELM_CHARGED_RED, ItemChargeDef.varbit(VarbitID.CHARGES_SERPENTINE_HELM_QUANTITY)); + + CHARGE_MAP.put(ItemID.WARPED_SCEPTRE_UNCHARGED, ItemChargeDef.uncharged()); + CHARGE_MAP.put(ItemID.WARPED_SCEPTRE, ItemChargeDef.varbit(VarbitID.CHARGES_WARPED_SCEPTRE_QUANTITY)); + + CHARGE_MAP.put(ItemID.TUMEKENS_SHADOW, ItemChargeDef.varbit(VarbitID.CHARGES_TUMEKENS_SHADOW_QUANTITY)); + CHARGE_MAP.put(ItemID.TUMEKENS_SHADOW_UNCHARGED, ItemChargeDef.uncharged()); + CHARGE_MAP.put(ItemID.DEADMAN_BLIGHTED_TUMEKENS_SHADOW, ItemChargeDef.varbit(VarbitID.CHARGES_TUMEKENS_SHADOW_QUANTITY)); + CHARGE_MAP.put(ItemID.DEADMAN_BLIGHTED_TUMEKENS_SHADOW_UNCHARGED, ItemChargeDef.uncharged()); + CHARGE_MAP.put(ItemID.TUMEKENS_HEKA, ItemChargeDef.varbit(VarbitID.CHARGES_TUMEKENS_SHADOW_QUANTITY)); + CHARGE_MAP.put(ItemID.TUMEKENS_HEKA_UNCHARGED, ItemChargeDef.uncharged()); + + CHARGE_MAP.put(ItemID.SANGUINESTI_STAFF, ItemChargeDef.varbit(VarbitID.CHARGES_SANGUINESTI_STAFF_QUANTITY)); + CHARGE_MAP.put(ItemID.SANGUINESTI_STAFF_UNCHARGED, ItemChargeDef.uncharged()); + CHARGE_MAP.put(ItemID.SANGUINESTI_STAFF_OR, ItemChargeDef.varbit(VarbitID.CHARGES_SANGUINESTI_STAFF_QUANTITY)); + CHARGE_MAP.put(ItemID.SANGUINESTI_STAFF_UNCHARGED_OR, ItemChargeDef.uncharged()); + + // Trident of the Seas + CHARGE_MAP.put(ItemID.TOTS, ItemChargeDef.varbit(VarbitID.CHARGES_TRIDENT_OF_THE_SEAS_QUANTITY)); + CHARGE_MAP.put(ItemID.TOTS_CHARGED, ItemChargeDef.varbit(VarbitID.CHARGES_TRIDENT_OF_THE_SEAS_QUANTITY)); + CHARGE_MAP.put(ItemID.TOTS_UNCHARGED, ItemChargeDef.uncharged()); + CHARGE_MAP.put(ItemID.TOTS_ORN, ItemChargeDef.varbit(VarbitID.CHARGES_TRIDENT_OF_THE_SEAS_QUANTITY)); + CHARGE_MAP.put(ItemID.TOTS_CHARGED_ORN, ItemChargeDef.varbit(VarbitID.CHARGES_TRIDENT_OF_THE_SEAS_QUANTITY)); + CHARGE_MAP.put(ItemID.TOTS_UNCHARGED_ORN, ItemChargeDef.uncharged()); + CHARGE_MAP.put(ItemID.TOTS_I_CHARGED, ItemChargeDef.varbit(VarbitID.CHARGES_TRIDENT_OF_THE_SEAS_E_QUANTITY)); + CHARGE_MAP.put(ItemID.TOTS_I_UNCHARGED, ItemChargeDef.uncharged()); + CHARGE_MAP.put(ItemID.TOTS_I_CHARGED_ORN, ItemChargeDef.varbit(VarbitID.CHARGES_TRIDENT_OF_THE_SEAS_E_QUANTITY)); + CHARGE_MAP.put(ItemID.TOTS_I_UNCHARGED_ORN, ItemChargeDef.uncharged()); + + // Trident of the Swamp + CHARGE_MAP.put(ItemID.TOXIC_TOTS_CHARGED, ItemChargeDef.varbit(665)); + CHARGE_MAP.put(ItemID.TOXIC_TOTS_UNCHARGED, ItemChargeDef.uncharged()); + CHARGE_MAP.put(ItemID.TOXIC_TOTS_CHARGED_ORN, ItemChargeDef.varbit(665)); + CHARGE_MAP.put(ItemID.TOXIC_TOTS_UNCHARGED_ORN, ItemChargeDef.uncharged()); + CHARGE_MAP.put(ItemID.TOXIC_TOTS_I_CHARGED, ItemChargeDef.varbit(665)); + CHARGE_MAP.put(ItemID.TOXIC_TOTS_I_UNCHARGED, ItemChargeDef.uncharged()); + CHARGE_MAP.put(ItemID.TOXIC_TOTS_I_CHARGED_ORN, ItemChargeDef.varbit(665)); + CHARGE_MAP.put(ItemID.TOXIC_TOTS_I_UNCHARGED_ORN, ItemChargeDef.uncharged()); + + // Toxic Staff of the Dead + CHARGE_MAP.put(ItemID.TOXIC_SOTD, ItemChargeDef.uncharged()); + CHARGE_MAP.put(ItemID.TOXIC_SOTD_CHARGED, ItemChargeDef.varbit(VarbitID.CHARGES_TOXIC_STAFF_OF_THE_DEAD_QUANTITY)); + CHARGE_MAP.put(ItemID.TOXIC_SOTD_DEADMAN, ItemChargeDef.uncharged()); + CHARGE_MAP.put(ItemID.TOXIC_SOTD_CHARGED_DEADMAN, ItemChargeDef.varbit(VarbitID.CHARGES_TOXIC_STAFF_OF_THE_DEAD_QUANTITY)); + + // Bow of Faerdhinen + CHARGE_MAP.put(ItemID.BOW_OF_FAERDHINEN_INACTIVE, ItemChargeDef.bowfaBlade(VarbitID.CHARGES_BOW_OF_FAERDHINEN_QUANTITY, VarbitID.CHARGES_BOW_OF_FAERDHINEN_CORRUPTION, true, false)); + CHARGE_MAP.put(ItemID.BOW_OF_FAERDHINEN, ItemChargeDef.bowfaBlade(VarbitID.CHARGES_BOW_OF_FAERDHINEN_QUANTITY, VarbitID.CHARGES_BOW_OF_FAERDHINEN_CORRUPTION, false, false)); + CHARGE_MAP.put(ItemID.BOW_OF_FAERDHINEN_INFINITE, ItemChargeDef.bowfaBlade(VarbitID.CHARGES_BOW_OF_FAERDHINEN_QUANTITY, VarbitID.CHARGES_BOW_OF_FAERDHINEN_CORRUPTION, false, true)); + CHARGE_MAP.put(ItemID.BOW_OF_FAERDHINEN_INFINITE_DUMMY, ItemChargeDef.bowfaBlade(VarbitID.CHARGES_BOW_OF_FAERDHINEN_QUANTITY, VarbitID.CHARGES_BOW_OF_FAERDHINEN_CORRUPTION, false, true)); + CHARGE_MAP.put(ItemID.BOW_OF_FAERDHINEN_INFINITE_ITHELL, ItemChargeDef.bowfaBlade(VarbitID.CHARGES_BOW_OF_FAERDHINEN_QUANTITY, VarbitID.CHARGES_BOW_OF_FAERDHINEN_CORRUPTION, false, true)); + CHARGE_MAP.put(ItemID.BOW_OF_FAERDHINEN_INFINITE_IORWERTH, ItemChargeDef.bowfaBlade(VarbitID.CHARGES_BOW_OF_FAERDHINEN_QUANTITY, VarbitID.CHARGES_BOW_OF_FAERDHINEN_CORRUPTION, false, true)); + CHARGE_MAP.put(ItemID.BOW_OF_FAERDHINEN_INFINITE_TRAHAEARN, ItemChargeDef.bowfaBlade(VarbitID.CHARGES_BOW_OF_FAERDHINEN_QUANTITY, VarbitID.CHARGES_BOW_OF_FAERDHINEN_CORRUPTION, false, true)); + CHARGE_MAP.put(ItemID.BOW_OF_FAERDHINEN_INFINITE_CADARN, ItemChargeDef.bowfaBlade(VarbitID.CHARGES_BOW_OF_FAERDHINEN_QUANTITY, VarbitID.CHARGES_BOW_OF_FAERDHINEN_CORRUPTION, false, true)); + CHARGE_MAP.put(ItemID.BOW_OF_FAERDHINEN_INFINITE_CRWYS, ItemChargeDef.bowfaBlade(VarbitID.CHARGES_BOW_OF_FAERDHINEN_QUANTITY, VarbitID.CHARGES_BOW_OF_FAERDHINEN_CORRUPTION, false, true)); + CHARGE_MAP.put(ItemID.BOW_OF_FAERDHINEN_INFINITE_MEILYR, ItemChargeDef.bowfaBlade(VarbitID.CHARGES_BOW_OF_FAERDHINEN_QUANTITY, VarbitID.CHARGES_BOW_OF_FAERDHINEN_CORRUPTION, false, true)); + CHARGE_MAP.put(ItemID.BOW_OF_FAERDHINEN_INFINITE_AMLODD, ItemChargeDef.bowfaBlade(VarbitID.CHARGES_BOW_OF_FAERDHINEN_QUANTITY, VarbitID.CHARGES_BOW_OF_FAERDHINEN_CORRUPTION, false, true)); + CHARGE_MAP.put(ItemID.BR_BOW_OF_FAERDHINEN, ItemChargeDef.bowfaBlade(VarbitID.CHARGES_BOW_OF_FAERDHINEN_QUANTITY, VarbitID.CHARGES_BOW_OF_FAERDHINEN_CORRUPTION, false, false)); + CHARGE_MAP.put(ItemID.BOW_OF_FAERDHINEN_INFINITE_DEADMAN, ItemChargeDef.bowfaBlade(VarbitID.CHARGES_BOW_OF_FAERDHINEN_QUANTITY, VarbitID.CHARGES_BOW_OF_FAERDHINEN_CORRUPTION, false, true)); + + // Blade of Saeldor + CHARGE_MAP.put(ItemID.BLADE_OF_SAELDOR_INACTIVE, ItemChargeDef.bowfaBlade(VarbitID.CHARGES_BLADE_OF_SAELDOR_QUANTITY, VarbitID.CHARGES_BLADE_OF_SAELDOR_CORRUPTION, true, false)); + CHARGE_MAP.put(ItemID.BLADE_OF_SAELDOR, ItemChargeDef.bowfaBlade(VarbitID.CHARGES_BLADE_OF_SAELDOR_QUANTITY, VarbitID.CHARGES_BLADE_OF_SAELDOR_CORRUPTION, false, false)); + CHARGE_MAP.put(ItemID.BLADE_OF_SAELDOR_INFINITE, ItemChargeDef.bowfaBlade(VarbitID.CHARGES_BLADE_OF_SAELDOR_QUANTITY, VarbitID.CHARGES_BLADE_OF_SAELDOR_CORRUPTION, false, true)); + CHARGE_MAP.put(ItemID.BLADE_OF_SAELDOR_INFINITE_DUMMY, ItemChargeDef.bowfaBlade(VarbitID.CHARGES_BLADE_OF_SAELDOR_QUANTITY, VarbitID.CHARGES_BLADE_OF_SAELDOR_CORRUPTION, false, true)); + CHARGE_MAP.put(ItemID.BLADE_OF_SAELDOR_INFINITE_ITHELL, ItemChargeDef.bowfaBlade(VarbitID.CHARGES_BLADE_OF_SAELDOR_QUANTITY, VarbitID.CHARGES_BLADE_OF_SAELDOR_CORRUPTION, false, true)); + CHARGE_MAP.put(ItemID.BLADE_OF_SAELDOR_INFINITE_IORWERTH, ItemChargeDef.bowfaBlade(VarbitID.CHARGES_BLADE_OF_SAELDOR_QUANTITY, VarbitID.CHARGES_BLADE_OF_SAELDOR_CORRUPTION, false, true)); + CHARGE_MAP.put(ItemID.BLADE_OF_SAELDOR_INFINITE_TRAHAEARN, ItemChargeDef.bowfaBlade(VarbitID.CHARGES_BLADE_OF_SAELDOR_QUANTITY, VarbitID.CHARGES_BLADE_OF_SAELDOR_CORRUPTION, false, true)); + CHARGE_MAP.put(ItemID.BLADE_OF_SAELDOR_INFINITE_CADARN, ItemChargeDef.bowfaBlade(VarbitID.CHARGES_BLADE_OF_SAELDOR_QUANTITY, VarbitID.CHARGES_BLADE_OF_SAELDOR_CORRUPTION, false, true)); + CHARGE_MAP.put(ItemID.BLADE_OF_SAELDOR_INFINITE_CRWYS, ItemChargeDef.bowfaBlade(VarbitID.CHARGES_BLADE_OF_SAELDOR_QUANTITY, VarbitID.CHARGES_BLADE_OF_SAELDOR_CORRUPTION, false, true)); + CHARGE_MAP.put(ItemID.BLADE_OF_SAELDOR_INFINITE_MEILYR, ItemChargeDef.bowfaBlade(VarbitID.CHARGES_BLADE_OF_SAELDOR_QUANTITY, VarbitID.CHARGES_BLADE_OF_SAELDOR_CORRUPTION, false, true)); + CHARGE_MAP.put(ItemID.BLADE_OF_SAELDOR_INFINITE_AMLODD, ItemChargeDef.bowfaBlade(VarbitID.CHARGES_BLADE_OF_SAELDOR_QUANTITY, VarbitID.CHARGES_BLADE_OF_SAELDOR_CORRUPTION, false, true)); + + // Venator Bow + CHARGE_MAP.put(ItemID.VENATOR_BOW_UNCHARGED, ItemChargeDef.uncharged()); + CHARGE_MAP.put(ItemID.VENATOR_BOW, ItemChargeDef.varbit(VarbitID.CHARGES_VENATOR_BOW_QUANTITY)); + CHARGE_MAP.put(ItemID.VENATOR_BOW_ORNAMENT, ItemChargeDef.varbit(VarbitID.CHARGES_VENATOR_BOW_QUANTITY)); + CHARGE_MAP.put(ItemID.VENATOR_BOW_ORNAMENT_UNCHARGED, ItemChargeDef.uncharged()); + + // Tonalztics & Arclight & Dizana's Quiver + CHARGE_MAP.put(ItemID.TONALZTICS_OF_RALOS_UNCHARGED, ItemChargeDef.uncharged()); + CHARGE_MAP.put(ItemID.TONALZTICS_OF_RALOS_CHARGED, ItemChargeDef.varbit(VarbitID.CHARGES_TONALZTICS_OF_RALOS_QUANTITY)); + CHARGE_MAP.put(ItemID.ARCLIGHT, ItemChargeDef.varbit(VarbitID.CHARGES_ARCLIGHT_QUANTITY)); + CHARGE_MAP.put(ItemID.ARCLIGHT_INACTIVE, ItemChargeDef.uncharged()); + + CHARGE_MAP.put(ItemID.DIZANAS_QUIVER_CHARGED, ItemChargeDef.quiver(false, false)); + CHARGE_MAP.put(ItemID.DIZANAS_QUIVER_CHARGED_TROUVER, ItemChargeDef.quiver(false, false)); + CHARGE_MAP.put(ItemID.DIZANAS_QUIVER_UNCHARGED, ItemChargeDef.quiver(true, false)); + CHARGE_MAP.put(ItemID.DIZANAS_QUIVER_UNCHARGED_TROUVER, ItemChargeDef.quiver(true, false)); + CHARGE_MAP.put(ItemID.DIZANAS_QUIVER_INFINITE, ItemChargeDef.quiver(false, true)); + CHARGE_MAP.put(ItemID.DIZANAS_QUIVER_INFINITE_TROUVER, ItemChargeDef.quiver(false, true)); + CHARGE_MAP.put(ItemID.DIZANAS_QUIVER_BROKEN, ItemChargeDef.quiver(true, false)); + CHARGE_MAP.put(ItemID.DIZANAS_QUIVER_INFINITE_BROKEN, ItemChargeDef.quiver(false, true)); + CHARGE_MAP.put(ItemID.DIZANAS_QUIVER_TROUVER_BROKEN, ItemChargeDef.quiver(true, false)); + CHARGE_MAP.put(ItemID.DIZANAS_QUIVER_TROUVER_MANGLED, ItemChargeDef.quiver(true, false)); + CHARGE_MAP.put(ItemID.DIZANAS_QUIVER_INFINITE_TROUVER_BROKEN, ItemChargeDef.quiver(false, true)); + CHARGE_MAP.put(ItemID.DIZANAS_QUIVER_INFINITE_TROUVER_MANGLED, ItemChargeDef.quiver(false, true)); + CHARGE_MAP.put(ItemID.SKILLCAPE_MAX_DIZANAS, ItemChargeDef.quiver(false, false)); + CHARGE_MAP.put(ItemID.SKILLCAPE_MAX_DIZANAS_TROUVER, ItemChargeDef.quiver(false, false)); + CHARGE_MAP.put(ItemID.SKILLCAPE_MAX_DIZANAS_BROKEN, ItemChargeDef.quiver(true, false)); + CHARGE_MAP.put(ItemID.SKILLCAPE_MAX_DIZANAS_TROUVER_BROKEN, ItemChargeDef.quiver(true, false)); + CHARGE_MAP.put(ItemID.SKILLCAPE_MAX_DIZANAS_TROUVER_MANGLED, ItemChargeDef.quiver(true, false)); + + // Tomes & Ash Sanctifier & Bonecrusher + CHARGE_MAP.put(ItemID.TOME_OF_FIRE, ItemChargeDef.varbit(VarbitID.CHARGES_TOME_OF_FIRE_QUANTITY)); + CHARGE_MAP.put(ItemID.TOME_OF_FIRE_UNCHARGED, ItemChargeDef.uncharged()); + CHARGE_MAP.put(ItemID.BR_TOME_OF_FIRE, ItemChargeDef.varbit(VarbitID.CHARGES_TOME_OF_FIRE_QUANTITY)); + CHARGE_MAP.put(ItemID.TOME_OF_WATER, ItemChargeDef.varbit(VarbitID.CHARGES_TOME_OF_WATER_QUANTITY)); + CHARGE_MAP.put(ItemID.TOME_OF_WATER_UNCHARGED, ItemChargeDef.uncharged()); + CHARGE_MAP.put(ItemID.TOME_OF_EARTH, ItemChargeDef.varbit(VarbitID.CHARGES_TOME_OF_EARTH_QUANTITY)); + CHARGE_MAP.put(ItemID.TOME_OF_EARTH_UNCHARGED, ItemChargeDef.uncharged()); + + CHARGE_MAP.put(ItemID.ASH_SANCTIFIER, ItemChargeDef.varbit(VarbitID.CHARGES_ASH_SANCTIFIER_QUANTITY)); + CHARGE_MAP.put(ItemID.BONECRUSHER, ItemChargeDef.varbit(VarbitID.CHARGES_BONECRUSHER_QUANTITY)); + CHARGE_MAP.put(ItemID.BONECRUSHER_NECKLACE, ItemChargeDef.varbit(VarbitID.CHARGES_BONECRUSHER_QUANTITY)); + CHARGE_MAP.put(ItemID.NATURE_STAFF_CHARGED, ItemChargeDef.varbit(VarbitID.CHARGES_BRYOPHYTAS_STAFF_QUANTITY)); + CHARGE_MAP.put(22368, ItemChargeDef.uncharged()); // BRYOPHYTAS_STAFF_UNCHARGED + CHARGE_MAP.put(ItemID.WATER_CIRCLET_CHARGED, ItemChargeDef.varbit(VarbitID.CHARGES_CIRCLET_OF_WATER_QUANTITY)); + CHARGE_MAP.put(ItemID.WATER_CIRCLET, ItemChargeDef.uncharged()); + + CHARGE_MAP.put(ItemID.ARCEUUS_SOULBEARER, ItemChargeDef.varbit(VarbitID.CHARGES_SOUL_BEARER_QUANTITY)); + CHARGE_MAP.put(19636, ItemChargeDef.uncharged()); + CHARGE_MAP.put(ItemID.CELESTIAL_RING_CHARGED, ItemChargeDef.varbit(VarbitID.CHARGES_CELESTIAL_RING_QUANTITY)); + CHARGE_MAP.put(ItemID.CELESTIAL_RING, ItemChargeDef.uncharged()); + + // Wilderness Weapons & Bracelet + CHARGE_MAP.put(ItemID.WILD_CAVE_CHAINMACE_CHARGED, ItemChargeDef.varbit(VarbitID.CHARGES_WILDERNESS_WEAPON_QUANTITY)); + CHARGE_MAP.put(ItemID.WILD_CAVE_CHAINMACE_UNCHARGED, ItemChargeDef.uncharged()); + CHARGE_MAP.put(ItemID.WILD_CAVE_BOW_CHARGED, ItemChargeDef.varbit(VarbitID.CHARGES_WILDERNESS_WEAPON_QUANTITY)); + CHARGE_MAP.put(ItemID.WILD_CAVE_BOW_UNCHARGED, ItemChargeDef.uncharged()); + CHARGE_MAP.put(ItemID.WILD_CAVE_SCEPTRE_CHARGED, ItemChargeDef.varbit(VarbitID.CHARGES_WILDERNESS_WEAPON_QUANTITY)); + CHARGE_MAP.put(ItemID.WILD_CAVE_SCEPTRE_UNCHARGED, ItemChargeDef.uncharged()); + CHARGE_MAP.put(ItemID.WILD_CAVE_SCEPTRE_CHARGED_RECOL, ItemChargeDef.varbit(VarbitID.CHARGES_WILDERNESS_WEAPON_QUANTITY)); + CHARGE_MAP.put(ItemID.WILD_CAVE_SCEPTRE_UNCHARGED_RECOL, ItemChargeDef.uncharged()); + CHARGE_MAP.put(ItemID.WILD_CAVE_WEBWEAVER_CHARGED, ItemChargeDef.varbit(VarbitID.CHARGES_WILDERNESS_WEAPON_QUANTITY)); + CHARGE_MAP.put(ItemID.WILD_CAVE_WEBWEAVER_UNCHARGED, ItemChargeDef.uncharged()); + CHARGE_MAP.put(ItemID.WILD_CAVE_URSINE_CHARGED, ItemChargeDef.varbit(VarbitID.CHARGES_WILDERNESS_WEAPON_QUANTITY)); + CHARGE_MAP.put(ItemID.WILD_CAVE_URSINE_UNCHARGED, ItemChargeDef.uncharged()); + CHARGE_MAP.put(ItemID.WILD_CAVE_ACCURSED_CHARGED, ItemChargeDef.varbit(VarbitID.CHARGES_WILDERNESS_WEAPON_QUANTITY)); + CHARGE_MAP.put(ItemID.WILD_CAVE_ACCURSED_UNCHARGED, ItemChargeDef.uncharged()); + CHARGE_MAP.put(ItemID.WILD_CAVE_ACCURSED_CHARGED_RECOL, ItemChargeDef.varbit(VarbitID.CHARGES_WILDERNESS_WEAPON_QUANTITY)); + CHARGE_MAP.put(ItemID.WILD_CAVE_ACCURSED_UNCHARGED_RECOL, ItemChargeDef.uncharged()); + CHARGE_MAP.put(ItemID.WILD_CAVE_BRACELET_CHARGED, ItemChargeDef.varbit(VarbitID.CHARGES_BRACELET_OF_ETHEREUM_QUANTITY)); + CHARGE_MAP.put(ItemID.WILD_CAVE_BRACELET_UNCHARGED, ItemChargeDef.uncharged()); + + // Ring of Suffering & Shadows & Echo boots & Blood fury & Misc + CHARGE_MAP.put(ItemID.ZENYTE_RING_ENCHANTED, ItemChargeDef.varbit(VarbitID.CHARGES_RING_OF_SUFFERING_QUANTITY)); + CHARGE_MAP.put(19710, ItemChargeDef.varbit(VarbitID.CHARGES_RING_OF_SUFFERING_QUANTITY)); + CHARGE_MAP.put(20655, ItemChargeDef.varbit(VarbitID.CHARGES_RING_OF_SUFFERING_QUANTITY)); + CHARGE_MAP.put(20657, ItemChargeDef.varbit(VarbitID.CHARGES_RING_OF_SUFFERING_QUANTITY)); + CHARGE_MAP.put(25246, ItemChargeDef.varbit(VarbitID.CHARGES_RING_OF_SUFFERING_QUANTITY)); + CHARGE_MAP.put(25248, ItemChargeDef.varbit(VarbitID.CHARGES_RING_OF_SUFFERING_QUANTITY)); + CHARGE_MAP.put(26761, ItemChargeDef.varbit(VarbitID.CHARGES_RING_OF_SUFFERING_QUANTITY)); + CHARGE_MAP.put(26762, ItemChargeDef.varbit(VarbitID.CHARGES_RING_OF_SUFFERING_QUANTITY)); + + CHARGE_MAP.put(ItemID.XERIC_TALISMAN, ItemChargeDef.varbit(VarbitID.CHARGES_XERICS_TALISMAN_QUANTITY)); + CHARGE_MAP.put(ItemID.XERIC_TALISMAN_EMPTY, ItemChargeDef.uncharged()); + CHARGE_MAP.put(ItemID.RING_OF_SHADOWS, ItemChargeDef.varbit(VarbitID.CHARGES_RING_OF_SHADOWS_QUANTITY)); + CHARGE_MAP.put(ItemID.RING_OF_SHADOWS_UNCHARGED, ItemChargeDef.uncharged()); + CHARGE_MAP.put(ItemID.ECHO_BOOTS, ItemChargeDef.varbit(VarbitID.CHARGES_ECHO_BOOTS_QUANTITY)); + CHARGE_MAP.put(ItemID.BLOOD_AMULET, ItemChargeDef.varbit(VarbitID.CHARGES_BLOOD_FURY_QUANTITY)); + CHARGE_MAP.put(ItemID.GIANTSOUL_AMULET_CHARGED, ItemChargeDef.varbit(VarbitID.CHARGES_GIANTSOUL_AMULET_QUANTITY)); + CHARGE_MAP.put(ItemID.GIANTSOUL_AMULET_UNCHARGED, ItemChargeDef.uncharged()); + CHARGE_MAP.put(ItemID.EYE_OF_AYAK, ItemChargeDef.varbit(VarbitID.CHARGES_EYE_OF_AYAK_QUANTITY)); + CHARGE_MAP.put(ItemID.EYE_OF_AYAK_UNCHARGED, ItemChargeDef.uncharged()); + CHARGE_MAP.put(ItemID.HORN_OF_PLENTY, ItemChargeDef.varbit(VarbitID.CHARGES_HORN_OF_PLENTY_QUANTITY)); + CHARGE_MAP.put(ItemID.HORN_OF_PLENTY_UNCHARGED, ItemChargeDef.uncharged()); + CHARGE_MAP.put(ItemID.SAILORS_AMULET, ItemChargeDef.varbit(VarbitID.CHARGES_SAILORS_AMULET_QUANTITY)); + CHARGE_MAP.put(ItemID.SAILORS_AMULET_EMPTY, ItemChargeDef.uncharged()); + CHARGE_MAP.put(ItemID.COWBELL_AMULET, ItemChargeDef.varbit(VarbitID.CHARGES_COWBELL_AMULET_QUANTITY)); + CHARGE_MAP.put(ItemID.COWBELL_AMULET_EMPTY, ItemChargeDef.uncharged()); + CHARGE_MAP.put(ItemID.PENDANT_OF_ATES, ItemChargeDef.varbit(VarbitID.CHARGES_PENDANT_OF_ATES_QUANTITY)); + CHARGE_MAP.put(ItemID.PENDANT_OF_ATES_EMPTY, ItemChargeDef.uncharged()); + CHARGE_MAP.put(ItemID.ALCHEMIST_RING, ItemChargeDef.varbit(VarbitID.CHARGES_ALCHEMISTS_AMULET_QUANTITY)); + CHARGE_MAP.put(ItemID.AMULET_OF_CHEMISTRY_IMBUED_CHARGED, ItemChargeDef.varbit(VarbitID.CHARGES_ALCHEMISTS_AMULET_QUANTITY)); + CHARGE_MAP.put(ItemID.AMULET_OF_CHEMISTRY_IMBUED_UNCHARGED, ItemChargeDef.uncharged()); + + // Crystal Armor & Tools + CHARGE_MAP.put(ItemID.CRYSTAL_HELMET, ItemChargeDef.varbit(VarbitID.CHARGES_CRYSTAL_ARMOUR_QUANTITY)); + CHARGE_MAP.put(ItemID.CRYSTAL_HELMET_INACTIVE, ItemChargeDef.uncharged()); + CHARGE_MAP.put(ItemID.CRYSTAL_CHESTPLATE, ItemChargeDef.varbit(VarbitID.CHARGES_CRYSTAL_ARMOUR_QUANTITY)); + CHARGE_MAP.put(ItemID.CRYSTAL_CHESTPLATE_INACTIVE, ItemChargeDef.uncharged()); + CHARGE_MAP.put(ItemID.CRYSTAL_PLATELEGS, ItemChargeDef.varbit(VarbitID.CHARGES_CRYSTAL_ARMOUR_QUANTITY)); + CHARGE_MAP.put(ItemID.CRYSTAL_PLATELEGS_INACTIVE, ItemChargeDef.uncharged()); + + int[] crystalArmourIds = {27697, 27701, 27709, 27713, 27721, 27725, 27733, 27737, 27745, 27749, 27757, 27761, 27769, 27773}; + for (int id : crystalArmourIds) + { + CHARGE_MAP.put(id, ItemChargeDef.varbit(VarbitID.CHARGES_CRYSTAL_ARMOUR_QUANTITY)); + } + int[] crystalArmourInactiveIds = {27699, 27703, 27711, 27715, 27723, 27727, 27735, 27739, 27747, 27751, 27759, 27763, 27771, 27775}; + for (int id : crystalArmourInactiveIds) + { + CHARGE_MAP.put(id, ItemChargeDef.uncharged()); + } + + CHARGE_MAP.put(ItemID.CRYSTAL_AXE, ItemChargeDef.varbit(VarbitID.CHARGES_CRYSTAL_TOOLS_QUANTITY)); + CHARGE_MAP.put(ItemID.CRYSTAL_AXE_INACTIVE, ItemChargeDef.uncharged()); + CHARGE_MAP.put(ItemID.CRYSTAL_AXE_2H, ItemChargeDef.varbit(VarbitID.CHARGES_CRYSTAL_TOOLS_QUANTITY)); + CHARGE_MAP.put(ItemID.CRYSTAL_AXE_2H_INACTIVE, ItemChargeDef.uncharged()); + CHARGE_MAP.put(ItemID.CRYSTAL_PICKAXE, ItemChargeDef.varbit(VarbitID.CHARGES_CRYSTAL_TOOLS_QUANTITY)); + CHARGE_MAP.put(ItemID.CRYSTAL_PICKAXE_INACTIVE, ItemChargeDef.uncharged()); + CHARGE_MAP.put(ItemID.CRYSTAL_HARPOON, ItemChargeDef.varbit(VarbitID.CHARGES_CRYSTAL_TOOLS_QUANTITY)); + CHARGE_MAP.put(ItemID.CRYSTAL_HARPOON_INACTIVE, ItemChargeDef.uncharged()); + + CHARGE_MAP.put(ItemID.CRYSTAL_BOW, ItemChargeDef.varbit(VarbitID.CHARGES_CRYSTAL_WEAK_QUANTITY)); + CHARGE_MAP.put(ItemID.CRYSTAL_BOW_INACTIVE, ItemChargeDef.uncharged()); + CHARGE_MAP.put(ItemID.CRYSTAL_HALBERD, ItemChargeDef.varbit(VarbitID.CHARGES_CRYSTAL_WEAK_QUANTITY)); + CHARGE_MAP.put(ItemID.CRYSTAL_HALBERD_INACTIVE, ItemChargeDef.uncharged()); + CHARGE_MAP.put(ItemID.CRYSTAL_SHIELD, ItemChargeDef.varbit(VarbitID.CHARGES_CRYSTAL_WEAK_QUANTITY)); + CHARGE_MAP.put(ItemID.CRYSTAL_SHIELD_INACTIVE, ItemChargeDef.uncharged()); + } + + private static void registerBarrows(BarrowsPiece piece, int baseId, int id100, int id75, int id50, int id25, int id0) + { + CHARGE_MAP.put(baseId, ItemChargeDef.barrows(piece, 100)); + CHARGE_MAP.put(id100, ItemChargeDef.barrows(piece, 100)); + CHARGE_MAP.put(id75, ItemChargeDef.barrows(piece, 75)); + CHARGE_MAP.put(id50, ItemChargeDef.barrows(piece, 50)); + CHARGE_MAP.put(id25, ItemChargeDef.barrows(piece, 25)); + CHARGE_MAP.put(id0, ItemChargeDef.barrows(piece, 0)); + } + + private static void registerMoons(MoonPiece piece, int baseId, int degradedId1, int degradedId2, int brokenId) + { + CHARGE_MAP.put(baseId, ItemChargeDef.moons(piece, 3000)); + CHARGE_MAP.put(degradedId1, ItemChargeDef.moons(piece, -1)); + if (degradedId2 != 0) + { + CHARGE_MAP.put(degradedId2, ItemChargeDef.moons(piece, -1)); + } + CHARGE_MAP.put(brokenId, ItemChargeDef.moons(piece, 0)); + } + + // Chat patterns + // 1. Blowpipe + private static final Pattern BLOWPIPE_CHECK_PATTERN = Pattern.compile( + "Darts:\\s*(?.+?)\\s+x\\s+(?[0-9,]+)\\.\\s*Scales:\\s*(?[0-9,]+)\\s*\\((?[0-9.]+)%\\)", + Pattern.CASE_INSENSITIVE + ); + private static final Pattern BLOWPIPE_CHECK_NO_DARTS_PATTERN = Pattern.compile( + "Darts:\\s*None\\.\\s*Scales:\\s*(?[0-9,]+)\\s*\\((?[0-9.]+)%\\)", + Pattern.CASE_INSENSITIVE + ); + private static final Pattern BLOWPIPE_CHECK_NO_SCALES_PATTERN = Pattern.compile( + "Darts:\\s*(?.+?)\\s+x\\s+(?[0-9,]+)\\.\\s*Scales:\\s*0\\s*\\(0%\\)", + Pattern.CASE_INSENSITIVE + ); + private static final Pattern BLOWPIPE_CHECK_EMPTY_PATTERN = Pattern.compile( + "Darts:\\s*None\\.\\s*Scales:\\s*0\\s*\\(0%\\)", + Pattern.CASE_INSENSITIVE + ); + private static final Pattern BLOWPIPE_LOAD_DARTS_PATTERN = Pattern.compile( + "You load the blowpipe with ([0-9,]+) (.+?)(?:\\.|\\z)", + Pattern.CASE_INSENSITIVE + ); + private static final Pattern BLOWPIPE_ADD_SCALES_PATTERN = Pattern.compile( + "You (?:add|load) ([0-9,]+) (?:Zulrah's )?scales (?:to|into) (?:the|your) blowpipe", + Pattern.CASE_INSENSITIVE + ); + private static final Pattern BLOWPIPE_UNCHARGE_PATTERN = Pattern.compile( + "You uncharge the blowpipe\\.", + Pattern.CASE_INSENSITIVE + ); + private static final Pattern BLOWPIPE_OUT_OF_DARTS_PATTERN = Pattern.compile( + "Your blowpipe has run out of darts\\.", + Pattern.CASE_INSENSITIVE + ); + private static final Pattern BLOWPIPE_OUT_OF_SCALES_PATTERN = Pattern.compile( + "Your blowpipe has run out of scales\\.", + Pattern.CASE_INSENSITIVE + ); + private static final Pattern BLOWPIPE_OUT_OF_CHARGES_PATTERN = Pattern.compile( + "Your blowpipe has run out of (?:charges|darts and scales)\\.", + Pattern.CASE_INSENSITIVE + ); + + // 2. Barrows & Moons colon check & degradation + private static final Pattern BARROWS_COLON_PATTERN = Pattern.compile( + "(?[^:]+?):\\s*(?\\d+)%\\s*(?:remaining|left|charges?|until degradation)?.*", + Pattern.CASE_INSENSITIVE + ); + private static final Pattern BARROWS_COLON_DEGRADED_PATTERN = Pattern.compile( + "(?[^:]+?):\\s*(?:fully\\s+degraded|degraded|broken|0% remaining).*?", + Pattern.CASE_INSENSITIVE + ); + private static final Pattern BARROWS_REPAIRED_SINGLE_PATTERN = Pattern.compile( + "(?:Your\\s+)?(?.+?)(?:\\s+(?:100|75|50|25|0))?\\s+has\\s+been\\s+repaired\\b.*", + Pattern.CASE_INSENSITIVE + ); + private static final Pattern BARROWS_REPAIRED_ALL_PATTERN = Pattern.compile( + "(?:You repair (?:all )?your (?:degraded )?(?:items|equipment|armour)|Your equipment has been repaired|All your items have been repaired)\\.?", + Pattern.CASE_INSENSITIVE + ); + private static final Pattern BARROWS_CHECK_PERCENT_PATTERN = Pattern.compile( + "(?:Your\\s+)?(?.+?)(?:\\s+(?:100|75|50|25|0))?\\s+(?:only\\s+)?has\\s+(\\d+)%\\s+(?:of\\s+its\\s+)?charges?\\s+(?:remaining|left)\\.?", + Pattern.CASE_INSENSITIVE + ); + private static final Pattern BARROWS_CHECK_NUMERIC_PATTERN = Pattern.compile( + "(?:Your\\s+)?(?.+?)(?:\\s+(?:100|75|50|25|0))?\\s+has\\s+([0-9,]+)\\s+charges?\\s+(?:remaining|left)\\.?", + Pattern.CASE_INSENSITIVE + ); + private static final Pattern BARROWS_DEGRADED_PATTERN = Pattern.compile( + "(?:Your\\s+)?(?.+?)(?:\\s+(?:100|75|50|25|0))?\\s+has\\s+(?:run\\s+out\\s+of\\s+charges|fully\\s+degraded)\\.?", + Pattern.CASE_INSENSITIVE + ); + + // 3. Moons of Peril check + private static final Pattern MOONS_CHECK_PATTERN = Pattern.compile( + "Your (?(?:Blood|Blue|Eclipse) moon [a-z]+)(?: only)? (?:has|have) (?[0-9,]+) charges? (?:remaining|left)\\.", + Pattern.CASE_INSENSITIVE + ); + + // 4. Dragonfire Shields + private static final Pattern DRAGONFIRE_CHECK_PATTERN = Pattern.compile( + "(?:The shield|Your shield|Your dragonfire shield|Your dragonfire ward|Your ancient wyvern shield) (?:only )?has (?[0-9,]+) charges?(?: remaining| left)?\\.", + Pattern.CASE_INSENSITIVE + ); + private static final Pattern DRAGONFIRE_INC_PATTERN = Pattern.compile( + "Your dragonfire (?:shield|ward) glows more brightly\\.", + Pattern.CASE_INSENSITIVE + ); + private static final Pattern DRAGONFIRE_VENT_PATTERN = Pattern.compile( + "You vent the shield's remaining charges harmlessly into the air\\.", + Pattern.CASE_INSENSITIVE + ); + private static final Pattern DRAGONFIRE_FULL_PATTERN = Pattern.compile( + "Your (?:dragonfire shield|dragonfire ward|ancient wyvern shield) is already fully charged\\.", + Pattern.CASE_INSENSITIVE + ); + private static final Pattern DRAGONFIRE_EMPTY_PATTERN = Pattern.compile( + "Your (?:shield|dragonfire shield|dragonfire ward|ancient wyvern shield) (?:has run out of charges|has no charges left)\\.", + Pattern.CASE_INSENSITIVE + ); + + // 5. Abyssal Tentacle & Scythe & Iban's & Tools + private static final Pattern TENTACLE_CHECK_PATTERN = Pattern.compile( + "Your (?:abyssal tentacle|tentacle whip) (?:only )?has ([0-9,]+) charges? (?:remaining|left)\\.", + Pattern.CASE_INSENSITIVE + ); + private static final Pattern TENTACLE_DEGRADED_PATTERN = Pattern.compile( + "Your (?:abyssal tentacle|tentacle whip) (?:has degraded|has run out of charges)\\.", + Pattern.CASE_INSENSITIVE + ); + + private static final Pattern SCYTHE_CHECK_PATTERN = Pattern.compile( + "Your Scythe of [Vv]itur (?:only )?has ([0-9,]+) charges? (?:remaining|left)\\.", + Pattern.CASE_INSENSITIVE + ); + private static final Pattern SCYTHE_EMPTY_PATTERN = Pattern.compile( + "Your Scythe of [Vv]itur (?:has run out of charges|has no charges left)\\.", + Pattern.CASE_INSENSITIVE + ); + + private static final Pattern IBANS_CHECK_PATTERN = Pattern.compile( + "(?:You have|Your staff (?:only )?has) ([0-9,]+) charges left(?: on the staff)?\\.", + Pattern.CASE_INSENSITIVE + ); + private static final Pattern IBANS_EMPTY_PATTERN = Pattern.compile( + "(?:You have no charges left on the staff\\.|Your staff has run out of charges\\.)", + Pattern.CASE_INSENSITIVE + ); + private static final Pattern IBANS_RECHARGE_PATTERN = Pattern.compile( + "You hold the staff above the well and feel the power of Zamorak flow through you\\.", + Pattern.CASE_INSENSITIVE + ); + + private static final Pattern SLAYER_STAFF_E_PATTERN = Pattern.compile( + "Your Slayer's staff \\(e\\) has ([0-9,]+) charges left\\.", + Pattern.CASE_INSENSITIVE + ); + private static final Pattern INFERNAL_CHECK_PATTERN = Pattern.compile( + "Your infernal (?:axe|pickaxe|harpoon) (?:only )?has ([0-9,]+) charges? left\\.", + Pattern.CASE_INSENSITIVE + ); + private static final Pattern SKULL_SCEPTRE_CHECK_PATTERN = Pattern.compile( + "Your [Ss]kull [Ss]ceptre has ([0-9,]+) charges left\\.", + Pattern.CASE_INSENSITIVE + ); + private static final Pattern CRYSTAL_SAW_CHECK_PATTERN = Pattern.compile( + "Your crystal saw has ([0-9,]+) charges left\\.", + Pattern.CASE_INSENSITIVE + ); + private static final Pattern JAR_GENERATOR_CHECK_PATTERN = Pattern.compile( + "Your jar generator has ([0-9,]+) charges left\\.", + Pattern.CASE_INSENSITIVE + ); + + // 6. Jewelry + private static final Pattern DODGY_CHECK_PATTERN = Pattern.compile( + "Your dodgy necklace (?:protects you\\..*It )?has (\\d+) charges? left\\.", + Pattern.CASE_INSENSITIVE + ); + private static final Pattern DODGY_BREAK_PATTERN = Pattern.compile( + "Your dodgy necklace protects you\\..*It then crumbles to dust\\.", + Pattern.CASE_INSENSITIVE + ); + + private static final Pattern FORGING_CHECK_PATTERN = Pattern.compile( + "You can smelt ([0-9]+|one) more pieces? of iron ore before a ring melts\\.", + Pattern.CASE_INSENSITIVE + ); + private static final Pattern FORGING_BREAK_PATTERN = Pattern.compile( + "Your Ring of Forging has melted\\.", + Pattern.CASE_INSENSITIVE + ); + + private static final Pattern RECOIL_CHECK_PATTERN = Pattern.compile( + "Your [Rr]ing of [Rr]ecoil has (?:one|(\\d+)) charges? left\\.", + Pattern.CASE_INSENSITIVE + ); + private static final Pattern RECOIL_BREAK_PATTERN = Pattern.compile( + "Your [Rr]ing of [Rr]ecoil has shattered\\.", + Pattern.CASE_INSENSITIVE + ); + + private static final Pattern BINDING_CHECK_PATTERN = Pattern.compile( + "Your Binding necklace has (\\d+) charges? left\\.", + Pattern.CASE_INSENSITIVE + ); + private static final Pattern BINDING_BREAK_PATTERN = Pattern.compile( + "Your Binding necklace has disintegrated\\.", + Pattern.CASE_INSENSITIVE + ); + + private static final Pattern CHEMISTRY_CHECK_PATTERN = Pattern.compile( + "Your amulet of chemistry (?:helps you create a \\d-dose potion\\. It )?has (\\d+|one) charges? left\\.", + Pattern.CASE_INSENSITIVE + ); + private static final Pattern CHEMISTRY_BREAK_PATTERN = Pattern.compile( + "Your amulet of chemistry helps you create a \\d-dose potion\\. It then crumbles to dust\\.", + Pattern.CASE_INSENSITIVE + ); + + private static final Pattern BOUNTY_CHECK_PATTERN = Pattern.compile( + "Your amulet of bounty (?:saves some seeds for you\\. It )?has (\\d+|one) charges? left\\.", + Pattern.CASE_INSENSITIVE + ); + private static final Pattern BOUNTY_BREAK_PATTERN = Pattern.compile( + "Your amulet of bounty saves some seeds for you\\. It then crumbles to dust\\.", + Pattern.CASE_INSENSITIVE + ); + + private static final Pattern SLAUGHTER_CHECK_PATTERN = Pattern.compile( + "Your bracelet of slaughter has ([0-9,]+) charges? remaining\\.", + Pattern.CASE_INSENSITIVE + ); + private static final Pattern EXPEDITIOUS_CHECK_PATTERN = Pattern.compile( + "Your expeditious bracelet has ([0-9,]+) charges? remaining\\.", + Pattern.CASE_INSENSITIVE + ); + private static final Pattern CLAY_CHECK_PATTERN = Pattern.compile( + "Your bracelet of clay has ([0-9,]+) charges? remaining\\.", + Pattern.CASE_INSENSITIVE + ); + private static final Pattern ENDURANCE_CHECK_PATTERN = Pattern.compile( + "Your ring of endurance has ([0-9,]+) charges? left\\.", + Pattern.CASE_INSENSITIVE + ); + + private static final Pattern BLOOD_ESSENCE_CHECK_PATTERN = Pattern.compile( + "Your blood essence has (?:activated! It now has )?([0-9,]+) charges? (?:remaining|left)\\.", + Pattern.CASE_INSENSITIVE + ); + private static final Pattern BLOOD_ESSENCE_EMPTY_PATTERN = Pattern.compile( + "Your blood essence has run out of charges and has disintegrated\\.", + Pattern.CASE_INSENSITIVE + ); + + private static final Pattern LOCKPICK_CHECK_PATTERN = Pattern.compile( + "Your strange old lockpick has (\\d+)(?:% of its charges remaining| charges? left)\\.", + Pattern.CASE_INSENSITIVE + ); + + private static final Pattern CHRONICLE_CHECK_PATTERN = Pattern.compile( + "Your book has ([0-9,]+) charges left\\.", + Pattern.CASE_INSENSITIVE + ); + private static final Pattern CHRONICLE_ADD_PATTERN = Pattern.compile( + "You add (?:[0-9,]+|a single) charges? to your book\\. It now has ([0-9,]+|one) charges?\\.", + Pattern.CASE_INSENSITIVE + ); + + // 7. Buckets, Cans, Diary & Containers + private static final Pattern COMPOST_BUCKET_HOLDING_PATTERN = Pattern.compile( + "Your bottomless compost bucket is currently holding ([0-9,]+|one|a single) uses?(?: of (?.+?)? ?compost)?\\.", + Pattern.CASE_INSENSITIVE + ); + private static final Pattern COMPOST_BUCKET_REMAINING_PATTERN = Pattern.compile( + "Your bottomless compost bucket has ([0-9,]+|a single) uses?(?: of (?.+?)? ?compost)? remaining\\.", + Pattern.CASE_INSENSITIVE + ); + private static final Pattern COMPOST_BUCKET_FILL_PATTERN = Pattern.compile( + "You fill your bottomless compost bucket with .*buckets? of (?.+?)? ?compost\\. Your bottomless compost bucket now contains a total of ([0-9,]+) uses\\.", + Pattern.CASE_INSENSITIVE + ); + private static final Pattern COMPOST_BUCKET_TREAT_PATTERN = Pattern.compile( + "You treat the .* with (?.*) ?compost\\.", + Pattern.CASE_INSENSITIVE + ); + private static final Pattern COMPOST_BUCKET_EMPTY_PATTERN = Pattern.compile( + "(?:Your bottomless compost bucket has run out of compost!|You discard the contents of your bottomless compost bucket\\.)", + Pattern.CASE_INSENSITIVE + ); + + private static final Pattern GRICOLLERS_CAN_CHECK_PATTERN = Pattern.compile( + "(?:Watering can charges remaining:\\s*([0-9,]+)%|Your Gricoller's can (?:contains|has)\\s*([0-9,]+)\\s*charges?)", + Pattern.CASE_INSENSITIVE + ); + private static final Pattern GRICOLLERS_CAN_FILL_PATTERN = Pattern.compile( + "You fill the watering can", + Pattern.CASE_INSENSITIVE + ); + private static final Pattern GRICOLLERS_CAN_WATER_PATTERN = Pattern.compile( + "You water", + Pattern.CASE_INSENSITIVE + ); + + private static final Pattern ESSENCE_POUCH_CHECK_PATTERN = Pattern.compile( + "There (?:is|are) ([0-9]+|one|two|three|four|five|six|seven|eight|nine|ten|eleven|twelve|thirteen|fourteen|fifteen|sixteen|seventeen|eighteen|nineteen|twenty|twenty-five|thirty|thirty-five|forty) (?normal|pure|daeyalt|guardian) essences? in (?:this|your(?: colossal| giant| large| medium)?) pouch\\.", + Pattern.CASE_INSENSITIVE + ); + private static final Pattern ESSENCE_POUCH_EMPTY_PATTERN = Pattern.compile( + "There is no essence in (?:this|your(?: colossal| giant| large| medium)?) pouch\\.", + Pattern.CASE_INSENSITIVE + ); + + private static final Pattern KHAREDST_CHECK_PATTERN = Pattern.compile( + "(?:(?:Kharedst's Memoirs)|(?:The Book of the Dead)) now has ([0-9,]+) (?:memories|memory|charges)", + Pattern.CASE_INSENSITIVE + ); + private static final Pattern KHAREDST_COVER_PATTERN = Pattern.compile( + "On the inside of the cover a message is displayed in dark ink\\. It reads: ([0-9,]+)/.+? (?:memories|memory) remain\\.", + Pattern.CASE_INSENSITIVE + ); + private static final Pattern KHAREDST_EMPTY_PATTERN = Pattern.compile( + "(?:(?:Kharedst's Memoirs)|(?:The Book of the Dead)) holds no charges\\.", + Pattern.CASE_INSENSITIVE + ); + + private static final Pattern QUETZAL_CHECK_PATTERN = Pattern.compile( + "Your quetzal whistle has ([0-9,]+) charges? remaining\\.", + Pattern.CASE_INSENSITIVE + ); + + private static final Pattern MAGIC_CAPE_CHECK_PATTERN = Pattern.compile( + "You have ([0-9,]+) spellbook swaps left today\\.", + Pattern.CASE_INSENSITIVE + ); + + private static final Pattern FALADOR_SHIELD_CHECK_PATTERN = Pattern.compile( + "You have (one|two|\\d+) remaining charges? for today\\.", + Pattern.CASE_INSENSITIVE + ); + + // 8. Bags & Sacks + private static final Pattern COAL_BAG_AMOUNT_PATTERN = Pattern.compile("The coal bag contains ([0-9]+) pieces? of coal\\.", Pattern.CASE_INSENSITIVE); + private static final Pattern COAL_BAG_ONE_PATTERN = Pattern.compile("The coal bag contains one piece of coal\\.", Pattern.CASE_INSENSITIVE); + private static final Pattern COAL_BAG_EMPTY_PATTERN = Pattern.compile("(The coal bag is empty\\.|You empty (all of )?the coal from the coal bag\\.|The coal bag is now empty\\.|You empty the coal bag\\.)", Pattern.CASE_INSENSITIVE); + private static final Pattern COAL_BAG_FULL_PATTERN = Pattern.compile("(The coal bag is now full\\.|The coal bag cannot hold any more coal\\.)", Pattern.CASE_INSENSITIVE); + private static final Pattern COAL_BAG_FILL_AMOUNT_PATTERN = Pattern.compile("You fill the coal bag with ([0-9]+) pieces? of coal\\.", Pattern.CASE_INSENSITIVE); + + private static final Pattern GEM_BAG_AMOUNT_PATTERN = Pattern.compile( + "The gem bag contains ([0-9]+) sapphires?, ([0-9]+) emeralds?, ([0-9]+) rub(?:y|ies), ([0-9]+) diamonds?, ([0-9]+) dragonstones?\\.", + Pattern.CASE_INSENSITIVE + ); + private static final Pattern GEM_BAG_TOTAL_PATTERN = Pattern.compile( + "The gem bag contains (\\d+) uncut gems?\\.", + Pattern.CASE_INSENSITIVE + ); + private static final Pattern GEM_BAG_EMPTY_PATTERN = Pattern.compile( + "(The gem bag is empty\\.|You empty (all of )?the gems from the gem bag\\.)", + Pattern.CASE_INSENSITIVE + ); + + private static final Pattern HERB_SACK_AMOUNT_PATTERN = Pattern.compile( + "The herb sack contains ([0-9]+) grimy herbs?\\.", + Pattern.CASE_INSENSITIVE + ); + private static final Pattern HERB_SACK_EMPTY_PATTERN = Pattern.compile( + "(The herb sack is empty\\.|You empty (all of )?the herbs from the herb sack\\.)", + Pattern.CASE_INSENSITIVE + ); + + private static final Pattern FISH_BARREL_AMOUNT_PATTERN = Pattern.compile( + "The fish barrel contains ([0-9]+) (?:raw )?fish\\.", + Pattern.CASE_INSENSITIVE + ); + private static final Pattern FISH_BARREL_EMPTY_PATTERN = Pattern.compile( + "(The fish barrel is empty\\.|You empty (all of )?the fish from the fish barrel\\.)", + Pattern.CASE_INSENSITIVE + ); + + private static final Pattern PLANK_SACK_AMOUNT_PATTERN = Pattern.compile( + "The plank sack contains ([0-9]+) planks?\\.", + Pattern.CASE_INSENSITIVE + ); + private static final Pattern PLANK_SACK_EMPTY_PATTERN = Pattern.compile( + "(The plank sack is empty\\.|You empty (all of )?the planks from the plank sack\\.)", + Pattern.CASE_INSENSITIVE + ); + + private static final Pattern SEED_BOX_AMOUNT_PATTERN = Pattern.compile( + "The seed box contains (\\d+) seeds?\\.", + Pattern.CASE_INSENSITIVE + ); + private static final Pattern SEED_BOX_EMPTY_PATTERN = Pattern.compile( + "The seed box is empty\\.", + Pattern.CASE_INSENSITIVE + ); + + private static final Pattern MEAT_POUCH_AMOUNT_PATTERN = Pattern.compile( + "Your meat pouch is currently holding (\\d+) meat", + Pattern.CASE_INSENSITIVE + ); + private static final Pattern MEAT_POUCH_EMPTY_PATTERN = Pattern.compile( + "(?:Your meat pouch is empty\\.|Your meat pouch is currently holding 0 meat)", + Pattern.CASE_INSENSITIVE + ); + + private static final Pattern FUR_POUCH_AMOUNT_PATTERN = Pattern.compile( + "Your fur pouch is currently holding (\\d+) fur", + Pattern.CASE_INSENSITIVE + ); + private static final Pattern FUR_POUCH_EMPTY_PATTERN = Pattern.compile( + "(?:Your fur pouch is empty\\.|Your fur pouch is currently holding 0 fur)", + Pattern.CASE_INSENSITIVE + ); + + private static final Pattern LOG_BASKET_AMOUNT_PATTERN = Pattern.compile( + "The log basket contains ([0-9]+) logs?\\.", + Pattern.CASE_INSENSITIVE + ); + + private final Client client; + private final EssentialsPlusConfig config; + private final EssentialsPlusPlugin plugin; + private final ItemManager itemManager; + private final ConfigManager configManager; + + // In-memory caches + private Integer cachedBlowpipeDarts = null; + private String cachedBlowpipeDartType = null; + private Integer cachedBlowpipeScales = null; + + private final Map cachedBarrows = new HashMap<>(); + private final Map cachedMoons = new HashMap<>(); + private final Map cachedCounts = new HashMap<>(); + + private final Map barrowsCombatTicks = new HashMap<>(); + private final Map moonsCombatTicks = new HashMap<>(); + + private int lastIronOreCount = -1; + private int lastCoalCount = -1; + private boolean pendingCoalFill = false; + private int pendingCoalFillTick = 0; + private boolean pendingCoalEmpty = false; + private int pendingCoalEmptyTick = 0; + private long cachedLootingBagCoins = 0; + private boolean lootingBagKnown = false; + private String lastAccountKey = null; + + private boolean isSmithingOrMaxCape(int itemId) + { + if (itemId <= 0 || itemManager == null) + { + return false; + } + net.runelite.api.ItemComposition comp = itemManager.getItemComposition(itemId); + if (comp == null || comp.getName() == null) + { + return false; + } + String name = comp.getName().toLowerCase(java.util.Locale.ENGLISH); + return name.contains("smithing cape") || name.equals("max cape"); + } + + public int getCoalBagCapacity() + { + ItemContainer worn = client.getItemContainer(InventoryID.WORN); + if (worn != null) + { + Item cape = worn.getItem(EquipmentInventorySlot.CAPE.getSlotIdx()); + if (cape != null && isSmithingOrMaxCape(cape.getId())) + { + return 36; + } + } + ItemContainer inv = client.getItemContainer(InventoryID.INV); + if (inv != null) + { + for (Item item : inv.getItems()) + { + if (item != null && isSmithingOrMaxCape(item.getId())) + { + return 36; + } + } + } + return 27; + } + + @Inject + public ItemCharges(Client client, EssentialsPlusConfig config, EssentialsPlusPlugin plugin, ItemManager itemManager, ConfigManager configManager) + { + this.client = client; + this.config = config; + this.plugin = plugin; + this.itemManager = itemManager; + this.configManager = configManager; + } + + public void reset() + { + saveAccountData(); + cachedBlowpipeDarts = null; + cachedBlowpipeDartType = null; + cachedBlowpipeScales = null; + cachedBarrows.clear(); + cachedMoons.clear(); + cachedCounts.clear(); + barrowsCombatTicks.clear(); + moonsCombatTicks.clear(); + lastIronOreCount = -1; + lastCoalCount = -1; + cachedLootingBagCoins = 0; + lootingBagKnown = false; + lastAccountKey = null; + } + + public void resetCoalBag() + { + cachedCounts.remove("coal_bag"); + String key = currentAccountKey(); + if (key != null && configManager != null) + { + configManager.unsetConfiguration(EssentialsPlusConfig.GROUP, CONFIG_PREFIX + "coal_bag." + key); + configManager.unsetConfiguration(EssentialsPlusConfig.GROUP, "coalBag." + key); + } + } + + public void resetAllTracked() + { + cachedBlowpipeDarts = null; + cachedBlowpipeDartType = null; + cachedBlowpipeScales = null; + cachedBarrows.clear(); + cachedMoons.clear(); + cachedCounts.clear(); + barrowsCombatTicks.clear(); + moonsCombatTicks.clear(); + + String currentKey = currentAccountKey(); + if (currentKey != null && configManager != null) + { + configManager.unsetConfiguration(EssentialsPlusConfig.GROUP, CONFIG_PREFIX + "blowpipe_darts." + currentKey); + configManager.unsetConfiguration(EssentialsPlusConfig.GROUP, CONFIG_PREFIX + "blowpipe_type." + currentKey); + configManager.unsetConfiguration(EssentialsPlusConfig.GROUP, CONFIG_PREFIX + "blowpipe_scales." + currentKey); + + for (BarrowsPiece piece : BarrowsPiece.values()) + { + configManager.unsetConfiguration(EssentialsPlusConfig.GROUP, CONFIG_PREFIX + "barrows_" + piece.getConfigKey() + "." + currentKey); + } + + for (MoonPiece piece : MoonPiece.values()) + { + configManager.unsetConfiguration(EssentialsPlusConfig.GROUP, CONFIG_PREFIX + "moons_" + piece.getConfigKey() + "." + currentKey); + } + + String[] keys = { + "coal_bag", "gem_bag", "herb_sack", "fish_barrel", "plank_sack", "seed_box", + "abyssal_tentacle", "scythe_of_vitur", "ibans_staff", "slayers_staff_e", "infernal_tools", "skull_sceptre", + "crystal_saw", "jar_generator", "blood_essence", "dodgy_necklace", "ring_of_forging", "ring_of_recoil", + "binding_necklace", "amulet_of_chemistry", "amulet_of_bounty", "bracelet_of_slaughter", "expeditious_bracelet", + "bracelet_of_clay", "flamtaer_bracelet", "inoculation_bracelet", "ring_of_endurance", "strange_old_lockpick", "chronicle", + "dragonfire_shield", "dragonfire_ward", "ancient_wyvern_shield", "bottomless_compost_bucket", "bottomless_milk_bucket", + "gricollers_can", "colossal_pouch", "essence_pouch_medium", "essence_pouch_large", "essence_pouch_giant", + "meat_pouch", "fur_pouch", "flamtaer_bag", "log_basket", "quetzal_whistle", "chugging_barrel", + "falador_shield", "kharedsts_memoirs", "ardougne_cloak", "explorers_ring", "fremennik_sea_boots", + "kandarin_headgear", "morytania_legs", "western_banner", "magic_cape" + }; + for (String k : keys) + { + configManager.unsetConfiguration(EssentialsPlusConfig.GROUP, CONFIG_PREFIX + k + "." + currentKey); + } + configManager.unsetConfiguration(EssentialsPlusConfig.GROUP, "coalBag." + currentKey); + } + } + + public void decrementCount(String key, int amount) + { + loadAccountDataIfNecessary(); + Integer val = cachedCounts.get(key); + if (val != null && val > 0) + { + cachedCounts.put(key, Math.max(0, val - amount)); + saveAccountData(); + } + } + + private String currentAccountKey() + { + long hash = client.getAccountHash(); + if (hash == -1) + { + return null; + } + RuneScapeProfileType profile = client.getWorldType() != null ? RuneScapeProfileType.getCurrent(client) : RuneScapeProfileType.STANDARD; + return EssentialsPlusPlugin.accountKey(hash, profile); + } + + private void loadAccountDataIfNecessary() + { + String currentKey = currentAccountKey(); + if (currentKey == null) + { + return; + } + + if (!currentKey.equals(lastAccountKey)) + { + lastAccountKey = currentKey; + cachedBarrows.clear(); + cachedMoons.clear(); + cachedCounts.clear(); + + if (configManager != null) + { + // 1. Blowpipe + String dartsStr = configManager.getConfiguration(EssentialsPlusConfig.GROUP, CONFIG_PREFIX + "blowpipe_darts." + currentKey); + cachedBlowpipeDarts = parseIntOrNull(dartsStr); + cachedBlowpipeDartType = configManager.getConfiguration(EssentialsPlusConfig.GROUP, CONFIG_PREFIX + "blowpipe_type." + currentKey); + String scalesStr = configManager.getConfiguration(EssentialsPlusConfig.GROUP, CONFIG_PREFIX + "blowpipe_scales." + currentKey); + cachedBlowpipeScales = parseIntOrNull(scalesStr); + + // 2. Barrows + for (BarrowsPiece piece : BarrowsPiece.values()) + { + String val = configManager.getConfiguration(EssentialsPlusConfig.GROUP, CONFIG_PREFIX + "barrows_" + piece.getConfigKey() + "." + currentKey); + Integer parsed = parseIntOrNull(val); + if (parsed != null) + { + cachedBarrows.put(piece, parsed); + } + } + + // 3. Moons + for (MoonPiece piece : MoonPiece.values()) + { + String val = configManager.getConfiguration(EssentialsPlusConfig.GROUP, CONFIG_PREFIX + "moons_" + piece.getConfigKey() + "." + currentKey); + Integer parsed = parseIntOrNull(val); + if (parsed != null) + { + cachedMoons.put(piece, parsed); + } + } + + // 4. Counts & Bags + String[] keys = { + "coal_bag", "gem_bag", "herb_sack", "fish_barrel", "plank_sack", "seed_box", + "abyssal_tentacle", "scythe_of_vitur", "ibans_staff", "slayers_staff_e", "infernal_tools", "skull_sceptre", + "crystal_saw", "jar_generator", "blood_essence", "dodgy_necklace", "ring_of_forging", "ring_of_recoil", + "binding_necklace", "amulet_of_chemistry", "amulet_of_bounty", "bracelet_of_slaughter", "expeditious_bracelet", + "bracelet_of_clay", "flamtaer_bracelet", "inoculation_bracelet", "ring_of_endurance", "strange_old_lockpick", "chronicle", + "dragonfire_shield", "dragonfire_ward", "ancient_wyvern_shield", "bottomless_compost_bucket", "bottomless_milk_bucket", + "gricollers_can", "colossal_pouch", "essence_pouch_medium", "essence_pouch_large", "essence_pouch_giant", + "meat_pouch", "fur_pouch", "flamtaer_bag", "log_basket", "quetzal_whistle", "chugging_barrel", + "falador_shield", "kharedsts_memoirs", "ardougne_cloak", "explorers_ring", "fremennik_sea_boots", + "kandarin_headgear", "morytania_legs", "western_banner", "magic_cape" + }; + for (String k : keys) + { + String val = configManager.getConfiguration(EssentialsPlusConfig.GROUP, CONFIG_PREFIX + k + "." + currentKey); + Integer parsed = parseIntOrNull(val); + if (parsed != null) + { + cachedCounts.put(k, parsed); + } + } + + // Backward compatibility: check old coal bag prefix + if (!cachedCounts.containsKey("coal_bag")) + { + String oldCoal = configManager.getConfiguration(EssentialsPlusConfig.GROUP, "coalBag." + currentKey); + Integer parsedCoal = parseIntOrNull(oldCoal); + if (parsedCoal != null) + { + cachedCounts.put("coal_bag", parsedCoal); + } + } + } + } + } + + private void saveAccountData() + { + String currentKey = lastAccountKey != null ? lastAccountKey : currentAccountKey(); + if (currentKey == null || configManager == null) + { + return; + } + + // Blowpipe + setOrUnset(CONFIG_PREFIX + "blowpipe_darts." + currentKey, cachedBlowpipeDarts); + setOrUnsetString(CONFIG_PREFIX + "blowpipe_type." + currentKey, cachedBlowpipeDartType); + setOrUnset(CONFIG_PREFIX + "blowpipe_scales." + currentKey, cachedBlowpipeScales); + + // Barrows + for (Map.Entry entry : cachedBarrows.entrySet()) + { + setOrUnset(CONFIG_PREFIX + "barrows_" + entry.getKey().getConfigKey() + "." + currentKey, entry.getValue()); + } + + // Moons + for (Map.Entry entry : cachedMoons.entrySet()) + { + setOrUnset(CONFIG_PREFIX + "moons_" + entry.getKey().getConfigKey() + "." + currentKey, entry.getValue()); + } + + // Counts + for (Map.Entry entry : cachedCounts.entrySet()) + { + setOrUnset(CONFIG_PREFIX + entry.getKey() + "." + currentKey, entry.getValue()); + } + } + + private void setOrUnset(String fullKey, Integer value) + { + if (configManager == null) return; + if (value != null) + { + configManager.setConfiguration(EssentialsPlusConfig.GROUP, fullKey, value); + } + else + { + configManager.unsetConfiguration(EssentialsPlusConfig.GROUP, fullKey); + } + } + + private void setOrUnsetString(String fullKey, String value) + { + if (configManager == null) return; + if (value != null && !value.isEmpty()) + { + configManager.setConfiguration(EssentialsPlusConfig.GROUP, fullKey, value); + } + else + { + configManager.unsetConfiguration(EssentialsPlusConfig.GROUP, fullKey); + } + } + + private Integer parseIntOrNull(String str) + { + if (str == null) + { + return null; + } + try + { + return Integer.parseInt(str.replace(",", "").trim()); + } + catch (NumberFormatException e) + { + return null; + } + } + + private int parseWordOrInt(String str) + { + if (str == null) return 0; + String s = str.trim().toLowerCase(); + switch (s) + { + case "one": case "a single": return 1; + case "two": case "both": return 2; + case "three": return 3; + case "four": return 4; + case "five": return 5; + case "six": return 6; + case "seven": return 7; + case "eight": return 8; + case "nine": return 9; + case "ten": return 10; + case "eleven": return 11; + case "twelve": return 12; + case "thirteen": return 13; + case "fourteen": return 14; + case "fifteen": return 15; + case "sixteen": return 16; + case "seventeen": return 17; + case "eighteen": return 18; + case "nineteen": return 19; + case "twenty": return 20; + case "twenty-five": return 25; + case "thirty": return 30; + case "thirty-five": return 35; + case "forty": return 40; + default: + Integer parsed = parseIntOrNull(s); + return parsed != null ? parsed : 0; + } + } + + @Subscribe + public void onGameTick(GameTick event) + { + loadAccountDataIfNecessary(); + updateLootingBag(); + + // Combat tick degradation for Barrows (~900 ticks = 1%) and Moons (~90 ticks = 1 charge) + Player me = client.getLocalPlayer(); + if (me != null && me.getInteracting() != null) + { + ItemContainer worn = client.getItemContainer(InventoryID.WORN); + if (worn != null) + { + for (EquipmentInventorySlot slot : new EquipmentInventorySlot[]{ + EquipmentInventorySlot.HEAD, EquipmentInventorySlot.BODY, + EquipmentInventorySlot.LEGS, EquipmentInventorySlot.WEAPON}) + { + Item item = worn.getItem(slot.getSlotIdx()); + if (item != null) + { + ItemChargeDef def = CHARGE_MAP.get(item.getId()); + if (def != null) + { + if (def.type == SourceType.BARROWS && def.barrowsPiece != null && def.barrowsTier > 0) + { + int ticks = barrowsCombatTicks.getOrDefault(def.barrowsPiece, 0) + 1; + if (ticks >= 900) + { + ticks = 0; + Integer cur = cachedBarrows.get(def.barrowsPiece); + if (cur != null && cur > 0) + { + cachedBarrows.put(def.barrowsPiece, cur - 1); + saveAccountData(); + } + } + barrowsCombatTicks.put(def.barrowsPiece, ticks); + } + else if (def.type == SourceType.MOONS && def.moonPiece != null && def.moonTier != 0) + { + int ticks = moonsCombatTicks.getOrDefault(def.moonPiece, 0) + 1; + if (ticks >= 90) + { + ticks = 0; + Integer cur = cachedMoons.get(def.moonPiece); + if (cur != null && cur > 0) + { + cachedMoons.put(def.moonPiece, cur - 1); + saveAccountData(); + } + } + moonsCombatTicks.put(def.moonPiece, ticks); + } + } + } + } + } + } + } + + @Subscribe + public void onAnimationChanged(AnimationChanged event) + { + if (!config.itemCharges()) + { + return; + } + + Player me = client.getLocalPlayer(); + if (event.getActor() != me || me == null) + { + return; + } + + int anim = me.getAnimation(); + if (anim == -1) + { + return; + } + + ItemContainer worn = client.getItemContainer(InventoryID.WORN); + if (worn == null) + { + return; + } + + Item weapon = worn.getItem(EquipmentInventorySlot.WEAPON.getSlotIdx()); + if (weapon == null) + { + return; + } + + int weaponId = weapon.getId(); + + // 1. Abyssal Tentacle (whip slash anim 1658) + if ((weaponId == ItemID.ABYSSAL_TENTACLE || weaponId == ItemID.LEAGUE_3_WHIP_TENTACLE) && anim == 1658) + { + decrementCount("abyssal_tentacle", 1); + } + // 2. Scythe of Vitur (scythe slash anim 8056, 1203) + else if ((weaponId == ItemID.SCYTHE_OF_VITUR || weaponId == ItemID.SCYTHE_OF_VITUR_OR + || weaponId == ItemID.SCYTHE_OF_VITUR_BL || weaponId == ItemID.DEADMAN_BLIGHTED_SCYTHE_OF_VITUR) + && (anim == 8056 || anim == 1203)) + { + decrementCount("scythe_of_vitur", 1); + } + // 3. Iban's Staff (cast anim 708, 710, 711) + else if ((weaponId == ItemID.IBANSTAFF || weaponId == ItemID.IBANSTAFF_UPGRADED + || weaponId == ItemID.IBAN_STAFF_ORN || weaponId == ItemID.IBAN_STAFF_UPGRADED_ORN) + && (anim == 708 || anim == 710 || anim == 711)) + { + decrementCount("ibans_staff", 1); + } + // 4. Toxic Blowpipe (attack anim 5061, 10023) + else if (CHARGE_MAP.containsKey(weaponId) && CHARGE_MAP.get(weaponId).type == SourceType.BLOWPIPE + && (anim == 5061 || anim == 10023)) + { + loadAccountDataIfNecessary(); + if (cachedBlowpipeDarts != null && cachedBlowpipeDarts > 0) + { + Item cape = worn.getItem(EquipmentInventorySlot.CAPE.getSlotIdx()); + int capeId = cape != null ? cape.getId() : -1; + boolean hasAssembler = false; + boolean hasAccumulator = false; + if (capeId > 0 && itemManager != null) + { + String name = itemManager.getItemComposition(capeId).getName().toLowerCase(); + hasAssembler = name.contains("assembler") || name.contains("dizana") || name.contains("quiver"); + hasAccumulator = name.contains("accumulator"); + } + + double consumeChance = hasAssembler ? 0.20 : (hasAccumulator ? 0.28 : 1.0); + if (java.util.concurrent.ThreadLocalRandom.current().nextDouble() < consumeChance) + { + cachedBlowpipeDarts = Math.max(0, cachedBlowpipeDarts - 1); + saveAccountData(); + } + } + } + } + + @Subscribe + public void onMenuOptionClicked(MenuOptionClicked event) + { + if (!config.itemCharges()) + { + return; + } + + int itemId = event.getItemId(); + String option = event.getMenuOption(); + if (option == null) + { + return; + } + + if (itemId == ItemID.COAL_BAG || itemId == ItemID.COAL_BAG_OPEN) + { + if (option.equalsIgnoreCase("Fill")) + { + pendingCoalFill = true; + pendingCoalFillTick = client.getTickCount(); + } + else if (option.equalsIgnoreCase("Empty")) + { + pendingCoalEmpty = true; + pendingCoalEmptyTick = client.getTickCount(); + } + } + } + + @Subscribe + public void onItemContainerChanged(ItemContainerChanged event) + { + if (!config.itemCharges()) + { + return; + } + + if (event.getContainerId() == InventoryID.WORN) + { + checkWornDegradedTiers(event.getItemContainer()); + } + else if (event.getContainerId() == InventoryID.INV) + { + ItemContainer inv = event.getItemContainer(); + int ironOre = 0; + int coal = 0; + for (Item item : inv.getItems()) + { + if (item != null) + { + if (item.getId() == ItemID.IRON_ORE) ironOre += item.getQuantity(); + else if (item.getId() == ItemID.COAL) coal += item.getQuantity(); + } + } + + // 1. Ring of Forging check + if (lastIronOreCount != -1 && ironOre < lastIronOreCount) + { + ItemContainer worn = client.getItemContainer(InventoryID.WORN); + if (worn != null) + { + Item ring = worn.getItem(EquipmentInventorySlot.RING.getSlotIdx()); + if (ring != null && ring.getId() == ItemID.RING_OF_FORGING) + { + int smelted = lastIronOreCount - ironOre; + decrementCount("ring_of_forging", smelted); + } + } + } + lastIronOreCount = ironOre; + + // 2. Coal Bag tracking: ONLY update when a Fill or Empty action was triggered on the bag + int currentTick = client.getTickCount(); + if (lastCoalCount != -1 && coal != lastCoalCount) + { + loadAccountDataIfNecessary(); + Integer bagCoal = cachedCounts.get("coal_bag"); + if (bagCoal == null) + { + bagCoal = 0; + } + + if (pendingCoalFill && currentTick - pendingCoalFillTick <= 2) + { + int diff = lastCoalCount - coal; // Coal lost from inventory into bag + if (diff > 0) + { + int cap = getCoalBagCapacity(); + int add = Math.min(diff, cap - bagCoal); + cachedCounts.put("coal_bag", bagCoal + add); + saveAccountData(); + } + pendingCoalFill = false; + } + else if (pendingCoalEmpty && currentTick - pendingCoalEmptyTick <= 2) + { + int diff = coal - lastCoalCount; // Coal gained into inventory from bag + if (diff > 0) + { + int rem = Math.min(diff, bagCoal); + cachedCounts.put("coal_bag", Math.max(0, bagCoal - rem)); + saveAccountData(); + } + pendingCoalEmpty = false; + } + } + lastCoalCount = coal; + } + } + + private void checkWornDegradedTiers(ItemContainer worn) + { + if (worn == null) return; + for (EquipmentInventorySlot slot : new EquipmentInventorySlot[]{ + EquipmentInventorySlot.HEAD, EquipmentInventorySlot.BODY, + EquipmentInventorySlot.LEGS, EquipmentInventorySlot.WEAPON}) + { + Item item = worn.getItem(slot.getSlotIdx()); + if (item != null) + { + ItemChargeDef def = CHARGE_MAP.get(item.getId()); + if (def != null) + { + if (def.type == SourceType.BARROWS && def.barrowsPiece != null) + { + Integer cached = cachedBarrows.get(def.barrowsPiece); + if (def.barrowsTier == 0) + { + cachedBarrows.put(def.barrowsPiece, 0); + saveAccountData(); + } + else if (cached != null && cached > def.barrowsTier) + { + cachedBarrows.put(def.barrowsPiece, def.barrowsTier); + saveAccountData(); + } + } + else if (def.type == SourceType.MOONS && def.moonPiece != null) + { + if (def.moonTier == 0) + { + cachedMoons.put(def.moonPiece, 0); + saveAccountData(); + } + } + } + } + } + } + + private void updateLootingBag() + { + ItemContainer container = client.getItemContainer(InventoryID.LOOTING_BAG); + if (container != null) + { + lootingBagKnown = true; + long coins = 0; + for (Item item : container.getItems()) + { + if (item != null && item.getId() == ItemID.COINS) + { + coins += item.getQuantity(); + } + } + cachedLootingBagCoins = coins; + } + } + + @Subscribe + public void onGameStateChanged(GameStateChanged event) + { + if (event.getGameState() != GameState.LOGGED_IN) + { + reset(); + } + } + + @Subscribe + public void onChatMessage(ChatMessage event) + { + if (event.getType() != ChatMessageType.GAMEMESSAGE && event.getType() != ChatMessageType.SPAM && event.getType() != ChatMessageType.MESBOX) + { + return; + } + + String message = Text.removeTags(event.getMessage()).replace('’', '\'').replace('‘', '\'').trim(); + loadAccountDataIfNecessary(); + + // 1. Toxic Blowpipe + Matcher mBpCheck = BLOWPIPE_CHECK_PATTERN.matcher(message); + if (mBpCheck.find()) + { + cachedBlowpipeDartType = mBpCheck.group("dart").trim(); + cachedBlowpipeDarts = parseIntOrNull(mBpCheck.group("darts")); + cachedBlowpipeScales = parseIntOrNull(mBpCheck.group("scales")); + saveAccountData(); + return; + } + + Matcher mBpNoDarts = BLOWPIPE_CHECK_NO_DARTS_PATTERN.matcher(message); + if (mBpNoDarts.find()) + { + cachedBlowpipeDartType = null; + cachedBlowpipeDarts = 0; + cachedBlowpipeScales = parseIntOrNull(mBpNoDarts.group("scales")); + saveAccountData(); + return; + } + + Matcher mBpNoScales = BLOWPIPE_CHECK_NO_SCALES_PATTERN.matcher(message); + if (mBpNoScales.find()) + { + cachedBlowpipeDartType = mBpNoScales.group("dart").trim(); + cachedBlowpipeDarts = parseIntOrNull(mBpNoScales.group("darts")); + cachedBlowpipeScales = 0; + saveAccountData(); + return; + } + + if (BLOWPIPE_CHECK_EMPTY_PATTERN.matcher(message).find() || BLOWPIPE_UNCHARGE_PATTERN.matcher(message).find() || BLOWPIPE_OUT_OF_CHARGES_PATTERN.matcher(message).find()) + { + cachedBlowpipeDartType = null; + cachedBlowpipeDarts = 0; + cachedBlowpipeScales = 0; + saveAccountData(); + return; + } + + Matcher mBpLoadDarts = BLOWPIPE_LOAD_DARTS_PATTERN.matcher(message); + if (mBpLoadDarts.find()) + { + int count = parseIntOrNull(mBpLoadDarts.group(1)); + String type = mBpLoadDarts.group(2).trim(); + cachedBlowpipeDartType = type; + cachedBlowpipeDarts = (cachedBlowpipeDarts != null ? cachedBlowpipeDarts : 0) + count; + saveAccountData(); + return; + } + + Matcher mBpAddScales = BLOWPIPE_ADD_SCALES_PATTERN.matcher(message); + if (mBpAddScales.find()) + { + int count = parseIntOrNull(mBpAddScales.group(1)); + cachedBlowpipeScales = (cachedBlowpipeScales != null ? cachedBlowpipeScales : 0) + count; + saveAccountData(); + return; + } + + if (BLOWPIPE_OUT_OF_DARTS_PATTERN.matcher(message).find()) + { + cachedBlowpipeDarts = 0; + saveAccountData(); + return; + } + + if (BLOWPIPE_OUT_OF_SCALES_PATTERN.matcher(message).find()) + { + cachedBlowpipeScales = 0; + saveAccountData(); + return; + } + + // 2. Moons of Peril check + Matcher mMoons = MOONS_CHECK_PATTERN.matcher(message); + if (mMoons.find()) + { + String itemName = mMoons.group("item"); + int charges = parseIntOrNull(mMoons.group("charges")); + MoonPiece piece = matchMoonPiece(itemName); + if (piece != null) + { + cachedMoons.put(piece, charges); + saveAccountData(); + return; + } + } + + // 3. Barrows & Moons colon check + Matcher mBarrowsColon = BARROWS_COLON_PATTERN.matcher(message); + if (mBarrowsColon.find()) + { + String itemName = mBarrowsColon.group("item"); + int pct = Integer.parseInt(mBarrowsColon.group("pct")); + BarrowsPiece piece = matchBarrowsPiece(itemName); + if (piece != null) + { + cachedBarrows.put(piece, pct); + saveAccountData(); + return; + } + MoonPiece moonPiece = matchMoonPiece(itemName); + if (moonPiece != null) + { + cachedMoons.put(moonPiece, (pct * 3000) / 100); + saveAccountData(); + return; + } + } + + Matcher mBarrowsColonDeg = BARROWS_COLON_DEGRADED_PATTERN.matcher(message); + if (mBarrowsColonDeg.find()) + { + String itemName = mBarrowsColonDeg.group("item"); + BarrowsPiece piece = matchBarrowsPiece(itemName); + if (piece != null) + { + cachedBarrows.put(piece, 0); + saveAccountData(); + return; + } + MoonPiece moonPiece = matchMoonPiece(itemName); + if (moonPiece != null) + { + cachedMoons.put(moonPiece, 0); + saveAccountData(); + return; + } + } + + if (BARROWS_REPAIRED_ALL_PATTERN.matcher(message).find()) + { + for (BarrowsPiece piece : BarrowsPiece.values()) + { + cachedBarrows.put(piece, 100); + } + for (MoonPiece piece : MoonPiece.values()) + { + cachedMoons.put(piece, 3000); + } + saveAccountData(); + return; + } + + Matcher mBarrowsRepaired = BARROWS_REPAIRED_SINGLE_PATTERN.matcher(message); + if (mBarrowsRepaired.find()) + { + String itemName = mBarrowsRepaired.group("item"); + BarrowsPiece piece = matchBarrowsPiece(itemName); + if (piece != null) + { + cachedBarrows.put(piece, 100); + saveAccountData(); + return; + } + MoonPiece moonPiece = matchMoonPiece(itemName); + if (moonPiece != null) + { + cachedMoons.put(moonPiece, 3000); + saveAccountData(); + return; + } + } + + Matcher mBarrowsPct = BARROWS_CHECK_PERCENT_PATTERN.matcher(message); + if (mBarrowsPct.find()) + { + String itemName = mBarrowsPct.group(1); + int pct = Integer.parseInt(mBarrowsPct.group(2)); + BarrowsPiece piece = matchBarrowsPiece(itemName); + if (piece != null) + { + cachedBarrows.put(piece, pct); + saveAccountData(); + return; + } + } + + Matcher mBarrowsNum = BARROWS_CHECK_NUMERIC_PATTERN.matcher(message); + if (mBarrowsNum.find()) + { + String itemName = mBarrowsNum.group(1); + Integer count = parseIntOrNull(mBarrowsNum.group(2)); + if (count != null) + { + int pct = (count <= 100) ? count : Math.max(0, Math.min(100, (int) Math.round((count * 100.0) / 100000.0))); + BarrowsPiece piece = matchBarrowsPiece(itemName); + if (piece != null) + { + cachedBarrows.put(piece, pct); + saveAccountData(); + return; + } + } + } + + Matcher mBarrowsDeg = BARROWS_DEGRADED_PATTERN.matcher(message); + if (mBarrowsDeg.find()) + { + String itemName = mBarrowsDeg.group("item"); + BarrowsPiece piece = matchBarrowsPiece(itemName); + if (piece != null) + { + cachedBarrows.put(piece, 0); + saveAccountData(); + return; + } + MoonPiece moonPiece = matchMoonPiece(itemName); + if (moonPiece != null) + { + cachedMoons.put(moonPiece, 0); + saveAccountData(); + return; + } + } + + // 4. Dragonfire Shields + Matcher mDf = DRAGONFIRE_CHECK_PATTERN.matcher(message); + if (mDf.find()) + { + int charges = parseIntOrNull(mDf.group("charges")); + cachedCounts.put("dragonfire_shield", charges); + cachedCounts.put("dragonfire_ward", charges); + cachedCounts.put("ancient_wyvern_shield", charges); + saveAccountData(); + return; + } + if (DRAGONFIRE_INC_PATTERN.matcher(message).find()) + { + Integer cur = cachedCounts.get("dragonfire_shield"); + if (cur != null) + { + cachedCounts.put("dragonfire_shield", Math.min(50, cur + 1)); + cachedCounts.put("dragonfire_ward", Math.min(50, cur + 1)); + saveAccountData(); + } + return; + } + if (DRAGONFIRE_VENT_PATTERN.matcher(message).find() || DRAGONFIRE_EMPTY_PATTERN.matcher(message).find()) + { + cachedCounts.put("dragonfire_shield", 0); + cachedCounts.put("dragonfire_ward", 0); + cachedCounts.put("ancient_wyvern_shield", 0); + saveAccountData(); + return; + } + if (DRAGONFIRE_FULL_PATTERN.matcher(message).find()) + { + cachedCounts.put("dragonfire_shield", 50); + cachedCounts.put("dragonfire_ward", 50); + cachedCounts.put("ancient_wyvern_shield", 50); + saveAccountData(); + return; + } + + // 5. Abyssal Tentacle & Scythe & Iban's & Tools + Matcher mTent = TENTACLE_CHECK_PATTERN.matcher(message); + if (mTent.find()) + { + cachedCounts.put("abyssal_tentacle", parseIntOrNull(mTent.group(1))); + saveAccountData(); + return; + } + if (TENTACLE_DEGRADED_PATTERN.matcher(message).find()) + { + cachedCounts.put("abyssal_tentacle", 0); + saveAccountData(); + return; + } + + Matcher mScythe = SCYTHE_CHECK_PATTERN.matcher(message); + if (mScythe.find()) + { + cachedCounts.put("scythe_of_vitur", parseIntOrNull(mScythe.group(1))); + saveAccountData(); + return; + } + if (SCYTHE_EMPTY_PATTERN.matcher(message).find()) + { + cachedCounts.put("scythe_of_vitur", 0); + saveAccountData(); + return; + } + + Matcher mIban = IBANS_CHECK_PATTERN.matcher(message); + if (mIban.find()) + { + cachedCounts.put("ibans_staff", parseIntOrNull(mIban.group(1))); + saveAccountData(); + return; + } + if (IBANS_EMPTY_PATTERN.matcher(message).find()) + { + cachedCounts.put("ibans_staff", 0); + saveAccountData(); + return; + } + if (IBANS_RECHARGE_PATTERN.matcher(message).find()) + { + cachedCounts.put("ibans_staff", 2500); + saveAccountData(); + return; + } + + Matcher mSlayerStaff = SLAYER_STAFF_E_PATTERN.matcher(message); + if (mSlayerStaff.find()) + { + cachedCounts.put("slayers_staff_e", parseIntOrNull(mSlayerStaff.group(1))); + saveAccountData(); + return; + } + + Matcher mInfernal = INFERNAL_CHECK_PATTERN.matcher(message); + if (mInfernal.find()) + { + cachedCounts.put("infernal_tools", parseIntOrNull(mInfernal.group(1))); + saveAccountData(); + return; + } + + Matcher mSkull = SKULL_SCEPTRE_CHECK_PATTERN.matcher(message); + if (mSkull.find()) + { + cachedCounts.put("skull_sceptre", parseIntOrNull(mSkull.group(1))); + saveAccountData(); + return; + } + + Matcher mSaw = CRYSTAL_SAW_CHECK_PATTERN.matcher(message); + if (mSaw.find()) + { + cachedCounts.put("crystal_saw", parseIntOrNull(mSaw.group(1))); + saveAccountData(); + return; + } + + Matcher mJar = JAR_GENERATOR_CHECK_PATTERN.matcher(message); + if (mJar.find()) + { + cachedCounts.put("jar_generator", parseIntOrNull(mJar.group(1))); + saveAccountData(); + return; + } + + // 6. Jewelry + Matcher mDodgy = DODGY_CHECK_PATTERN.matcher(message); + if (mDodgy.find()) + { + cachedCounts.put("dodgy_necklace", parseIntOrNull(mDodgy.group(1))); + saveAccountData(); + return; + } + if (DODGY_BREAK_PATTERN.matcher(message).find()) + { + cachedCounts.put("dodgy_necklace", 10); + saveAccountData(); + return; + } + + Matcher mForging = FORGING_CHECK_PATTERN.matcher(message); + if (mForging.find()) + { + cachedCounts.put("ring_of_forging", parseWordOrInt(mForging.group(1))); + saveAccountData(); + return; + } + if (FORGING_BREAK_PATTERN.matcher(message).find()) + { + cachedCounts.put("ring_of_forging", 140); + saveAccountData(); + return; + } + + Matcher mRecoil = RECOIL_CHECK_PATTERN.matcher(message); + if (mRecoil.find()) + { + String grp = mRecoil.group(1); + cachedCounts.put("ring_of_recoil", grp == null ? 1 : parseIntOrNull(grp)); + saveAccountData(); + return; + } + if (RECOIL_BREAK_PATTERN.matcher(message).find()) + { + cachedCounts.put("ring_of_recoil", 40); + saveAccountData(); + return; + } + + Matcher mBinding = BINDING_CHECK_PATTERN.matcher(message); + if (mBinding.find()) + { + cachedCounts.put("binding_necklace", parseIntOrNull(mBinding.group(1))); + saveAccountData(); + return; + } + if (BINDING_BREAK_PATTERN.matcher(message).find()) + { + cachedCounts.put("binding_necklace", 16); + saveAccountData(); + return; + } + + Matcher mChem = CHEMISTRY_CHECK_PATTERN.matcher(message); + if (mChem.find()) + { + cachedCounts.put("amulet_of_chemistry", parseWordOrInt(mChem.group(1))); + saveAccountData(); + return; + } + if (CHEMISTRY_BREAK_PATTERN.matcher(message).find()) + { + cachedCounts.put("amulet_of_chemistry", 5); + saveAccountData(); + return; + } + + Matcher mBounty = BOUNTY_CHECK_PATTERN.matcher(message); + if (mBounty.find()) + { + cachedCounts.put("amulet_of_bounty", parseWordOrInt(mBounty.group(1))); + saveAccountData(); + return; + } + if (BOUNTY_BREAK_PATTERN.matcher(message).find()) + { + cachedCounts.put("amulet_of_bounty", 10); + saveAccountData(); + return; + } + + Matcher mSlaughter = SLAUGHTER_CHECK_PATTERN.matcher(message); + if (mSlaughter.find()) + { + cachedCounts.put("bracelet_of_slaughter", parseIntOrNull(mSlaughter.group(1))); + saveAccountData(); + return; + } + + Matcher mExpeditious = EXPEDITIOUS_CHECK_PATTERN.matcher(message); + if (mExpeditious.find()) + { + cachedCounts.put("expeditious_bracelet", parseIntOrNull(mExpeditious.group(1))); + saveAccountData(); + return; + } + + Matcher mClay = CLAY_CHECK_PATTERN.matcher(message); + if (mClay.find()) + { + cachedCounts.put("bracelet_of_clay", parseIntOrNull(mClay.group(1))); + saveAccountData(); + return; + } + + Matcher mEndurance = ENDURANCE_CHECK_PATTERN.matcher(message); + if (mEndurance.find()) + { + cachedCounts.put("ring_of_endurance", parseIntOrNull(mEndurance.group(1))); + saveAccountData(); + return; + } + + Matcher mEss = BLOOD_ESSENCE_CHECK_PATTERN.matcher(message); + if (mEss.find()) + { + cachedCounts.put("blood_essence", parseIntOrNull(mEss.group(1))); + saveAccountData(); + return; + } + if (BLOOD_ESSENCE_EMPTY_PATTERN.matcher(message).find()) + { + cachedCounts.put("blood_essence", 0); + saveAccountData(); + return; + } + + Matcher mLock = LOCKPICK_CHECK_PATTERN.matcher(message); + if (mLock.find()) + { + cachedCounts.put("strange_old_lockpick", parseIntOrNull(mLock.group(1))); + saveAccountData(); + return; + } + + Matcher mChron = CHRONICLE_CHECK_PATTERN.matcher(message); + if (mChron.find()) + { + cachedCounts.put("chronicle", parseIntOrNull(mChron.group(1))); + saveAccountData(); + return; + } + Matcher mChronAdd = CHRONICLE_ADD_PATTERN.matcher(message); + if (mChronAdd.find()) + { + cachedCounts.put("chronicle", parseWordOrInt(mChronAdd.group(1))); + saveAccountData(); + return; + } + + // 7. Buckets, Cans, Pouches & Diary + Matcher mCompHold = COMPOST_BUCKET_HOLDING_PATTERN.matcher(message); + if (mCompHold.find()) + { + cachedCounts.put("bottomless_compost_bucket", parseWordOrInt(mCompHold.group(1))); + saveAccountData(); + return; + } + Matcher mCompRem = COMPOST_BUCKET_REMAINING_PATTERN.matcher(message); + if (mCompRem.find()) + { + cachedCounts.put("bottomless_compost_bucket", parseWordOrInt(mCompRem.group(1))); + saveAccountData(); + return; + } + Matcher mCompFill = COMPOST_BUCKET_FILL_PATTERN.matcher(message); + if (mCompFill.find()) + { + cachedCounts.put("bottomless_compost_bucket", parseIntOrNull(mCompFill.group(1))); + saveAccountData(); + return; + } + if (COMPOST_BUCKET_TREAT_PATTERN.matcher(message).find()) + { + decrementCount("bottomless_compost_bucket", 1); + return; + } + if (COMPOST_BUCKET_EMPTY_PATTERN.matcher(message).find()) + { + cachedCounts.put("bottomless_compost_bucket", 0); + saveAccountData(); + return; + } + + Matcher mGricoller = GRICOLLERS_CAN_CHECK_PATTERN.matcher(message); + if (mGricoller.find()) + { + if (mGricoller.group(1) != null) + { + int pct = Integer.parseInt(mGricoller.group(1)); + cachedCounts.put("gricollers_can", pct * 10); + } + else if (mGricoller.group(2) != null) + { + int charges = parseIntOrNull(mGricoller.group(2)); + cachedCounts.put("gricollers_can", charges); + } + saveAccountData(); + return; + } + if (GRICOLLERS_CAN_FILL_PATTERN.matcher(message).find()) + { + cachedCounts.put("gricollers_can", 1000); + saveAccountData(); + return; + } + if (GRICOLLERS_CAN_WATER_PATTERN.matcher(message).find()) + { + decrementCount("gricollers_can", 1); + return; + } + + Matcher mPouch = ESSENCE_POUCH_CHECK_PATTERN.matcher(message); + if (mPouch.find()) + { + int amount = parseWordOrInt(mPouch.group(1)); + cachedCounts.put("colossal_pouch", amount); + saveAccountData(); + return; + } + if (ESSENCE_POUCH_EMPTY_PATTERN.matcher(message).find()) + { + cachedCounts.put("colossal_pouch", 0); + saveAccountData(); + return; + } + + Matcher mKharedst = KHAREDST_CHECK_PATTERN.matcher(message); + if (mKharedst.find()) + { + cachedCounts.put("kharedsts_memoirs", parseIntOrNull(mKharedst.group(1))); + saveAccountData(); + return; + } + Matcher mKharedstCover = KHAREDST_COVER_PATTERN.matcher(message); + if (mKharedstCover.find()) + { + cachedCounts.put("kharedsts_memoirs", parseIntOrNull(mKharedstCover.group(1))); + saveAccountData(); + return; + } + if (KHAREDST_EMPTY_PATTERN.matcher(message).find()) + { + cachedCounts.put("kharedsts_memoirs", 0); + saveAccountData(); + return; + } + + Matcher mQuetzal = QUETZAL_CHECK_PATTERN.matcher(message); + if (mQuetzal.find()) + { + cachedCounts.put("quetzal_whistle", parseIntOrNull(mQuetzal.group(1))); + saveAccountData(); + return; + } + + Matcher mMagicCape = MAGIC_CAPE_CHECK_PATTERN.matcher(message); + if (mMagicCape.find()) + { + cachedCounts.put("magic_cape", parseIntOrNull(mMagicCape.group(1))); + saveAccountData(); + return; + } + + Matcher mFalador = FALADOR_SHIELD_CHECK_PATTERN.matcher(message); + if (mFalador.find()) + { + cachedCounts.put("falador_shield", parseWordOrInt(mFalador.group(1))); + saveAccountData(); + return; + } + + // 8. Coal Bag & Gem Bag & Herb Sack & Fish Barrel & Plank Sack & Seed Box & Pouches + Matcher mCoal = COAL_BAG_AMOUNT_PATTERN.matcher(message); + if (mCoal.find()) + { + cachedCounts.put("coal_bag", parseIntOrNull(mCoal.group(1))); + saveAccountData(); + return; + } + if (COAL_BAG_ONE_PATTERN.matcher(message).find()) + { + cachedCounts.put("coal_bag", 1); + saveAccountData(); + return; + } + if (COAL_BAG_EMPTY_PATTERN.matcher(message).find()) + { + cachedCounts.put("coal_bag", 0); + saveAccountData(); + return; + } + if (COAL_BAG_FULL_PATTERN.matcher(message).find()) + { + cachedCounts.put("coal_bag", getCoalBagCapacity()); + saveAccountData(); + return; + } + Matcher mCoalFill = COAL_BAG_FILL_AMOUNT_PATTERN.matcher(message); + if (mCoalFill.find()) + { + int filled = parseIntOrNull(mCoalFill.group(1)); + int current = cachedCounts.getOrDefault("coal_bag", 0); + cachedCounts.put("coal_bag", Math.min(getCoalBagCapacity(), current + filled)); + saveAccountData(); + return; + } + + Matcher mGem = GEM_BAG_AMOUNT_PATTERN.matcher(message); + if (mGem.find()) + { + int s = parseIntOrNull(mGem.group(1)); + int e = parseIntOrNull(mGem.group(2)); + int r = parseIntOrNull(mGem.group(3)); + int d = parseIntOrNull(mGem.group(4)); + int ds = parseIntOrNull(mGem.group(5)); + cachedCounts.put("gem_bag", s + e + r + d + ds); + saveAccountData(); + return; + } + Matcher mGemTotal = GEM_BAG_TOTAL_PATTERN.matcher(message); + if (mGemTotal.find()) + { + cachedCounts.put("gem_bag", parseIntOrNull(mGemTotal.group(1))); + saveAccountData(); + return; + } + if (GEM_BAG_EMPTY_PATTERN.matcher(message).find()) + { + cachedCounts.put("gem_bag", 0); + saveAccountData(); + return; + } + + Matcher mHerb = HERB_SACK_AMOUNT_PATTERN.matcher(message); + if (mHerb.find()) + { + cachedCounts.put("herb_sack", parseIntOrNull(mHerb.group(1))); + saveAccountData(); + return; + } + if (HERB_SACK_EMPTY_PATTERN.matcher(message).find()) + { + cachedCounts.put("herb_sack", 0); + saveAccountData(); + return; + } + + Matcher mFish = FISH_BARREL_AMOUNT_PATTERN.matcher(message); + if (mFish.find()) + { + cachedCounts.put("fish_barrel", parseIntOrNull(mFish.group(1))); + saveAccountData(); + return; + } + if (FISH_BARREL_EMPTY_PATTERN.matcher(message).find()) + { + cachedCounts.put("fish_barrel", 0); + saveAccountData(); + return; + } + + Matcher mPlank = PLANK_SACK_AMOUNT_PATTERN.matcher(message); + if (mPlank.find()) + { + cachedCounts.put("plank_sack", parseIntOrNull(mPlank.group(1))); + saveAccountData(); + return; + } + if (PLANK_SACK_EMPTY_PATTERN.matcher(message).find()) + { + cachedCounts.put("plank_sack", 0); + saveAccountData(); + return; + } + + Matcher mSeed = SEED_BOX_AMOUNT_PATTERN.matcher(message); + if (mSeed.find()) + { + cachedCounts.put("seed_box", parseIntOrNull(mSeed.group(1))); + saveAccountData(); + return; + } + if (SEED_BOX_EMPTY_PATTERN.matcher(message).find()) + { + cachedCounts.put("seed_box", 0); + saveAccountData(); + return; + } + + Matcher mMeat = MEAT_POUCH_AMOUNT_PATTERN.matcher(message); + if (mMeat.find()) + { + cachedCounts.put("meat_pouch", parseIntOrNull(mMeat.group(1))); + saveAccountData(); + return; + } + if (MEAT_POUCH_EMPTY_PATTERN.matcher(message).find()) + { + cachedCounts.put("meat_pouch", 0); + saveAccountData(); + return; + } + + Matcher mFur = FUR_POUCH_AMOUNT_PATTERN.matcher(message); + if (mFur.find()) + { + cachedCounts.put("fur_pouch", parseIntOrNull(mFur.group(1))); + saveAccountData(); + return; + } + if (FUR_POUCH_EMPTY_PATTERN.matcher(message).find()) + { + cachedCounts.put("fur_pouch", 0); + saveAccountData(); + return; + } + + Matcher mLogBasket = LOG_BASKET_AMOUNT_PATTERN.matcher(message); + if (mLogBasket.find()) + { + cachedCounts.put("log_basket", parseIntOrNull(mLogBasket.group(1))); + saveAccountData(); + } + } + + private BarrowsPiece matchBarrowsPiece(String itemName) + { + if (itemName == null) + { + return null; + } + String s = itemName.toLowerCase().replace("'", "").replace("-", " ").trim(); + if (s.contains("dharok")) + { + if (s.contains("helm") || s.contains("head")) return BarrowsPiece.DHAROK_HELM; + if (s.contains("body") || s.contains("platebody") || s.contains("chest")) return BarrowsPiece.DHAROK_BODY; + if (s.contains("leg") || s.contains("plateleg")) return BarrowsPiece.DHAROK_LEGS; + if (s.contains("axe") || s.contains("great") || s.contains("weapon")) return BarrowsPiece.DHAROK_WEAPON; + } + else if (s.contains("ahrim")) + { + if (s.contains("hood") || s.contains("head") || s.contains("helm")) return BarrowsPiece.AHRIM_HOOD; + if (s.contains("top") || s.contains("body") || s.contains("robetop")) return BarrowsPiece.AHRIM_BODY; + if (s.contains("skirt") || s.contains("leg") || s.contains("bottom") || s.contains("robeskirt")) return BarrowsPiece.AHRIM_LEGS; + if (s.contains("staff") || s.contains("weapon")) return BarrowsPiece.AHRIM_WEAPON; + } + else if (s.contains("guthan")) + { + if (s.contains("helm") || s.contains("head")) return BarrowsPiece.GUTHAN_HELM; + if (s.contains("body") || s.contains("plate") || s.contains("chest")) return BarrowsPiece.GUTHAN_BODY; + if (s.contains("skirt") || s.contains("chain") || s.contains("leg")) return BarrowsPiece.GUTHAN_LEGS; + if (s.contains("spear") || s.contains("war") || s.contains("weapon")) return BarrowsPiece.GUTHAN_WEAPON; + } + else if (s.contains("karil")) + { + if (s.contains("coif") || s.contains("head") || s.contains("helm")) return BarrowsPiece.KARIL_COIF; + if (s.contains("top") || s.contains("body") || s.contains("leather")) return BarrowsPiece.KARIL_BODY; + if (s.contains("skirt") || s.contains("leg") || s.contains("bottom")) return BarrowsPiece.KARIL_LEGS; + if (s.contains("crossbow") || s.contains("bow") || s.contains("weapon")) return BarrowsPiece.KARIL_WEAPON; + } + else if (s.contains("torag")) + { + if (s.contains("helm") || s.contains("head")) return BarrowsPiece.TORAG_HELM; + if (s.contains("body") || s.contains("plate") || s.contains("chest")) return BarrowsPiece.TORAG_BODY; + if (s.contains("leg")) return BarrowsPiece.TORAG_LEGS; + if (s.contains("hammer") || s.contains("weapon")) return BarrowsPiece.TORAG_WEAPON; + } + else if (s.contains("verac")) + { + if (s.contains("helm") || s.contains("head")) return BarrowsPiece.VERAC_HELM; + if (s.contains("brassard") || s.contains("body") || s.contains("top")) return BarrowsPiece.VERAC_BODY; + if (s.contains("skirt") || s.contains("plate") || s.contains("leg")) return BarrowsPiece.VERAC_LEGS; + if (s.contains("flail") || s.contains("weapon")) return BarrowsPiece.VERAC_WEAPON; + } + return null; + } + + private MoonPiece matchMoonPiece(String itemName) + { + if (itemName == null) + { + return null; + } + String s = itemName.toLowerCase().replace("'", "").replace("-", " ").trim(); + if (s.contains("blood moon") || s.contains("bloodmoon")) + { + if (s.contains("chest") || s.contains("body") || s.contains("top")) return MoonPiece.BLOOD_CHEST; + if (s.contains("helm") || s.contains("head") || s.contains("hood")) return MoonPiece.BLOOD_HELM; + if (s.contains("tasset") || s.contains("leg") || s.contains("skirt") || s.contains("bottom")) return MoonPiece.BLOOD_LEGS; + } + else if (s.contains("blue moon") || s.contains("bluemoon")) + { + if (s.contains("chest") || s.contains("body") || s.contains("top")) return MoonPiece.BLUE_CHEST; + if (s.contains("helm") || s.contains("head") || s.contains("hood")) return MoonPiece.BLUE_HELM; + if (s.contains("tasset") || s.contains("leg") || s.contains("skirt") || s.contains("bottom")) return MoonPiece.BLUE_LEGS; + } + else if (s.contains("eclipse moon") || s.contains("eclipsemoon")) + { + if (s.contains("chest") || s.contains("body") || s.contains("top")) return MoonPiece.ECLIPSE_CHEST; + if (s.contains("helm") || s.contains("head") || s.contains("hood")) return MoonPiece.ECLIPSE_HELM; + if (s.contains("tasset") || s.contains("leg") || s.contains("skirt") || s.contains("bottom")) return MoonPiece.ECLIPSE_LEGS; + } + return null; + } + + public List chargesFor(int itemId) + { + if (!config.itemCharges()) + { + return Collections.emptyList(); + } + + boolean weapons = plugin.getHelperValue("itemChargesWeapons", Boolean.class, true); + boolean bags = plugin.getHelperValue("itemChargesBags", Boolean.class, true); + + Color primaryColor = plugin.getHelperValue("itemChargesColor", Color.class, Color.WHITE); + Color secondaryColor = plugin.getHelperValue("itemChargesSecondaryColor", Color.class, new Color(0xC0C0C0)); + Color lowColor = plugin.getHelperValue("itemChargesLowColor", Color.class, new Color(0xFF3333)); + int lowAt = plugin.getHelperValue("itemChargesLowAt", Integer.class, 0); + + ItemChargeDef def = CHARGE_MAP.get(itemId); + if (def == null && itemManager != null) + { + int canonical = itemManager.canonicalize(itemId); + if (canonical != itemId) + { + def = CHARGE_MAP.get(canonical); + } + } + + List result = new ArrayList<>(2); + + if (def != null) + { + switch (def.type) + { + case VARBIT: + if (!weapons) + { + return Collections.emptyList(); + } + if (def.isUncharged) + { + result.add(new ChargeValue("0", lowAt > 0 ? lowColor : primaryColor)); + return result; + } + if (def.isInfinite) + { + result.add(new ChargeValue("∞", primaryColor)); + return result; + } + if (def.primaryVarbit != -1) + { + int val = client.getVarbitValue(def.primaryVarbit); + Color c = (lowAt > 0 && val <= lowAt) ? lowColor : primaryColor; + result.add(new ChargeValue(formatQuantity(val), c)); + } + return result; + + case BOWFA_BLADE: + if (!weapons) + { + return Collections.emptyList(); + } + if (def.isUncharged) + { + result.add(new ChargeValue("0", lowAt > 0 ? lowColor : primaryColor)); + return result; + } + if (def.isInfinite) + { + result.add(new ChargeValue("∞", primaryColor)); + return result; + } + if (def.secondaryVarbit != -1 && client.getVarbitValue(def.secondaryVarbit) == 1) + { + result.add(new ChargeValue("∞", primaryColor)); + return result; + } + if (def.primaryVarbit != -1) + { + int val = client.getVarbitValue(def.primaryVarbit); + Color c = (lowAt > 0 && val <= lowAt) ? lowColor : primaryColor; + result.add(new ChargeValue(formatQuantity(val), c)); + } + return result; + + case DIZANAS_QUIVER: + if (!weapons) + { + return Collections.emptyList(); + } + if (def.isUncharged) + { + result.add(new ChargeValue("0", lowAt > 0 ? lowColor : primaryColor)); + return result; + } + if (def.isInfinite) + { + result.add(new ChargeValue("∞", primaryColor)); + int blessing = client.getVarbitValue(def.secondaryVarbit); + if (blessing > 0) + { + result.add(new ChargeValue(formatQuantity(blessing), secondaryColor)); + } + return result; + } + int ammo = client.getVarbitValue(def.primaryVarbit); + int blessing = client.getVarbitValue(def.secondaryVarbit); + Color c = (lowAt > 0 && ammo <= lowAt) ? lowColor : primaryColor; + result.add(new ChargeValue(formatQuantity(ammo), c)); + if (blessing > 0) + { + result.add(new ChargeValue(formatQuantity(blessing), secondaryColor)); + } + return result; + + case BLOWPIPE: + if (!weapons) + { + return Collections.emptyList(); + } + loadAccountDataIfNecessary(); + if (def.isUncharged) + { + result.add(new ChargeValue("0", lowAt > 0 ? lowColor : primaryColor)); + result.add(new ChargeValue("0", secondaryColor)); + return result; + } + + // Top line: Darts + Integer darts = cachedBlowpipeDarts; + String dartsText = (darts != null) ? formatQuantity(darts) : (def.hasDarts ? "?" : "0"); + Color dartColor = colorForDart(cachedBlowpipeDartType, secondaryColor); + if (darts != null && darts == 0) + { + dartColor = lowColor; + } + result.add(new ChargeValue(dartsText, dartColor)); + + // Bottom line: Scales + Integer scales = cachedBlowpipeScales; + if (scales == null) + { + int varbitVal = client.getVarbitValue(VarbitID.CHARGES_TOXIC_BLOWPIPE_QUANTITY); + if (varbitVal > 0) + { + scales = varbitVal; + } + } + + String scalesText = (scales != null) ? formatQuantity(scales) : (def.hasDarts ? "?" : "0"); + Color scalesColor = (scales != null && ((lowAt > 0 && scales <= lowAt) || scales == 0)) ? lowColor : primaryColor; + result.add(new ChargeValue(scalesText, scalesColor)); + return result; + + case BARROWS: + if (!weapons) + { + return Collections.emptyList(); + } + loadAccountDataIfNecessary(); + int tier = def.barrowsTier; + if (tier == 0) + { + result.add(new ChargeValue("0%", lowColor)); + return result; + } + + Integer cachedPct = cachedBarrows.get(def.barrowsPiece); + if (cachedPct == null) + { + result.add(new ChargeValue("?", primaryColor)); + return result; + } + + Color barrowsColor = (cachedPct <= 25 || (lowAt > 0 && cachedPct <= lowAt)) ? lowColor : primaryColor; + result.add(new ChargeValue(cachedPct + "%", barrowsColor)); + return result; + + case MOONS: + if (!weapons) + { + return Collections.emptyList(); + } + loadAccountDataIfNecessary(); + if (def.moonTier == 0) + { + result.add(new ChargeValue("0%", lowColor)); + return result; + } + + Integer cachedMoonCharges = cachedMoons.get(def.moonPiece); + if (cachedMoonCharges == null) + { + if (def.moonTier > 0) + { + cachedMoonCharges = def.moonTier; + } + else + { + result.add(new ChargeValue("?", primaryColor)); + return result; + } + } + + int moonPct = Math.max(0, Math.min(100, (cachedMoonCharges * 100) / 3000)); + Color moonColor = (moonPct <= 25 || (lowAt > 0 && moonPct <= lowAt)) ? lowColor : primaryColor; + result.add(new ChargeValue(moonPct + "%", moonColor)); + return result; + + case MASTER_SCROLL_BOOK: + if (!weapons) + { + return Collections.emptyList(); + } + int totalScrolls = 0; + for (int v : MASTER_SCROLL_BOOK_VARBITS) + { + totalScrolls += client.getVarbitValue(v); + } + Color scrollColor = (lowAt > 0 && totalScrolls <= lowAt) ? lowColor : primaryColor; + result.add(new ChargeValue(formatQuantity(totalScrolls), scrollColor)); + return result; + + case GHOMMALS_HILT: + if (!weapons) + { + return Collections.emptyList(); + } + int used = client.getVarbitValue(def.primaryVarbit); + int remaining = Math.max(0, def.fixedAmount - used); + Color hiltColor = (remaining == 0 || (lowAt > 0 && remaining <= lowAt)) ? lowColor : primaryColor; + result.add(new ChargeValue(String.valueOf(remaining), hiltColor)); + return result; + + case FIXED_VALUE: + if (!weapons && !bags) + { + return Collections.emptyList(); + } + Color fixColor = (lowAt > 0 && def.fixedAmount <= lowAt) ? lowColor : primaryColor; + result.add(new ChargeValue(String.valueOf(def.fixedAmount), fixColor)); + return result; + + case CONFIG_COUNT: + if (!weapons) + { + return Collections.emptyList(); + } + if (def.isUncharged) + { + result.add(new ChargeValue("0", lowAt > 0 ? lowColor : primaryColor)); + return result; + } + loadAccountDataIfNecessary(); + Integer count = cachedCounts.get(def.configKey); + if (count == null) + { + if (def.defaultCharges != null) + { + Color countColor = (def.defaultCharges <= 0 || (lowAt > 0 && def.defaultCharges <= lowAt)) ? lowColor : primaryColor; + result.add(new ChargeValue(formatQuantity(def.defaultCharges), countColor)); + } + else + { + result.add(new ChargeValue("?", primaryColor)); + } + } + else + { + Color countColor = (count <= 0 || (lowAt > 0 && count <= lowAt)) ? lowColor : primaryColor; + result.add(new ChargeValue(formatQuantity(count), countColor)); + } + return result; + + case BAG: + if (!bags) + { + return Collections.emptyList(); + } + loadAccountDataIfNecessary(); + Integer bagCount = cachedCounts.get(def.configKey); + if (bagCount == null) + { + result.add(new ChargeValue("?", primaryColor)); + } + else + { + Color countColor = (lowAt > 0 && bagCount <= lowAt) ? lowColor : primaryColor; + result.add(new ChargeValue(String.valueOf(bagCount), countColor)); + } + return result; + + case LOOTING_BAG: + if (!bags) + { + return Collections.emptyList(); + } + updateLootingBag(); + if (!lootingBagKnown) + { + result.add(new ChargeValue("?", primaryColor)); + } + else + { + result.add(new ChargeValue(QuantityFormatter.quantityToStackSize(cachedLootingBagCoins), primaryColor)); + } + return result; + } + } + + // Fallback: Check item name for parenthesized dose / charges (e.g. Prayer potion(4), Ring of dueling(8), Apples(5), Waterskin(4), etc.) + String name = null; + if (itemManager != null) + { + net.runelite.api.ItemComposition comp = itemManager.getItemComposition(itemId); + if (comp != null) + { + name = comp.getName(); + } + } + else if (client != null) + { + net.runelite.api.ItemComposition comp = client.getItemDefinition(itemId); + if (comp != null) + { + name = comp.getName(); + } + } + + if (name != null) + { + Matcher m = NUMBER_IN_PARENS_PATTERN.matcher(name); + if (m.find()) + { + int num = Integer.parseInt(m.group(2)); + if (isPotion(name)) + { + Color doseColor; + switch (num) + { + case 4: + doseColor = primaryColor; + break; + case 3: + doseColor = new Color(0x4DD0E1); // cyan + break; + case 2: + doseColor = new Color(0xFFB74D); // orange + break; + case 1: + default: + doseColor = lowColor; // red + break; + } + result.add(new ChargeValue(String.valueOf(num), doseColor)); + return result; + } + else + { + Color c = (lowAt > 0 && num <= lowAt) ? lowColor : primaryColor; + result.add(new ChargeValue(String.valueOf(num), c)); + return result; + } + } + + String lower = name.toLowerCase(); + if (lower.endsWith("(0)") || lower.endsWith("(empty)") || lower.equals("basket") || lower.equals("empty sack") || lower.equals("vial")) + { + result.add(new ChargeValue("0", lowAt > 0 ? lowColor : primaryColor)); + return result; + } + } + + return Collections.emptyList(); + } + + private static boolean isPotion(String name) + { + String lower = name.toLowerCase(); + return lower.contains("potion") || lower.contains("brew") || lower.contains("restore") + || lower.contains("mix") || lower.contains("serum") || lower.contains("balm") + || lower.contains("elixir") || lower.contains("dressing") || lower.contains("salts") + || lower.contains("tears of elidinis") || lower.contains("overload") || lower.contains("salve") + || lower.contains("guthix rest") || lower.contains("menaphite remedy") || lower.contains("absorption") + || lower.contains("surge") || lower.contains("egniol") || lower.contains("xeric's aid") + || lower.contains("twisted") || lower.contains("kodai") || lower.contains("elder") + || lower.contains("revitalisation") || lower.contains("prayer enhance") || lower.contains("nectar") + || lower.contains("liquid adrenaline") || lower.contains("ambrosia") || lower.contains("antidote") + || lower.contains("antivenom") || lower.contains("antifire") || lower.contains("antipoison") + || lower.contains("sanfew") || lower.contains("relicym") || lower.contains("combat") + || lower.contains("ranging") || lower.contains("magic") || lower.contains("strength") + || lower.contains("defence") || lower.contains("attack") || lower.contains("agility") + || lower.contains("hunter") || lower.contains("fishing") || lower.contains("stamina") + || lower.contains("energy") || lower.contains("bastion") || lower.contains("battlemage") + || lower.contains("zamorak") || lower.contains("saradomin") || lower.contains("guthix") + || lower.contains("super") || lower.contains("divine") || lower.contains("extended") + || lower.contains("blighted"); + } + + private Color colorForDart(String dartType, Color defaultColor) + { + if (dartType == null) + { + return defaultColor; + } + String lower = dartType.toLowerCase(); + if (lower.contains("dragon")) + { + return new Color(0xFF7043); // reddish orange + } + if (lower.contains("amethyst")) + { + return new Color(0xBA68C8); // purple + } + if (lower.contains("rune")) + { + return new Color(0x4DD0E1); // cyan + } + if (lower.contains("adamant")) + { + return new Color(0x81C784); // light green + } + if (lower.contains("mithril")) + { + return new Color(0x64B5F6); // steel blue + } + if (lower.contains("black")) + { + return new Color(0x616161); // dark gray + } + if (lower.contains("steel")) + { + return new Color(0xB0BEC5); // silver + } + if (lower.contains("iron")) + { + return new Color(0x9E9E9E); // gray + } + if (lower.contains("bronze")) + { + return new Color(0xA1887F); // bronze brown + } + return defaultColor; + } + + private String formatQuantity(int val) + { + if (val >= 10_000_000) + { + return (val / 1_000_000) + "M"; + } + if (val >= 100_000) + { + return (val / 1_000) + "K"; + } + if (val >= 1_000) + { + if (val % 1000 == 0) + { + return (val / 1000) + "K"; + } + double k = val / 1000.0; + return String.format(java.util.Locale.ENGLISH, "%.1fK", k); + } + return String.valueOf(val); + } +} diff --git a/src/main/java/com/example/essentialsplus/helpers/ItemChargesOverlay.java b/src/main/java/com/example/essentialsplus/helpers/ItemChargesOverlay.java new file mode 100644 index 0000000..3390e83 --- /dev/null +++ b/src/main/java/com/example/essentialsplus/helpers/ItemChargesOverlay.java @@ -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 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); + } + } + } +} diff --git a/src/main/java/com/example/essentialsplus/helpers/ItemPriceOverlay.java b/src/main/java/com/example/essentialsplus/helpers/ItemPriceOverlay.java new file mode 100644 index 0000000..b56ac19 --- /dev/null +++ b/src/main/java/com/example/essentialsplus/helpers/ItemPriceOverlay.java @@ -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"; + } +} diff --git a/src/main/java/com/example/essentialsplus/helpers/TimerTextSize.java b/src/main/java/com/example/essentialsplus/helpers/TimerTextSize.java new file mode 100644 index 0000000..cf60ffc --- /dev/null +++ b/src/main/java/com/example/essentialsplus/helpers/TimerTextSize.java @@ -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; + } +} diff --git a/src/main/java/com/example/essentialsplus/helpers/TreeCooldownOverlay.java b/src/main/java/com/example/essentialsplus/helpers/TreeCooldownOverlay.java new file mode 100644 index 0000000..3f480d6 --- /dev/null +++ b/src/main/java/com/example/essentialsplus/helpers/TreeCooldownOverlay.java @@ -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); + } +} diff --git a/src/main/java/com/example/essentialsplus/helpers/TreeCooldowns.java b/src/main/java/com/example/essentialsplus/helpers/TreeCooldowns.java new file mode 100644 index 0000000..97f7188 --- /dev/null +++ b/src/main/java/com/example/essentialsplus/helpers/TreeCooldowns.java @@ -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 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 trackedTrees = new ConcurrentHashMap<>(); + private final Map respawnTimers = new ConcurrentHashMap<>(); + private final Map knownTreeIds = new ConcurrentHashMap<>(); + private final Map knownStumpIds = new ConcurrentHashMap<>(); + private final Set 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 getActiveRespawnTimers() + { + return respawnTimers.values(); + } + + public Collection getActiveChopTimers() + { + List 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; + } +} diff --git a/src/main/java/com/example/essentialsplus/helpers/combat/AnimationData.java b/src/main/java/com/example/essentialsplus/helpers/combat/AnimationData.java new file mode 100644 index 0000000..a9c680c --- /dev/null +++ b/src/main/java/com/example/essentialsplus/helpers/combat/AnimationData.java @@ -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 DATA; + private static final Map> SPELL_BOOK_ANIMATIONS; + private static final Map NOT_ATTACKS; + private static final Set 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 builder = new ImmutableMap.Builder<>(); + ImmutableMap.Builder notAttacksBuilder = new ImmutableMap.Builder<>(); + Map> spellBookBuilder = new HashMap<>(); + ImmutableSet.Builder 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 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()); + } + } +} diff --git a/src/main/java/com/example/essentialsplus/helpers/combat/AttackProcedure.java b/src/main/java/com/example/essentialsplus/helpers/combat/AttackProcedure.java new file mode 100644 index 0000000..88a9bd1 --- /dev/null +++ b/src/main/java/com/example/essentialsplus/helpers/combat/AttackProcedure.java @@ -0,0 +1,8 @@ +package com.example.essentialsplus.helpers.combat; + +public enum AttackProcedure +{ + POWERED_STAVE, + MANUAL_AUTO_CAST, + MELEE_OR_RANGE +} diff --git a/src/main/java/com/example/essentialsplus/helpers/combat/AttackSpeed.java b/src/main/java/com/example/essentialsplus/helpers/combat/AttackSpeed.java new file mode 100644 index 0000000..8601299 --- /dev/null +++ b/src/main/java/com/example/essentialsplus/helpers/combat/AttackSpeed.java @@ -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 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 NON_STANDARD_MAGIC_WEAPON_SPEEDS = new ImmutableMap.Builder() + .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(); + } +} diff --git a/src/main/java/com/example/essentialsplus/helpers/combat/AttackStyle.java b/src/main/java/com/example/essentialsplus/helpers/combat/AttackStyle.java new file mode 100644 index 0000000..fb0af04 --- /dev/null +++ b/src/main/java/com/example/essentialsplus/helpers/combat/AttackStyle.java @@ -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; + } +} diff --git a/src/main/java/com/example/essentialsplus/helpers/combat/AttackType.java b/src/main/java/com/example/essentialsplus/helpers/combat/AttackType.java new file mode 100644 index 0000000..c2fa86b --- /dev/null +++ b/src/main/java/com/example/essentialsplus/helpers/combat/AttackType.java @@ -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); + } +} diff --git a/src/main/java/com/example/essentialsplus/helpers/combat/Attacking.java b/src/main/java/com/example/essentialsplus/helpers/combat/Attacking.java new file mode 100644 index 0000000..2acc969 --- /dev/null +++ b/src/main/java/com/example/essentialsplus/helpers/combat/Attacking.java @@ -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 UNRELIABLE_ANIMATIONS = new ImmutableSet.Builder() + .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 SPECIAL_NPCS = new ImmutableSet.Builder() + .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; + } + } +} diff --git a/src/main/java/com/example/essentialsplus/helpers/combat/CastingSoundData.java b/src/main/java/com/example/essentialsplus/helpers/combat/CastingSoundData.java new file mode 100644 index 0000000..8e5be22 --- /dev/null +++ b/src/main/java/com/example/essentialsplus/helpers/combat/CastingSoundData.java @@ -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 SOUNDS; + + static + { + ImmutableMap.Builder 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; + } +} diff --git a/src/main/java/com/example/essentialsplus/helpers/combat/CombatUtils.java b/src/main/java/com/example/essentialsplus/helpers/combat/CombatUtils.java new file mode 100644 index 0000000..443f8ab --- /dev/null +++ b/src/main/java/com/example/essentialsplus/helpers/combat/CombatUtils.java @@ -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 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 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 WEAPON_ID_MAPPING_WORKAROUNDS = new ImmutableMap.Builder() + .put(ItemID.VENATOR_BOW_ORNAMENT, ItemID.VENATOR_BOW) + .build(); + + private static final Map NON_STANDARD_ATTACK_SPEEDS = new ImmutableMap.Builder() + .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(); + } +} diff --git a/src/main/java/com/example/essentialsplus/helpers/combat/Damage.java b/src/main/java/com/example/essentialsplus/helpers/combat/Damage.java new file mode 100644 index 0000000..0df989f --- /dev/null +++ b/src/main/java/com/example/essentialsplus/helpers/combat/Damage.java @@ -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 hpExpEarned = new ArrayDeque<>(); + private final ArrayDeque 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(); + } +} diff --git a/src/main/java/com/example/essentialsplus/helpers/combat/ManualCasting.java b/src/main/java/com/example/essentialsplus/helpers/combat/ManualCasting.java new file mode 100644 index 0000000..7cf525d --- /dev/null +++ b/src/main/java/com/example/essentialsplus/helpers/combat/ManualCasting.java @@ -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; + } +} diff --git a/src/main/java/com/example/essentialsplus/helpers/combat/PoweredStaves.java b/src/main/java/com/example/essentialsplus/helpers/combat/PoweredStaves.java new file mode 100644 index 0000000..a8c2697 --- /dev/null +++ b/src/main/java/com/example/essentialsplus/helpers/combat/PoweredStaves.java @@ -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 ids; + @Getter + private final Set animations; + + private static Set ListToSet(int... id) + { + ImmutableSet.Builder 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 spell, int... id) + { + this.ids = ListToSet(id); + this.animations = spell; + } + + protected static final int UNKNOWN_SPELL = 0xDEADBEEF; + protected static final ImmutableMap> POWERED_STAVES; + + static + { + ImmutableMap.Builder> builder = new ImmutableMap.Builder<>(); + + for (PoweredStaves p : values()) + { + for (int id : p.ids) + { + ImmutableMap.Builder 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 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); + } +} diff --git a/src/main/java/com/example/essentialsplus/helpers/combat/Spellbook.java b/src/main/java/com/example/essentialsplus/helpers/combat/Spellbook.java new file mode 100644 index 0000000..af0a339 --- /dev/null +++ b/src/main/java/com/example/essentialsplus/helpers/combat/Spellbook.java @@ -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 BOOKS; + + static + { + ImmutableMap.Builder 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); + } +} diff --git a/src/main/java/com/example/essentialsplus/helpers/combat/WeaponType.java b/src/main/java/com/example/essentialsplus/helpers/combat/WeaponType.java new file mode 100644 index 0000000..fb8a1a9 --- /dev/null +++ b/src/main/java/com/example/essentialsplus/helpers/combat/WeaponType.java @@ -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 WEAPON_TYPES; + + @Getter + private final AttackType[] attackTypes; + + static + { + ImmutableMap.Builder 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); + } +} diff --git a/src/main/java/com/example/essentialsplus/helpers/combat/variablespeed/Amoxliatl.java b/src/main/java/com/example/essentialsplus/helpers/combat/variablespeed/Amoxliatl.java new file mode 100644 index 0000000..6a2da3d --- /dev/null +++ b/src/main/java/com/example/essentialsplus/helpers/combat/variablespeed/Amoxliatl.java @@ -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; + } +} diff --git a/src/main/java/com/example/essentialsplus/helpers/combat/variablespeed/BloodMoonSet.java b/src/main/java/com/example/essentialsplus/helpers/combat/variablespeed/BloodMoonSet.java new file mode 100644 index 0000000..4c24399 --- /dev/null +++ b/src/main/java/com/example/essentialsplus/helpers/combat/variablespeed/BloodMoonSet.java @@ -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; + } +} diff --git a/src/main/java/com/example/essentialsplus/helpers/combat/variablespeed/DoomOfMokhaiotl.java b/src/main/java/com/example/essentialsplus/helpers/combat/variablespeed/DoomOfMokhaiotl.java new file mode 100644 index 0000000..a0176da --- /dev/null +++ b/src/main/java/com/example/essentialsplus/helpers/combat/variablespeed/DoomOfMokhaiotl.java @@ -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 DOOM_REGION_IDS = new ImmutableSet.Builder() + .add(5269) + .add(13668) + .add(14180) + .build(); + + private static final Set COOLDOWN_OVERWRITES_IDS = new ImmutableSet.Builder() + .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 NO_COOLDOWN_WEAPON = new ImmutableSet.Builder() + .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; + } +} diff --git a/src/main/java/com/example/essentialsplus/helpers/combat/variablespeed/EyeOfAyak.java b/src/main/java/com/example/essentialsplus/helpers/combat/variablespeed/EyeOfAyak.java new file mode 100644 index 0000000..a8d1fd6 --- /dev/null +++ b/src/main/java/com/example/essentialsplus/helpers/combat/variablespeed/EyeOfAyak.java @@ -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; + } +} diff --git a/src/main/java/com/example/essentialsplus/helpers/combat/variablespeed/IVariableSpeed.java b/src/main/java/com/example/essentialsplus/helpers/combat/variablespeed/IVariableSpeed.java new file mode 100644 index 0000000..4cd1b66 --- /dev/null +++ b/src/main/java/com/example/essentialsplus/helpers/combat/variablespeed/IVariableSpeed.java @@ -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 + ); +} diff --git a/src/main/java/com/example/essentialsplus/helpers/combat/variablespeed/MaggotKing.java b/src/main/java/com/example/essentialsplus/helpers/combat/variablespeed/MaggotKing.java new file mode 100644 index 0000000..812e76c --- /dev/null +++ b/src/main/java/com/example/essentialsplus/helpers/combat/variablespeed/MaggotKing.java @@ -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; + } +} diff --git a/src/main/java/com/example/essentialsplus/helpers/combat/variablespeed/PurgingStaffSpec.java b/src/main/java/com/example/essentialsplus/helpers/combat/variablespeed/PurgingStaffSpec.java new file mode 100644 index 0000000..654c746 --- /dev/null +++ b/src/main/java/com/example/essentialsplus/helpers/combat/variablespeed/PurgingStaffSpec.java @@ -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; + } +} diff --git a/src/main/java/com/example/essentialsplus/helpers/combat/variablespeed/RapidAttackStyle.java b/src/main/java/com/example/essentialsplus/helpers/combat/variablespeed/RapidAttackStyle.java new file mode 100644 index 0000000..ba45aec --- /dev/null +++ b/src/main/java/com/example/essentialsplus/helpers/combat/variablespeed/RapidAttackStyle.java @@ -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; + } +} diff --git a/src/main/java/com/example/essentialsplus/helpers/combat/variablespeed/RedKerisSpec.java b/src/main/java/com/example/essentialsplus/helpers/combat/variablespeed/RedKerisSpec.java new file mode 100644 index 0000000..fa8833a --- /dev/null +++ b/src/main/java/com/example/essentialsplus/helpers/combat/variablespeed/RedKerisSpec.java @@ -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; + } +} diff --git a/src/main/java/com/example/essentialsplus/helpers/combat/variablespeed/RoyalTitans.java b/src/main/java/com/example/essentialsplus/helpers/combat/variablespeed/RoyalTitans.java new file mode 100644 index 0000000..688eb4c --- /dev/null +++ b/src/main/java/com/example/essentialsplus/helpers/combat/variablespeed/RoyalTitans.java @@ -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 iceElementals = new HashSet<>(); + private final Set fireElementals = new HashSet<>(); + private boolean removeDead = false; + + private static final Set ONE_SHOT_SPELLS = new ImmutableSet.Builder() + .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; + } +} diff --git a/src/main/java/com/example/essentialsplus/helpers/combat/variablespeed/Scurrius.java b/src/main/java/com/example/essentialsplus/helpers/combat/variablespeed/Scurrius.java new file mode 100644 index 0000000..fb2159c --- /dev/null +++ b/src/main/java/com/example/essentialsplus/helpers/combat/variablespeed/Scurrius.java @@ -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; + } +} diff --git a/src/main/java/com/example/essentialsplus/helpers/combat/variablespeed/ShadowCrash.java b/src/main/java/com/example/essentialsplus/helpers/combat/variablespeed/ShadowCrash.java new file mode 100644 index 0000000..8dfbdb5 --- /dev/null +++ b/src/main/java/com/example/essentialsplus/helpers/combat/variablespeed/ShadowCrash.java @@ -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 fireballs; + private ArrayList 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 getLines() + { + final var result = new ArrayList(); + + while (fireballs.size() >= 3) + { + LocalPoint first = null; + final Set 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 + } +} diff --git a/src/main/java/com/example/essentialsplus/helpers/combat/variablespeed/TombsOfAmascut.java b/src/main/java/com/example/essentialsplus/helpers/combat/variablespeed/TombsOfAmascut.java new file mode 100644 index 0000000..898c234 --- /dev/null +++ b/src/main/java/com/example/essentialsplus/helpers/combat/variablespeed/TombsOfAmascut.java @@ -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; + } +} diff --git a/src/main/java/com/example/essentialsplus/helpers/combat/variablespeed/TormentedDemons.java b/src/main/java/com/example/essentialsplus/helpers/combat/variablespeed/TormentedDemons.java new file mode 100644 index 0000000..9dfc3dd --- /dev/null +++ b/src/main/java/com/example/essentialsplus/helpers/combat/variablespeed/TormentedDemons.java @@ -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 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 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; + } + } +} diff --git a/src/main/java/com/example/essentialsplus/helpers/combat/variablespeed/VariableSpeed.java b/src/main/java/com/example/essentialsplus/helpers/combat/variablespeed/VariableSpeed.java new file mode 100644 index 0000000..96bb6fa --- /dev/null +++ b/src/main/java/com/example/essentialsplus/helpers/combat/variablespeed/VariableSpeed.java @@ -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); + } + } +} diff --git a/src/main/java/com/example/essentialsplus/helpers/combat/variablespeed/state/IStateTracker.java b/src/main/java/com/example/essentialsplus/helpers/combat/variablespeed/state/IStateTracker.java new file mode 100644 index 0000000..8c16f06 --- /dev/null +++ b/src/main/java/com/example/essentialsplus/helpers/combat/variablespeed/state/IStateTracker.java @@ -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) + { + } +} diff --git a/src/main/java/com/example/essentialsplus/helpers/combat/variablespeed/state/MarkOfDarkness.java b/src/main/java/com/example/essentialsplus/helpers/combat/variablespeed/state/MarkOfDarkness.java new file mode 100644 index 0000000..20ced21 --- /dev/null +++ b/src/main/java/com/example/essentialsplus/helpers/combat/variablespeed/state/MarkOfDarkness.java @@ -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."; + + 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; + } +} diff --git a/src/main/java/com/example/essentialsplus/helpers/combat/variablespeed/state/TickCount.java b/src/main/java/com/example/essentialsplus/helpers/combat/variablespeed/state/TickCount.java new file mode 100644 index 0000000..707d021 --- /dev/null +++ b/src/main/java/com/example/essentialsplus/helpers/combat/variablespeed/state/TickCount.java @@ -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; + } +} diff --git a/src/main/java/com/example/essentialsplus/helpers/combat/variablespeed/state/Yama.java b/src/main/java/com/example/essentialsplus/helpers/combat/variablespeed/state/Yama.java new file mode 100644 index 0000000..95649b1 --- /dev/null +++ b/src/main/java/com/example/essentialsplus/helpers/combat/variablespeed/state/Yama.java @@ -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 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(); + } + } +} diff --git a/src/main/java/com/example/essentialsplus/helpers/combat/variablespeed/state/YamaPhase.java b/src/main/java/com/example/essentialsplus/helpers/combat/variablespeed/state/YamaPhase.java new file mode 100644 index 0000000..8f75e2f --- /dev/null +++ b/src/main/java/com/example/essentialsplus/helpers/combat/variablespeed/state/YamaPhase.java @@ -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; + } +} diff --git a/src/main/java/com/example/essentialsplus/loot/LootEvent.java b/src/main/java/com/example/essentialsplus/loot/LootEvent.java new file mode 100644 index 0000000..04f5dab --- /dev/null +++ b/src/main/java/com/example/essentialsplus/loot/LootEvent.java @@ -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 i = new LinkedHashMap<>(); + + public LootEvent() + { + } + + public LootEvent(long t) + { + this.t = t; + } +} diff --git a/src/main/java/com/example/essentialsplus/loot/LootRecord.java b/src/main/java/com/example/essentialsplus/loot/LootRecord.java new file mode 100644 index 0000000..acc3c0b --- /dev/null +++ b/src/main/java/com/example/essentialsplus/loot/LootRecord.java @@ -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 items = new LinkedHashMap<>(); + private com.example.essentialsplus.loot.combat.CombatStats combatStats; + /** Time-bucketed kills/drops, used for time range filtering. */ + private List 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 getSortedItems() + { + List list = new ArrayList<>(items.values()); + list.sort((a, b) -> Long.compare(b.getTotalPrice(), a.getTotalPrice())); + return list; + } + + public synchronized List 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 getEvents() + { + if (events == null) + { + events = new ArrayList<>(); + } + return events; + } + + private LootEvent currentEvent(long now) + { + List 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 list = getEvents(); + if (list.size() < 2) + { + return; + } + List 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 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 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 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(); + } +} diff --git a/src/main/java/com/example/essentialsplus/loot/LootTrackerStore.java b/src/main/java/com/example/essentialsplus/loot/LootTrackerStore.java new file mode 100644 index 0000000..5c26154 --- /dev/null +++ b/src/main/java/com/example/essentialsplus/loot/LootTrackerStore.java @@ -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>() {}.getType(); + + private final Gson gson; + private final ScheduledExecutorService executor; + private final Map records = new ConcurrentHashMap<>(); + private final List listeners = new CopyOnWriteArrayList<>(); + + private final Map recentDropHashes = Collections.synchronizedMap(new LinkedHashMap() + { + @Override + protected boolean removeEldestEntry(Map.Entry 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 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 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 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 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 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 getRecords(com.example.essentialsplus.ui.TimeFilter filter) + { + if (!filter.isCurrent()) + { + return getRecords(filter.start()); + } + List out = new ArrayList<>(); + for (LootRecord record : records.values()) + { + LootRecord view = record.view(filter); + if (!view.isEmpty()) + { + out.add(view); + } + } + return out; + } + + public List getRecords(long start) + { + List 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 list) + { + long total = 0; + for (LootRecord record : list) + { + total += record.getTotalValue(); + } + return total; + } + + public static int totalKills(Collection list) + { + int sum = 0; + for (LootRecord record : list) + { + sum += record.getKills(); + } + return sum; + } + + public static long totalSuppliesCost(Collection list) + { + long sum = 0; + for (LootRecord record : list) + { + if (record.hasCombatStats()) + { + sum += record.getCombatStats().getTotalSuppliesCost(); + } + } + return sum; + } + + public static double overallEfficiency(Collection 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 getTopOverallItems(int limit) + { + Map 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 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 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); + } + } + }); + } +} diff --git a/src/main/java/com/example/essentialsplus/loot/TrackedLootItem.java b/src/main/java/com/example/essentialsplus/loot/TrackedLootItem.java new file mode 100644 index 0000000..bdc4c06 --- /dev/null +++ b/src/main/java/com/example/essentialsplus/loot/TrackedLootItem.java @@ -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(); + } +} diff --git a/src/main/java/com/example/essentialsplus/loot/combat/CombatStats.java b/src/main/java/com/example/essentialsplus/loot/combat/CombatStats.java new file mode 100644 index 0000000..922430f --- /dev/null +++ b/src/main/java/com/example/essentialsplus/loot/combat/CombatStats.java @@ -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 totalFoodUsed = new LinkedHashMap<>(); + private Map totalPotionsUsed = new LinkedHashMap<>(); + private Map 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 lastFoodUsed = new LinkedHashMap<>(); + private Map lastPotionsUsed = new LinkedHashMap<>(); + private Map 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 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 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 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"; + } +} diff --git a/src/main/java/com/example/essentialsplus/loot/combat/CombatTracker.java b/src/main/java/com/example/essentialsplus/loot/combat/CombatTracker.java new file mode 100644 index 0000000..6373c60 --- /dev/null +++ b/src/main/java/com/example/essentialsplus/loot/combat/CombatTracker.java @@ -0,0 +1,1172 @@ +package com.example.essentialsplus.loot.combat; + +import com.example.essentialsplus.loot.LootRecord; +import com.example.essentialsplus.loot.LootTrackerStore; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import javax.inject.Inject; +import javax.inject.Singleton; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; +import net.runelite.api.Actor; +import net.runelite.api.Client; +import net.runelite.api.EquipmentInventorySlot; +import net.runelite.api.Hitsplat; +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.Projectile; +import net.runelite.api.coords.WorldPoint; +import net.runelite.api.events.ActorDeath; +import net.runelite.api.events.AnimationChanged; +import net.runelite.api.events.GameTick; +import net.runelite.api.events.HitsplatApplied; +import net.runelite.api.events.ItemContainerChanged; +import net.runelite.api.events.NpcDespawned; +import net.runelite.api.events.ProjectileMoved; +import net.runelite.api.gameval.InventoryID; +import net.runelite.client.game.ItemManager; +import net.runelite.client.game.ItemStats; +import net.runelite.client.util.Text; + +@Slf4j +@Singleton +public class CombatTracker +{ + private final Client client; + private final ItemManager itemManager; + private final LootTrackerStore lootStore; + + @Getter + private FightSession activeSession; + + // Pending session awaiting potential ground loot or timeout + private FightSession pendingDeathSession; + private int pendingDeathTick = -1; + + public static final String DOOM_OF_MOKHAIOTL = "Doom of Mokhaiotl"; + + private Map lastInventorySnapshot; + private Map lastEquipmentSnapshot; + private Map lastQuiverSnapshot; + private int lastPlayerAnimation = -1; + + // Projectile tracking for defensive prayer detection + private int lastIncomingProjectileTick = -1; + private boolean lastIncomingProjectileIsMagic = false; + + public enum AttackCategory + { + MELEE_STAB, + MELEE_SLASH, + MELEE_CRUSH, + RANGED, + MAGIC, + UNKNOWN; + + public boolean isMelee() + { + return this == MELEE_STAB || this == MELEE_SLASH || this == MELEE_CRUSH; + } + } + + public enum OverheadProtection + { + NONE, + MELEE, + RANGED, + MAGIC, + RANGE_MAGE, + ALL + } + + @Inject + public CombatTracker(Client client, ItemManager itemManager, LootTrackerStore lootStore) + { + this.client = client; + this.itemManager = itemManager; + this.lootStore = lootStore; + } + + public static boolean isDoomEncounter(String name) + { + if (name == null) + { + return false; + } + String lower = name.toLowerCase(Locale.ENGLISH); + return lower.contains("mokhaiotl") + || lower.contains("demonic larva") + || lower.contains("volatile earth"); + } + + public static boolean isDoomMinion(String name) + { + if (name == null) + { + return false; + } + String lower = name.toLowerCase(Locale.ENGLISH); + return lower.contains("demonic larva") || lower.contains("volatile earth"); + } + + public static String getCanonicalEncounterName(String npcName) + { + if (npcName == null) + { + return null; + } + if (isDoomEncounter(npcName)) + { + return DOOM_OF_MOKHAIOTL; + } + return npcName.trim(); + } + + public void onHitsplatApplied(HitsplatApplied event) + { + Actor actor = event.getActor(); + Hitsplat hitsplat = event.getHitsplat(); + if (actor == null || hitsplat == null) + { + return; + } + + Player localPlayer = client.getLocalPlayer(); + if (localPlayer == null) + { + return; + } + + // 1. Damage taken by local player -> Evaluate Defensive Prayer (Blocked vs Missed) + if (actor == localPlayer) + { + if (activeSession != null) + { + int amount = hitsplat.getAmount(); + activeSession.recordHitReceived(amount); + activeSession.setLastCombatTick(client.getTickCount()); + + evaluateIncomingHitPrayer(hitsplat, localPlayer); + } + return; + } + + // 2. Damage dealt to an NPC -> Track Attack & Offensive Accuracy + if (actor instanceof NPC && hitsplat.isMine()) + { + NPC npc = (NPC) actor; + String rawNpcName = Text.removeTags(npc.getName()); + if (rawNpcName == null || rawNpcName.isEmpty()) + { + return; + } + + String canonicalName = getCanonicalEncounterName(rawNpcName); + int tick = client.getTickCount(); + + if (activeSession != null && isDoomEncounter(canonicalName) && isDoomEncounter(activeSession.getTargetName())) + { + activeSession.setTargetIndex(npc.getIndex()); + activeSession.setLastCombatTick(tick); + } + else if (activeSession == null || activeSession.getTargetIndex() != npc.getIndex()) + { + activeSession = new FightSession(canonicalName, npc.getIndex(), tick, localPlayer.getWorldLocation(), getEquippedWeaponSpeed(), npc.getCombatLevel()); + } + + // Attacking confirms activity! Reset idle ticks immediately, even on 0 damage / shield / splash! + activeSession.resetTicksSinceLastAttack(); + + if (activeSession.getAttacksDealt() == 0 || (tick - activeSession.getLastAttackTick() >= activeSession.getWeaponSpeed())) + { + activeSession.recordAttack(getEquippedWeaponSpeed(), tick); + recordAttackStyleAndOverhead(npc); + } + + activeSession.recordDamageDealt(hitsplat.getAmount()); + activeSession.setLastCombatTick(tick); + } + } + + public void onAnimationChanged(AnimationChanged event) + { + Player localPlayer = client.getLocalPlayer(); + if (event.getActor() != localPlayer || localPlayer == null) + { + return; + } + + int anim = localPlayer.getAnimation(); + if (isAttackAnimation(anim)) + { + int tick = client.getTickCount(); + Actor interacting = localPlayer.getInteracting(); + String interactingName = (interacting instanceof NPC) ? Text.removeTags(interacting.getName()) : null; + String canonicalInteracting = getCanonicalEncounterName(interactingName); + + if (activeSession != null) + { + if (isDoomEncounter(canonicalInteracting) && isDoomEncounter(activeSession.getTargetName())) + { + if (interacting instanceof NPC) + { + activeSession.setTargetIndex(((NPC) interacting).getIndex()); + } + } + activeSession.resetTicksSinceLastAttack(); + activeSession.recordAttack(getEquippedWeaponSpeed(), tick); + activeSession.setLastCombatTick(tick); + + NPC targetNpc = findActiveNpc(); + if (targetNpc != null) + { + recordAttackStyleAndOverhead(targetNpc); + } + } + else if (interacting instanceof NPC) + { + NPC npc = (NPC) interacting; + activeSession = new FightSession(canonicalInteracting != null ? canonicalInteracting : interactingName, + npc.getIndex(), tick, localPlayer.getWorldLocation(), getEquippedWeaponSpeed(), npc.getCombatLevel()); + activeSession.recordAttack(getEquippedWeaponSpeed(), tick); + recordAttackStyleAndOverhead(npc); + } + } + lastPlayerAnimation = anim; + } + + public void onProjectileMoved(ProjectileMoved event) + { + Projectile p = event.getProjectile(); + if (p == null) + { + return; + } + + Player local = client.getLocalPlayer(); + if (local != null && p.getInteracting() == local) + { + lastIncomingProjectileTick = client.getTickCount(); + lastIncomingProjectileIsMagic = isMagicProjectileId(p.getId()); + } + } + + public void onActorDeath(ActorDeath event) + { + Actor actor = event.getActor(); + if (actor instanceof NPC) + { + handleNpcDeath((NPC) actor); + } + } + + public void onNpcDespawned(NpcDespawned event) + { + NPC npc = event.getNpc(); + if (npc != null && npc.isDead()) + { + handleNpcDeath(npc); + } + } + + public void handleNpcDeath(NPC npc) + { + if (activeSession == null || npc == null) + { + return; + } + + String deadNpcName = Text.removeTags(npc.getName()); + if (deadNpcName == null) + { + return; + } + + // Minions/adds in Doom (Demonic larva, Volatile earth) dying do not end the Doom fight session! + if (isDoomMinion(deadNpcName)) + { + log.debug("Doom minion {} died, continuing main fight session", deadNpcName); + return; + } + + String target = activeSession.getTargetName(); + boolean match = (npc.getIndex() == activeSession.getTargetIndex()); + if (!match && target != null) + { + String dLower = deadNpcName.toLowerCase(Locale.ENGLISH); + String tLower = target.toLowerCase(Locale.ENGLISH); + match = dLower.equals(tLower) || dLower.contains(tLower) || tLower.contains(dLower) + || (isDoomEncounter(tLower) && isDoomEncounter(dLower)); + } + + if (!match) + { + return; + } + + FightSession completed = activeSession; + activeSession = null; + + // If monster never drops ground loot (e.g. Barrows Brothers, Jad, Zuk), finalize immediately! + if (isZeroGroundLootMonster(target)) + { + finalizeSession(completed, 0, true); + } + else + { + // Otherwise store as pending death session. If loot drops within 4 ticks, onLootReceivedForEnemy will finalize with loot value. + // If no loot drops after 4 ticks, onGameTick will finalize with 0 loot value. + pendingDeathSession = completed; + pendingDeathTick = client.getTickCount(); + } + } + + public void onGameTick(GameTick event) + { + Player localPlayer = client.getLocalPlayer(); + if (localPlayer == null) + { + return; + } + + int currentTick = client.getTickCount(); + + // Finalize pending death session if 4 ticks elapsed without loot (monster dropped nothing) + if (pendingDeathSession != null) + { + if (currentTick - pendingDeathTick > 4) + { + finalizeSession(pendingDeathSession, 0, true); + pendingDeathSession = null; + } + } + + if (activeSession != null) + { + // Check if combat timed out (35 ticks = 21s of no combat activity) + if (currentTick - activeSession.getLastCombatTick() > 35) + { + activeSession = null; + return; + } + + // If localPlayer is actively interacting and in attack animation with target or encounter target, reset idle timer + Actor interacting = localPlayer.getInteracting(); + int anim = localPlayer.getAnimation(); + if (isAttackAnimation(anim) && isAttackingEncounterTarget(interacting)) + { + activeSession.resetTicksSinceLastAttack(); + } + + activeSession.onGameTick(localPlayer.getWorldLocation()); + } + + // Update inventory & equipment snapshot + ItemContainer inv = client.getItemContainer(InventoryID.INV); + if (inv != null) + { + lastInventorySnapshot = copyContainer(inv); + } + ItemContainer equip = client.getItemContainer(InventoryID.WORN); + if (equip != null) + { + lastEquipmentSnapshot = copyContainer(equip); + } + ItemContainer quiver = client.getItemContainer(InventoryID.DIZANAS_QUIVER_AMMO); + if (quiver != null) + { + lastQuiverSnapshot = copyContainer(quiver); + } + } + + private boolean isAttackingEncounterTarget(Actor interacting) + { + if (activeSession == null || !(interacting instanceof NPC)) + { + return false; + } + NPC npc = (NPC) interacting; + if (npc.getIndex() == activeSession.getTargetIndex()) + { + return true; + } + String target = activeSession.getTargetName(); + String npcName = Text.removeTags(npc.getName()); + if (isDoomEncounter(target) && isDoomEncounter(npcName)) + { + return true; + } + return false; + } + + public void onItemContainerChanged(ItemContainerChanged event) + { + if (activeSession == null && pendingDeathSession == null) + { + return; + } + + int cid = event.getContainerId(); + if (cid != InventoryID.INV && cid != InventoryID.WORN && cid != InventoryID.DIZANAS_QUIVER_AMMO) + { + return; + } + + ItemContainer currentContainer = event.getItemContainer(); + if (currentContainer == null) + { + return; + } + + Map snapshot; + if (cid == InventoryID.INV) + { + snapshot = lastInventorySnapshot; + } + else if (cid == InventoryID.WORN) + { + snapshot = lastEquipmentSnapshot; + } + else + { + snapshot = lastQuiverSnapshot; + } + + if (snapshot == null) + { + if (cid == InventoryID.INV) + { + lastInventorySnapshot = copyContainer(currentContainer); + } + else if (cid == InventoryID.WORN) + { + lastEquipmentSnapshot = copyContainer(currentContainer); + } + else + { + lastQuiverSnapshot = copyContainer(currentContainer); + } + return; + } + + Map currentMap = copyContainer(currentContainer); + for (Map.Entry entry : snapshot.entrySet()) + { + int id = entry.getKey(); + int prevQty = entry.getValue(); + int currentQty = currentMap.getOrDefault(id, 0); + + if (prevQty > currentQty) + { + int consumed = prevQty - currentQty; + categorizeAndRecordSupply(id, consumed); + } + } + + if (cid == InventoryID.INV) + { + lastInventorySnapshot = currentMap; + } + else if (cid == InventoryID.WORN) + { + lastEquipmentSnapshot = currentMap; + } + else + { + lastQuiverSnapshot = currentMap; + } + } + + private void recordAttackStyleAndOverhead(NPC npc) + { + if (activeSession == null || npc == null) + { + return; + } + + AttackCategory playerStyle = getPlayerAttackCategory(); + OverheadProtection npcProt = getNpcOverheadProtection(npc); + + boolean isOnPrayer = isAttackOnPrayer(playerStyle, npcProt); + boolean isCorrectWep = isWeaponCorrect(npc.getName(), playerStyle, isOnPrayer); + + activeSession.recordAttackAgainstEnemy(isOnPrayer, isCorrectWep); + } + + private void evaluateIncomingHitPrayer(Hitsplat hitsplat, Player localPlayer) + { + if (activeSession == null || localPlayer == null) + { + return; + } + + boolean protMelee = client.isPrayerActive(Prayer.PROTECT_FROM_MELEE) || client.isPrayerActive(Prayer.RP_DAMPEN_MELEE); + boolean protRange = client.isPrayerActive(Prayer.PROTECT_FROM_MISSILES) || client.isPrayerActive(Prayer.RP_DAMPEN_RANGED); + boolean protMage = client.isPrayerActive(Prayer.PROTECT_FROM_MAGIC) || client.isPrayerActive(Prayer.RP_DAMPEN_MAGIC); + boolean anyProt = protMelee || protRange || protMage; + + int amount = hitsplat.getAmount(); + boolean isShieldBlock = (amount == 0); + + NPC npc = findActiveNpc(); + String name = activeSession.getTargetName().toLowerCase(Locale.ENGLISH); + + int currentTick = client.getTickCount(); + boolean recentProjectile = (currentTick - lastIncomingProjectileTick <= 3); + + boolean isMeleeAttack = false; + boolean isRangedAttack = false; + boolean isMagicAttack = false; + + if (recentProjectile) + { + if (lastIncomingProjectileIsMagic) + { + isMagicAttack = true; + } + else + { + isRangedAttack = true; + } + } + else if (npc != null && localPlayer.getWorldLocation() != null && npc.getWorldLocation() != null + && localPlayer.getWorldLocation().distanceTo(npc.getWorldLocation()) <= 2) + { + isMeleeAttack = true; + } + else if (name.contains("cow") || name.contains("brutus") || name.contains("horror") || name.contains("abyssal") || name.contains("gargoyle") + || name.contains("dharok") || name.contains("guthan") || name.contains("torag") || name.contains("verac")) + { + isMeleeAttack = true; + } + else if (name.contains("karil") || name.contains("ranger")) + { + isRangedAttack = true; + } + else if (name.contains("ahrim") || name.contains("kraken") || name.contains("wizard")) + { + isMagicAttack = true; + } + + boolean blocked; + if (isMeleeAttack) + { + blocked = protMelee; + } + else if (isRangedAttack) + { + blocked = protRange; + } + else if (isMagicAttack) + { + blocked = protMage; + } + else + { + blocked = anyProt && isShieldBlock; + } + + if (blocked) + { + activeSession.recordPrayerBlock(); + } + else if (amount > 0) + { + // a 0 off prayer is fine (maybe on purpose); only count hits that actually did damage + activeSession.recordPrayerMiss(amount); + } + } + + private AttackCategory getPlayerAttackCategory() + { + ItemContainer equip = client.getItemContainer(InventoryID.WORN); + if (equip == null) + { + return AttackCategory.MELEE_CRUSH; + } + + Item weapon = equip.getItem(EquipmentInventorySlot.WEAPON.getSlotIdx()); + if (weapon == null || weapon.getId() <= 0) + { + return AttackCategory.MELEE_CRUSH; + } + + ItemStats stats = itemManager.getItemStats(weapon.getId()); + ItemComposition comp = itemManager.getItemComposition(weapon.getId()); + String name = (comp != null && comp.getName() != null) ? comp.getName().toLowerCase(Locale.ENGLISH) : ""; + + if (stats != null && stats.getEquipment() != null) + { + var eq = stats.getEquipment(); + if (name.contains("bow") || name.contains("blowpipe") || name.contains("dart") + || name.contains("knife") || name.contains("chinchompa") || name.contains("ballista") + || name.contains("javelin") || eq.getArange() > 20) + { + return AttackCategory.RANGED; + } + if (name.contains("staff") || name.contains("wand") || name.contains("trident") + || name.contains("shadow") || name.contains("sanguinesti") || eq.getAmagic() > 20) + { + return AttackCategory.MAGIC; + } + + if (eq.getAstab() >= eq.getAslash() && eq.getAstab() >= eq.getAcrush()) + { + return AttackCategory.MELEE_STAB; + } + if (eq.getAslash() >= eq.getAcrush()) + { + return AttackCategory.MELEE_SLASH; + } + return AttackCategory.MELEE_CRUSH; + } + + if (name.contains("bow") || name.contains("dart") || name.contains("knife") || name.contains("blowpipe")) + { + return AttackCategory.RANGED; + } + if (name.contains("staff") || name.contains("wand")) + { + return AttackCategory.MAGIC; + } + return AttackCategory.MELEE_SLASH; + } + + private OverheadProtection getNpcOverheadProtection(NPC npc) + { + if (npc == null) + { + return OverheadProtection.NONE; + } + + short[] spriteIds = npc.getOverheadSpriteIds(); + if (spriteIds != null && spriteIds.length > 0) + { + short sprite = spriteIds[0]; + if (sprite == 0 || sprite == 12) return OverheadProtection.MELEE; + if (sprite == 1 || sprite == 13) return OverheadProtection.RANGED; + if (sprite == 2 || sprite == 14) return OverheadProtection.MAGIC; + if (sprite == 6) return OverheadProtection.RANGE_MAGE; + if (sprite == 7 || sprite == 8 || sprite == 9) return OverheadProtection.ALL; + } + + int id = npc.getId(); + if (id == 7144) return OverheadProtection.MELEE; + if (id == 7145) return OverheadProtection.RANGED; + if (id == 7146) return OverheadProtection.MAGIC; + + if (id == 13597) return OverheadProtection.MELEE; + if (id == 13598) return OverheadProtection.RANGED; + if (id == 13599) return OverheadProtection.MAGIC; + + if (id == 11790) return OverheadProtection.MELEE; + if (id == 11791) return OverheadProtection.RANGED; + if (id == 11792) return OverheadProtection.MAGIC; + + if (id == 9022 || id == 9036) return OverheadProtection.MAGIC; + if (id == 9023 || id == 9037) return OverheadProtection.RANGED; + if (id == 9024 || id == 9038) return OverheadProtection.MELEE; + + if (id == 1158 || id == 6500 || id == 6501) return OverheadProtection.RANGE_MAGE; + if (id == 1160 || id == 6502 || id == 6503) return OverheadProtection.MELEE; + + return OverheadProtection.NONE; + } + + private boolean isAttackOnPrayer(AttackCategory style, OverheadProtection protection) + { + if (protection == OverheadProtection.NONE) + { + return false; + } + if (protection == OverheadProtection.ALL) + { + return true; + } + if (style.isMelee() && (protection == OverheadProtection.MELEE)) + { + return true; + } + if (style == AttackCategory.RANGED && (protection == OverheadProtection.RANGED || protection == OverheadProtection.RANGE_MAGE)) + { + return true; + } + if (style == AttackCategory.MAGIC && (protection == OverheadProtection.MAGIC || protection == OverheadProtection.RANGE_MAGE)) + { + return true; + } + return false; + } + + private boolean isWeaponCorrect(String npcName, AttackCategory style, boolean isOnPrayer) + { + if (isOnPrayer) + { + return false; // Attacking into overhead prayer is never correct + } + + if (npcName == null) + { + return true; + } + String lower = npcName.toLowerCase(Locale.ENGLISH); + + if (lower.contains("brutus") || lower.contains("gargoyle")) + { + return style == AttackCategory.MELEE_CRUSH; + } + if (lower.contains("dagannoth rex")) + { + return style == AttackCategory.MAGIC; + } + if (lower.contains("dagannoth prime")) + { + return style == AttackCategory.RANGED; + } + if (lower.contains("dagannoth supreme")) + { + return style.isMelee(); + } + if (lower.contains("corporeal beast")) + { + return style == AttackCategory.MELEE_STAB; + } + if (lower.contains("whisperer")) + { + return style == AttackCategory.MAGIC; + } + if (lower.contains("vardorvis") || lower.contains("duke sucellus")) + { + return style == AttackCategory.MELEE_SLASH; + } + if (lower.contains("leviathan")) + { + return style == AttackCategory.RANGED || style == AttackCategory.MAGIC; + } + if (lower.contains("ahrim")) + { + return style == AttackCategory.RANGED || style.isMelee(); + } + if (lower.contains("dharok") || lower.contains("guthan") || lower.contains("torag") || lower.contains("verac") || lower.contains("karil")) + { + return style == AttackCategory.MAGIC; + } + + return true; + } + + public static boolean isZeroGroundLootMonster(String name) + { + if (name == null) + { + return false; + } + String lower = name.toLowerCase(Locale.ENGLISH); + + // Barrows brothers: never drop ground loot! + if (lower.contains("dharok") || lower.contains("ahrim") || lower.contains("karil") + || lower.contains("guthan") || lower.contains("torag") || lower.contains("verac")) + { + return true; + } + + // Fight Caves & Inferno wave monsters & bosses + if (lower.contains("tztok-jad") || lower.contains("tzkal-zuk") || lower.contains("yt-hurkot") + || lower.contains("jal-") || lower.contains("tz-kih") || lower.contains("tz-kek")) + { + return true; + } + + // Fortis Colosseum wave monsters & bosses + if (lower.contains("fremennik warband") || lower.contains("serpent shaman") + || lower.contains("jaguar warrior") || lower.contains("sol heredit") + || lower.contains("manticore") || lower.contains("minotaur")) + { + return true; + } + + // Quest bosses / special encounters with no ground drops + if (lower.contains("galvek") || lower.contains("seren") || lower.contains("glough") + || lower.contains("vanstrom klause")) + { + return true; + } + + return false; + } + + private void finalizeSession(FightSession session, long lootValue, boolean incrementKills) + { + if (session == null) + { + return; + } + + String enemyName = session.getTargetName().trim(); + LootRecord record = lootStore.getOrCreateRecord(enemyName, "NPC"); + if (record != null) + { + if (incrementKills) + { + record.addKills(1); + } + record.addCombatFight(session, lootValue); + lootStore.saveDebounced(); + lootStore.notifyListeners(); + } + } + + private NPC findActiveNpc() + { + if (activeSession == null) + { + return null; + } + Player localPlayer = client.getLocalPlayer(); + if (localPlayer != null && localPlayer.getInteracting() instanceof NPC) + { + NPC npc = (NPC) localPlayer.getInteracting(); + if (npc.getIndex() == activeSession.getTargetIndex()) + { + return npc; + } + } + for (NPC npc : client.getNpcs()) + { + if (npc != null && npc.getIndex() == activeSession.getTargetIndex()) + { + return npc; + } + } + return null; + } + + private static boolean isMagicProjectileId(int id) + { + // Common magic projectile IDs: spells, tridents, shadow, barrages, elemental + return (id >= 93 && id <= 131) || id == 1040 || id == 1042 || id == 1251 || id == 1252 + || id == 1539 || id == 2147 || id == 1707 || id == 1046 || (id >= 360 && id <= 382); + } + + private void categorizeAndRecordSupply(int itemId, int quantity) + { + FightSession session = activeSession != null ? activeSession : pendingDeathSession; + if (session == null || quantity <= 0) + { + return; + } + + ItemComposition comp = itemManager.getItemComposition(itemId); + if (comp == null || comp.getName() == null) + { + return; + } + + String name = comp.getName(); + String lower = name.toLowerCase(Locale.ENGLISH); + int price = Math.max(0, itemManager.getItemPrice(itemId)); + + // Check inventory actions for "Eat" or "Drink" + String[] actions = comp.getInventoryActions(); + boolean hasEat = false; + boolean hasDrink = false; + if (actions != null) + { + for (String act : actions) + { + if ("Eat".equalsIgnoreCase(act)) + { + hasEat = true; + } + if ("Drink".equalsIgnoreCase(act)) + { + hasDrink = true; + } + } + } + + // 1. Food + int heal = getFoodHeal(lower); + if (hasEat || heal > 0) + { + if (heal <= 0) heal = 16; + long totalCost = (long) price * quantity; + session.recordFood(name, quantity, heal * quantity, totalCost); + return; + } + + // 2. Potions + if (hasDrink || lower.contains("potion") || lower.contains("brew") || lower.contains("restore") + || lower.contains("combat") || lower.contains("ranging") || lower.contains("stamina") + || lower.contains("antidote") || lower.contains("anti-venom") || lower.contains("sanfew") + || lower.contains("super defence") || lower.contains("super attack") || lower.contains("super strength") + || lower.contains("menaphite") || lower.contains("remedy") || lower.contains("guthix rest") + || lower.contains("serum") || lower.contains("elixir") || lower.contains("mix")) + { + int doses = 1; + if (lower.contains("(4)")) doses = 4; + else if (lower.contains("(3)")) doses = 3; + else if (lower.contains("(2)")) doses = 2; + long costPerDose = (long) (price / doses) * quantity; + + String baseName = name.replaceAll("\\(\\d\\)", "").trim(); + session.recordPotion(baseName, quantity, costPerDose); + + if (lower.contains("brew")) + { + session.addHpHealed(16 * quantity); + } + return; + } + + // 3. Ammo / Runes / Charges + if (lower.contains("dart") || lower.contains("arrow") || lower.contains("bolt") + || lower.contains("rune") || lower.contains("scale") || lower.contains("chinchompa") + || lower.contains("knife") || lower.contains("thrownaxe") || lower.contains("javelin")) + { + long totalCost = (long) price * quantity; + session.recordAmmo(name, quantity, totalCost); + } + } + + public void setDoomDelve(int delve) + { + if (delve <= 0) + { + return; + } + + if (activeSession != null && isDoomEncounter(activeSession.getTargetName())) + { + activeSession.setDelve(delve); + } + if (pendingDeathSession != null && isDoomEncounter(pendingDeathSession.getTargetName())) + { + pendingDeathSession.setDelve(delve); + } + attachDelveToLastDoomRecord(delve, -1); + } + + public void onDoomDelveCompleted(int delve, long lootValue) + { + FightSession session = activeSession != null ? activeSession : pendingDeathSession; + if (session != null && isDoomEncounter(session.getTargetName())) + { + if (delve > 0) + { + session.setDelve(delve); + } + finalizeSession(session, Math.max(0, lootValue), true); + activeSession = null; + pendingDeathSession = null; + } + else + { + attachDelveToLastDoomRecord(delve, lootValue); + } + } + + private void attachDelveToLastDoomRecord(int delve, long lootValue) + { + LootRecord record = lootStore.getRecord(DOOM_OF_MOKHAIOTL); + if (record != null && record.hasCombatStats()) + { + List history = record.getCombatStats().getFightHistory(); + if (!history.isEmpty()) + { + SingleFightRecord last = history.get(history.size() - 1); + boolean changed = false; + if (delve > 0 && (last.getDelve() <= 0 || last.getDelve() != delve)) + { + last.setDelve(delve); + record.getCombatStats().setLastDelve(delve); + if (delve > record.getCombatStats().getMaxDelve()) + { + record.getCombatStats().setMaxDelve(delve); + } + changed = true; + } + if (lootValue > 0 && last.getLootValue() == 0 && (System.currentTimeMillis() - last.getTimestamp() < 180_000)) + { + last.setLootValue(lootValue); + last.setNetProfit(lootValue - last.getSuppliesCost()); + changed = true; + } + if (changed) + { + lootStore.saveDebounced(); + lootStore.notifyListeners(); + } + } + } + } + + public void onLootReceivedForEnemy(String enemyName) + { + onLootReceivedForEnemy(enemyName, 0, 0); + } + + public void onLootReceivedForEnemy(String enemyName, long lootValue) + { + onLootReceivedForEnemy(enemyName, lootValue, 0); + } + + public void onLootReceivedForEnemy(String enemyName, long lootValue, int delve) + { + if (enemyName == null) + { + return; + } + + String lootEnemy = enemyName.trim().toLowerCase(Locale.ENGLISH); + if (isDoomEncounter(lootEnemy)) + { + lootEnemy = DOOM_OF_MOKHAIOTL.toLowerCase(Locale.ENGLISH); + if (delve > 0) + { + setDoomDelve(delve); + } + } + + // 1. Check if pendingDeathSession matches this loot + if (pendingDeathSession != null) + { + String target = pendingDeathSession.getTargetName().trim().toLowerCase(Locale.ENGLISH); + if (target.equals(lootEnemy) || target.contains(lootEnemy) || lootEnemy.contains(target) + || (target.contains("doom") && lootEnemy.contains("doom")) + || (target.contains("mokhaiotl") && lootEnemy.contains("mokhaiotl"))) + { + if (delve > 0) + { + pendingDeathSession.setDelve(delve); + } + finalizeSession(pendingDeathSession, lootValue, false); + pendingDeathSession = null; + return; + } + } + + // 2. Check if activeSession matches this loot + if (activeSession != null) + { + String target = activeSession.getTargetName().trim().toLowerCase(Locale.ENGLISH); + if (target.equals(lootEnemy) || target.contains(lootEnemy) || lootEnemy.contains(target) + || (target.contains("doom") && lootEnemy.contains("doom")) + || (target.contains("mokhaiotl") && lootEnemy.contains("mokhaiotl"))) + { + if (delve > 0) + { + activeSession.setDelve(delve); + } + finalizeSession(activeSession, lootValue, false); + activeSession = null; + return; + } + } + + // 3. For delayed reward chests (e.g. Doom of Mokhaiotl): + // If a fight record was already finalized recently with 0 lootValue, attach the chest loot! + String canonical = isDoomEncounter(enemyName) ? DOOM_OF_MOKHAIOTL : enemyName.trim(); + LootRecord record = lootStore.getRecord(canonical); + if (record != null && record.hasCombatStats()) + { + List history = record.getCombatStats().getFightHistory(); + if (!history.isEmpty()) + { + SingleFightRecord last = history.get(history.size() - 1); + boolean changed = false; + if (delve > 0 && (last.getDelve() <= 0 || last.getDelve() != delve)) + { + last.setDelve(delve); + record.getCombatStats().setLastDelve(delve); + if (delve > record.getCombatStats().getMaxDelve()) + { + record.getCombatStats().setMaxDelve(delve); + } + changed = true; + } + if (lootValue > 0 && last.getLootValue() == 0 && (System.currentTimeMillis() - last.getTimestamp() < 180_000)) + { + last.setLootValue(lootValue); + last.setNetProfit(lootValue - last.getSuppliesCost()); + changed = true; + } + if (changed) + { + lootStore.saveDebounced(); + lootStore.notifyListeners(); + } + } + } + } + + private int getEquippedWeaponSpeed() + { + ItemContainer equip = client.getItemContainer(InventoryID.WORN); + if (equip != null) + { + Item weapon = equip.getItem(EquipmentInventorySlot.WEAPON.getSlotIdx()); + if (weapon != null && weapon.getId() > 0) + { + ItemStats stats = itemManager.getItemStats(weapon.getId()); + if (stats != null && stats.getEquipment() != null) + { + int speed = stats.getEquipment().getAspeed(); + if (speed > 0) + { + return speed; + } + } + } + } + return 4; + } + + private boolean isAttackAnimation(int anim) + { + if (anim <= 0 || anim == 808 || anim == 819 || anim == 824 || anim == 829 || anim == 830) + { + return false; + } + Player localPlayer = client.getLocalPlayer(); + return localPlayer != null && (localPlayer.getInteracting() instanceof NPC || activeSession != null); + } + + private static int getFoodHeal(String lowerName) + { + if (lowerName.contains("anglerfish")) return 22; + if (lowerName.contains("manta ray")) return 22; + if (lowerName.contains("tuna potato")) return 22; + if (lowerName.contains("dark crab")) return 22; + if (lowerName.contains("sea turtle")) return 21; + if (lowerName.contains("shark")) return 20; + if (lowerName.contains("karambwan")) return 18; + if (lowerName.contains("monkfish")) return 16; + if (lowerName.contains("swordfish")) return 14; + if (lowerName.contains("bass")) return 13; + if (lowerName.contains("lobster")) return 12; + if (lowerName.contains("meat") || lowerName.contains("beef") || lowerName.contains("chicken")) return 3; + if (lowerName.contains("bread")) return 5; + if (lowerName.contains("cake")) return 4; + return 0; + } + + private static Map copyContainer(ItemContainer container) + { + Map map = new HashMap<>(); + for (Item item : container.getItems()) + { + if (item != null && item.getId() > 0 && item.getQuantity() > 0) + { + map.merge(item.getId(), item.getQuantity(), Integer::sum); + } + } + return map; + } +} diff --git a/src/main/java/com/example/essentialsplus/loot/combat/FightSession.java b/src/main/java/com/example/essentialsplus/loot/combat/FightSession.java new file mode 100644 index 0000000..4b79349 --- /dev/null +++ b/src/main/java/com/example/essentialsplus/loot/combat/FightSession.java @@ -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 foodUsed = new LinkedHashMap<>(); + private final Map potionsUsed = new LinkedHashMap<>(); + private final Map 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()); + } +} diff --git a/src/main/java/com/example/essentialsplus/loot/combat/SingleFightRecord.java b/src/main/java/com/example/essentialsplus/loot/combat/SingleFightRecord.java new file mode 100644 index 0000000..195edda --- /dev/null +++ b/src/main/java/com/example/essentialsplus/loot/combat/SingleFightRecord.java @@ -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 foodUsed = new LinkedHashMap<>(); + private Map potionsUsed = new LinkedHashMap<>(); + private Map 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; +} diff --git a/src/main/java/com/example/essentialsplus/loot/droptable/DropLine.java b/src/main/java/com/example/essentialsplus/loot/droptable/DropLine.java new file mode 100644 index 0000000..ef21da5 --- /dev/null +++ b/src/main/java/com/example/essentialsplus/loot/droptable/DropLine.java @@ -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; + } + } +} diff --git a/src/main/java/com/example/essentialsplus/loot/droptable/DropTable.java b/src/main/java/com/example/essentialsplus/loot/droptable/DropTable.java new file mode 100644 index 0000000..deedd56 --- /dev/null +++ b/src/main/java/com/example/essentialsplus/loot/droptable/DropTable.java @@ -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 variants; + + public static final class Variant + { + public final String name; + public final List lines; + + Variant(String name, List 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 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 buildVariants(String page, List lines) + { + Map> groups = new LinkedHashMap<>(); + for (DropLine l : lines) + { + groups.computeIfAbsent(l.source, k -> new ArrayList<>()).add(l); + } + List out = new ArrayList<>(); + if (groups.isEmpty()) + { + return out; + } + int max = groups.values().stream().mapToInt(List::size).max().orElse(0); + List mains = new ArrayList<>(); + List extras = new ArrayList<>(); + for (Map.Entry> 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 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 rates(Variant variant) + { + Map> 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 out = new LinkedHashMap<>(); + for (Map.Entry> e : byItem.entrySet()) + { + List 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); + } +} diff --git a/src/main/java/com/example/essentialsplus/loot/gear/CombatChoice.java b/src/main/java/com/example/essentialsplus/loot/gear/CombatChoice.java new file mode 100644 index 0000000..85aef2e --- /dev/null +++ b/src/main/java/com/example/essentialsplus/loot/gear/CombatChoice.java @@ -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); + } +} diff --git a/src/main/java/com/example/essentialsplus/loot/gear/DpsCalc.java b/src/main/java/com/example/essentialsplus/loot/gear/DpsCalc.java new file mode 100644 index 0000000..07e6b3f --- /dev/null +++ b/src/main/java/com/example/essentialsplus/loot/gear/DpsCalc.java @@ -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"); + } +} diff --git a/src/main/java/com/example/essentialsplus/loot/gear/FightService.java b/src/main/java/com/example/essentialsplus/loot/gear/FightService.java new file mode 100644 index 0000000..2af689e --- /dev/null +++ b/src/main/java/com/example/essentialsplus/loot/gear/FightService.java @@ -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> bank; + private final List> listeners = new CopyOnWriteArrayList<>(); + + /** base item (source WORN) per canonical id, null value = not equipable */ + private final Map 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 pool = Collections.emptyList(); + private long poolHash; + private int targetNpcIndex = -1; + private FightSnapshot.Target lastTarget; + private Monster lastMonster; + private volatile long gearVersion; + private volatile List 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> 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 l) + { + listeners.add(l); + l.accept(last); + } + + public void removeListener(Consumer 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 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 l : listeners) + { + l.accept(snap); + } + } + + private void startSearch(long version, Loadout worn, PlayerSetup setup, FightSnapshot.Target target) + { + List items = pool; + Monster m = target != null ? target.calcMonster() : null; + if (m == null || items.isEmpty()) + { + best = Collections.emptyList(); + bestVersion = version; + return; + } + executor.execute(() -> + { + List 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 b = bank.get(); + if (b != null) + { + h = h * 31 + b.size(); + for (Map.Entry e : b.entrySet()) + { + h += e.getKey() * 1_000_003L ^ (e.getValue() > 0 ? 1 : 0); + } + } + return h; + } + + private List readPool(Loadout worn) + { + Map 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 b = bank.get(); + if (b != null) + { + for (Map.Entry 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 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; + } +} diff --git a/src/main/java/com/example/essentialsplus/loot/gear/FightSnapshot.java b/src/main/java/com/example/essentialsplus/loot/gear/FightSnapshot.java new file mode 100644 index 0000000..3e09c82 --- /dev/null +++ b/src/main/java/com/example/essentialsplus/loot/gear/FightSnapshot.java @@ -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 pool; + public final Target target; + public final Session session; + /** Best setup per style against the target, highest DPS first */ + public final List 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 pool, Target target, + Session session, List 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; + } +} diff --git a/src/main/java/com/example/essentialsplus/loot/gear/GearItem.java b/src/main/java/com/example/essentialsplus/loot/gear/GearItem.java new file mode 100644 index 0000000..1de539e --- /dev/null +++ b/src/main/java/com/example/essentialsplus/loot/gear/GearItem.java @@ -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; + } + } +} diff --git a/src/main/java/com/example/essentialsplus/loot/gear/GearOptimizer.java b/src/main/java/com/example/essentialsplus/loot/gear/GearOptimizer.java new file mode 100644 index 0000000..95799c1 --- /dev/null +++ b/src/main/java/com/example/essentialsplus/loot/gear/GearOptimizer.java @@ -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

" + escape(text) + "
"); + l.setFont(FontManager.getRunescapeSmallFont()); + l.setForeground(new Color(200, 170, 110)); + l.setBorder(BorderFactory.createCompoundBorder( + BorderFactory.createMatteBorder(0, 2, 0, 0, Ui.WARN), + BorderFactory.createEmptyBorder(3, 6, 3, 4))); + return l; + } + + private static String escape(String s) + { + return s.replace("&", "&").replace("<", "<").replace(">", ">"); + } +} diff --git a/src/main/java/com/example/essentialsplus/progress/ui/ProgressRow.java b/src/main/java/com/example/essentialsplus/progress/ui/ProgressRow.java new file mode 100644 index 0000000..b9e5b26 --- /dev/null +++ b/src/main/java/com/example/essentialsplus/progress/ui/ProgressRow.java @@ -0,0 +1,135 @@ +package com.example.essentialsplus.progress.ui; + +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.Image; +import java.awt.event.MouseAdapter; +import java.awt.event.MouseEvent; +import java.util.function.Supplier; +import javax.swing.JComponent; +import javax.swing.SwingUtilities; +import net.runelite.client.ui.FontManager; +import net.runelite.client.ui.PluginPanel; + +/** Clickable list row: icon, name, subtitle, "done / total" and a completion bar. */ +class ProgressRow extends JComponent +{ + private static final int ICON = 32; + + private final Supplier icon; + private final String title; + private final String subtitle; + private final int done; + private final int total; + private final boolean clickable; + private boolean hover; + + ProgressRow(Supplier icon, String title, String subtitle, int done, int total, Runnable onClick) + { + this.icon = icon; + this.title = title; + this.subtitle = subtitle; + this.done = done; + this.total = total; + this.clickable = onClick != null; + setToolTipText(title); + if (clickable) + { + 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(); + } + } + }); + } + } + + @Override + public Dimension getPreferredSize() + { + return new Dimension(PluginPanel.PANEL_WIDTH - 24, 48); + } + + @Override + protected void paintComponent(Graphics g0) + { + Graphics2D g = Ui.prepare(g0); + int w = getWidth(); + int h = getHeight(); + boolean complete = total > 0 && done >= total; + g.setColor(hover ? Ui.CARD_HOVER : Ui.CARD); + g.fillRoundRect(0, 0, w, h, 8, 8); + if (complete) + { + g.setColor(Ui.UP); + g.fillRoundRect(0, 0, 3, h, 3, 3); + } + + int pad = 8; + int x = pad; + if (icon != null) + { + ProgressUi.icon(g, icon.get(), x - 2, (h - ICON) / 2, ICON, false); + x += ICON + 4; + } + int right = w - pad - (clickable ? 10 : 0); + if (clickable) + { + g.setFont(FontManager.getRunescapeBoldFont().deriveFont(14f)); + g.setColor(hover ? Ui.ACCENT : Ui.FAINT); + Ui.drawRight(g, "›", w - pad + 1, h / 2 + 5); + } + + Font small = FontManager.getRunescapeSmallFont(); + g.setFont(small); + FontMetrics sfm = g.getFontMetrics(); + String count = total > 0 ? done + "/" + total : ""; + int countW = sfm.stringWidth(count); + + g.setFont(FontManager.getRunescapeBoldFont()); + FontMetrics fm = g.getFontMetrics(); + int base = 6 + fm.getAscent(); + g.setColor(complete ? Ui.UP : Ui.TEXT); + g.drawString(ProgressUi.ellipsize(fm, title, right - x - countW - 6), x, base); + g.setFont(small); + g.setColor(Ui.MUTED); + Ui.drawRight(g, count, right, base); + + int subBase = base + sfm.getAscent() + 3; + String pct = total > 0 ? ProgressUi.pct(done, total) : ""; + g.setColor(complete ? Ui.UP : Ui.TEXT); + Ui.drawRight(g, pct, right, subBase); + if (subtitle != null) + { + g.setColor(Ui.FAINT); + g.drawString(ProgressUi.ellipsize(sfm, subtitle, right - x - sfm.stringWidth(pct) - 6), x, subBase); + } + ProgressUi.bar(g, x, h - 9, right - x, 4, ProgressUi.ratio(done, total), ProgressUi.progressColor(done, total)); + g.dispose(); + } +} diff --git a/src/main/java/com/example/essentialsplus/progress/ui/ProgressUi.java b/src/main/java/com/example/essentialsplus/progress/ui/ProgressUi.java new file mode 100644 index 0000000..01017c8 --- /dev/null +++ b/src/main/java/com/example/essentialsplus/progress/ui/ProgressUi.java @@ -0,0 +1,137 @@ +package com.example.essentialsplus.progress.ui; + +import com.example.essentialsplus.ui.Ui; +import java.awt.BasicStroke; +import java.awt.Color; +import java.awt.FontMetrics; +import java.awt.Graphics2D; +import java.awt.Image; +import java.util.Locale; + +/** Colours and painting helpers shared by the progress views. */ +final class ProgressUi +{ + static final Color TRACK = new Color(255, 255, 255, 22); + static final Color DONE_BG = new Color(34, 48, 38); + static final Color[] CA_TIER_COLORS = { + new Color(152, 206, 110), new Color(96, 176, 232), new Color(232, 164, 64), + new Color(206, 110, 214), new Color(236, 96, 96), new Color(255, 210, 74), + }; + static final Color[] DIARY_TIER_COLORS = {CA_TIER_COLORS[0], CA_TIER_COLORS[1], CA_TIER_COLORS[2], CA_TIER_COLORS[3]}; + + private ProgressUi() + { + } + + static double ratio(int done, int total) + { + return total > 0 ? Math.max(0, Math.min(1, (double) done / total)) : 0; + } + + static String pct(int done, int total) + { + if (total <= 0) + { + return "-"; + } + double p = 100.0 * done / total; + return (p >= 100 || p == 0 ? String.format(Locale.US, "%.0f", p) : String.format(Locale.US, "%.1f", p)) + "%"; + } + + /** Gold while in progress, green when complete. */ + static Color progressColor(int done, int total) + { + return total > 0 && done >= total ? Ui.UP : Ui.GOLD; + } + + static void bar(Graphics2D g, int x, int y, int w, int h, double ratio, Color color) + { + g.setColor(TRACK); + g.fillRoundRect(x, y, w, h, h, h); + int fill = (int) Math.round(w * ratio); + if (fill > 0) + { + g.setColor(color); + g.fillRoundRect(x, y, Math.max(fill, h), h, h, h); + } + } + + /** Round check mark: filled green when done, empty ring otherwise. */ + static void check(Graphics2D g, int x, int y, int size, boolean done) + { + if (done) + { + g.setColor(Ui.UP); + g.fillOval(x, y, size, size); + g.setColor(new Color(20, 32, 24)); + g.setStroke(new BasicStroke(2f, BasicStroke.CAP_ROUND, BasicStroke.JOIN_ROUND)); + g.drawPolyline(new int[]{x + size * 27 / 100, x + size * 44 / 100, x + size * 74 / 100}, + new int[]{y + size * 52 / 100, y + size * 68 / 100, y + size * 34 / 100}, 3); + } + else + { + g.setColor(Ui.FAINT); + g.setStroke(new BasicStroke(1.5f)); + g.drawOval(x + 1, y + 1, size - 2, size - 2); + } + } + + /** Small rounded label, e.g. a tier name. */ + static int chip(Graphics2D g, String text, int x, int baseline, Color color) + { + FontMetrics fm = g.getFontMetrics(); + int w = fm.stringWidth(text) + 8; + int h = fm.getAscent() + 4; + g.setColor(Ui.alpha(color, 40)); + g.fillRoundRect(x, baseline - fm.getAscent() - 2, w, h, 6, 6); + g.setColor(color); + g.drawString(text, x + 4, baseline); + return w; + } + + /** Draws an icon image centered in a square box; nothing if not loaded yet. */ + static void icon(Graphics2D g, Image img, int x, int y, int box, boolean faded) + { + if (img == null) + { + return; + } + int w = img.getWidth(null); + int h = img.getHeight(null); + if (w <= 0 || h <= 0) + { + return; + } + double scale = Math.min(1.0, Math.min((double) box / w, (double) box / h)); + int dw = (int) (w * scale); + int dh = (int) (h * scale); + java.awt.Composite old = g.getComposite(); + if (faded) + { + g.setComposite(java.awt.AlphaComposite.getInstance(java.awt.AlphaComposite.SRC_OVER, 0.28f)); + } + g.drawImage(img, x + (box - dw) / 2, y + (box - dh) / 2, dw, dh, null); + g.setComposite(old); + } + + static String ellipsize(FontMetrics fm, String s, int max) + { + if (s == null) + { + return ""; + } + 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 ""; + } +} diff --git a/src/main/java/com/example/essentialsplus/progress/ui/TaskRow.java b/src/main/java/com/example/essentialsplus/progress/ui/TaskRow.java new file mode 100644 index 0000000..1d27acf --- /dev/null +++ b/src/main/java/com/example/essentialsplus/progress/ui/TaskRow.java @@ -0,0 +1,152 @@ +package com.example.essentialsplus.progress.ui; + +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.Graphics; +import java.awt.Graphics2D; +import java.awt.event.MouseAdapter; +import java.awt.event.MouseEvent; +import java.util.List; +import javax.swing.BorderFactory; +import javax.swing.JComponent; +import javax.swing.JLabel; +import javax.swing.JPanel; +import javax.swing.SwingUtilities; +import net.runelite.client.ui.FontManager; +import net.runelite.client.ui.PluginPanel; + +/** + * A single task: check mark, name, description and a tier chip. Rows with details (requirements, notes) + * expand on click. + */ +class TaskRow extends JPanel +{ + /** text column width for html wrapping */ + private static final int TEXT_WIDTH = PluginPanel.PANEL_WIDTH - 24 - 44; + private static final int CHECK = 14; + + private final boolean done; + private boolean partial; + private boolean hover; + + /** + * @param chips (text, color) pairs drawn under the name, may be empty + * @param details lines shown when expanded; no expansion when empty + */ + TaskRow(String name, String description, List chips, List details, boolean done, + boolean expanded, Runnable onToggle) + { + super(new BorderLayout(0, 0)); + this.done = done; + setOpaque(false); + setBorder(BorderFactory.createEmptyBorder(6, 8 + CHECK + 8, 6, 8)); + + StringBuilder html = new StringBuilder("
"); + html.append("").append(escape(name)).append(""); + if (description != null && !description.isEmpty()) + { + html.append("
").append(escape(description)).append(""); + } + if (!chips.isEmpty()) + { + html.append("
"); + for (Object[] chip : chips) + { + html.append("").append(escape((String) chip[0])).append("  "); + } + } + boolean expandable = !details.isEmpty(); + if (expandable && expanded) + { + html.append("
"); + for (String d : details) + { + html.append("
").append(escape(d)).append(""); + } + } + if (expandable) + { + html.append("
").append(expanded ? "▲ less" : "▼ requirements").append(""); + } + JLabel label = new JLabel(html.append("
").toString()); + label.setFont(FontManager.getRunescapeSmallFont()); + add(label, BorderLayout.CENTER); + + if (expandable || onToggle != null) + { + setCursor(Cursor.getPredefinedCursor(Cursor.HAND_CURSOR)); + MouseAdapter mouse = 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) && onToggle != null) + { + onToggle.run(); + } + } + }; + addMouseListener(mouse); + label.addMouseListener(mouse); + } + } + + /** Started but not finished: half-filled check. */ + void setPartial(boolean partial) + { + this.partial = partial; + } + + @Override + public Dimension getMaximumSize() + { + return new Dimension(Integer.MAX_VALUE, getPreferredSize().height); + } + + @Override + protected void paintComponent(Graphics g0) + { + Graphics2D g = Ui.prepare(g0); + g.setColor(done ? ProgressUi.DONE_BG : hover ? Ui.CARD_HOVER : Ui.CARD); + g.fillRoundRect(0, 0, getWidth(), getHeight(), 8, 8); + if (partial && !done) + { + g.setColor(Ui.WARN); + g.setStroke(new java.awt.BasicStroke(1.5f)); + g.drawOval(9, 9, CHECK - 2, CHECK - 2); + g.fillArc(9, 9, CHECK - 2, CHECK - 2, 90, 180); + } + else + { + ProgressUi.check(g, 8, 8, CHECK, done); + } + g.dispose(); + } + + private static String hex(Color c) + { + return String.format("#%02x%02x%02x", c.getRed(), c.getGreen(), c.getBlue()); + } + + private static String escape(String s) + { + return s.replace("&", "&").replace("<", "<").replace(">", ">"); + } +} diff --git a/src/main/java/com/example/essentialsplus/skills/SkillGoals.java b/src/main/java/com/example/essentialsplus/skills/SkillGoals.java new file mode 100644 index 0000000..bb6bf22 --- /dev/null +++ b/src/main/java/com/example/essentialsplus/skills/SkillGoals.java @@ -0,0 +1,130 @@ +package com.example.essentialsplus.skills; + +import java.util.HashMap; +import java.util.Map; +import net.runelite.api.Experience; +import net.runelite.client.config.ConfigManager; + +/** Level goal per skill and character, with the xp the skill had when the goal was set. Stored in the config. */ +public class SkillGoals +{ + private static final String GROUP = com.example.essentialsplus.EssentialsPlusConfig.GROUP; + + /** A target level and the xp at the moment it was set (start of the progress bar). */ + public static final class Goal + { + public final int level; + public final int startXp; + + Goal(int level, int startXp) + { + this.level = level; + this.startXp = startXp; + } + + public int targetXp() + { + return Experience.getXpForLevel(level); + } + + /** Progress from the start xp to the goal, 0..1. */ + public double progress(int xp) + { + int target = targetXp(); + if (xp >= target) + { + return 1; + } + int span = target - startXp; + return span <= 0 ? 0 : Math.max(0, (double) (xp - startXp) / span); + } + + public int xpLeft(int xp) + { + return Math.max(0, targetXp() - xp); + } + } + + private final ConfigManager configManager; + private final Map> cache = new HashMap<>(); + + public SkillGoals(ConfigManager configManager) + { + this.configManager = configManager; + } + + public synchronized Goal get(String account, String skill) + { + return account == null ? null : load(account).get(skill); + } + + public synchronized void set(String account, String skill, int level, int currentXp) + { + if (account == null) + { + return; + } + Map goals = load(account); + Goal old = goals.get(skill); + // keep the original start when only the target changes, unless the old goal was already reached + int start = old != null && currentXp < old.targetXp() ? Math.min(old.startXp, currentXp) : currentXp; + goals.put(skill, new Goal(level, start)); + save(account, goals); + } + + public synchronized void clear(String account, String skill) + { + if (account == null) + { + return; + } + Map goals = load(account); + if (goals.remove(skill) != null) + { + save(account, goals); + } + } + + private Map load(String account) + { + return cache.computeIfAbsent(account, a -> + { + Map goals = new HashMap<>(); + String raw = configManager.getConfiguration(GROUP, configKey(a)); + if (raw != null) + { + for (String entry : raw.split(",")) + { + String[] kv = entry.split("[=:]"); + try + { + goals.put(kv[0], new Goal(Integer.parseInt(kv[1]), Integer.parseInt(kv[2]))); + } + catch (RuntimeException ignored) + { + } + } + } + return goals; + }); + } + + private void save(String account, Map goals) + { + StringBuilder sb = new StringBuilder(); + goals.forEach((skill, g) -> sb.append(sb.length() == 0 ? "" : ",").append(skill).append('=').append(g.level).append(':').append(g.startXp)); + if (sb.length() == 0) + { + configManager.unsetConfiguration(GROUP, configKey(account)); + } + else + { + configManager.setConfiguration(GROUP, configKey(account), sb.toString()); + } + } + + private static String configKey(String account) + { + return "skillGoals_" + account.replaceAll("[^A-Za-z0-9_-]", "_"); + } +} diff --git a/src/main/java/com/example/essentialsplus/skills/SkillHistory.java b/src/main/java/com/example/essentialsplus/skills/SkillHistory.java new file mode 100644 index 0000000..344c8d8 --- /dev/null +++ b/src/main/java/com/example/essentialsplus/skills/SkillHistory.java @@ -0,0 +1,372 @@ +package com.example.essentialsplus.skills; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import com.example.essentialsplus.ui.TimeFilter; +import com.example.essentialsplus.ui.TimeRange; +import net.runelite.api.Experience; +import net.runelite.api.Skill; + +/** Experience timeline of one character. */ +public class SkillHistory +{ + public static final Skill[] SKILLS = Arrays.stream(Skill.values()).filter(s -> s != Skill.OVERALL).toArray(Skill[]::new); + + private static final long MINUTE = 60_000L; + /** gaps between samples up to this long count as active training time */ + private static final long ACTIVE_GAP = 5 * MINUTE; + + public String key; + public String name; + /** skill names in the order of {@link XpSample#x}, so files survive new skills being added */ + public List skills = new ArrayList<>(); + public List samples = new ArrayList<>(); + + public long resetSessionAt; + public long resetNowAt; + + public transient boolean dirty; + + public SkillHistory() + { + } + + public SkillHistory(String key, String name) + { + this.key = key; + this.name = name; + this.skills = currentNames(); + } + + static List currentNames() + { + List names = new ArrayList<>(); + for (Skill s : SKILLS) + { + names.add(s.name()); + } + return names; + } + + /** Maps arrays of older files onto the current skill order. */ + void normalize() + { + if (samples == null) + { + samples = new ArrayList<>(); + } + samples.removeIf(s -> s == null || s.x == null); + List current = currentNames(); + if (skills == null || skills.isEmpty()) + { + skills = current; + } + if (skills.equals(current)) + { + return; + } + int[] map = new int[current.size()]; + for (int i = 0; i < map.length; i++) + { + map[i] = skills.indexOf(current.get(i)); + } + for (XpSample s : samples) + { + int[] x = new int[map.length]; + for (int i = 0; i < map.length; i++) + { + x[i] = map[i] >= 0 && map[i] < s.x.length ? s.x[map[i]] : 0; + } + s.x = x; + } + skills = current; + dirty = true; + } + + public synchronized XpSample latest() + { + return samples.isEmpty() ? null : samples.get(samples.size() - 1); + } + + /** + * Adds a sample if anything changed. Within the same minute the newest sample is overwritten + * (xp only grows, so the minute's last value is all that matters). + */ + synchronized boolean record(int[] xp, long now) + { + XpSample last = latest(); + if (last != null && Arrays.equals(last.x, xp)) + { + return false; + } + if (last != null) + { + for (int i = 0; i < xp.length; i++) + { + if (xp[i] < last.get(i)) + { + // xp never drops - stats are still loading after login/hop + return false; + } + } + } + if (last != null && samples.size() > 1 && last.t / MINUTE == now / MINUTE) + { + last.t = now; + last.x = xp; + } + else + { + samples.add(new XpSample(now, xp)); + } + dirty = true; + return true; + } + + /** Baseline at {@code start}: last sample at or before it, else the first sample. */ + public synchronized XpSample at(long start) + { + XpSample base = null; + for (XpSample s : samples) + { + if (s.t <= start) + { + base = s; + } + else + { + break; + } + } + return base != null ? base : samples.isEmpty() ? null : samples.get(0); + } + + /** Samples strictly after {@code start}, preceded by the baseline. */ + public synchronized List range(long start) + { + List out = new ArrayList<>(); + XpSample base = at(start); + if (base == null) + { + return out; + } + out.add(base); + for (XpSample s : samples) + { + if (s.t > start && s != base) + { + out.add(s); + } + } + return out; + } + + public synchronized void reset(TimeRange range) + { + if (range == TimeRange.SESSION) + { + resetSessionAt = System.currentTimeMillis(); + dirty = true; + } + else if (range == TimeRange.CURRENT) + { + resetNowAt = System.currentTimeMillis(); + dirty = true; + } + } + + /** Start of the current training streak of a skill (or the total for {@code skill < 0}); {@link Long#MAX_VALUE} when idle. */ + public synchronized long currentStart(int skill, long now) + { + return TimeFilter.streakStart(samples.size(), i -> samples.get(i).t, + i -> i > 0 && samples.get(i).value(skill) > samples.get(i - 1).value(skill), now); + } + + /** Start of the filter's range per skill: the same for every skill, except per training streak for "Now". */ + public synchronized long[] starts(TimeFilter filter) + { + long[] out = new long[SKILLS.length]; + long start = filter.start(); + long now = System.currentTimeMillis(); + for (int i = 0; i < out.length; i++) + { + out[i] = filter.isCurrent() ? currentStart(i, now) : start; + } + return out; + } + + /** Common start of the filter's range: the earliest active streak for "Now" (now when idle). */ + public synchronized long start(TimeFilter filter) + { + if (!filter.isCurrent()) + { + return filter.start(); + } + long now = System.currentTimeMillis(); + long start = currentStart(-1, now); + return start == Long.MAX_VALUE ? now : start; + } + + /** Start of the rate calculation range per skill, clamped by Reset Hourly markers. */ + public synchronized long[] rateStarts(TimeFilter filter) + { + long[] out = new long[SKILLS.length]; + long now = System.currentTimeMillis(); + for (int i = 0; i < out.length; i++) + { + if (filter.isCurrent()) + { + long streak = currentStart(i, now); + out[i] = streak == Long.MAX_VALUE ? Long.MAX_VALUE : Math.max(streak, resetNowAt); + } + else if (filter.getRange() == TimeRange.SESSION) + { + out[i] = Math.max(filter.start(), resetSessionAt); + } + else + { + out[i] = filter.start(); + } + } + return out; + } + + /** Common start of the rate calculation range, clamped by Reset Hourly markers. */ + public synchronized long rateStart(TimeFilter filter) + { + if (filter.isCurrent()) + { + long now = System.currentTimeMillis(); + long start = currentStart(-1, now); + long base = start == Long.MAX_VALUE ? now : start; + return Math.max(base, resetNowAt); + } + if (filter.getRange() == TimeRange.SESSION) + { + return Math.max(filter.start(), resetSessionAt); + } + return filter.start(); + } + + /** Xp gained per skill in the filter's range; skills idle for "Now" count as nothing. */ + public synchronized long[] gained(TimeFilter filter) + { + if (!filter.isCurrent()) + { + return gained(filter.start()); + } + long[] starts = starts(filter); + long[] g = new long[SKILLS.length]; + XpSample last = latest(); + for (int i = 0; i < g.length; i++) + { + XpSample base = starts[i] == Long.MAX_VALUE ? null : at(starts[i]); + g[i] = base == null || last == null ? 0 : Math.max(0, last.get(i) - base.get(i)); + } + return g; + } + + /** Xp gained in a single skill since {@code start}. */ + public synchronized long gainedSince(long start, int skill) + { + if (start == Long.MAX_VALUE || skill < 0 || skill >= SKILLS.length) + { + return 0; + } + XpSample base = at(start); + XpSample last = latest(); + return base == null || last == null ? 0 : Math.max(0, last.get(skill) - base.get(skill)); + } + + /** Total xp gained across all skills since {@code start}. */ + public synchronized long gainedTotalSince(long start) + { + if (start == Long.MAX_VALUE) + { + return 0; + } + XpSample base = at(start); + XpSample last = latest(); + return base == null || last == null ? 0 : Math.max(0, last.total() - base.total()); + } + + /** Xp gained per skill since {@code start}. */ + public synchronized long[] gained(long start) + { + long[] g = new long[SKILLS.length]; + XpSample base = at(start); + XpSample last = latest(); + if (base == null || last == null) + { + return g; + } + for (int i = 0; i < g.length; i++) + { + g[i] = Math.max(0, last.get(i) - base.get(i)); + } + return g; + } + + /** Time spent gaining xp since {@code start} (sum of short gaps between xp changes). */ + public synchronized long activeMillis(long start, int skill) + { + List list = range(start); + long active = 0; + for (int i = 1; i < list.size(); i++) + { + XpSample a = list.get(i - 1); + XpSample b = list.get(i); + long gap = b.t - Math.max(a.t, start); + if (gap > 0 && gap <= ACTIVE_GAP && b.value(skill) > a.value(skill)) + { + active += gap; + } + } + return active; + } + + /** Level-ups since {@code start}, oldest first. */ + public synchronized List levelUps(long start) + { + List out = new ArrayList<>(); + List list = range(start); + for (int i = 1; i < list.size(); i++) + { + XpSample a = list.get(i - 1); + XpSample b = list.get(i); + for (int s = 0; s < SKILLS.length; s++) + { + int from = level(a.get(s)); + int to = level(b.get(s)); + if (to > from) + { + out.add(new LevelUp(b.t, s, from, to)); + } + } + } + return out; + } + + public static int level(int xp) + { + return Experience.getLevelForXp(Math.max(0, xp)); + } + + /** One or more levels gained in a skill between two samples. */ + public static final class LevelUp + { + public final long t; + public final int skill; + public final int from; + public final int to; + + LevelUp(long t, int skill, int from, int to) + { + this.t = t; + this.skill = skill; + this.from = from; + this.to = to; + } + } +} diff --git a/src/main/java/com/example/essentialsplus/skills/SkillStore.java b/src/main/java/com/example/essentialsplus/skills/SkillStore.java new file mode 100644 index 0000000..e8a7ffe --- /dev/null +++ b/src/main/java/com/example/essentialsplus/skills/SkillStore.java @@ -0,0 +1,164 @@ +package com.example.essentialsplus.skills; + +import com.example.essentialsplus.WealthStore; +import com.example.essentialsplus.ui.TimeRange; +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 SkillHistory} files (one per character) in {@code essentials-plus/skills}. */ +@Slf4j +@Singleton +public class SkillStore +{ + public static final File DIR = new File(WealthStore.DIR, "skills"); + + private static final long HOUR = 3_600_000L; + private static final long DAY = 24 * HOUR; + + private final Gson gson; + private final Map histories = new ConcurrentHashMap<>(); + + @Inject + public SkillStore(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)) + { + SkillHistory h = gson.fromJson(reader, SkillHistory.class); + if (h != null && h.key != null) + { + h.normalize(); + histories.putIfAbsent(h.key, h); + } + } + catch (Exception e) + { + log.warn("Skills: could not read {}", f, e); + } + } + } + + public SkillHistory get(String key) + { + return key == null ? null : histories.get(key); + } + + public void resetHourly(String key, TimeRange range) + { + SkillHistory h = get(key); + if (h != null) + { + h.reset(range); + save(h); + } + } + + /** All characters, most recently active first. */ + public List all() + { + List list = new ArrayList<>(histories.values()); + list.removeIf(h -> h.latest() == null); + list.sort((a, b) -> Long.compare(b.latest().t, a.latest().t)); + return list; + } + + /** @return true if a new data point was stored */ + public boolean record(String key, String name, int[] xp, long now) + { + SkillHistory h = histories.computeIfAbsent(key, k -> new SkillHistory(k, name)); + h.name = name; + return h.record(xp, now); + } + + public void saveDirty() + { + for (SkillHistory h : histories.values()) + { + if (h.dirty) + { + save(h); + } + } + } + + public synchronized void save(SkillHistory h) + { + String json; + synchronized (h) + { + compact(h, System.currentTimeMillis()); + 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("Skills: could not save {}", h.key, e); + } + } + + /** + * Samples older than 7 days keep only the last value per hour, older than 90 days per day. + * The very first sample is kept as baseline for "All". + */ + static void compact(SkillHistory h, long now) + { + List in = h.samples; + if (in.size() < 3) + { + return; + } + List out = new ArrayList<>(in.size()); + out.add(in.get(0)); + for (int i = 1; i < in.size(); i++) + { + XpSample s = in.get(i); + long age = now - s.t; + long size = age > 90 * DAY ? DAY : age > 7 * DAY ? HOUR : 0; + XpSample prev = out.get(out.size() - 1); + if (size > 0 && out.size() > 1 && prev.t / size == s.t / size) + { + out.set(out.size() - 1, s); + } + else + { + out.add(s); + } + } + h.samples = out; + } +} diff --git a/src/main/java/com/example/essentialsplus/skills/SkillsHudOverlay.java b/src/main/java/com/example/essentialsplus/skills/SkillsHudOverlay.java new file mode 100644 index 0000000..64aa80d --- /dev/null +++ b/src/main/java/com/example/essentialsplus/skills/SkillsHudOverlay.java @@ -0,0 +1,224 @@ +package com.example.essentialsplus.skills; + +import com.example.essentialsplus.EssentialsPlusConfig; +import com.example.essentialsplus.EssentialsPlusPlugin; +import com.example.essentialsplus.ui.TimeFilter; +import com.example.essentialsplus.ui.Ui; +import java.awt.Color; +import java.awt.Dimension; +import java.awt.Graphics2D; +import java.awt.Point; +import java.awt.image.BufferedImage; +import java.util.ArrayList; +import java.util.EnumMap; +import java.util.List; +import java.util.Map; +import javax.inject.Inject; +import java.util.Locale; +import net.runelite.api.Experience; +import net.runelite.api.MenuAction; +import net.runelite.api.Skill; +import net.runelite.client.game.SkillIconManager; +import net.runelite.client.ui.overlay.OverlayPanel; +import net.runelite.client.ui.overlay.OverlayPosition; +import net.runelite.client.ui.overlay.components.ComponentOrientation; +import net.runelite.client.ui.overlay.components.ImageComponent; +import net.runelite.client.ui.overlay.components.LineComponent; +import net.runelite.client.ui.overlay.components.ProgressBarComponent; +import net.runelite.client.ui.overlay.components.SplitComponent; +import net.runelite.client.ui.overlay.components.TitleComponent; +import net.runelite.client.util.ImageUtil; + +/** In-game panel listing the xp gained per skill in the selected time range. */ +public class SkillsHudOverlay extends OverlayPanel +{ + private static final int ICON = 14; + + private final EssentialsPlusPlugin plugin; + private final EssentialsPlusConfig config; + private final SkillIconManager iconManager; + private final Map icons = new EnumMap<>(Skill.class); + + @Inject + SkillsHudOverlay(EssentialsPlusPlugin plugin, EssentialsPlusConfig config, SkillIconManager iconManager) + { + super(plugin); + this.plugin = plugin; + this.config = config; + this.iconManager = iconManager; + setPosition(OverlayPosition.TOP_LEFT); + panelComponent.setPreferredSize(new Dimension(170, 0)); + addMenuEntry(MenuAction.RUNELITE_OVERLAY, "Reset Hourly", "Skills"); + } + + @Override + public Dimension render(Graphics2D graphics) + { + if (!config.showSkillsHud()) + { + return null; + } + SkillHistory history = plugin.getSkillStore().get(plugin.getSkillKey()); + if (history == null) + { + return null; + } + boolean showLevel = config.skillsHudLevel(); + boolean showPercent = config.skillsHudPercent(); + boolean showXpLeft = config.skillsHudXpLeft(); + boolean showTime = config.skillsHudTimeToLevel(); + boolean showXpHour = config.skillsHudXpHour(); + boolean showBar = config.skillsHudProgressBar(); + boolean details = showLevel || showPercent || showXpLeft || showTime || showXpHour; + panelComponent.setPreferredSize(new Dimension(details ? 200 : 170, 0)); + + TimeFilter filter = plugin.getTimeFilter(); + long[] starts = history.starts(filter); + long[] rateStarts = history.rateStarts(filter); + long[] gained = history.gained(filter); + + List order = new ArrayList<>(); + long total = 0; + for (int i = 0; i < gained.length; i++) + { + if (gained[i] > 0) + { + order.add(i); + total += gained[i]; + } + } + order.sort((a, b) -> Long.compare(gained[b], gained[a])); + + panelComponent.getChildren().add(TitleComponent.builder() + .text("XP gained (" + filter.getLabel() + ")") + .color(Ui.ACCENT) + .build()); + + if (order.isEmpty()) + { + panelComponent.getChildren().add(LineComponent.builder() + .left(filter.isCurrent() ? "Not training" : "No xp yet") + .leftColor(Ui.MUTED) + .build()); + return super.render(graphics); + } + + XpSample last = history.latest(); + int limit = Math.max(1, config.skillsHudMax()); + for (int n = 0; n < Math.min(limit, order.size()); n++) + { + int i = order.get(n); + Skill skill = SkillHistory.SKILLS[i]; + int levels = SkillHistory.level(last.get(i)) - SkillHistory.level(history.at(starts[i]).get(i)); + String right = "+" + Ui.gp(gained[i]) + (levels > 0 ? " (+" + levels + " lvl)" : ""); + LineComponent line = LineComponent.builder() + .left(skill.getName()) + .right(right) + .rightColor(levels > 0 ? Ui.UP : Color.WHITE) + .build(); + BufferedImage icon = icon(skill); + if (icon == null) + { + panelComponent.getChildren().add(line); + } + else + { + panelComponent.getChildren().add(SplitComponent.builder() + .first(new ImageComponent(icon)) + .second(line) + .orientation(ComponentOrientation.HORIZONTAL) + .gap(new Point(4, 0)) + .build()); + } + + int xp = last.get(i); + int lvl = SkillHistory.level(xp); + boolean maxed = lvl >= Experience.MAX_VIRT_LEVEL; + int lo = Experience.getXpForLevel(lvl); + int hi = maxed ? lo : Experience.getXpForLevel(lvl + 1); + double progress = maxed ? 1 : Math.max(0, Math.min(1, (xp - lo) / (double) (hi - lo))); + long xpLeft = maxed ? 0 : hi - xp; + if (details) + { + long active = showTime || showXpHour ? history.activeMillis(rateStarts[i], i) : 0; + long rateGained = history.gainedSince(rateStarts[i], i); + long xph = active >= Ui.MINUTE ? rateGained * Ui.HOUR / active : 0; + List leftParts = new ArrayList<>(); + List rightParts = new ArrayList<>(); + if (showLevel) + { + leftParts.add("Lvl " + lvl); + } + if (showPercent) + { + leftParts.add(maxed ? "max" : String.format(Locale.US, "%.1f%%", progress * 100)); + } + if (showXpHour) + { + leftParts.add(xph > 0 ? Ui.gp(xph) + "/h" : "-/h"); + } + if (showXpLeft && !maxed) + { + rightParts.add(Ui.gp(xpLeft) + " left"); + } + if (showTime && !maxed) + { + rightParts.add(xph > 0 ? duration(xpLeft * Ui.HOUR / xph) : "-"); + } + panelComponent.getChildren().add(LineComponent.builder() + .left(String.join(" \u00b7 ", leftParts)) + .leftColor(Ui.MUTED) + .right(String.join(" \u00b7 ", rightParts)) + .rightColor(Ui.MUTED) + .build()); + } + if (showBar) + { + ProgressBarComponent bar = new ProgressBarComponent(); + bar.setMinimum(0); + bar.setMaximum(100); + bar.setValue(progress * 100); + bar.setLabelDisplayMode(ProgressBarComponent.LabelDisplayMode.TEXT_ONLY); + bar.setCenterLabel(""); + bar.setForegroundColor(Ui.alpha(Ui.UP, 190)); + bar.setBackgroundColor(new Color(0, 0, 0, 120)); + bar.setPreferredSize(new Dimension(0, 8)); + panelComponent.getChildren().add(bar); + } + } + if (order.size() > 1) + { + panelComponent.getChildren().add(LineComponent.builder() + .left("Total") + .leftColor(Ui.GOLD) + .right("+" + Ui.gp(total)) + .rightColor(Ui.GOLD) + .build()); + } + return super.render(graphics); + } + + private static String duration(long ms) + { + long minutes = Math.max(1, ms / Ui.MINUTE); + if (minutes < 60) + { + return minutes + "m"; + } + long hours = minutes / 60; + if (hours < 24) + { + return hours + "h " + (minutes % 60) + "m"; + } + return (hours / 24) + "d " + (hours % 24) + "h"; + } + + private BufferedImage icon(Skill skill) + { + return icons.computeIfAbsent(skill, s -> + { + BufferedImage img = iconManager.getSkillImage(s, true); + return img == null ? null : ImageUtil.resizeImage(img, ICON, ICON); + }); + } +} diff --git a/src/main/java/com/example/essentialsplus/skills/XpSample.java b/src/main/java/com/example/essentialsplus/skills/XpSample.java new file mode 100644 index 0000000..9af33bf --- /dev/null +++ b/src/main/java/com/example/essentialsplus/skills/XpSample.java @@ -0,0 +1,42 @@ +package com.example.essentialsplus.skills; + +/** Experience of every skill at one moment ({@link SkillHistory#SKILLS} order). */ +public class XpSample +{ + public long t; + public int[] x; + + public XpSample() + { + } + + public XpSample(long t, int[] x) + { + this.t = t; + this.x = x; + } + + public long total() + { + long sum = 0; + if (x != null) + { + for (int v : x) + { + sum += v; + } + } + return sum; + } + + public int get(int skill) + { + return x != null && skill >= 0 && skill < x.length ? x[skill] : 0; + } + + /** Skill xp, or the total for {@code skill < 0}. */ + public long value(int skill) + { + return skill < 0 ? total() : get(skill); + } +} diff --git a/src/main/java/com/example/essentialsplus/ui/AccountCard.java b/src/main/java/com/example/essentialsplus/ui/AccountCard.java new file mode 100644 index 0000000..edca26e --- /dev/null +++ b/src/main/java/com/example/essentialsplus/ui/AccountCard.java @@ -0,0 +1,404 @@ +package com.example.essentialsplus.ui; + +import com.example.essentialsplus.data.Snapshot; +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.GradientPaint; +import java.awt.Graphics; +import java.awt.Graphics2D; +import java.awt.Polygon; +import java.awt.event.MouseAdapter; +import java.awt.event.MouseEvent; +import java.awt.geom.Path2D; +import java.util.Collections; +import java.util.List; +import javax.swing.JComponent; +import javax.swing.JMenuItem; +import javax.swing.JPopupMenu; +import javax.swing.SwingUtilities; +import net.runelite.client.ui.FontManager; + +/** + * Card for one character or - in summary style - the total of all characters. + * Shows value, change over the selected range, status and a sparkline. + */ +class AccountCard extends JComponent +{ + private final boolean summary; + private String title = ""; + private String subtitle = ""; + private long value; + private boolean hasValue; + private long delta; + private double deltaRatio = Double.NaN; + private String rangeLabel = ""; + private String status = ""; + private Color statusColor = Ui.MUTED; + private boolean statusDot; + private List spark = Collections.emptyList(); + private boolean selected; + private boolean hover; + /** summary style options (overview right-click menu) */ + private boolean showSpark = true; + private boolean showPct = true; + private String rightText = ""; + private Runnable onResetHourly; + + AccountCard(boolean summary, Runnable onClick, Runnable onDelete) + { + this(summary, onClick, onDelete, null); + } + + AccountCard(boolean summary, Runnable onClick, Runnable onDelete, Runnable onResetHourly) + { + this.summary = summary; + this.onResetHourly = onResetHourly; + 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 (e.isPopupTrigger() || SwingUtilities.isRightMouseButton(e)) + { + showMenu(e, onDelete); + } + else if (SwingUtilities.isLeftMouseButton(e)) + { + onClick.run(); + } + } + + @Override + public void mouseReleased(MouseEvent e) + { + if (e.isPopupTrigger()) + { + showMenu(e, onDelete); + } + } + }); + } + + void setOnResetHourly(Runnable onResetHourly) + { + this.onResetHourly = onResetHourly; + } + + private void showMenu(MouseEvent e, Runnable onDelete) + { + if (onDelete == null && onResetHourly == null) + { + return; + } + JPopupMenu menu = new JPopupMenu(); + if (onResetHourly != null) + { + JMenuItem reset = new JMenuItem("Reset Hourly"); + reset.addActionListener(a -> onResetHourly.run()); + menu.add(reset); + } + if (onDelete != null) + { + JMenuItem delete = new JMenuItem("Delete data of " + title + "..."); + delete.addActionListener(a -> onDelete.run()); + menu.add(delete); + } + menu.show(this, e.getX(), e.getY()); + } + + void update(String title, String subtitle, Long value, long delta, double deltaRatio, String rangeLabel, + String status, Color statusColor, boolean statusDot, List spark, boolean selected) + { + update(title, subtitle, value, delta, deltaRatio, rangeLabel, status, statusColor, statusDot, spark, selected, ""); + } + + void update(String title, String subtitle, Long value, long delta, double deltaRatio, String rangeLabel, + String status, Color statusColor, boolean statusDot, List spark, boolean selected, String rightText) + { + this.title = title; + this.subtitle = subtitle; + this.hasValue = value != null; + this.value = value == null ? 0 : value; + this.delta = delta; + this.deltaRatio = deltaRatio; + this.rangeLabel = rangeLabel; + this.status = status; + this.statusColor = statusColor; + this.statusDot = statusDot; + this.spark = spark; + this.selected = selected; + this.rightText = rightText == null ? "" : rightText; + String tip = hasValue ? title + ": " + Ui.exact(this.value) : null; + if (tip != null && !this.rightText.isEmpty()) + { + tip += " (" + this.rightText + ")"; + } + setToolTipText(tip); + repaint(); + } + + void setOptions(boolean showSpark, boolean showPct) + { + this.showSpark = showSpark; + this.showPct = showPct; + revalidate(); + repaint(); + } + + void copyFrom(AccountCard other) + { + if (other == null) + { + return; + } + this.title = other.title; + this.subtitle = other.subtitle; + this.hasValue = other.hasValue; + this.value = other.value; + this.delta = other.delta; + this.deltaRatio = other.deltaRatio; + this.rangeLabel = other.rangeLabel; + this.status = other.status; + this.statusColor = other.statusColor; + this.statusDot = other.statusDot; + this.spark = other.spark; + this.rightText = other.rightText; + this.onResetHourly = other.onResetHourly; + setToolTipText(other.getToolTipText()); + repaint(); + } + + @Override + public Dimension getPreferredSize() + { + return new Dimension(net.runelite.client.ui.PluginPanel.PANEL_WIDTH - 16, summary ? (showSpark ? 125 : 84) : 60); + } + + @Override + public Dimension getMaximumSize() + { + return new Dimension(Integer.MAX_VALUE, summary ? (showSpark ? 130 : 84) : 60); + } + + @Override + public Dimension getMinimumSize() + { + return new Dimension(10, summary ? (showSpark ? 110 : 80) : 50); + } + + @Override + protected void paintComponent(Graphics g0) + { + Graphics2D g = Ui.prepare(g0); + int w = getWidth(); + int h = getHeight(); + + g.setColor(selected ? Ui.CARD_SELECTED : hover ? Ui.CARD_HOVER : Ui.CARD); + g.fillRoundRect(0, 0, w, h, 8, 8); + if (selected) + { + g.setColor(Ui.ACCENT); + g.fillRoundRect(0, 0, 3, h, 3, 3); + } + + int pad = 9; + int right = w - pad; + Font bold = FontManager.getRunescapeBoldFont(); + Font small = FontManager.getRunescapeSmallFont(); + + if (summary) + { + g.setFont(bold); + FontMetrics fm = g.getFontMetrics(); + g.setColor(Ui.ACCENT); + g.drawString(title, pad, 6 + fm.getAscent()); + + g.setFont(bold.deriveFont(14f)); + g.setColor(hover ? Ui.ACCENT : Ui.FAINT); + Ui.drawRight(g, "›", right, 6 + fm.getAscent()); + + if (!subtitle.isEmpty()) + { + g.setFont(small); + g.setColor(Ui.MUTED); + FontMetrics sfm = g.getFontMetrics(); + int titleW = fm.stringWidth(title); + int subW = sfm.stringWidth(subtitle); + int maxSubX = right - 14; + if (maxSubX - subW > pad + titleW + 8) + { + Ui.drawRight(g, subtitle, maxSubX, 6 + fm.getAscent()); + } + } + + Font big = bold.deriveFont(26f); + g.setFont(big); + FontMetrics bfm = g.getFontMetrics(); + int valueBase = 22 + bfm.getAscent(); + g.setColor(hasValue ? Ui.GOLD : Ui.FAINT); + g.drawString(hasValue ? Ui.gp(value) : "-", pad, valueBase); + + g.setFont(small); + FontMetrics sfm = g.getFontMetrics(); + int line = valueBase + sfm.getAscent() + 3; + int x = paintDelta(g, sfm, pad, line); + if (!rangeLabel.isEmpty() && hasValue) + { + g.setColor(Ui.FAINT); + g.drawString(rangeLabel, x + 4, line); + } + if (!status.isEmpty()) + { + g.setColor(statusColor); + Ui.drawRight(g, Ui.ellipsize(sfm, status, w / 2 - pad), right, line); + } + else if (!rightText.isEmpty()) + { + g.setColor(Ui.deltaColor(delta)); + Ui.drawRight(g, rightText, right, line); + } + int sparkH = Math.min(28, Math.max(0, h - line - 10)); + if (sparkH > 6 && showSpark) + { + paintSpark(g, pad, h - sparkH - 6, w - pad * 2, sparkH); + } + } + else + { + g.setFont(bold); + FontMetrics fm = g.getFontMetrics(); + int base1 = 5 + fm.getAscent(); + String valueText = hasValue ? Ui.gp(value) : "-"; + int valueW = fm.stringWidth(valueText); + g.setColor(Ui.TEXT); + g.drawString(Ui.ellipsize(fm, title, w - pad * 2 - valueW - 8), pad, base1); + g.setColor(hasValue ? Ui.GOLD : Ui.FAINT); + Ui.drawRight(g, valueText, right, base1); + + g.setFont(small); + FontMetrics sfm = g.getFontMetrics(); + int base2 = base1 + sfm.getAscent() + 3; + int sx = pad; + if (statusDot) + { + g.setColor(Ui.alpha(statusColor, 70)); + g.fillOval(sx - 1, base2 - sfm.getAscent() / 2 - 4, 8, 8); + g.setColor(statusColor); + g.fillOval(sx + 1, base2 - sfm.getAscent() / 2 - 2, 4, 4); + sx += 10; + } + String deltaText = hasValue && delta != 0 ? Ui.signedGp(delta) : ""; + int deltaW = deltaText.isEmpty() ? 0 : sfm.stringWidth(deltaText) + 9; + g.setColor(statusColor); + g.drawString(Ui.ellipsize(sfm, status, right - sx - deltaW - 6), sx, base2); + if (!deltaText.isEmpty()) + { + paintDelta(g, sfm, right - deltaW, base2); + } + paintSpark(g, pad, base2 + 4, w - pad * 2, h - base2 - 8); + } + Ui.paintDragOutline(g, this); + g.dispose(); + } + + /** Draws "▲ +1.2M (+3.4%)" with a painted triangle. Returns x after the text. */ + private int paintDelta(Graphics2D g, FontMetrics fm, int x, int baseline) + { + if (!hasValue) + { + return x; + } + Color c = Ui.deltaColor(delta); + g.setColor(c); + int cy = baseline - fm.getAscent() / 2 + 1; + if (delta != 0) + { + Polygon tri = delta > 0 + ? new Polygon(new int[]{x, x + 6, x + 3}, new int[]{cy + 2, cy + 2, cy - 3}, 3) + : new Polygon(new int[]{x, x + 6, x + 3}, new int[]{cy - 3, cy - 3, cy + 2}, 3); + g.fillPolygon(tri); + x += 9; + } + String s = Ui.signedGp(delta); + if (summary && showPct && !Double.isNaN(deltaRatio) && delta != 0) + { + s += " (" + Ui.pct(deltaRatio) + ")"; + } + if (!summary && delta == 0) + { + return x; + } + g.drawString(s, x, baseline); + return x + fm.stringWidth(s); + } + + private void paintSpark(Graphics2D g, int x, int y, int w, int h) + { + if (spark.size() < 2 || h < 4 || w < 10) + { + return; + } + long lo = Long.MAX_VALUE; + long hi = Long.MIN_VALUE; + for (Snapshot s : spark) + { + lo = Math.min(lo, s.total()); + hi = Math.max(hi, s.total()); + } + long t0 = spark.get(0).t; + long t1 = Math.max(t0 + 1, spark.get(spark.size() - 1).t); + double span = Math.max(1, hi - lo); + + Path2D line = new Path2D.Double(); + double prevY = 0; + for (int i = 0; i < spark.size(); i++) + { + Snapshot s = spark.get(i); + double px = x + (double) (s.t - t0) / (t1 - t0) * w; + double py = hi == lo ? y + h / 2.0 : y + h - (s.total() - lo) / span * h; + if (i == 0) + { + line.moveTo(px, py); + } + else + { + line.lineTo(px, prevY); + line.lineTo(px, py); + } + prevY = py; + } + Color c = Ui.deltaColor(delta); + if (delta == 0) + { + c = Ui.GOLD; + } + Path2D area = new Path2D.Double(line); + area.lineTo(x + w, y + h); + area.lineTo(x, y + h); + area.closePath(); + g.setPaint(new GradientPaint(0, y, Ui.alpha(c, 60), 0, y + h, Ui.alpha(c, 0))); + g.fill(area); + g.setStroke(new BasicStroke(1.2f)); + g.setColor(Ui.alpha(c, 200)); + g.draw(line); + } +} diff --git a/src/main/java/com/example/essentialsplus/ui/Analysis.java b/src/main/java/com/example/essentialsplus/ui/Analysis.java new file mode 100644 index 0000000..2bf4fcb --- /dev/null +++ b/src/main/java/com/example/essentialsplus/ui/Analysis.java @@ -0,0 +1,419 @@ +package com.example.essentialsplus.ui; + +import com.example.essentialsplus.data.ItemLedger; +import com.example.essentialsplus.data.ItemSnapshot; +import com.example.essentialsplus.data.Snapshot; +import java.time.DayOfWeek; +import java.time.Instant; +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.HashMap; +import java.util.List; +import java.util.Map; + +/** Derived figures: candles, daily changes, statistics and item movers. */ +public final class Analysis +{ + private static final long[] BUCKETS = { + Ui.MINUTE, 5 * Ui.MINUTE, 15 * Ui.MINUTE, 30 * Ui.MINUTE, Ui.HOUR, 2 * Ui.HOUR, 3 * Ui.HOUR, 6 * Ui.HOUR, 12 * Ui.HOUR, Ui.DAY, 7 * Ui.DAY, 30 * Ui.DAY + }; + + private Analysis() + { + } + + // ------------------------------------------------------------------ candles + + static final class Candle + { + long t0; + long t1; + long o; + long h; + long l; + long c; + boolean empty; + + long change() + { + return c - o; + } + } + + /** Smallest bucket size yielding at most {@code maxCount} candles. */ + static long pickBucket(long span, int maxCount) + { + for (long b : BUCKETS) + { + if (span / b <= maxCount) + { + return b; + } + } + return BUCKETS[BUCKETS.length - 1]; + } + + static long bucketStart(long t, long bucket) + { + ZonedDateTime z = Instant.ofEpochMilli(t).atZone(ZoneId.systemDefault()); + if (bucket >= 30 * Ui.DAY) + { + return z.truncatedTo(ChronoUnit.DAYS).withDayOfMonth(1).toInstant().toEpochMilli(); + } + if (bucket >= 7 * Ui.DAY) + { + return z.truncatedTo(ChronoUnit.DAYS).with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)).toInstant().toEpochMilli(); + } + if (bucket >= Ui.DAY) + { + return z.truncatedTo(ChronoUnit.DAYS).toInstant().toEpochMilli(); + } + if (bucket < Ui.HOUR) + { + int minutes = (int) (bucket / Ui.MINUTE); + ZonedDateTime m = z.truncatedTo(ChronoUnit.MINUTES); + return m.withMinute(m.getMinute() - m.getMinute() % minutes).toInstant().toEpochMilli(); + } + int hours = (int) (bucket / Ui.HOUR); + ZonedDateTime h = z.truncatedTo(ChronoUnit.HOURS); + return h.withHour(h.getHour() - h.getHour() % hours).toInstant().toEpochMilli(); + } + + static long bucketEnd(long bucketStart, long bucket) + { + ZonedDateTime z = Instant.ofEpochMilli(bucketStart).atZone(ZoneId.systemDefault()); + if (bucket >= 30 * Ui.DAY) + { + return z.plusMonths(1).toInstant().toEpochMilli(); + } + if (bucket >= 7 * Ui.DAY) + { + return z.plusWeeks(1).toInstant().toEpochMilli(); + } + if (bucket >= Ui.DAY) + { + return z.plusDays(1).toInstant().toEpochMilli(); + } + return bucketStart + bucket; + } + + /** + * OHLC candles over [start, end]. A candle opens at the previous close; buckets without data + * become flat candles once a value is known. + */ + static List candles(List pts, long start, long end, long bucket) + { + List out = new ArrayList<>(); + if (pts.isEmpty()) + { + return out; + } + int i = 0; + Long prevClose = null; + for (long b0 = bucketStart(start, bucket); b0 <= end; ) + { + long b1 = bucketEnd(b0, bucket); + Candle c = new Candle(); + c.t0 = b0; + c.t1 = b1; + boolean any = false; + long h = Long.MIN_VALUE; + long l = Long.MAX_VALUE; + long first = 0; + long last = 0; + while (i < pts.size() && (pts.get(i).t < b1 || (b1 > end && pts.get(i).t <= end))) + { + Snapshot p = pts.get(i++); + if (!any) + { + first = p.open(); + } + any = true; + h = Math.max(h, p.high()); + l = Math.min(l, p.low()); + last = p.close(); + } + if (any) + { + c.o = prevClose != null ? prevClose : first; + c.h = Math.max(h, c.o); + c.l = Math.min(l, c.o); + c.c = last; + prevClose = last; + out.add(c); + } + else if (prevClose != null) + { + c.o = c.h = c.l = c.c = prevClose; + c.empty = true; + out.add(c); + } + b0 = b1; + } + return out; + } + + static long changeBucket(long span) + { + if (span <= 3 * Ui.HOUR) + { + return 5 * Ui.MINUTE; + } + if (span <= 2 * Ui.DAY) + { + return Ui.HOUR; + } + if (span <= 400 * Ui.DAY) + { + return Ui.DAY; + } + return 7 * Ui.DAY; + } + + // ------------------------------------------------------------------ statistics + + public static final class Stats + { + public boolean has; + public long change; + public double ratio = Double.NaN; + public long perDay; + public long peak; + public long peakT; + public long low; + public long lowT; + public Candle best; + public Candle worst; + public int up; + public int down; + public int periods; + public double maxDrawdown; + public double volatility = Double.NaN; + public long bucket; + } + + static Stats stats(List pts) + { + Stats s = new Stats(); + if (pts.isEmpty()) + { + return s; + } + s.has = true; + long first = pts.get(0).total(); + long last = pts.get(pts.size() - 1).total(); + s.change = last - first; + s.ratio = first != 0 ? (double) s.change / first : Double.NaN; + + long t0 = pts.get(0).t; + long t1 = pts.get(pts.size() - 1).t; + s.perDay = Math.round(s.change / Math.max(1.0, (t1 - t0) / (double) Ui.DAY)); + + s.peak = Long.MIN_VALUE; + s.low = Long.MAX_VALUE; + double peakSoFar = 0; + for (Snapshot p : pts) + { + if (p.high() > s.peak) + { + s.peak = p.high(); + s.peakT = p.t; + } + if (p.low() < s.low) + { + s.low = p.low(); + s.lowT = p.t; + } + if (peakSoFar > 0) + { + s.maxDrawdown = Math.max(s.maxDrawdown, (peakSoFar - p.low()) / peakSoFar); + } + peakSoFar = Math.max(peakSoFar, p.high()); + } + + s.bucket = changeBucket(t1 - t0); + List periods = candles(pts, t0, t1, s.bucket); + List returns = new ArrayList<>(); + for (Candle c : periods) + { + long ch = c.change(); + if (ch > 0) + { + s.up++; + } + else if (ch < 0) + { + s.down++; + } + if (ch != 0 && (s.best == null || ch > s.best.change())) + { + s.best = c; + } + if (ch != 0 && (s.worst == null || ch < s.worst.change())) + { + s.worst = c; + } + if (c.o > 0) + { + returns.add((double) ch / c.o); + } + } + if (s.worst != null && s.worst.change() >= 0) + { + s.worst = null; + } + if (s.best != null && s.best.change() <= 0) + { + s.best = null; + } + s.periods = periods.size(); + if (returns.size() >= 2) + { + double mean = returns.stream().mapToDouble(d -> d).average().orElse(0); + double var = returns.stream().mapToDouble(d -> (d - mean) * (d - mean)).sum() / (returns.size() - 1); + s.volatility = Math.sqrt(var); + } + return s; + } + + // ------------------------------------------------------------------ item movers + + static final class Mover + { + int id; + long q0; + long q1; + long v0; + long v1; + long priceEffect; + long qtyEffect; + boolean ignorePrices; + + long delta() + { + return ignorePrices ? qtyEffect : v1 - v0; + } + + double priceChange() + { + long p0 = q0 > 0 ? v0 / q0 : 0; + long p1 = q1 > 0 ? v1 / q1 : 0; + return p0 > 0 && p1 > 0 ? (double) (p1 - p0) / p0 : Double.NaN; + } + } + + static final class Movers + { + boolean available; + /** price movements left out: only quantity changes count */ + boolean ignorePrices; + long since; + long priceEffect; + long qtyEffect; + List gainers = new ArrayList<>(); + List losers = new ArrayList<>(); + } + + /** + * Compares holdings at {@code start} with the latest holdings and splits every item's value change + * into a market effect (old quantity x price change) and an activity effect (quantity change x new price). + */ + static Movers movers(List ledgers, long start, int limit, boolean ignorePrices) + { + Movers m = new Movers(); + m.ignorePrices = ignorePrices; + Map byId = new HashMap<>(); + m.since = Long.MAX_VALUE; + long idleSince = Long.MAX_VALUE; + for (ItemLedger ledger : ledgers) + { + ItemSnapshot end = ledger.last(); + ItemSnapshot begin = ledger.at(start); + if (end == null || begin == null) + { + continue; + } + m.available = true; + if (begin == end) + { + // unchanged characters (e.g. not played) must not push "since" back to their last login + idleSince = Math.min(idleSince, begin.t); + continue; + } + m.since = Math.min(m.since, begin.t); + + Map a = toMap(begin); + Map b = toMap(end); + java.util.Set ids = new java.util.HashSet<>(a.keySet()); + ids.addAll(b.keySet()); + for (int id : ids) + { + long[] x = a.getOrDefault(id, new long[]{0, 0}); + long[] y = b.getOrDefault(id, new long[]{0, 0}); + long p0 = x[1] > 0 ? x[1] : y[1]; + long p1 = y[1] > 0 ? y[1] : x[1]; + Mover mv = byId.computeIfAbsent(id, k -> + { + Mover n = new Mover(); + n.id = k; + n.ignorePrices = ignorePrices; + return n; + }); + mv.q0 += x[0]; + mv.q1 += y[0]; + mv.v0 += x[0] * x[1]; + mv.v1 += y[0] * y[1]; + mv.priceEffect += x[0] * (p1 - p0); + mv.qtyEffect += (y[0] - x[0]) * p1; + } + } + + if (m.since == Long.MAX_VALUE) + { + m.since = idleSince; + } + List all = new ArrayList<>(); + for (Mover mv : byId.values()) + { + if (ignorePrices) + { + mv.priceEffect = 0; + } + m.priceEffect += mv.priceEffect; + m.qtyEffect += mv.qtyEffect; + if (mv.delta() != 0) + { + all.add(mv); + } + } + all.sort((x, y) -> Long.compare(y.delta(), x.delta())); + for (Mover mv : all) + { + if (mv.delta() > 0 && m.gainers.size() < limit) + { + m.gainers.add(mv); + } + } + for (int i = all.size() - 1; i >= 0 && m.losers.size() < limit; i--) + { + if (all.get(i).delta() < 0) + { + m.losers.add(all.get(i)); + } + } + return m; + } + + private static Map toMap(ItemSnapshot s) + { + Map map = new HashMap<>(); + for (int i = 0; i < s.ids.length; i++) + { + map.put(s.ids[i], new long[]{s.q[i], s.p[i]}); + } + return map; + } +} diff --git a/src/main/java/com/example/essentialsplus/ui/BreakdownView.java b/src/main/java/com/example/essentialsplus/ui/BreakdownView.java new file mode 100644 index 0000000..5a5c897 --- /dev/null +++ b/src/main/java/com/example/essentialsplus/ui/BreakdownView.java @@ -0,0 +1,300 @@ +package com.example.essentialsplus.ui; + +import com.example.essentialsplus.WealthCategory; +import com.example.essentialsplus.data.SourceValue; +import java.awt.Dimension; +import java.awt.FontMetrics; +import java.awt.Graphics; +import java.awt.Graphics2D; +import java.awt.event.MouseEvent; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import javax.swing.JComponent; +import net.runelite.client.ui.FontManager; + +/** Current value per category with share, change over the range, share bar and individual sources. */ +class BreakdownView extends JComponent +{ + private static final int ROW = 40; + private static final int SUB = 15; + + private static final class Row + { + WealthCategory category; + long value; + long start; + boolean hasStart; + List subs = new ArrayList<>(); + int y; + } + + private final List rows = new ArrayList<>(); + private final List hits = new ArrayList<>(); + private long total; + private int hoverY = -1; + private java.util.function.BiConsumer onOpen; + + BreakdownView() + { + setToolTipText(""); + java.awt.event.MouseAdapter mouse = new java.awt.event.MouseAdapter() + { + @Override + public void mouseMoved(MouseEvent e) + { + hoverY = e.getY(); + Object[] hit = hitAt(e.getY()); + setCursor(java.awt.Cursor.getPredefinedCursor(hit != null ? java.awt.Cursor.HAND_CURSOR : java.awt.Cursor.DEFAULT_CURSOR)); + repaint(); + } + + @Override + public void mouseExited(MouseEvent e) + { + hoverY = -1; + repaint(); + } + + @Override + public void mousePressed(MouseEvent e) + { + Object[] hit = hitAt(e.getY()); + if (hit != null && onOpen != null) + { + onOpen.accept((String) hit[2], (String) hit[3]); + } + } + }; + addMouseListener(mouse); + addMouseMotionListener(mouse); + } + + /** Called with (view, label) when a row is clicked. */ + void setOnOpen(java.util.function.BiConsumer onOpen) + { + this.onOpen = onOpen; + } + + private Object[] hitAt(int y) + { + for (Object[] h : hits) + { + if (y >= (int) h[0] && y < (int) h[1]) + { + return h; + } + } + return null; + } + + static String viewOf(WealthCategory c) + { + switch (c) + { + case BANK: + return "bank"; + case INVENTORY: + return "inventory"; + case EQUIPMENT: + return "equipment"; + case STORAGE: + return "storage"; + case COFFERS: + return "coffers"; + case GRAND_EXCHANGE: + return "ge"; + default: + return null; + } + } + + private static String viewOf(String sourceName, WealthCategory c) + { + switch (sourceName) + { + case "Looting bag": + return "lootingBag"; + case "Seed vault": + return "seedVault"; + case "Rune pouch": + return "runePouch"; + default: + return viewOf(c); + } + } + + /** + * @param start breakdown at the start of the range, or null if unknown + */ + void update(long[] current, List sources, long[] start) + { + rows.clear(); + total = 0; + if (current != null) + { + for (WealthCategory c : WealthCategory.values()) + { + long v = c.ordinal() < current.length ? current[c.ordinal()] : 0; + long s = start != null && c.ordinal() < start.length ? start[c.ordinal()] : 0; + total += v; + if (v == 0 && s == 0) + { + continue; + } + Row r = new Row(); + r.category = c; + r.value = v; + r.start = s; + r.hasStart = start != null && c != WealthCategory.IMPORTED && (start.length <= WealthCategory.IMPORTED.ordinal() || start[WealthCategory.IMPORTED.ordinal()] == 0); + + Map merged = new LinkedHashMap<>(); + for (SourceValue sv : sources) + { + if (sv.category == c.ordinal() && sv.value > 0) + { + merged.merge(sv.name, sv.value, Long::sum); + } + } + boolean onlySelf = merged.size() == 1 && merged.containsKey(c.getLabel()); + if (!onlySelf) + { + merged.forEach((n, v2) -> r.subs.add(new SourceValue(n, c.ordinal(), v2))); + r.subs.sort((a, b) -> Long.compare(b.value, a.value)); + } + rows.add(r); + } + } + rows.removeIf(r -> r.value == 0 && r.category == WealthCategory.IMPORTED); + rows.sort((a, b) -> Long.compare(b.value, a.value)); + revalidate(); + repaint(); + } + + @Override + public String getToolTipText(MouseEvent e) + { + for (Row r : rows) + { + if (e.getY() >= r.y && e.getY() < r.y + ROW) + { + String s = "" + r.category.getLabel() + "
" + Ui.exact(r.value); + if (r.hasStart) + { + s += "
Start of range: " + Ui.exact(r.start) + "
Change: " + Ui.exact(r.value - r.start); + } + return s + ""; + } + } + return null; + } + + @Override + public Dimension getPreferredSize() + { + int h = 8; + for (Row r : rows) + { + h += ROW + r.subs.size() * SUB; + } + return new Dimension(10, rows.isEmpty() ? 40 : h); + } + + @Override + protected void paintComponent(Graphics g0) + { + Graphics2D g = Ui.prepare(g0); + int w = getWidth(); + g.setColor(Ui.CARD); + g.fillRoundRect(0, 0, w, getHeight(), 8, 8); + + g.setFont(FontManager.getRunescapeSmallFont()); + FontMetrics sfm = g.getFontMetrics(); + if (rows.isEmpty()) + { + g.setColor(Ui.MUTED); + String s = "No data yet"; + g.drawString(s, (w - sfm.stringWidth(s)) / 2, 20 + sfm.getAscent() / 2); + g.dispose(); + return; + } + + int pad = 8; + int y = 4; + hits.clear(); + for (Row r : rows) + { + r.y = y; + String view = viewOf(r.category); + if (view != null) + { + hits.add(new Object[]{y, y + ROW, view, r.category.getLabel()}); + if (hoverY >= y && hoverY < y + ROW) + { + g.setColor(Ui.CARD_HOVER.brighter()); + g.fillRoundRect(3, y, w - 6, ROW, 6, 6); + } + } + double share = total > 0 ? (double) r.value / total : 0; + + g.setFont(FontManager.getRunescapeFont()); + FontMetrics fm = g.getFontMetrics(); + int base = y + 3 + fm.getAscent(); + g.setColor(r.category.getColor()); + g.fillRoundRect(pad, base - fm.getAscent() + 3, 8, 8, 3, 3); + g.setColor(Ui.TEXT); + g.drawString(r.category.getLabel(), pad + 13, base); + g.setColor(Ui.GOLD); + Ui.drawRight(g, Ui.gp(r.value), w - pad, base); + + g.setFont(FontManager.getRunescapeSmallFont()); + int base2 = base + sfm.getAscent() + 1; + g.setColor(Ui.MUTED); + g.drawString(String.format(Locale.US, "%.1f%%", share * 100), pad + 13, base2); + if (r.hasStart) + { + long d = r.value - r.start; + g.setColor(Ui.deltaColor(d)); + String ds = d == 0 ? "±0" : Ui.signedGp(d); + if (d != 0 && r.start > 0) + { + ds += " (" + Ui.pct((double) d / r.start) + ")"; + } + Ui.drawRight(g, ds, w - pad, base2); + } + + int barY = y + ROW - 7; + int barW = w - pad * 2; + g.setColor(Ui.alpha(java.awt.Color.WHITE, 14)); + g.fillRoundRect(pad, barY, barW, 4, 4, 4); + g.setColor(r.category.getColor()); + g.fillRoundRect(pad, barY, Math.max(3, (int) Math.round(barW * share)), 4, 4, 4); + y += ROW; + + for (SourceValue s : r.subs) + { + String subView = viewOf(s.name, r.category); + if (subView != null) + { + hits.add(new Object[]{y, y + SUB, subView, s.name}); + if (hoverY >= y && hoverY < y + SUB) + { + g.setColor(Ui.CARD_HOVER.brighter()); + g.fillRoundRect(3, y, w - 6, SUB, 4, 4); + } + } + int sb = y + sfm.getAscent(); + g.setColor(Ui.FAINT); + g.fillOval(pad + 14, sb - sfm.getAscent() / 2 - 1, 3, 3); + String val = Ui.gp(s.value); + g.setColor(Ui.MUTED); + g.drawString(Ui.ellipsize(sfm, s.name, w - pad * 2 - 24 - sfm.stringWidth(val) - 6), pad + 22, sb); + Ui.drawRight(g, val, w - pad, sb); + y += SUB; + } + } + g.dispose(); + } +} diff --git a/src/main/java/com/example/essentialsplus/ui/CardDrag.java b/src/main/java/com/example/essentialsplus/ui/CardDrag.java new file mode 100644 index 0000000..73539b1 --- /dev/null +++ b/src/main/java/com/example/essentialsplus/ui/CardDrag.java @@ -0,0 +1,138 @@ +package com.example.essentialsplus.ui; + +import java.awt.Component; +import java.awt.Container; +import java.awt.Cursor; +import java.awt.Point; +import java.awt.event.MouseAdapter; +import java.awt.event.MouseEvent; +import java.util.function.Supplier; +import javax.swing.JComponent; +import javax.swing.JPopupMenu; +import javax.swing.SwingUtilities; + +/** + * Overview card mouse handling: left click opens, left drag moves the card within its container + * (live, other cards make room), right click shows the card's options. + */ +final class CardDrag extends MouseAdapter +{ + private static final int THRESHOLD = 5; + + private final JComponent card; + private final Container container; + /** components before this index (the title) never move */ + private final int firstIndex; + private final Runnable onClick; + private final Supplier menu; + private final Runnable onDrop; + private Point pressed; + private boolean dragging; + + private CardDrag(JComponent card, Container container, int firstIndex, Runnable onClick, Supplier menu, Runnable onDrop) + { + this.card = card; + this.container = container; + this.firstIndex = firstIndex; + this.onClick = onClick; + this.menu = menu; + this.onDrop = onDrop; + } + + static void install(JComponent card, Container container, int firstIndex, Runnable onClick, Supplier menu, Runnable onDrop) + { + CardDrag d = new CardDrag(card, container, firstIndex, onClick, menu, onDrop); + card.addMouseListener(d); + card.addMouseMotionListener(d); + } + + /** True while a card is being dragged: cards skip their hover look. */ + static boolean isDragging(JComponent card) + { + return Boolean.TRUE.equals(card.getClientProperty("cardDragging")); + } + + @Override + public void mousePressed(MouseEvent e) + { + if (e.isPopupTrigger() || SwingUtilities.isRightMouseButton(e)) + { + JPopupMenu m = menu.get(); + if (m != null) + { + m.show(card, e.getX(), e.getY()); + } + return; + } + if (SwingUtilities.isLeftMouseButton(e)) + { + pressed = e.getLocationOnScreen(); + dragging = false; + } + } + + @Override + public void mouseDragged(MouseEvent e) + { + if (pressed == null || !SwingUtilities.isLeftMouseButton(e)) + { + return; + } + if (!dragging) + { + if (Math.abs(e.getYOnScreen() - pressed.y) < THRESHOLD && Math.abs(e.getXOnScreen() - pressed.x) < THRESHOLD) + { + return; + } + dragging = true; + card.putClientProperty("cardDragging", true); + card.setCursor(Cursor.getPredefinedCursor(Cursor.MOVE_CURSOR)); + card.repaint(); + } + int y = SwingUtilities.convertPoint(card, e.getPoint(), container).y; + int target = firstIndex; + int current = -1; + Component[] comps = container.getComponents(); + for (int i = firstIndex; i < comps.length; i++) + { + if (comps[i] == card) + { + current = i; + continue; + } + if (comps[i].getY() + comps[i].getHeight() / 2 < y) + { + target++; + } + } + if (current >= 0 && target != current) + { + container.remove(card); + container.add(card, target); + container.revalidate(); + container.repaint(); + } + } + + @Override + public void mouseReleased(MouseEvent e) + { + if (pressed == null) + { + return; + } + pressed = null; + if (dragging) + { + dragging = false; + card.putClientProperty("cardDragging", false); + card.setCursor(Cursor.getPredefinedCursor(Cursor.HAND_CURSOR)); + card.repaint(); + onDrop.run(); + } + else if (SwingUtilities.isLeftMouseButton(e) && card.contains(e.getPoint())) + { + onClick.run(); + } + } +} diff --git a/src/main/java/com/example/essentialsplus/ui/EssentialsPlusPanel.java b/src/main/java/com/example/essentialsplus/ui/EssentialsPlusPanel.java new file mode 100644 index 0000000..d4d9f34 --- /dev/null +++ b/src/main/java/com/example/essentialsplus/ui/EssentialsPlusPanel.java @@ -0,0 +1,806 @@ +package com.example.essentialsplus.ui; + +import com.example.essentialsplus.EssentialsPlusPlugin; +import com.example.essentialsplus.WealthStore; +import com.example.essentialsplus.skills.SkillHistory; +import com.example.essentialsplus.currency.ui.CurrencyPanel; +import com.example.essentialsplus.loot.ui.LootSummaryCard; +import com.example.essentialsplus.loot.ui.LootTrackerPanel; +import com.example.essentialsplus.progress.ui.ProgressCard; +import com.example.essentialsplus.progress.ui.ProgressPanel; +import com.example.essentialsplus.wiki.ui.WikiCard; +import com.example.essentialsplus.wiki.ui.WikiPanel; +import java.awt.BorderLayout; +import java.awt.CardLayout; +import java.awt.Cursor; +import java.awt.Dimension; +import java.awt.GridLayout; +import javax.swing.BorderFactory; +import javax.swing.JButton; +import javax.swing.JCheckBoxMenuItem; +import javax.swing.JComponent; +import javax.swing.JMenu; +import javax.swing.JMenuItem; +import javax.swing.JPopupMenu; +import javax.swing.JRadioButtonMenuItem; +import javax.swing.JScrollPane; +import javax.swing.ScrollPaneConstants; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import javax.swing.JLabel; +import javax.swing.JPanel; +import javax.swing.SwingUtilities; +import net.runelite.client.ui.ColorScheme; +import net.runelite.client.ui.FontManager; +import net.runelite.client.ui.PluginPanel; +import net.runelite.client.util.LinkBrowser; + +/** + * Root side-panel for the Essentials Plus suite. + * Hosts the Feature Overview and sub-feature views (Wealth Tracker, Loot Tracker, Skills, Achievements, Wiki) plus a footer + * with the global time range and utilities that stays visible in every view. + */ +public class EssentialsPlusPanel extends PluginPanel +{ + private static final String VIEW_OVERVIEW = "OVERVIEW"; + private static final String VIEW_WEALTH = "WEALTH"; + private static final String VIEW_LOOT = "LOOT"; + private static final String VIEW_SKILLS = "SKILLS"; + private static final String VIEW_WIKI = "WIKI"; + private static final String VIEW_PROGRESS = "PROGRESS"; + private static final String VIEW_CURRENCY = "CURRENCY"; + + private final CardLayout cardLayout = new CardLayout(); + private final JPanel container = new JPanel(cardLayout); + + private final EssentialsPlusPlugin plugin; + private final OverviewPanel overviewPanel; + private final WealthPanel wealthPanel; + private final LootTrackerPanel lootTrackerPanel; + private final SkillsPanel skillsPanel; + private final WikiPanel wikiPanel; + private final ProgressPanel progressPanel; + private final CurrencyPanel currencyPanel; + private final Runnable currencyListener; + /** collection log / diary / combat achievement changes, from the client thread */ + private final Runnable progressListener; + private final Segmented rangeBar; + private final Runnable rangeListener; + /** "Now" and "Sess" move with the clock; loot views only refresh on new loot otherwise */ + private final javax.swing.Timer clockTicker; + + public EssentialsPlusPanel(EssentialsPlusPlugin plugin) + { + super(false); + this.plugin = plugin; + + setLayout(new BorderLayout()); + setBackground(Ui.BG); + + overviewPanel = new OverviewPanel(this, plugin); + wealthPanel = new WealthPanel(plugin, this::showOverview); + lootTrackerPanel = new LootTrackerPanel(plugin.getLootTrackerStore(), plugin.getItemManager(), plugin.getWikiApi(), plugin.getWikiExecutor(), + plugin.getCollectionLog(), plugin.getTimeFilter(), plugin.getFightService(), plugin::openWikiPage, this::showOverview); + skillsPanel = new SkillsPanel(plugin, this::showOverview); + wikiPanel = new WikiPanel(plugin, this::showOverview); + progressPanel = new ProgressPanel(plugin, this::showOverview); + currencyPanel = new CurrencyPanel(plugin.getCurrencyTracker(), plugin.getItemManager(), plugin.getTimeFilter(), this::showOverview); + currencyListener = currencyPanel::refreshLater; + plugin.getCurrencyTracker().addListener(currencyListener); + overviewPanel.attach(currencyPanel, wealthPanel, progressPanel); + progressListener = () -> SwingUtilities.invokeLater(overviewPanel::refreshProgress); + plugin.getCollectionLog().addListener(progressListener); + plugin.getProgress().addListener(progressListener); + + // Synchronize the summary card on the overview panel with WealthPanel's live data + wealthPanel.setOnSummaryUpdated(overviewPanel::updateSummaryCard); + + container.setOpaque(false); + container.add(overviewPanel, VIEW_OVERVIEW); + container.add(wealthPanel.getWrappedPanel(), VIEW_WEALTH); + container.add(lootTrackerPanel, VIEW_LOOT); + container.add(skillsPanel.getWrappedPanel(), VIEW_SKILLS); + container.add(wikiPanel, VIEW_WIKI); + container.add(progressPanel, VIEW_PROGRESS); + container.add(currencyPanel, VIEW_CURRENCY); + + add(container, BorderLayout.CENTER); + rangeBar = Segmented.ranges(plugin.getTimeFilter().getRange(), plugin.getTimeFilter()::set); + add(buildFooter(), BorderLayout.SOUTH); + + TimeFilter filter = plugin.getTimeFilter(); + rangeListener = () -> SwingUtilities.invokeLater(() -> + { + rangeBar.setSelected(filter.getRange().ordinal()); + overviewPanel.refreshSkills(); + currencyPanel.refresh(); + }); + filter.addListener(rangeListener); + clockTicker = new javax.swing.Timer(10_000, e -> + { + if (filter.isCurrent()) + { + lootTrackerPanel.rebuild(); + overviewPanel.refreshLoot(); + overviewPanel.refreshSkills(); + } + // Keep wealth and currency overview cards fresh + wealthPanel.refreshLater(); + currencyPanel.refresh(); + }); + clockTicker.start(); + + cardLayout.show(container, VIEW_OVERVIEW); + } + + /** Global footer: time range for every view + refresh / data folder. */ + private JPanel buildFooter() + { + JPanel footer = new JPanel(new BorderLayout(0, 4)); + footer.setBackground(Ui.BG); + footer.setBorder(BorderFactory.createCompoundBorder( + BorderFactory.createMatteBorder(1, 0, 0, 0, ColorScheme.DARKER_GRAY_COLOR), + BorderFactory.createEmptyBorder(6, 8, 8, 8))); + + rangeBar.setPreferredSize(new Dimension(10, 24)); + rangeBar.setToolTipText("Time range for Wealth, Loot, Skills and the xp HUD"); + footer.add(rangeBar, BorderLayout.NORTH); + + JPanel buttons = new JPanel(new GridLayout(1, 2, 6, 0)); + buttons.setOpaque(false); + buttons.add(createSmallButton("Refresh", () -> + { + plugin.requestUpdate(); + refreshLater(); + skillsPanel.refreshLater(); + lootTrackerPanel.rebuild(); + })); + buttons.add(createSmallButton("Data Folder", () -> + { + WealthStore.DIR.mkdirs(); + LinkBrowser.open(WealthStore.DIR.getAbsolutePath()); + })); + footer.add(buttons, BorderLayout.CENTER); + return footer; + } + + public void showOverview() + { + cardLayout.show(container, VIEW_OVERVIEW); + wealthPanel.refreshLater(); + overviewPanel.refreshLoot(); + overviewPanel.refreshSkills(); + overviewPanel.refreshWiki(); + } + + public void showWealthTracker() + { + cardLayout.show(container, VIEW_WEALTH); + wealthPanel.refreshLater(); + } + + public void showLootTracker() + { + cardLayout.show(container, VIEW_LOOT); + lootTrackerPanel.rebuild(); + } + + public void showSkills() + { + cardLayout.show(container, VIEW_SKILLS); + skillsPanel.refreshLater(); + } + + public void showProgress() + { + cardLayout.show(container, VIEW_PROGRESS); + progressPanel.onShow(); + } + + public void showCurrencies() + { + cardLayout.show(container, VIEW_CURRENCY); + currencyPanel.refresh(); + } + + public void showWiki() + { + cardLayout.show(container, VIEW_WIKI); + wikiPanel.onShow(); + } + + /** Helper switches changed in the config. */ + public void refreshHelpersLater() + { + SwingUtilities.invokeLater(overviewPanel::refreshHelpers); + } + + /** A wiki page was opened in-game: update the "last viewed" line of the overview card. */ + public void refreshWikiLater() + { + SwingUtilities.invokeLater(overviewPanel::refreshWiki); + } + + public void refreshLater() + { + wealthPanel.refreshLater(); + overviewPanel.refreshLoot(); + refreshSkillsLater(); + } + + /** Xp changed: coalesced refresh of the skills tab and overview card. */ + public void refreshSkillsLater() + { + SwingUtilities.invokeLater(() -> + { + skillsPanel.refreshLater(); + overviewPanel.refreshSkillsThrottled(); + }); + } + + public void dispose() + { + clockTicker.stop(); + wealthPanel.dispose(); + skillsPanel.dispose(); + plugin.getTimeFilter().removeListener(rangeListener); + plugin.getCollectionLog().removeListener(progressListener); + plugin.getProgress().removeListener(progressListener); + plugin.getCurrencyTracker().removeListener(currencyListener); + } + + public WealthPanel getWealthPanel() + { + return wealthPanel; + } + + public void refreshWealthLater() + { + if (wealthPanel != null) + { + wealthPanel.refreshLater(); + } + } + + private static JButton createSmallButton(String text, Runnable action) + { + JButton b = new JButton(text); + b.setFont(FontManager.getRunescapeSmallFont()); + b.setForeground(Ui.MUTED); + b.setFocusPainted(false); + b.setPreferredSize(new Dimension(b.getPreferredSize().width, 24)); + b.setMargin(new java.awt.Insets(2, 4, 2, 4)); + b.setCursor(Cursor.getPredefinedCursor(Cursor.HAND_CURSOR)); + b.addActionListener(e -> action.run()); + return b; + } + + /** + * Main Overview view listing the features available in Essentials Plus. Cards can be dragged into a new order + * (left mouse) and have display options (right mouse); both are saved in the config. + */ + static final class OverviewPanel extends JPanel + { + private static final String CURRENCY = "currency"; + private static final String WEALTH = "wealth"; + private static final String LOOT = "loot"; + private static final String SKILLS = "skills"; + private static final String PROGRESS = "progress"; + private static final String WIKI = "wiki"; + private static final String HELPERS = "helpers"; + private static final List DEFAULT_ORDER = Arrays.asList(CURRENCY, WEALTH, LOOT, SKILLS, PROGRESS, WIKI, HELPERS); + private static final Map NAMES = new HashMap<>(); + + static + { + NAMES.put(CURRENCY, "Points & Marks"); + NAMES.put(WEALTH, "Total wealth"); + NAMES.put(LOOT, "Fights & Loot"); + NAMES.put(SKILLS, "Skills"); + NAMES.put(PROGRESS, "Achievements"); + NAMES.put(WIKI, "Wiki"); + NAMES.put(HELPERS, "Helpers"); + } + + private final EssentialsPlusPanel parent; + private final EssentialsPlusPlugin plugin; + private final AccountCard totalWealthCard; + private final LootSummaryCard lootSummaryCard; + private final SkillsCard skillsCard; + private final WikiCard wikiCard; + private final ProgressCard progressCard; + private final HelpersCard helpersCard; + private final JPanel topContainer; + /** card id -> component, in default order */ + private final Map cards = new LinkedHashMap<>(); + private ProgressPanel progressPanel; + private CurrencyPanel currencyPanel; + private WealthPanel wealthPanel; + private long lastSkillsPaint; + + OverviewPanel(EssentialsPlusPanel parent, EssentialsPlusPlugin plugin) + { + this.parent = parent; + this.plugin = plugin; + + setBackground(Ui.BG); + setLayout(new BorderLayout()); + + topContainer = new JPanel(new net.runelite.client.ui.DynamicGridLayout(0, 1, 0, 8)); + topContainer.setOpaque(false); + topContainer.setBorder(BorderFactory.createEmptyBorder(10, 8, 8, 8)); + + // Header title banner (clean, bold title only) + JPanel header = new JPanel(new BorderLayout()); + header.setOpaque(false); + header.setBorder(BorderFactory.createEmptyBorder(2, 2, 4, 2)); + + JLabel title = new JLabel("Essentials Plus"); + title.setFont(FontManager.getRunescapeBoldFont().deriveFont(16f)); + title.setForeground(ColorScheme.BRAND_ORANGE); + title.setToolTipText("Drag cards to reorder, right-click a card for options"); + header.add(title, BorderLayout.WEST); + header.addMouseListener(new java.awt.event.MouseAdapter() + { + @Override + public void mousePressed(java.awt.event.MouseEvent e) + { + if (e.isPopupTrigger() || SwingUtilities.isRightMouseButton(e)) + { + JPopupMenu menu = new JPopupMenu(); + addLayoutItems(menu); + menu.show(header, e.getX(), e.getY()); + } + } + }); + + topContainer.add(header); + + // cards open their tab through CardDrag (on release, so a drag does not open it) + Runnable none = () -> { }; + totalWealthCard = new AccountCard(true, none, null); + lootSummaryCard = new LootSummaryCard(plugin.getLootTrackerStore(), plugin.getItemManager(), plugin.getTimeFilter(), null); + plugin.getFightService().addListener(snap -> SwingUtilities.invokeLater(() -> lootSummaryCard.setFight(snap))); + skillsCard = new SkillsCard(none); + progressCard = new ProgressCard(none); + wikiCard = new WikiCard(none); + helpersCard = new HelpersCard(plugin); + refreshSkills(); + refreshWiki(); + + JPanel listWrap = new JPanel(new BorderLayout()); + listWrap.setOpaque(false); + listWrap.add(topContainer, BorderLayout.NORTH); + + JScrollPane scroll = new JScrollPane(listWrap); + scroll.setBorder(null); + scroll.setOpaque(false); + scroll.getViewport().setOpaque(false); + scroll.setHorizontalScrollBarPolicy(ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER); + scroll.setVerticalScrollBarPolicy(ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED); + scroll.getVerticalScrollBar().setUnitIncrement(16); + scroll.getVerticalScrollBar().setPreferredSize(new Dimension(8, 0)); + + add(scroll, BorderLayout.CENTER); + } + + /** Second step once all tabs exist: registers the cards, applies the saved options and order. */ + void attach(CurrencyPanel currencyPanel, WealthPanel wealthPanel, ProgressPanel progressPanel) + { + this.currencyPanel = currencyPanel; + this.wealthPanel = wealthPanel; + this.progressPanel = progressPanel; + register(CURRENCY, currencyPanel.createOverviewCard(() -> { }), parent::showCurrencies); + register(WEALTH, totalWealthCard, parent::showWealthTracker); + register(LOOT, lootSummaryCard, parent::showLootTracker); + register(SKILLS, skillsCard, parent::showSkills); + register(PROGRESS, progressCard, parent::showProgress); + register(WIKI, wikiCard, parent::showWiki); + register(HELPERS, helpersCard, none()); + applyOptions(); + layoutCards(); + refreshProgress(); + } + + private static Runnable none() + { + return () -> { }; + } + + private void register(String id, JComponent card, Runnable open) + { + cards.put(id, card); + CardDrag.install(card, topContainer, 1, open, () -> menu(id), this::saveOrder); + } + + // ---- settings + + private String get(String name) + { + String v = plugin.getOverviewSetting(name); + return v == null ? "" : v; + } + + private boolean flag(String name, boolean def) + { + String v = plugin.getOverviewSetting(name); + return v == null || v.isEmpty() ? def : Boolean.parseBoolean(v); + } + + private int mode(String name) + { + try + { + return Integer.parseInt(get(name)); + } + catch (NumberFormatException e) + { + return 0; + } + } + + private void set(String name, Object value) + { + plugin.setOverviewSetting(name, value == null ? null : String.valueOf(value)); + applyOptions(); + } + + private static List split(String raw) + { + List out = new ArrayList<>(); + for (String part : raw.split(",")) + { + if (!part.trim().isEmpty()) + { + out.add(part.trim()); + } + } + return out; + } + + private void applyOptions() + { + currencyPanel.setOverviewOptions(com.example.essentialsplus.currency.Currency.byName(get("currency.pin")), + flag("currency.tracked", true)); + totalWealthCard.setOptions(flag("wealth.chart", true), flag("wealth.pct", true)); + wealthPanel.setOverviewKeys(new HashSet<>(split(get("wealth.characters")))); + lootSummaryCard.setMode(mode("loot.mode")); + lootSummaryCard.setFightOptions(flag("loot.target", true), flag("loot.last", false), flag("loot.hp", true), + flag("loot.dps", true), flag("loot.session", true), flag("loot.kills", true)); + skillsCard.setOptions(mode("skills.mode"), flag("skills.chart", true)); + skillsCard.setTopSkills(mode("skills.top"), s -> plugin.getSkillIconManager().getSkillImage(s, true)); + progressCard.setCounts(flag("progress.counts", false)); + wikiCard.setShowLast(flag("wiki.last", true)); + topContainer.revalidate(); + topContainer.repaint(); + } + + /** Saved order first (unknown ids dropped), then cards added since; hidden cards left out. */ + private void layoutCards() + { + List order = new ArrayList<>(); + for (String id : split(get("order"))) + { + if (cards.containsKey(id) && !order.contains(id)) + { + order.add(id); + } + } + for (String id : DEFAULT_ORDER) + { + if (!order.contains(id)) + { + order.add(Math.min(DEFAULT_ORDER.indexOf(id), order.size()), id); + } + } + List hidden = split(get("hidden")); + while (topContainer.getComponentCount() > 1) + { + topContainer.remove(1); + } + for (String id : order) + { + if (!hidden.contains(id)) + { + topContainer.add(cards.get(id)); + } + } + topContainer.revalidate(); + topContainer.repaint(); + } + + /** After a drag: visible cards in their new order, hidden ones keep their place at the end. */ + private void saveOrder() + { + List order = new ArrayList<>(); + for (java.awt.Component c : topContainer.getComponents()) + { + for (Map.Entry e : cards.entrySet()) + { + if (e.getValue() == c) + { + order.add(e.getKey()); + } + } + } + for (String id : cards.keySet()) + { + if (!order.contains(id)) + { + order.add(id); + } + } + plugin.setOverviewSetting("order", String.join(",", order)); + } + + private void setHidden(String id, boolean hide) + { + List hidden = split(get("hidden")); + hidden.remove(id); + if (hide) + { + hidden.add(id); + } + plugin.setOverviewSetting("hidden", String.join(",", hidden)); + layoutCards(); + } + + // ---- right-click menus + + private JPopupMenu menu(String id) + { + JPopupMenu menu = new JPopupMenu(); + if (!HELPERS.equals(id)) + { + JMenuItem open = new JMenuItem("Open " + NAMES.get(id)); + open.addActionListener(a -> openCard(id)); + menu.add(open); + menu.addSeparator(); + } + switch (id) + { + case CURRENCY: + currencyItems(menu); + break; + case WEALTH: + wealthItems(menu); + break; + case LOOT: + radios(menu, "loot.mode", "Total value", "Latest drop", "Top source"); + menu.addSeparator(); + check(menu, "Show kill count", "loot.kills", true); + check(menu, "Show target while fighting", "loot.target", true); + JMenu fightMenu = new JMenu("Target display"); + check(fightMenu, "Keep last target after the fight", "loot.last", false); + check(fightMenu, "Health bar", "loot.hp", true); + check(fightMenu, "Max hit / DPS / hit chance", "loot.dps", true); + check(fightMenu, "Fight time / damage", "loot.session", true); + menu.add(fightMenu); + break; + case SKILLS: + radios(menu, "skills.mode", "Xp gained", "Total xp", "Total level"); + menu.addSeparator(); + check(menu, "Show chart", "skills.chart", true); + JMenu top = new JMenu("Top skills"); + int topCount = mode("skills.top"); + for (int n = 0; n <= 3; n++) + { + int value = n; + top.add(radio(n == 0 ? "Off" : "Top " + n, topCount == n, () -> set("skills.top", value == 0 ? null : value))); + } + menu.add(top); + menu.addSeparator(); + JMenuItem reset = new JMenuItem("Reset Hourly"); + reset.addActionListener(e -> + { + SkillHistory h = SkillsPanel.resolve(plugin.getSkillStore(), null, plugin.getSkillKey()); + if (h != null) + { + plugin.resetSkillsHourly(h.key); + refreshSkills(); + } + }); + menu.add(reset); + break; + case PROGRESS: + { + boolean counts = flag("progress.counts", false); + menu.add(radio("Percent complete", !counts, () -> set("progress.counts", null))); + menu.add(radio("Done / total", counts, () -> set("progress.counts", true))); + break; + } + case WIKI: + check(menu, "Show last viewed page", "wiki.last", true); + break; + default: + break; + } + menu.addSeparator(); + JMenuItem hide = new JMenuItem("Hide card"); + hide.addActionListener(a -> setHidden(id, true)); + menu.add(hide); + addLayoutItems(menu); + return menu; + } + + private void openCard(String id) + { + switch (id) + { + case CURRENCY: + parent.showCurrencies(); + break; + case WEALTH: + parent.showWealthTracker(); + break; + case LOOT: + parent.showLootTracker(); + break; + case SKILLS: + parent.showSkills(); + break; + case PROGRESS: + parent.showProgress(); + break; + default: + parent.showWiki(); + break; + } + } + + private void addLayoutItems(JPopupMenu menu) + { + List hidden = split(get("hidden")); + if (!hidden.isEmpty()) + { + JMenu show = new JMenu("Show hidden card"); + for (String h : hidden) + { + if (NAMES.containsKey(h)) + { + JMenuItem item = new JMenuItem(NAMES.get(h)); + item.addActionListener(a -> setHidden(h, false)); + show.add(item); + } + } + menu.add(show); + } + JMenuItem reset = new JMenuItem("Reset layout"); + reset.addActionListener(a -> + { + plugin.setOverviewSetting("order", null); + plugin.setOverviewSetting("hidden", null); + layoutCards(); + }); + menu.add(reset); + } + + private void currencyItems(JPopupMenu menu) + { + String pin = get("currency.pin"); + menu.add(radio("Auto: what I'm playing", pin.isEmpty(), () -> set("currency.pin", null))); + List tracked = currencyPanel.tracked(); + if (!tracked.isEmpty()) + { + JMenu pinMenu = new JMenu("Always show"); + for (com.example.essentialsplus.currency.Currency c : tracked) + { + pinMenu.add(radio(c.label, c.name().equals(pin), () -> set("currency.pin", c.name()))); + } + menu.add(pinMenu); + } + menu.addSeparator(); + check(menu, "Show tracked count", "currency.tracked", true); + } + + private void wealthItems(JPopupMenu menu) + { + boolean isHourly = wealthPanel != null ? wealthPanel.isHourly() : (plugin.getTimeFilter().isCurrent() || plugin.getTimeFilter().getRange() == TimeRange.SESSION); + if (isHourly) + { + JMenuItem reset = new JMenuItem("Reset Hourly"); + reset.addActionListener(e -> plugin.resetWealthHourly(null)); + menu.add(reset); + menu.addSeparator(); + } + Set chosen = new HashSet<>(split(get("wealth.characters"))); + menu.add(radio("All characters", chosen.isEmpty(), () -> set("wealth.characters", null))); + for (com.example.essentialsplus.data.AccountData a : plugin.getStore().all()) + { + String key = a.key; + JCheckBoxMenuItem item = new JCheckBoxMenuItem(a.name == null ? key : a.name, chosen.contains(key)); + item.addActionListener(e -> + { + Set next = new HashSet<>(split(get("wealth.characters"))); + if (!next.remove(key)) + { + next.add(key); + } + set("wealth.characters", next.isEmpty() ? null : String.join(",", next)); + }); + menu.add(item); + } + menu.addSeparator(); + check(menu, "Show chart", "wealth.chart", true); + check(menu, "Show % change", "wealth.pct", true); + } + + private void radios(JPopupMenu menu, String name, String... labels) + { + int current = mode(name); + for (int i = 0; i < labels.length; i++) + { + int value = i; + menu.add(radio(labels[i], current == i, () -> set(name, value == 0 ? null : value))); + } + } + + private void check(JComponent menu, String label, String name, boolean def) + { + boolean on = flag(name, def); + JCheckBoxMenuItem item = new JCheckBoxMenuItem(label, on); + item.addActionListener(e -> set(name, !on == def ? null : !on)); + menu.add(item); + } + + private static JRadioButtonMenuItem radio(String label, boolean selected, Runnable action) + { + JRadioButtonMenuItem item = new JRadioButtonMenuItem(label, selected); + item.addActionListener(e -> action.run()); + return item; + } + + // ---- data + + void updateSummaryCard(AccountCard source) + { + totalWealthCard.copyFrom(source); + } + + void refreshLoot() + { + lootSummaryCard.refresh(); + } + + void refreshProgress() + { + if (progressPanel != null) + { + progressPanel.fillCard(progressCard); + } + } + + void refreshHelpers() + { + helpersCard.refresh(); + } + + void refreshWiki() + { + java.util.List recent = plugin.getWikiRecent(); + wikiCard.setLastPage(recent.isEmpty() ? null : recent.get(0)); + } + + void refreshSkillsThrottled() + { + if (System.currentTimeMillis() - lastSkillsPaint >= 1000) + { + refreshSkills(); + } + } + + void refreshSkills() + { + lastSkillsPaint = System.currentTimeMillis(); + SkillsPanel.fillCard(skillsCard, + SkillsPanel.resolve(plugin.getSkillStore(), null, plugin.getSkillKey()), + plugin.getTimeFilter(), "Skills"); + } + } +} diff --git a/src/main/java/com/example/essentialsplus/ui/GeWindow.java b/src/main/java/com/example/essentialsplus/ui/GeWindow.java new file mode 100644 index 0000000..0bac0c1 --- /dev/null +++ b/src/main/java/com/example/essentialsplus/ui/GeWindow.java @@ -0,0 +1,428 @@ +package com.example.essentialsplus.ui; + +import com.example.essentialsplus.data.GeRow; +import com.example.essentialsplus.data.GeSlot; +import java.awt.BasicStroke; +import java.awt.BorderLayout; +import java.awt.Color; +import java.awt.Dimension; +import java.awt.FontMetrics; +import java.awt.Graphics; +import java.awt.Graphics2D; +import java.awt.Rectangle; +import java.awt.RenderingHints; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Locale; +import javax.swing.BorderFactory; +import javax.swing.JComponent; +import javax.swing.JFrame; +import javax.swing.JLabel; +import javax.swing.JPanel; +import javax.swing.JScrollPane; +import javax.swing.Scrollable; +import javax.swing.WindowConstants; +import net.runelite.client.ui.FontManager; + +/** Grand Exchange slots in detail: progress, escrow, collection box, value, age, speed. */ +class GeWindow extends JFrame +{ + private static final int CARD_W = 470; + private static final int CARD_H = 205; + private static final int GAP = 10; + private static final int HEADER_H = 28; + private static final Color BUY = new Color(72, 208, 130); + private static final Color SELL = new Color(255, 170, 60); + + private final ItemIcons icons; + private final JLabel title = new JLabel(); + private final JLabel subtitle = new JLabel(); + private final Slots slots = new Slots(); + long lastLoad; + String selection; + + GeWindow(ItemIcons icons, Runnable onClose) + { + this.icons = icons; + setDefaultCloseOperation(WindowConstants.DISPOSE_ON_CLOSE); + addWindowListener(new java.awt.event.WindowAdapter() + { + @Override + public void windowClosed(java.awt.event.WindowEvent e) + { + onClose.run(); + } + }); + + JPanel root = new JPanel(new BorderLayout(0, 10)); + root.setBackground(Ui.BG); + root.setBorder(BorderFactory.createEmptyBorder(12, 12, 12, 12)); + JPanel titles = new JPanel(new BorderLayout(0, 2)); + titles.setOpaque(false); + title.setFont(FontManager.getRunescapeBoldFont().deriveFont(24f)); + title.setForeground(Ui.ACCENT); + subtitle.setFont(FontManager.getRunescapeFont()); + subtitle.setForeground(Ui.MUTED); + titles.add(title, BorderLayout.NORTH); + titles.add(subtitle, BorderLayout.SOUTH); + root.add(titles, BorderLayout.NORTH); + + JScrollPane scroll = new JScrollPane(slots); + scroll.setBorder(null); + scroll.getViewport().setBackground(Ui.BG); + scroll.getVerticalScrollBar().setUnitIncrement(24); + root.add(scroll, BorderLayout.CENTER); + + setContentPane(root); + setSize(CARD_W * 2 + GAP + 60, 980); + setMinimumSize(new Dimension(CARD_W + 60, 400)); + } + + void setTitleText(String who) + { + setTitle("Wealth Tracker - Grand Exchange - " + who); + title.setText("Grand Exchange - " + who); + } + + void setRows(List rows) + { + lastLoad = System.currentTimeMillis(); + int used = 0; + int characters = 0; + long value = 0; + long readyItems = 0; + long readyCoins = 0; + String lastChar = null; + for (GeRow r : rows) + { + if (!r.character.equals(lastChar)) + { + characters++; + lastChar = r.character; + } + if (r.offer != null) + { + used++; + value += slotValue(r); + readyItems += r.offer.collectItems; + readyCoins += r.offer.collectCoins; + } + } + String s = used + " / " + characters * 8 + " slots in use - " + Ui.exact(value) + " in offers"; + if (readyItems > 0 || readyCoins > 0) + { + s += " - ready to collect: " + (readyItems > 0 ? String.format(Locale.US, "%,d items", readyItems) : "") + + (readyItems > 0 && readyCoins > 0 ? " + " : "") + (readyCoins > 0 ? Ui.gp(readyCoins) + " gp" : ""); + } + subtitle.setText(s); + slots.setRows(rows, characters > 1); + } + + private static long slotValue(GeRow r) + { + GeSlot s = r.offer; + return (s.escrowItems + s.collectItems) * (long) r.marketPrice + s.escrowCoins + s.collectCoins; + } + + static String duration(long ms) + { + long m = Math.max(0, ms) / 60_000; + if (m < 60) + { + return m + "m"; + } + long h = m / 60; + if (h < 24) + { + return h + "h " + (m % 60) + "m"; + } + return (h / 24) + "d " + (h % 24) + "h"; + } + + private final class Slots extends JComponent implements Scrollable + { + private List rows = Collections.emptyList(); + private boolean grouped; + + void setRows(List rows, boolean grouped) + { + this.rows = rows; + this.grouped = grouped; + revalidate(); + repaint(); + } + + private int columns() + { + int w = getParent() != null ? getParent().getWidth() : getWidth(); + return Math.max(1, (w + GAP) / (CARD_W + GAP)); + } + + @Override + public Dimension getPreferredSize() + { + int cols = columns(); + int h = 0; + for (List group : groups()) + { + h += (grouped ? HEADER_H : 0) + ((group.size() + cols - 1) / cols) * (CARD_H + GAP); + } + return new Dimension(cols * (CARD_W + GAP), h); + } + + private List> groups() + { + List> out = new ArrayList<>(); + String last = null; + for (GeRow r : rows) + { + if (!r.character.equals(last)) + { + out.add(new ArrayList<>()); + last = r.character; + } + out.get(out.size() - 1).add(r); + } + return out; + } + + @Override + public Dimension getPreferredScrollableViewportSize() + { + return getPreferredSize(); + } + + @Override + public int getScrollableUnitIncrement(Rectangle visibleRect, int orientation, int direction) + { + return 24; + } + + @Override + public int getScrollableBlockIncrement(Rectangle visibleRect, int orientation, int direction) + { + return visibleRect.height - 40; + } + + @Override + public boolean getScrollableTracksViewportWidth() + { + return true; + } + + @Override + public boolean getScrollableTracksViewportHeight() + { + return false; + } + + @Override + protected void paintComponent(Graphics g0) + { + Graphics2D g = Ui.prepare(g0); + g.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR); + int cols = columns(); + int y = 0; + for (List group : groups()) + { + if (grouped) + { + g.setFont(FontManager.getRunescapeBoldFont()); + g.setColor(Ui.TEXT); + g.drawString(group.get(0).character, 2, y + 18); + y += HEADER_H; + } + for (int i = 0; i < group.size(); i++) + { + int x = (i % cols) * (CARD_W + GAP); + int cy = y + (i / cols) * (CARD_H + GAP); + paintCard(g, group.get(i), x, cy); + } + y += ((group.size() + cols - 1) / cols) * (CARD_H + GAP); + } + g.dispose(); + } + + private void paintCard(Graphics2D g, GeRow r, int x, int y) + { + GeSlot s = r.offer; + if (s == null) + { + g.setColor(new Color(34, 34, 34)); + g.fillRoundRect(x, y, CARD_W, CARD_H, 10, 10); + g.setStroke(new BasicStroke(1f, BasicStroke.CAP_BUTT, BasicStroke.JOIN_MITER, 10f, new float[]{4f, 4f}, 0f)); + g.setColor(new Color(70, 70, 70)); + g.drawRoundRect(x + 1, y + 1, CARD_W - 2, CARD_H - 2, 10, 10); + g.setFont(FontManager.getRunescapeFont()); + FontMetrics fm = g.getFontMetrics(); + g.setColor(Ui.FAINT); + String t = "Slot " + (r.slot + 1) + " - empty"; + g.drawString(t, x + (CARD_W - fm.stringWidth(t)) / 2, y + CARD_H / 2 + 4); + return; + } + + long now = System.currentTimeMillis(); + boolean cancelled = s.state != null && s.state.startsWith("CANCELLED"); + boolean complete = s.done >= s.total || "BOUGHT".equals(s.state) || "SOLD".equals(s.state); + Color side = s.buy ? BUY : SELL; + + g.setColor(Ui.CARD); + g.fillRoundRect(x, y, CARD_W, CARD_H, 10, 10); + g.setColor(side); + g.fillRoundRect(x, y, 4, CARD_H, 4, 4); + + int pad = 14; + g.drawImage(icons.icon(s.item, this), x + pad, y + 10, 36, 32, null); + + g.setFont(FontManager.getRunescapeBoldFont()); + FontMetrics bfm = g.getFontMetrics(); + g.setColor(Ui.TEXT); + String name = r.name != null ? r.name : "Item " + s.item; + g.drawString(Ui.ellipsize(bfm, name, CARD_W - 220), x + pad + 46, y + 24); + + // badge + g.setFont(FontManager.getRunescapeSmallFont()); + FontMetrics sfm = g.getFontMetrics(); + String badge = s.buy ? "BUY" : "SELL"; + int bw = sfm.stringWidth(badge) + 12; + int bx = x + pad + 46; + g.setColor(Ui.alpha(side, 50)); + g.fillRoundRect(bx, y + 30, bw, 15, 6, 6); + g.setColor(side); + g.drawString(badge, bx + 6, y + 30 + sfm.getAscent() + 1); + + String stateText = cancelled ? "Cancelled" : complete ? "Completed" : s.buy ? "Buying" : "Selling"; + Color stateColor = cancelled ? Ui.DOWN : complete ? Ui.UP : Ui.MUTED; + g.setFont(FontManager.getRunescapeFont()); + FontMetrics fm = g.getFontMetrics(); + g.setColor(stateColor); + Ui.drawRight(g, stateText, x + CARD_W - pad, y + 24); + g.setFont(FontManager.getRunescapeSmallFont()); + g.setColor(Ui.FAINT); + Ui.drawRight(g, "Slot " + (r.slot + 1), x + CARD_W - pad, y + 42); + + // progress + int barX = x + pad; + int barY = y + 52; + int barW = CARD_W - pad * 2; + int barH = 18; + double ratio = s.total > 0 ? Math.min(1, (double) s.done / s.total) : 0; + g.setColor(new Color(20, 20, 20)); + g.fillRoundRect(barX, barY, barW, barH, 6, 6); + g.setColor(cancelled ? new Color(170, 60, 50) : complete ? new Color(40, 150, 70) : new Color(220, 130, 20)); + g.fillRoundRect(barX, barY, (int) Math.round(barW * ratio), barH, 6, 6); + g.setFont(FontManager.getRunescapeBoldFont()); + FontMetrics pfm = g.getFontMetrics(); + String prog = String.format(Locale.US, "%s %,d / %,d (%.1f%%)", s.buy ? "Bought" : "Sold", s.done, s.total, ratio * 100); + g.setColor(Color.WHITE); + g.drawString(prog, barX + (barW - pfm.stringWidth(prog)) / 2, barY + (barH + pfm.getAscent()) / 2 - 1); + + // details + List left = new ArrayList<>(); + List leftC = new ArrayList<>(); + List right = new ArrayList<>(); + List rightC = new ArrayList<>(); + + left.add(new String[]{"Price each", String.format(Locale.US, "%,d gp", s.price)}); + leftC.add(Ui.TEXT); + if (r.marketPrice > 0) + { + double diff = (double) (s.price - r.marketPrice) / r.marketPrice; + left.add(new String[]{"GE price now", String.format(Locale.US, "%,d (%s)", r.marketPrice, Ui.pct(diff))}); + leftC.add(Ui.MUTED); + } + left.add(new String[]{s.buy ? "Spent" : "Received", Ui.gp(s.spent) + " gp"}); + leftC.add(Ui.TEXT); + if (s.done > 0) + { + left.add(new String[]{"Avg per item", String.format(Locale.US, "%,d gp", s.spent / s.done)}); + leftC.add(Ui.MUTED); + } + left.add(new String[]{"Offer total", Ui.gp(s.total * s.price) + " gp"}); + leftC.add(Ui.MUTED); + + if (r.limitInfo != null && (r.limitInfo.limit > 0 || r.limitInfo.bought > 0)) + { + String limitStr; + if (r.limitInfo.limit > 0) + { + limitStr = String.format(Locale.US, "%,d / %,d", r.limitInfo.bought, r.limitInfo.limit); + if (r.limitInfo.windowActive && r.limitInfo.resetInMs > 0) + { + limitStr += " (" + duration(r.limitInfo.resetInMs) + ")"; + } + } + else + { + limitStr = String.format(Locale.US, "%,d", r.limitInfo.bought); + } + left.add(new String[]{"4h Buy Limit", limitStr}); + leftC.add(r.limitInfo.remaining == 0 && r.limitInfo.limit > 0 ? Ui.DOWN : Ui.TEXT); + } + + String leftIn = s.buy + ? Ui.gp(s.escrowCoins) + " gp (" + String.format(Locale.US, "%,d", Math.max(0, s.total - s.done)) + " left)" + : String.format(Locale.US, "%,d items", s.escrowItems); + right.add(new String[]{"Left in offer", leftIn}); + rightC.add(Ui.TEXT); + String ready = (s.collectItems > 0 ? String.format(Locale.US, "%,d items", s.collectItems) : "") + + (s.collectItems > 0 && s.collectCoins > 0 ? " + " : "") + + (s.collectCoins > 0 ? Ui.gp(s.collectCoins) + " gp" : ""); + right.add(new String[]{"Ready to collect", ready.isEmpty() ? "nothing" : ready}); + rightC.add(ready.isEmpty() ? Ui.FAINT : Ui.GOLD); + right.add(new String[]{"Slot value", Ui.gp(slotValue(r)) + " gp"}); + rightC.add(Ui.GOLD); + + if (s.placedAt > 0) + { + right.add(new String[]{"Placed", duration(now - s.placedAt) + " ago"}); + } + else + { + right.add(new String[]{"Placed", "> " + duration(now - s.trackedSince) + " ago"}); + } + rightC.add(Ui.TEXT); + right.add(new String[]{"Last trade", s.lastTradeAt > 0 ? duration(now - s.lastTradeAt) + " ago" : "-"}); + rightC.add(Ui.MUTED); + + if (!complete && !cancelled) + { + long since = s.placedAt > 0 ? s.placedAt : s.trackedSince; + long doneSince = s.placedAt > 0 ? s.done : s.done - s.trackedDone; + double hours = (now - since) / 3_600_000.0; + if (hours > 0.05 && doneSince > 0) + { + double rate = doneSince / hours; + long etaMs = (long) ((s.total - s.done) / rate * 3_600_000); + right.add(new String[]{"Speed / ETA", String.format(Locale.US, "%,.0f/h - ~%s", rate, duration(etaMs))}); + } + else + { + right.add(new String[]{"Speed / ETA", "-"}); + } + rightC.add(Ui.MUTED); + } + + g.setFont(FontManager.getRunescapeFont()); + fm = g.getFontMetrics(); + int colW = (CARD_W - pad * 2 - 16) / 2; + paintColumn(g, fm, left, leftC, x + pad, y + 90, colW); + paintColumn(g, fm, right, rightC, x + pad + colW + 16, y + 90, colW); + } + + private void paintColumn(Graphics2D g, FontMetrics fm, List rows, List colors, int x, int y, int w) + { + int lh = 16; + for (int i = 0; i < rows.size(); i++) + { + int base = y + i * lh + fm.getAscent(); + g.setColor(Ui.FAINT); + g.drawString(rows.get(i)[0], x, base); + g.setColor(colors.get(i)); + int labelW = fm.stringWidth(rows.get(i)[0]) + 8; + Ui.drawRight(g, Ui.ellipsize(fm, rows.get(i)[1], w - labelW), x + w, base); + } + } + } +} diff --git a/src/main/java/com/example/essentialsplus/ui/HelpersCard.java b/src/main/java/com/example/essentialsplus/ui/HelpersCard.java new file mode 100644 index 0000000..739ff73 --- /dev/null +++ b/src/main/java/com/example/essentialsplus/ui/HelpersCard.java @@ -0,0 +1,412 @@ +package com.example.essentialsplus.ui; + +import com.example.essentialsplus.EssentialsPlusPlugin; +import com.example.essentialsplus.helpers.TimerTextSize; +import java.awt.Color; +import java.awt.Dimension; +import java.awt.Graphics; +import java.awt.Graphics2D; +import javax.swing.BorderFactory; +import javax.swing.BoxLayout; +import javax.swing.JCheckBox; +import javax.swing.JCheckBoxMenuItem; +import javax.swing.JMenu; +import javax.swing.JMenuItem; +import javax.swing.JPopupMenu; +import javax.swing.JRadioButtonMenuItem; +import javax.swing.SwingUtilities; +import javax.swing.JLabel; +import javax.swing.JPanel; +import net.runelite.client.ui.FontManager; +import net.runelite.client.ui.PluginPanel; + +/** Overview card with on/off switches for the small in-game helpers (attack timers, ...). */ +public class HelpersCard extends JPanel +{ + private final EssentialsPlusPlugin plugin; + private final JCheckBox attackTimer = box("Attack timer", "Ticks until your next attack, above your head. Right-click for options"); + private final JCheckBox enemyAttackStyle = box("Enemy attack style", "Melee, range or mage icon above NPCs attacking you. Right-click for options"); + private final JCheckBox itemPrices = box("Item prices", "GE and high alch price when hovering inventory items. Right-click for options"); + private final JCheckBox enemyAttackTimer = box("Enemy attack timer", "Ticks until attacking NPCs hit next, above their heads. Right-click for options"); + private final JCheckBox treeCooldowns = box("Tree cooldowns", "Respawn-Countdown auf gefällten Bäumen + Rest-Zeit auf langlebigen Bäumen. Rechtsklick für Optionen"); + private final JCheckBox itemCharges = box("Item charges", "Charge-Zahl auf Item-Icons (Blowpipe, Trident, Tome, …) + Loot-Bag-Geld & Coal-Bag-Füllstand. Rechtsklick für Optionen"); + + public HelpersCard(EssentialsPlusPlugin plugin) + { + this.plugin = plugin; + setOpaque(false); + setLayout(new BoxLayout(this, BoxLayout.Y_AXIS)); + setBorder(BorderFactory.createEmptyBorder(6, 10, 8, 10)); + + JLabel title = new JLabel("Helpers"); + title.setFont(FontManager.getRunescapeBoldFont()); + title.setForeground(Ui.ACCENT); + title.setBorder(BorderFactory.createEmptyBorder(0, 0, 4, 0)); + add(title); + + attackTimer.addActionListener(e -> plugin.setHelperOption("attackTimer", attackTimer.isSelected())); + enemyAttackTimer.addActionListener(e -> plugin.setHelperOption("enemyAttackTimer", enemyAttackTimer.isSelected())); + optionsMenu(attackTimer, "attackTimer", "Attack timer", Color.WHITE); + optionsMenu(enemyAttackTimer, "enemyAttackTimer", "Enemy attack timer", new Color(0xFF7043)); + add(attackTimer); + add(enemyAttackTimer); + enemyAttackStyle.addActionListener(e -> plugin.setHelperOption("enemyAttackStyle", enemyAttackStyle.isSelected())); + enemyAttackStyle.addMouseListener(new java.awt.event.MouseAdapter() + { + @Override + public void mousePressed(java.awt.event.MouseEvent e) + { + if (e.isPopupTrigger() || SwingUtilities.isRightMouseButton(e)) + { + styleMenu().show(enemyAttackStyle, e.getX(), e.getY()); + } + } + }); + add(enemyAttackStyle); + itemPrices.addActionListener(e -> plugin.setHelperOption("itemPrices", itemPrices.isSelected())); + itemPrices.addMouseListener(new java.awt.event.MouseAdapter() + { + @Override + public void mousePressed(java.awt.event.MouseEvent e) + { + if (e.isPopupTrigger() || SwingUtilities.isRightMouseButton(e)) + { + priceMenu().show(itemPrices, e.getX(), e.getY()); + } + } + }); + add(itemPrices); + treeCooldowns.addActionListener(e -> plugin.setHelperOption("treeCooldowns", treeCooldowns.isSelected())); + treeCooldowns.addMouseListener(new java.awt.event.MouseAdapter() + { + @Override + public void mousePressed(java.awt.event.MouseEvent e) + { + if (e.isPopupTrigger() || SwingUtilities.isRightMouseButton(e)) + { + treeMenu().show(treeCooldowns, e.getX(), e.getY()); + } + } + }); + add(treeCooldowns); + itemCharges.addActionListener(e -> plugin.setHelperOption("itemCharges", itemCharges.isSelected())); + itemCharges.addMouseListener(new java.awt.event.MouseAdapter() + { + @Override + public void mousePressed(java.awt.event.MouseEvent e) + { + if (e.isPopupTrigger() || SwingUtilities.isRightMouseButton(e)) + { + chargesMenu().show(itemCharges, e.getX(), e.getY()); + } + } + }); + add(itemCharges); + refresh(); + } + + /** Re-reads the switches from the config (also changed in the RuneLite settings). */ + public void refresh() + { + attackTimer.setSelected(plugin.isHelperOption("attackTimer")); + enemyAttackTimer.setSelected(plugin.isHelperOption("enemyAttackTimer")); + enemyAttackStyle.setSelected(plugin.isHelperOption("enemyAttackStyle")); + itemPrices.setSelected(plugin.isHelperOption("itemPrices")); + treeCooldowns.setSelected(plugin.isHelperOption("treeCooldowns")); + itemCharges.setSelected(plugin.isHelperOption("itemCharges")); + } + + private static final Color READY = new Color(0x66FF66); + private static final int[] HEIGHTS = {10, 30, 60, 100}; + private static final String[] HEIGHT_LABELS = {"Low", "Normal", "High", "Very high"}; + private static final int[] STYLE_HEIGHTS = {10, 30, 60, 100, 150}; + private static final String[] STYLE_HEIGHT_LABELS = {"Low", "Normal", "High", "Very high", "Highest"}; + + /** Right-click on a switch: look of that timer (bar, size, colors, height). */ + private void optionsMenu(JCheckBox box, String prefix, String name, Color defColor) + { + box.addMouseListener(new java.awt.event.MouseAdapter() + { + @Override + public void mousePressed(java.awt.event.MouseEvent e) + { + if (e.isPopupTrigger() || SwingUtilities.isRightMouseButton(e)) + { + buildMenu(prefix, name, defColor).show(box, e.getX(), e.getY()); + } + } + }); + } + + private JPopupMenu buildMenu(String prefix, String name, Color defColor) + { + JPopupMenu menu = new JPopupMenu(); + + boolean bar = plugin.getHelperValue(prefix + "Bar", Boolean.class, false); + JCheckBoxMenuItem barItem = new JCheckBoxMenuItem("Show tick bar", bar); + barItem.addActionListener(e -> plugin.setHelperOption(prefix + "Bar", !bar)); + menu.add(barItem); + + JMenu size = new JMenu("Text size"); + TimerTextSize currentSize = plugin.getHelperValue(prefix + "Size", TimerTextSize.class, TimerTextSize.NORMAL); + for (TimerTextSize s : TimerTextSize.values()) + { + JRadioButtonMenuItem item = new JRadioButtonMenuItem(s.toString(), s == currentSize); + item.addActionListener(e -> plugin.setHelperOption(prefix + "Size", s)); + size.add(item); + } + menu.add(size); + + JMenu height = new JMenu("Height"); + int currentHeight = plugin.getHelperValue(prefix + "Offset", Integer.class, 30); + for (int i = 0; i < HEIGHTS.length; i++) + { + int value = HEIGHTS[i]; + JRadioButtonMenuItem item = new JRadioButtonMenuItem(HEIGHT_LABELS[i], value == currentHeight); + item.addActionListener(e -> plugin.setHelperOption(prefix + "Offset", value)); + height.add(item); + } + menu.add(height); + + menu.addSeparator(); + menu.add(colorItem("Color...", prefix + "Color", name + " color", defColor)); + menu.add(colorItem("Color at 0...", prefix + "ReadyColor", name + " color at 0", READY)); + + menu.addSeparator(); + JMenuItem reset = new JMenuItem("Reset look"); + reset.addActionListener(e -> + { + for (String key : new String[]{"Bar", "Size", "Offset", "Color", "ReadyColor"}) + { + plugin.setHelperOption(prefix + key, null); + } + }); + menu.add(reset); + return menu; + } + + private JPopupMenu priceMenu() + { + JPopupMenu menu = new JPopupMenu(); + for (String[] opt : new String[][]{{"itemPricesGe", "Show GE price"}, {"itemPricesHa", "Show HA price"}}) + { + boolean on = plugin.getHelperValue(opt[0], Boolean.class, true); + JCheckBoxMenuItem item = new JCheckBoxMenuItem(opt[1], on); + item.addActionListener(e -> plugin.setHelperOption(opt[0], on ? false : null)); + menu.add(item); + } + return menu; + } + + private JPopupMenu styleMenu() + { + JPopupMenu menu = new JPopupMenu(); + boolean text = plugin.getHelperValue("enemyAttackStyleText", Boolean.class, false); + menu.add(radio("Prayer icon", !text, () -> plugin.setHelperOption("enemyAttackStyleText", null))); + menu.add(radio("Text (Melee / Range / Mage)", text, () -> plugin.setHelperOption("enemyAttackStyleText", true))); + menu.addSeparator(); + JMenu size = new JMenu("Size"); + TimerTextSize current = plugin.getHelperValue("enemyAttackStyleSize", TimerTextSize.class, TimerTextSize.NORMAL); + for (TimerTextSize s : TimerTextSize.values()) + { + size.add(radio(s.toString(), s == current, () -> plugin.setHelperOption("enemyAttackStyleSize", s))); + } + menu.add(size); + JMenu height = new JMenu("Height"); + int currentHeight = plugin.getHelperValue("enemyAttackStyleOffset", Integer.class, 60); + for (int i = 0; i < STYLE_HEIGHTS.length; i++) + { + int value = STYLE_HEIGHTS[i]; + height.add(radio(STYLE_HEIGHT_LABELS[i], value == currentHeight, () -> plugin.setHelperOption("enemyAttackStyleOffset", value))); + } + menu.add(height); + menu.addSeparator(); + JMenuItem reset = new JMenuItem("Reset look"); + reset.addActionListener(e -> + { + for (String key : new String[]{"enemyAttackStyleText", "enemyAttackStyleSize", "enemyAttackStyleOffset"}) + { + plugin.setHelperOption(key, null); + } + }); + menu.add(reset); + return menu; + } + + private static final int[] TREE_HEIGHTS = {20, 50, 80, 120}; + private static final String[] TREE_HEIGHT_LABELS = {"Niedrig", "Normal", "Hoch", "Sehr hoch"}; + + private JPopupMenu treeMenu() + { + JPopupMenu menu = new JPopupMenu(); + for (String[] opt : new String[][]{ + {"treeCooldownRespawn", "Respawn-Countdown anzeigen"}, + {"treeCooldownChop", "Hack-Restzeit anzeigen"}, + {"treeCooldownBar", "Fortschrittsbalken anzeigen"} + }) + { + boolean on = plugin.getHelperValue(opt[0], Boolean.class, !opt[0].equals("treeCooldownBar")); + JCheckBoxMenuItem item = new JCheckBoxMenuItem(opt[1], on); + item.addActionListener(e -> plugin.setHelperOption(opt[0], on ? false : null)); + menu.add(item); + } + menu.addSeparator(); + + JMenu size = new JMenu("Textgröße"); + TimerTextSize currentSize = plugin.getHelperValue("treeCooldownSize", TimerTextSize.class, TimerTextSize.NORMAL); + for (TimerTextSize s : TimerTextSize.values()) + { + size.add(radio(s.toString(), s == currentSize, () -> plugin.setHelperOption("treeCooldownSize", s))); + } + menu.add(size); + + JMenu height = new JMenu("Höhe"); + int currentHeight = plugin.getHelperValue("treeCooldownOffset", Integer.class, 50); + for (int i = 0; i < TREE_HEIGHTS.length; i++) + { + int value = TREE_HEIGHTS[i]; + height.add(radio(TREE_HEIGHT_LABELS[i], value == currentHeight, () -> plugin.setHelperOption("treeCooldownOffset", value))); + } + menu.add(height); + + menu.addSeparator(); + menu.add(colorItem("Farbe...", "treeCooldownColor", "Tree Cooldown Farbe", new Color(0x8FBF3F))); + menu.add(colorItem("Farbe bei Bereit...", "treeCooldownReadyColor", "Tree Cooldown Farbe bei Bereit", READY)); + + menu.addSeparator(); + JMenuItem reset = new JMenuItem("Aussehen zurücksetzen"); + reset.addActionListener(e -> + { + for (String key : new String[]{"treeCooldownRespawn", "treeCooldownChop", "treeCooldownBar", "treeCooldownSize", "treeCooldownOffset", "treeCooldownColor", "treeCooldownReadyColor"}) + { + plugin.setHelperOption(key, null); + } + }); + menu.add(reset); + return menu; + } + + private static final int[] CHARGES_LOW_VALUES = {0, 10, 50, 100, 500, 1000}; + private static final String[] CHARGES_LOW_LABELS = {"Aus (0)", "≤ 10", "≤ 50", "≤ 100", "≤ 500", "≤ 1000"}; + + private JPopupMenu chargesMenu() + { + JPopupMenu menu = new JPopupMenu(); + + for (String[] opt : new String[][]{ + {"itemChargesWeapons", "Waffen & Rüstung anzeigen"}, + {"itemChargesBags", "Looting- & Coal-Bag anzeigen"}, + {"itemChargesBank", "In der Bank anzeigen"} + }) + { + boolean on = plugin.getHelperValue(opt[0], Boolean.class, true); + JCheckBoxMenuItem item = new JCheckBoxMenuItem(opt[1], on); + item.addActionListener(e -> plugin.setHelperOption(opt[0], on ? false : true)); + menu.add(item); + } + + menu.addSeparator(); + + JMenu size = new JMenu("Textgröße"); + TimerTextSize currentSize = plugin.getHelperValue("itemChargesSize", TimerTextSize.class, TimerTextSize.SMALL); + for (TimerTextSize s : TimerTextSize.values()) + { + size.add(radio(s.toString(), s == currentSize, () -> plugin.setHelperOption("itemChargesSize", s))); + } + menu.add(size); + + JMenu lowThreshold = new JMenu("Warnung bei wenig Ladung"); + int currentLowAt = plugin.getHelperValue("itemChargesLowAt", Integer.class, 0); + for (int i = 0; i < CHARGES_LOW_VALUES.length; i++) + { + int value = CHARGES_LOW_VALUES[i]; + lowThreshold.add(radio(CHARGES_LOW_LABELS[i], value == currentLowAt, () -> plugin.setHelperOption("itemChargesLowAt", value))); + } + menu.add(lowThreshold); + + menu.addSeparator(); + menu.add(colorItem("Farbe...", "itemChargesColor", "Item Charges Primärfarbe", Color.WHITE)); + menu.add(colorItem("Sekundärfarbe...", "itemChargesSecondaryColor", "Item Charges Sekundärfarbe", new Color(0xC0C0C0))); + menu.add(colorItem("Warnfarbe...", "itemChargesLowColor", "Item Charges Warnfarbe", new Color(0xFF3333))); + + menu.addSeparator(); + JMenuItem forgetCharges = new JMenuItem("Gespeicherte Ladungen zurücksetzen"); + forgetCharges.addActionListener(e -> + { + if (plugin.getItemCharges() != null) + { + plugin.getItemCharges().resetAllTracked(); + } + }); + menu.add(forgetCharges); + + JMenuItem forgetCoal = new JMenuItem("Coal bag vergessen"); + forgetCoal.addActionListener(e -> + { + if (plugin.getItemCharges() != null) + { + plugin.getItemCharges().resetCoalBag(); + } + }); + menu.add(forgetCoal); + + JMenuItem reset = new JMenuItem("Aussehen zurücksetzen"); + reset.addActionListener(e -> + { + for (String key : new String[]{"itemChargesWeapons", "itemChargesBags", "itemChargesBank", "itemChargesSize", "itemChargesLowAt", "itemChargesColor", "itemChargesSecondaryColor", "itemChargesLowColor"}) + { + plugin.setHelperOption(key, null); + } + }); + menu.add(reset); + return menu; + } + + private static JRadioButtonMenuItem radio(String label, boolean selected, Runnable action) + { + JRadioButtonMenuItem item = new JRadioButtonMenuItem(label, selected); + item.addActionListener(e -> action.run()); + return item; + } + + private JMenuItem colorItem(String label, String key, String title, Color def) + { + JMenuItem item = new JMenuItem(label); + item.addActionListener(e -> + { + Color current = plugin.getHelperValue(key, Color.class, def); + var picker = plugin.getColorPickerManager().create(SwingUtilities.getWindowAncestor(this), current, title, true); + picker.setLocationRelativeTo(this); + picker.setOnColorChange(c -> plugin.setHelperOption(key, c)); + picker.setVisible(true); + }); + return item; + } + + private static JCheckBox box(String text, String tooltip) + { + JCheckBox b = new JCheckBox(text); + b.setFont(FontManager.getRunescapeSmallFont()); + b.setForeground(Ui.TEXT); + b.setOpaque(false); + b.setFocusPainted(false); + b.setToolTipText(tooltip); + return b; + } + + @Override + public Dimension getPreferredSize() + { + return new Dimension(PluginPanel.PANEL_WIDTH - 16, super.getPreferredSize().height); + } + + @Override + protected void paintComponent(Graphics g0) + { + Graphics2D g = Ui.prepare(g0); + g.setColor(Ui.CARD); + g.fillRoundRect(0, 0, getWidth(), getHeight(), 8, 8); + Ui.paintDragOutline(g, this); + g.dispose(); + } +} diff --git a/src/main/java/com/example/essentialsplus/ui/ItemIcons.java b/src/main/java/com/example/essentialsplus/ui/ItemIcons.java new file mode 100644 index 0000000..906beb9 --- /dev/null +++ b/src/main/java/com/example/essentialsplus/ui/ItemIcons.java @@ -0,0 +1,50 @@ +package com.example.essentialsplus.ui; + +import java.awt.Component; +import java.util.HashMap; +import java.util.Map; +import net.runelite.client.game.ItemManager; +import net.runelite.client.util.AsyncBufferedImage; + +/** Item icons and names shared by all views (Swing thread only). */ +final class ItemIcons +{ + private final ItemManager itemManager; + private final Map icons = new HashMap<>(); + private final Map names = new HashMap<>(); + + ItemIcons(ItemManager itemManager) + { + this.itemManager = itemManager; + } + + AsyncBufferedImage icon(int id, Component repaintTarget) + { + AsyncBufferedImage img = icons.get(id); + if (img == null) + { + img = itemManager.getImage(id); + icons.put(id, img); + img.onLoaded(repaintTarget::repaint); + } + return img; + } + + String name(int id) + { + return names.getOrDefault(id, "Item " + id); + } + + boolean hasName(int id) + { + return names.containsKey(id); + } + + void putName(int id, String name) + { + if (name != null) + { + names.put(id, name); + } + } +} diff --git a/src/main/java/com/example/essentialsplus/ui/ItemsWindow.java b/src/main/java/com/example/essentialsplus/ui/ItemsWindow.java new file mode 100644 index 0000000..fec5fef --- /dev/null +++ b/src/main/java/com/example/essentialsplus/ui/ItemsWindow.java @@ -0,0 +1,434 @@ +package com.example.essentialsplus.ui; + +import com.example.essentialsplus.data.ItemRow; +import java.awt.BorderLayout; +import java.awt.Color; +import java.awt.Dimension; +import java.awt.Font; +import java.awt.FontMetrics; +import java.awt.Graphics; +import java.awt.Graphics2D; +import java.awt.Rectangle; +import java.awt.RenderingHints; +import java.awt.event.MouseAdapter; +import java.awt.event.MouseEvent; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; +import java.util.Locale; +import javax.swing.BorderFactory; +import javax.swing.JComponent; +import javax.swing.JFrame; +import javax.swing.JLabel; +import javax.swing.JPanel; +import javax.swing.JScrollPane; +import javax.swing.JTextField; +import javax.swing.Scrollable; +import javax.swing.WindowConstants; +import javax.swing.event.DocumentEvent; +import javax.swing.event.DocumentListener; +import net.runelite.client.ui.FontManager; + +/** Window listing the items of a container (bank, inventory, ...) as a bank-like grid or a table. */ +class ItemsWindow extends JFrame +{ + private final ItemIcons icons; + private final JLabel title = new JLabel(); + private final JLabel subtitle = new JLabel(); + private final JTextField search = new JTextField(); + private final Grid grid = new Grid(); + private List rows = Collections.emptyList(); + private int sort; + private boolean list; + long lastLoad; + String view; + String label; + String selection; + + ItemsWindow(ItemIcons icons, Runnable onClose) + { + this.icons = icons; + setDefaultCloseOperation(WindowConstants.DISPOSE_ON_CLOSE); + addWindowListener(new java.awt.event.WindowAdapter() + { + @Override + public void windowClosed(java.awt.event.WindowEvent e) + { + onClose.run(); + } + }); + + JPanel root = new JPanel(new BorderLayout(0, 10)); + root.setBackground(Ui.BG); + root.setBorder(BorderFactory.createEmptyBorder(12, 12, 12, 12)); + + JPanel head = new JPanel(new BorderLayout(12, 4)); + head.setOpaque(false); + JPanel titles = new JPanel(new BorderLayout(0, 2)); + titles.setOpaque(false); + title.setFont(FontManager.getRunescapeBoldFont().deriveFont(24f)); + title.setForeground(Ui.ACCENT); + subtitle.setFont(FontManager.getRunescapeFont()); + subtitle.setForeground(Ui.MUTED); + titles.add(title, BorderLayout.NORTH); + titles.add(subtitle, BorderLayout.SOUTH); + head.add(titles, BorderLayout.WEST); + + JPanel controls = new JPanel(new BorderLayout(8, 0)); + controls.setOpaque(false); + search.setFont(FontManager.getRunescapeFont()); + search.setBackground(Ui.CARD); + search.setForeground(Ui.TEXT); + search.setCaretColor(Ui.TEXT); + search.setBorder(BorderFactory.createCompoundBorder( + BorderFactory.createLineBorder(new Color(60, 60, 60)), BorderFactory.createEmptyBorder(4, 8, 4, 8))); + search.setPreferredSize(new Dimension(200, 30)); + search.setToolTipText("Search items"); + search.getDocument().addDocumentListener(new DocumentListener() + { + @Override + public void insertUpdate(DocumentEvent e) + { + applyFilter(); + } + + @Override + public void removeUpdate(DocumentEvent e) + { + applyFilter(); + } + + @Override + public void changedUpdate(DocumentEvent e) + { + applyFilter(); + } + }); + Segmented sortBar = new Segmented(new String[]{"Value", "Qty", "Name"}, 0, i -> + { + sort = i; + applyFilter(); + }); + sortBar.setPreferredSize(new Dimension(210, 30)); + Segmented viewBar = new Segmented(new String[]{"Grid", "List"}, 0, i -> + { + list = i == 1; + grid.revalidate(); + grid.repaint(); + }); + viewBar.setPreferredSize(new Dimension(140, 30)); + JPanel bars = new JPanel(new BorderLayout(8, 0)); + bars.setOpaque(false); + bars.add(sortBar, BorderLayout.WEST); + bars.add(viewBar, BorderLayout.EAST); + controls.add(search, BorderLayout.WEST); + controls.add(bars, BorderLayout.EAST); + JPanel controlsWrap = new JPanel(new BorderLayout()); + controlsWrap.setOpaque(false); + controlsWrap.add(controls, BorderLayout.SOUTH); + head.add(controlsWrap, BorderLayout.EAST); + root.add(head, BorderLayout.NORTH); + + JScrollPane scroll = new JScrollPane(grid); + scroll.setBorder(null); + scroll.getViewport().setBackground(Ui.BG); + scroll.getVerticalScrollBar().setUnitIncrement(24); + root.add(scroll, BorderLayout.CENTER); + + setContentPane(root); + setSize(900, 680); + setMinimumSize(new Dimension(520, 360)); + } + + void setTitleText(String titleText) + { + setTitle("Wealth Tracker - " + titleText); + title.setText(titleText); + } + + void setRows(List rows) + { + this.rows = rows; + lastLoad = System.currentTimeMillis(); + applyFilter(); + } + + private void applyFilter() + { + String q = search.getText().trim().toLowerCase(Locale.ROOT); + List shown = new ArrayList<>(); + long total = 0; + long all = 0; + for (ItemRow r : rows) + { + all += r.value; + if (q.isEmpty() || (r.name != null && r.name.toLowerCase(Locale.ROOT).contains(q))) + { + shown.add(r); + total += r.value; + } + } + if (sort == 1) + { + shown.sort(Comparator.comparingLong((ItemRow r) -> -r.qty)); + } + else if (sort == 2) + { + shown.sort(Comparator.comparing((ItemRow r) -> r.name == null ? "" : r.name, String.CASE_INSENSITIVE_ORDER)); + } + subtitle.setText(q.isEmpty() + ? rows.size() + " items - " + Ui.exact(all) + : shown.size() + " of " + rows.size() + " items - " + Ui.exact(total)); + grid.setRows(shown, all); + } + + /** Painted item grid / list. */ + private final class Grid extends JComponent implements Scrollable + { + private static final int TILE_W = 64; + private static final int TILE_H = 66; + private static final int ROW_H = 38; + private List shown = Collections.emptyList(); + private long total; + private int hover = -1; + + Grid() + { + setToolTipText(""); + MouseAdapter mouse = new MouseAdapter() + { + @Override + public void mouseMoved(MouseEvent e) + { + int h = indexAt(e.getX(), e.getY()); + if (h != hover) + { + hover = h; + repaint(); + } + } + + @Override + public void mouseExited(MouseEvent e) + { + hover = -1; + repaint(); + } + }; + addMouseListener(mouse); + addMouseMotionListener(mouse); + } + + void setRows(List shown, long total) + { + this.shown = shown; + this.total = total; + revalidate(); + repaint(); + } + + private int columns() + { + return Math.max(1, (getWidth() - 8) / TILE_W); + } + + private int indexAt(int x, int y) + { + int i; + if (list) + { + i = (y - 4) / ROW_H; + } + else + { + int col = (x - 4) / TILE_W; + if (col >= columns()) + { + return -1; + } + i = ((y - 4) / TILE_H) * columns() + col; + } + return i >= 0 && i < shown.size() ? i : -1; + } + + @Override + public String getToolTipText(MouseEvent e) + { + int i = indexAt(e.getX(), e.getY()); + if (i < 0) + { + return null; + } + ItemRow r = shown.get(i); + return "" + r.name + "
" + String.format(Locale.US, "%,d", r.qty) + " x " + Ui.exact(r.price) + + "
= " + Ui.exact(r.value) + ""; + } + + @Override + public Dimension getPreferredSize() + { + int w = getParent() != null ? getParent().getWidth() : 600; + if (list) + { + return new Dimension(w, shown.size() * ROW_H + 8); + } + int cols = Math.max(1, (w - 8) / TILE_W); + int rowsCount = (shown.size() + cols - 1) / cols; + return new Dimension(w, rowsCount * TILE_H + 8); + } + + @Override + public Dimension getPreferredScrollableViewportSize() + { + return getPreferredSize(); + } + + @Override + public int getScrollableUnitIncrement(Rectangle visibleRect, int orientation, int direction) + { + return 24; + } + + @Override + public int getScrollableBlockIncrement(Rectangle visibleRect, int orientation, int direction) + { + return visibleRect.height - 40; + } + + @Override + public boolean getScrollableTracksViewportWidth() + { + return true; + } + + @Override + public boolean getScrollableTracksViewportHeight() + { + return false; + } + + @Override + protected void paintComponent(Graphics g0) + { + Graphics2D g = Ui.prepare(g0); + g.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR); + Rectangle clip = g.getClipBounds(); + if (shown.isEmpty()) + { + g.setFont(FontManager.getRunescapeFont()); + g.setColor(Ui.MUTED); + String s = rows.isEmpty() ? "No items" : "No matching items"; + FontMetrics fm = g.getFontMetrics(); + g.drawString(s, (getWidth() - fm.stringWidth(s)) / 2, 40); + g.dispose(); + return; + } + if (list) + { + paintList(g, clip); + } + else + { + paintGrid(g, clip); + } + g.dispose(); + } + + private void paintGrid(Graphics2D g, Rectangle clip) + { + int cols = columns(); + Font small = FontManager.getRunescapeSmallFont(); + for (int i = 0; i < shown.size(); i++) + { + int x = 4 + (i % cols) * TILE_W; + int y = 4 + (i / cols) * TILE_H; + if (clip != null && (y + TILE_H < clip.y || y > clip.y + clip.height)) + { + continue; + } + ItemRow r = shown.get(i); + g.setColor(i == hover ? Ui.CARD_HOVER.brighter() : Ui.CARD); + g.fillRoundRect(x + 2, y + 2, TILE_W - 4, TILE_H - 4, 8, 8); + g.drawImage(icons.icon(r.id, this), x + (TILE_W - 36) / 2, y + 10, 36, 32, null); + + g.setFont(small); + FontMetrics fm = g.getFontMetrics(); + if (r.qty > 1 || r.id == 995) + { + String q = stackText(r.qty); + g.setColor(Color.BLACK); + g.drawString(q, x + 6, y + 6 + fm.getAscent()); + g.setColor(stackColor(r.qty)); + g.drawString(q, x + 5, y + 5 + fm.getAscent()); + } + String v = Ui.gp(r.value); + g.setColor(r.value > 0 ? Ui.GOLD : Ui.FAINT); + g.drawString(v, x + (TILE_W - fm.stringWidth(v)) / 2, y + TILE_H - 7); + } + } + + private void paintList(Graphics2D g, Rectangle clip) + { + int w = getWidth(); + for (int i = 0; i < shown.size(); i++) + { + int y = 4 + i * ROW_H; + if (clip != null && (y + ROW_H < clip.y || y > clip.y + clip.height)) + { + continue; + } + ItemRow r = shown.get(i); + g.setColor(i == hover ? Ui.CARD_HOVER.brighter() : (i % 2 == 0 ? Ui.CARD : new Color(34, 34, 34))); + g.fillRoundRect(4, y + 1, w - 8, ROW_H - 2, 6, 6); + g.drawImage(icons.icon(r.id, this), 10, y + 3, 36, 32, null); + + int right = w - 14; + g.setFont(FontManager.getRunescapeBoldFont()); + FontMetrics bfm = g.getFontMetrics(); + int base = y + (ROW_H + bfm.getAscent()) / 2 - 2; + g.setColor(r.value > 0 ? Ui.GOLD : Ui.FAINT); + Ui.drawRight(g, Ui.gp(r.value), right, base); + + g.setFont(FontManager.getRunescapeFont()); + FontMetrics fm = g.getFontMetrics(); + int colShare = right - 90; + int colPrice = colShare - 70; + int colQty = colPrice - 110; + g.setColor(Ui.MUTED); + String share = total > 0 ? String.format(Locale.US, "%.1f%%", r.value * 100.0 / total) : ""; + Ui.drawRight(g, share, colShare, base); + Ui.drawRight(g, Ui.gp(r.price) + " ea", colPrice, base); + g.setColor(Ui.TEXT); + Ui.drawRight(g, String.format(Locale.US, "%,d", r.qty), colQty, base); + g.drawString(Ui.ellipsize(fm, r.name, colQty - 120 - 56), 56, base); + } + } + } + + private static String stackText(long qty) + { + if (qty >= 10_000_000) + { + return (qty / 1_000_000) + "M"; + } + if (qty >= 100_000) + { + return (qty / 1_000) + "K"; + } + return String.valueOf(qty); + } + + private static Color stackColor(long qty) + { + if (qty >= 10_000_000) + { + return new Color(0, 255, 128); + } + if (qty >= 100_000) + { + return Color.WHITE; + } + return new Color(255, 255, 0); + } +} diff --git a/src/main/java/com/example/essentialsplus/ui/MoversView.java b/src/main/java/com/example/essentialsplus/ui/MoversView.java new file mode 100644 index 0000000..5835da3 --- /dev/null +++ b/src/main/java/com/example/essentialsplus/ui/MoversView.java @@ -0,0 +1,230 @@ +package com.example.essentialsplus.ui; + +import java.awt.Color; +import java.awt.Dimension; +import java.awt.FontMetrics; +import java.awt.Graphics; +import java.awt.Graphics2D; +import java.awt.RenderingHints; +import java.awt.event.MouseEvent; +import java.time.Instant; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import javax.swing.JComponent; +import net.runelite.client.ui.FontManager; + +/** + * "What changed": wealth change split into market (price) and activity (quantity) effects, + * plus the items that gained and lost the most value. + */ +class MoversView extends JComponent +{ + private static final int HEADER = 58; + private static final int TITLE = 18; + private static final int ROW = 30; + private static final DateTimeFormatter SINCE = DateTimeFormatter.ofPattern("d MMM HH:mm", Locale.ENGLISH); + + private final ItemIcons icons; + private Analysis.Movers movers = new Analysis.Movers(); + private final List hitRows = new ArrayList<>(); + + MoversView(ItemIcons icons) + { + this.icons = icons; + setToolTipText(""); + } + + void update(Analysis.Movers movers) + { + this.movers = movers; + revalidate(); + repaint(); + } + + @Override + public Dimension getPreferredSize() + { + if (!movers.available) + { + return new Dimension(10, 54); + } + int h = HEADER + 6; + if (!movers.gainers.isEmpty()) + { + h += TITLE + movers.gainers.size() * ROW; + } + if (!movers.losers.isEmpty()) + { + h += TITLE + movers.losers.size() * ROW; + } + return new Dimension(10, h + 4); + } + + @Override + public String getToolTipText(MouseEvent e) + { + if (!movers.available) + { + return null; + } + if (e.getY() < HEADER) + { + if (movers.ignorePrices) + { + return "Activity = items gained or lost, valued at current prices" + + "
Price changes of items you hold only count in the All range" + + "
Activity: " + Ui.exact(movers.qtyEffect) + ""; + } + return "Market = value change of items you already had (price movement)" + + "
Activity = items gained or lost, valued at current prices" + + "
Market: " + Ui.exact(movers.priceEffect) + "
Activity: " + Ui.exact(movers.qtyEffect) + ""; + } + for (Object[] r : hitRows) + { + int y = (int) r[0]; + if (e.getY() >= y && e.getY() < y + ROW) + { + Analysis.Mover m = (Analysis.Mover) r[1]; + double pc = m.priceChange(); + return "" + icons.name(m.id) + "" + + "
Value: " + Ui.exact(m.v0) + " → " + Ui.exact(m.v1) + + "
Quantity: " + String.format(Locale.US, "%,d", m.q0) + " → " + String.format(Locale.US, "%,d", m.q1) + + (Double.isNaN(pc) ? "" : "
Price: " + Ui.pct(pc)) + + (movers.ignorePrices ? "" : "
From prices: " + Ui.exact(m.priceEffect)) + + "
From quantity: " + Ui.exact(m.qtyEffect) + ""; + } + } + return null; + } + + @Override + protected void paintComponent(Graphics g0) + { + Graphics2D g = Ui.prepare(g0); + int w = getWidth(); + g.setColor(Ui.CARD); + g.fillRoundRect(0, 0, w, getHeight(), 8, 8); + g.setFont(FontManager.getRunescapeSmallFont()); + FontMetrics sfm = g.getFontMetrics(); + hitRows.clear(); + + if (!movers.available) + { + g.setColor(Ui.MUTED); + String[] lines = {"Item history is collected while", "you play - check back later"}; + int y = 22; + for (String s : lines) + { + g.drawString(s, (w - sfm.stringWidth(s)) / 2, y); + y += sfm.getHeight(); + } + g.dispose(); + return; + } + + int pad = 8; + // ---- effect split bar + long pe = movers.priceEffect; + long qe = movers.qtyEffect; + int y = 6; + if (movers.ignorePrices) + { + effectLine(g, sfm, pad, w, y + sfm.getAscent(), "Activity (items)", qe); + g.setColor(Ui.FAINT); + g.drawString("Price changes only count in All", pad, y + sfm.getAscent() + 14); + } + else + { + effectLine(g, sfm, pad, w, y + sfm.getAscent(), "Market (prices)", pe); + effectLine(g, sfm, pad, w, y + sfm.getAscent() + 14, "Activity (items)", qe); + } + + int barY = y + 32; + int barW = w - pad * 2; + long abs = Math.abs(pe) + Math.abs(qe); + g.setColor(Ui.alpha(Color.WHITE, 14)); + g.fillRoundRect(pad, barY, barW, 5, 5, 5); + if (abs > 0) + { + int pw = (int) Math.round(barW * (double) Math.abs(pe) / abs); + g.setColor(Ui.alpha(pe >= 0 ? Ui.UP : Ui.DOWN, 150)); + g.fillRoundRect(pad, barY, pw, 5, 5, 5); + g.setColor(pe != 0 && qe != 0 ? Ui.alpha(qe >= 0 ? Ui.UP : Ui.DOWN, 255) : Ui.alpha(qe >= 0 ? Ui.UP : Ui.DOWN, 255)); + g.fillRoundRect(pad + pw, barY, barW - pw, 5, 5, 5); + } + g.setColor(Ui.FAINT); + String since = "since " + Instant.ofEpochMilli(movers.since).atZone(ZoneId.systemDefault()).format(SINCE); + g.drawString(since, pad, barY + 8 + sfm.getAscent()); + + y = HEADER + 6; + y = section(g, sfm, "GAINED", movers.gainers, y, w, pad); + section(g, sfm, "LOST", movers.losers, y, w, pad); + g.dispose(); + } + + private static void effectLine(Graphics2D g, FontMetrics fm, int pad, int w, int base, String label, long value) + { + g.setColor(Ui.MUTED); + g.drawString(label, pad, base); + g.setColor(Ui.deltaColor(value)); + Ui.drawRight(g, Ui.signedGp(value), w - pad, base); + } + + private int section(Graphics2D g, FontMetrics sfm, String title, List list, int y, int w, int pad) + { + if (list.isEmpty()) + { + return y; + } + g.setColor(Ui.alpha(Color.WHITE, 12)); + g.drawLine(pad, y + 1, w - pad, y + 1); + g.setColor(Ui.FAINT); + g.drawString(title, pad, y + 4 + sfm.getAscent()); + y += TITLE; + + g.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR); + for (Analysis.Mover m : list) + { + hitRows.add(new Object[]{y, m}); + g.drawImage(icons.icon(m.id, this), pad - 2, y, 32, 28, null); + + long d = m.delta(); + String val = Ui.signedGp(d); + g.setFont(FontManager.getRunescapeBoldFont()); + FontMetrics bfm = g.getFontMetrics(); + int vw = bfm.stringWidth(val); + g.setColor(Ui.deltaColor(d)); + Ui.drawRight(g, val, w - pad, y + 3 + bfm.getAscent()); + + int tx = pad + 34; + g.setFont(FontManager.getRunescapeFont()); + FontMetrics nfm = g.getFontMetrics(); + g.setColor(Ui.TEXT); + g.drawString(Ui.ellipsize(nfm, icons.name(m.id), w - tx - vw - pad - 4), tx, y + 2 + nfm.getAscent()); + + g.setFont(FontManager.getRunescapeSmallFont()); + StringBuilder sub = new StringBuilder(); + long dq = m.q1 - m.q0; + if (dq != 0) + { + sub.append(dq > 0 ? "+" : "").append(Ui.gp(dq)).append(" qty"); + } + double pc = m.priceChange(); + if (!Double.isNaN(pc) && Math.abs(pc) >= 0.0005) + { + if (sub.length() > 0) + { + sub.append(" "); + } + sub.append("price ").append(Ui.pct(pc)); + } + g.setColor(Ui.MUTED); + g.drawString(Ui.ellipsize(sfm, sub.toString(), w - tx - pad), tx, y + ROW - 4); + y += ROW; + } + return y; + } +} diff --git a/src/main/java/com/example/essentialsplus/ui/Segmented.java b/src/main/java/com/example/essentialsplus/ui/Segmented.java new file mode 100644 index 0000000..2bcd536 --- /dev/null +++ b/src/main/java/com/example/essentialsplus/ui/Segmented.java @@ -0,0 +1,134 @@ +package com.example.essentialsplus.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.function.IntConsumer; +import javax.swing.JComponent; +import net.runelite.client.ui.FontManager; + +/** Segmented control (time range, chart style). */ +public class Segmented extends JComponent +{ + private final String[] labels; + private final IntConsumer onSelect; + private int selected; + private int hover = -1; + + public Segmented(String[] labels, int selected, IntConsumer onSelect) + { + this.labels = labels; + this.selected = selected; + this.onSelect = onSelect; + setCursor(Cursor.getPredefinedCursor(Cursor.HAND_CURSOR)); + MouseAdapter mouse = new MouseAdapter() + { + @Override + public void mouseMoved(MouseEvent e) + { + int h = indexAt(e.getX()); + if (h != hover) + { + hover = h; + repaint(); + } + } + + @Override + public void mouseExited(MouseEvent e) + { + hover = -1; + repaint(); + } + + @Override + public void mousePressed(MouseEvent e) + { + int i = indexAt(e.getX()); + if (i >= 0) + { + setSelected(i); + onSelect.accept(i); + } + } + }; + addMouseListener(mouse); + addMouseMotionListener(mouse); + } + + static Segmented ranges(TimeRange initial, java.util.function.Consumer onSelect) + { + TimeRange[] values = TimeRange.values(); + String[] labels = new String[values.length]; + for (int i = 0; i < values.length; i++) + { + labels[i] = values[i].getLabel(); + } + return new Segmented(labels, initial.ordinal(), i -> onSelect.accept(values[i])); + } + + public void setSelected(int index) + { + selected = index; + repaint(); + } + + private int indexAt(int x) + { + int i = x * labels.length / Math.max(1, getWidth()); + return i >= 0 && i < labels.length ? i : -1; + } + + @Override + public Dimension getPreferredSize() + { + if (isPreferredSizeSet()) + { + return super.getPreferredSize(); + } + FontMetrics fm = getFontMetrics(FontManager.getRunescapeSmallFont()); + int w = 0; + for (String l : labels) + { + w = Math.max(w, fm.stringWidth(l)); + } + return new Dimension((w + 16) * labels.length, 24); + } + + @Override + protected void paintComponent(Graphics g0) + { + Graphics2D g = Ui.prepare(g0); + int w = getWidth(); + int h = getHeight(); + g.setColor(Ui.CARD); + g.fillRoundRect(0, 0, w, h, 8, 8); + g.setFont(h >= 28 ? FontManager.getRunescapeBoldFont() : FontManager.getRunescapeSmallFont()); + FontMetrics fm = g.getFontMetrics(); + + for (int i = 0; i < labels.length; i++) + { + int x0 = w * i / labels.length; + int x1 = w * (i + 1) / labels.length; + boolean sel = i == selected; + if (sel) + { + g.setColor(Ui.ACCENT); + g.fillRoundRect(x0 + 2, 2, x1 - x0 - 4, h - 4, 6, 6); + } + else if (i == hover) + { + g.setColor(Ui.CARD_HOVER.brighter()); + g.fillRoundRect(x0 + 2, 2, x1 - x0 - 4, h - 4, 6, 6); + } + g.setColor(sel ? Color.WHITE : i == hover ? Ui.TEXT : Ui.MUTED); + g.drawString(labels[i], x0 + (x1 - x0 - fm.stringWidth(labels[i])) / 2, (h + fm.getAscent()) / 2 - 1); + } + g.dispose(); + } +} diff --git a/src/main/java/com/example/essentialsplus/ui/Series.java b/src/main/java/com/example/essentialsplus/ui/Series.java new file mode 100644 index 0000000..11e2a8a --- /dev/null +++ b/src/main/java/com/example/essentialsplus/ui/Series.java @@ -0,0 +1,196 @@ +package com.example.essentialsplus.ui; + +import com.example.essentialsplus.WealthCategory; +import com.example.essentialsplus.data.AccountData; +import com.example.essentialsplus.data.Snapshot; +import java.util.ArrayList; +import java.util.List; +import java.util.TreeSet; + +/** Builds chart-ready timelines out of stored history. */ +final class Series +{ + private Series() + { + } + + static long earliest(List accounts) + { + long min = Long.MAX_VALUE; + for (AccountData a : accounts) + { + List h = a.history; + if (!h.isEmpty()) + { + min = Math.min(min, h.get(0).t); + } + } + return min == Long.MAX_VALUE ? System.currentTimeMillis() : min; + } + + /** + * Points inside [start, end]. The value valid at {@code start} is carried in, and the latest + * known value is extended to {@code end} so the line reaches "now". + */ + /** + * Timeline of one character over [start, end]. With {@code ignorePrices} GE price movements are taken out, + * so only items gained or lost change the value (the last point keeps the real current value). + */ + static List forAccount(AccountData acc, long start, long end, boolean ignorePrices) + { + List out = forAccount(acc, start, end); + if (!ignorePrices) + { + return out; + } + List neutral = new ArrayList<>(out.size()); + for (Snapshot s : out) + { + neutral.add(s.shifted(s.market() - acc.market)); + } + return neutral; + } + + /** Total without GE price movements, comparable between points of one character. */ + static long activityTotal(Snapshot s) + { + return s.total() - s.market(); + } + + static List forAccount(AccountData acc, long start, long end) + { + List history = acc.history; + List out = new ArrayList<>(); + Snapshot before = null; + for (Snapshot s : history) + { + if (s.t < start) + { + before = s; + } + else if (s.t <= end) + { + out.add(s); + } + } + if (before != null && (out.isEmpty() || out.get(0).t > start)) + { + Snapshot first = new Snapshot(start, before.v); + first.m = before.m; + out.add(0, first); + } + + long[] latest = acc.bankKnown && acc.current != null ? acc.current : null; + if (latest == null && !out.isEmpty()) + { + latest = out.get(out.size() - 1).v; + } + if (latest != null && !out.isEmpty() && out.get(out.size() - 1).t < end) + { + out.add(endPoint(acc, end, latest)); + } + else if (latest != null && out.isEmpty() && acc.bankKnown) + { + out.add(endPoint(acc, end, latest)); + } + return AccountData.sanitizeHistory(out); + } + + private static Snapshot endPoint(AccountData acc, long end, long[] latest) + { + Snapshot s = new Snapshot(end, latest); + s.m = acc.market; + return s; + } + + /** Sums several timelines, carrying each one's last value forward. */ + static List combine(List> all) + { + if (all.size() == 1) + { + return all.get(0); + } + TreeSet times = new TreeSet<>(); + for (List s : all) + { + for (Snapshot p : s) + { + times.add(p.t); + } + } + + int[] idx = new int[all.size()]; + List out = new ArrayList<>(times.size()); + for (long t : times) + { + long[] v = new long[WealthCategory.COUNT]; + long lo = 0; + long hi = 0; + long open = 0; + long close = 0; + String agg = null; + for (int i = 0; i < all.size(); i++) + { + List s = all.get(i); + while (idx[i] + 1 < s.size() && s.get(idx[i] + 1).t <= t) + { + idx[i]++; + } + if (s.isEmpty() || s.get(idx[i]).t > t) + { + continue; + } + Snapshot p = s.get(idx[i]); + for (int c = 0; c < v.length; c++) + { + v[c] += p.get(WealthCategory.values()[c]); + } + lo += p.low(); + hi += p.high(); + open += p.open(); + close += p.close(); + if (p.isAggregate()) + { + agg = p.g != null ? p.g : Snapshot.DAY; + } + } + Snapshot sum = new Snapshot(t, v); + if (agg != null) + { + sum.lo = lo; + sum.hi = hi; + sum.o = open; + sum.c = close; + sum.g = agg; + } + out.add(sum); + } + return out; + } + + static long[] sumCurrent(List accounts) + { + long[] v = new long[WealthCategory.COUNT]; + for (AccountData a : accounts) + { + if (a.current != null && a.bankKnown) + { + for (int i = 0; i < Math.min(v.length, a.current.length); i++) + { + v[i] += a.current[i]; + } + } + } + return v; + } + + static long sum(long[] v) + { + long s = 0; + for (long x : v) + { + s += x; + } + return s; + } +} diff --git a/src/main/java/com/example/essentialsplus/ui/SkillsCard.java b/src/main/java/com/example/essentialsplus/ui/SkillsCard.java new file mode 100644 index 0000000..66dad0a --- /dev/null +++ b/src/main/java/com/example/essentialsplus/ui/SkillsCard.java @@ -0,0 +1,338 @@ +package com.example.essentialsplus.ui; + +import java.awt.BasicStroke; +import java.awt.Cursor; +import java.awt.Dimension; +import java.awt.Font; +import java.awt.FontMetrics; +import java.awt.GradientPaint; +import java.awt.Graphics; +import java.awt.Graphics2D; +import java.awt.event.MouseAdapter; +import java.awt.event.MouseEvent; +import java.awt.geom.Path2D; +import java.awt.image.BufferedImage; +import java.util.ArrayList; +import java.util.EnumMap; +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import javax.swing.JComponent; +import javax.swing.SwingUtilities; +import net.runelite.api.Skill; +import net.runelite.client.ui.FontManager; +import net.runelite.client.util.ImageUtil; + +/** Summary card of xp gained in the selected range (overview entry and head of the skills tab). */ +class SkillsCard extends JComponent +{ + private final boolean clickable; + private String title = "Skills"; + private String subtitle = ""; + private long gained; + private boolean hasData; + private int levels; + private String rangeLabel = ""; + private String right = ""; + private long[] spark = new long[0]; + private long[] sparkT = new long[0]; + private boolean hover; + /** 0 = xp gained, 1 = total xp, 2 = total level (overview right-click menu) */ + private int mode; + private boolean showChart = true; + private long totalXp; + private int totalLevel; + private static final int TOP_ROW = 15; + /** Skills with the most xp gained listed under the value (overview right-click menu), 0-3. */ + private int topCount; + private long[] skillGains = new long[0]; + private Function iconSource; + private final Map icons = new EnumMap<>(Skill.class); + + SkillsCard(Runnable onClick) + { + this.clickable = onClick != null; + if (clickable) + { + 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(); + } + } + }); + } + } + + void update(String title, String subtitle, boolean hasData, long gained, int levels, String rangeLabel, String right, + long[] sparkT, long[] spark) + { + this.title = title; + this.subtitle = subtitle; + this.hasData = hasData; + this.gained = gained; + this.levels = levels; + this.rangeLabel = rangeLabel; + this.right = right; + this.sparkT = sparkT; + this.spark = spark; + repaint(); + } + + void setOptions(int mode, boolean showChart) + { + this.mode = mode; + this.showChart = showChart; + revalidate(); + repaint(); + } + + void setTopSkills(int count, Function iconSource) + { + this.topCount = count; + this.iconSource = iconSource; + revalidate(); + repaint(); + } + + /** Xp gained per skill, indexed like {@code SkillHistory.SKILLS}. */ + void setSkillGains(long[] gains) + { + int before = topSkills().size(); + this.skillGains = gains; + if (topSkills().size() != before) + { + revalidate(); + } + repaint(); + } + + private List topSkills() + { + List out = new ArrayList<>(); + if (topCount <= 0 || !hasData) + { + return out; + } + for (int i = 0; i < skillGains.length; i++) + { + if (skillGains[i] > 0) + { + out.add(i); + } + } + out.sort((a, b) -> Long.compare(skillGains[b], skillGains[a])); + return out.subList(0, Math.min(topCount, out.size())); + } + + private BufferedImage icon(Skill skill) + { + if (iconSource == null) + { + return null; + } + return icons.computeIfAbsent(skill, s -> + { + BufferedImage img = iconSource.apply(s); + return img == null ? null : ImageUtil.resizeImage(img, 12, 12); + }); + } + + void setTotals(long totalXp, int totalLevel) + { + this.totalXp = totalXp; + this.totalLevel = totalLevel; + repaint(); + } + + @Override + public Dimension getPreferredSize() + { + return new Dimension(net.runelite.client.ui.PluginPanel.PANEL_WIDTH - 16, (showChart ? 120 : 84) + topHeight()); + } + + @Override + public Dimension getMaximumSize() + { + return new Dimension(Integer.MAX_VALUE, (showChart ? 130 : 84) + topHeight()); + } + + @Override + public Dimension getMinimumSize() + { + return new Dimension(10, (showChart ? 110 : 80) + topHeight()); + } + + private int topHeight() + { + int n = topSkills().size(); + return n == 0 ? 0 : n * TOP_ROW + 2; + } + + @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 rightX = w - pad; + Font bold = FontManager.getRunescapeBoldFont(); + Font small = FontManager.getRunescapeSmallFont(); + + g.setFont(bold); + FontMetrics fm = g.getFontMetrics(); + int head = 6 + fm.getAscent(); + g.setColor(Ui.ACCENT); + g.drawString(title, pad, head); + int subRight = rightX; + if (clickable) + { + g.setFont(bold.deriveFont(14f)); + g.setColor(hover ? Ui.ACCENT : Ui.FAINT); + Ui.drawRight(g, "›", rightX, head); + subRight -= 14; + } + if (!subtitle.isEmpty()) + { + g.setFont(small); + g.setColor(Ui.MUTED); + FontMetrics sfm = g.getFontMetrics(); + int avail = subRight - (pad + fm.stringWidth(title) + 8); + Ui.drawRight(g, Ui.ellipsize(sfm, subtitle, avail), subRight, head); + } + + g.setFont(bold.deriveFont(26f)); + FontMetrics bfm = g.getFontMetrics(); + int valueBase = 22 + bfm.getAscent(); + String big; + if (!hasData) + { + g.setColor(Ui.FAINT); + big = "-"; + } + else if (mode == 1) + { + g.setColor(Ui.GOLD); + big = Ui.gp(totalXp) + " xp"; + } + else if (mode == 2) + { + g.setColor(Ui.GOLD); + big = "Level " + totalLevel; + } + else + { + g.setColor(gained > 0 ? Ui.UP : Ui.FAINT); + big = "+" + Ui.gp(gained) + " xp"; + } + g.drawString(big, pad, valueBase); + + g.setFont(small); + FontMetrics sfm = g.getFontMetrics(); + int line = valueBase + sfm.getAscent() + 3; + if (!hasData) + { + g.setColor(Ui.FAINT); + g.drawString("Log in to start tracking xp", pad, line); + } + else + { + String lv = mode == 1 ? "+" + Ui.gp(gained) + " xp" : levels == 1 ? "+1 level" : "+" + levels + " levels"; + g.setColor((mode == 1 ? gained : levels) > 0 ? Ui.UP : Ui.MUTED); + g.drawString(lv, pad, line); + int x = pad + sfm.stringWidth(lv); + g.setColor(Ui.FAINT); + g.drawString(" " + rangeLabel, x, line); + if (!right.isEmpty()) + { + g.setColor(Ui.TEXT); + Ui.drawRight(g, right, rightX, line); + } + } + + List top = topSkills(); + if (!top.isEmpty()) + { + line += 2; + for (int idx : top) + { + Skill skill = com.example.essentialsplus.skills.SkillHistory.SKILLS[idx]; + line += TOP_ROW; + BufferedImage img = icon(skill); + int textX = pad; + if (img != null) + { + g.drawImage(img, pad, line - 11, null); + textX += 16; + } + g.setColor(Ui.TEXT); + g.drawString(skill.getName(), textX, line); + g.setColor(Ui.UP); + Ui.drawRight(g, "+" + Ui.gp(skillGains[idx]) + " xp", rightX, line); + } + } + + int sparkH = Math.min(28, Math.max(0, h - line - 10)); + if (sparkH > 6 && showChart) + { + paintSpark(g, pad, h - sparkH - 6, w - pad * 2, sparkH); + } + Ui.paintDragOutline(g, this); + g.dispose(); + } + + private void paintSpark(Graphics2D g, int x, int y, int w, int h) + { + if (spark.length < 2) + { + return; + } + long hi = Math.max(1, spark[spark.length - 1]); + long t0 = sparkT[0]; + long t1 = Math.max(t0 + 1, sparkT[sparkT.length - 1]); + Path2D line = new Path2D.Double(); + double prevY = y + h; + line.moveTo(x, prevY); + for (int i = 1; i < spark.length; i++) + { + double px = x + (double) (sparkT[i] - t0) / (t1 - t0) * w; + double py = y + h - (double) spark[i] / hi * h; + line.lineTo(px, prevY); + line.lineTo(px, py); + prevY = py; + } + line.lineTo(x + w, prevY); + Path2D area = new Path2D.Double(line); + area.lineTo(x + w, y + h); + area.lineTo(x, y + h); + area.closePath(); + g.setPaint(new GradientPaint(0, y, Ui.alpha(Ui.UP, 60), 0, y + h, Ui.alpha(Ui.UP, 0))); + g.fill(area); + g.setStroke(new BasicStroke(1.2f)); + g.setColor(Ui.alpha(Ui.UP, 200)); + g.draw(line); + } +} diff --git a/src/main/java/com/example/essentialsplus/ui/SkillsPanel.java b/src/main/java/com/example/essentialsplus/ui/SkillsPanel.java new file mode 100644 index 0000000..1b155e6 --- /dev/null +++ b/src/main/java/com/example/essentialsplus/ui/SkillsPanel.java @@ -0,0 +1,1007 @@ +package com.example.essentialsplus.ui; + +import com.example.essentialsplus.EssentialsPlusPlugin; +import com.example.essentialsplus.skills.SkillGoals; +import com.example.essentialsplus.skills.SkillHistory; +import com.example.essentialsplus.skills.SkillStore; +import com.example.essentialsplus.skills.XpSample; +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.event.MouseAdapter; +import java.awt.event.MouseEvent; +import java.awt.image.BufferedImage; +import java.time.Instant; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.EnumMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import javax.swing.BorderFactory; +import javax.swing.JButton; +import javax.swing.JCheckBox; +import javax.swing.JCheckBoxMenuItem; +import javax.swing.JMenuItem; +import javax.swing.JPopupMenu; +import javax.swing.JComboBox; +import javax.swing.JComponent; +import javax.swing.JLabel; +import javax.swing.JPanel; +import javax.swing.JSpinner; +import javax.swing.SpinnerNumberModel; +import javax.swing.SwingUtilities; +import javax.swing.Timer; +import net.runelite.api.Experience; +import net.runelite.api.Skill; +import net.runelite.client.ui.DynamicGridLayout; +import net.runelite.client.ui.FontManager; +import net.runelite.client.ui.PluginPanel; +import net.runelite.client.util.ImageUtil; + +/** Skills tab: xp gained per skill over the global time range, with charts and level-up history. */ +public class SkillsPanel extends PluginPanel +{ + private static final int LEVEL_UP_LIMIT = 15; + private static final DateTimeFormatter WHEN = DateTimeFormatter.ofPattern("d MMM HH:mm", Locale.ENGLISH); + + private final EssentialsPlusPlugin plugin; + private final SkillStore store; + private final TimeFilter timeFilter; + private final Runnable rangeListener = this::refreshLater; + private final Timer coalesce; + private final Timer ticker; + private final Map icons = new EnumMap<>(Skill.class); + + private final SkillsCard card = new SkillsCard(null); + private final JComboBox characterBox = new JComboBox<>(); + private final JLabel chartTitle = new JLabel(); + private final JLabel showAll = new JLabel("Show all"); + private final XpChart cumulative = new XpChart(XpChart.Mode.CUMULATIVE, 150); + private final XpChart perPeriod = new XpChart(XpChart.Mode.BARS, 100); + private final StatsView stats = new StatsView(2); + private final JPanel skillList = new JPanel(new DynamicGridLayout(0, 1, 0, 2)); + private final JPanel levelList = new JPanel(new DynamicGridLayout(0, 1, 0, 2)); + private final JLabel levelEmpty = new JLabel(); + private final JCheckBox hudBox = new JCheckBox("Show xp HUD in-game"); + private final SkillRow[] rows = new SkillRow[SkillHistory.SKILLS.length]; + private final List rowOrder = new ArrayList<>(); + + private String selectedKey; + /** character whose skills are shown (goals belong to it) */ + private String shownKey; + private int selectedSkill = -1; + private boolean updatingCombo; + + public SkillsPanel(EssentialsPlusPlugin plugin, Runnable onBack) + { + super(true); + this.plugin = plugin; + this.store = plugin.getSkillStore(); + this.timeFilter = plugin.getTimeFilter(); + + setBackground(Ui.BG); + setLayout(new DynamicGridLayout(0, 1, 0, 6)); + + JPanel backRow = new JPanel(new BorderLayout(6, 0)); + backRow.setOpaque(false); + backRow.setBorder(BorderFactory.createEmptyBorder(0, 0, 4, 0)); + JButton backBtn = button("← Overview", onBack); + backBtn.setForeground(Ui.ACCENT); + backRow.add(backBtn, BorderLayout.WEST); + JLabel titleLabel = new JLabel("Skills"); + titleLabel.setFont(FontManager.getRunescapeBoldFont()); + titleLabel.setForeground(Ui.ACCENT); + titleLabel.setHorizontalAlignment(JLabel.RIGHT); + backRow.add(titleLabel, BorderLayout.CENTER); + add(backRow); + + characterBox.setFont(FontManager.getRunescapeSmallFont()); + characterBox.setBackground(Ui.CARD); + characterBox.setFocusable(false); + characterBox.addActionListener(e -> + { + Choice c = (Choice) characterBox.getSelectedItem(); + if (!updatingCombo && c != null) + { + selectedKey = c.key; + refresh(); + } + }); + characterBox.setVisible(false); + add(characterBox); + + card.addMouseListener(new MouseAdapter() + { + @Override + public void mousePressed(MouseEvent e) + { + maybePopup(e); + } + + @Override + public void mouseReleased(MouseEvent e) + { + maybePopup(e); + } + + private void maybePopup(MouseEvent e) + { + if (e.isPopupTrigger() || SwingUtilities.isRightMouseButton(e)) + { + JPopupMenu menu = new JPopupMenu(); + JMenuItem reset = new JMenuItem("Reset Hourly"); + reset.addActionListener(ev -> + { + plugin.resetSkillsHourly(shownKey); + refreshLater(); + }); + menu.add(reset); + menu.show(card, e.getX(), e.getY()); + } + } + }); + add(card); + + hudBox.setFont(FontManager.getRunescapeSmallFont()); + hudBox.setForeground(Ui.TEXT); + hudBox.setOpaque(false); + hudBox.setFocusPainted(false); + hudBox.setToolTipText("In-game overlay with the xp gained per skill in the selected time range"); + hudBox.setSelected(plugin.isSkillsHudEnabled()); + hudBox.addActionListener(e -> plugin.setSkillsHudEnabled(hudBox.isSelected())); + JButton[] hudOptions = new JButton[1]; + hudOptions[0] = button("Options", () -> showHudOptions(hudOptions[0])); + hudOptions[0].setToolTipText("Choose what the in-game xp HUD shows"); + JPanel hudRow = new JPanel(new BorderLayout(6, 0)); + hudRow.setOpaque(false); + hudRow.add(hudBox, BorderLayout.CENTER); + hudRow.add(hudOptions[0], BorderLayout.EAST); + add(hudRow); + + showAll.setFont(FontManager.getRunescapeSmallFont()); + showAll.setForeground(Ui.ACCENT); + showAll.setCursor(Cursor.getPredefinedCursor(Cursor.HAND_CURSOR)); + showAll.addMouseListener(new MouseAdapter() + { + @Override + public void mousePressed(MouseEvent e) + { + selectSkill(-1); + } + }); + JPanel chartHeader = new JPanel(new BorderLayout(6, 0)); + chartHeader.setOpaque(false); + chartHeader.setBorder(BorderFactory.createEmptyBorder(6, 2, 0, 2)); + JLabel xpLabel = new JLabel("XP GAINED"); + xpLabel.setFont(FontManager.getRunescapeSmallFont()); + xpLabel.setForeground(Ui.MUTED); + chartHeader.add(xpLabel, BorderLayout.WEST); + chartTitle.setFont(FontManager.getRunescapeSmallFont()); + chartTitle.setForeground(Ui.TEXT); + chartTitle.setHorizontalAlignment(JLabel.RIGHT); + chartHeader.add(chartTitle, BorderLayout.CENTER); + chartHeader.add(showAll, BorderLayout.EAST); + add(chartHeader); + add(cumulative); + add(stats); + add(WealthPanel.sectionHeader("XP PER PERIOD", null, null, null)); + add(perPeriod); + + add(WealthPanel.sectionHeader("SKILLS (click to chart, cog for a goal)", null, null, null)); + skillList.setOpaque(false); + for (int i = 0; i < rows.length; i++) + { + rows[i] = new SkillRow(i); + } + add(skillList); + + add(WealthPanel.sectionHeader("LEVEL UPS", null, null, null)); + levelList.setOpaque(false); + levelEmpty.setFont(FontManager.getRunescapeSmallFont()); + levelEmpty.setForeground(Ui.FAINT); + levelEmpty.setHorizontalAlignment(JLabel.CENTER); + add(levelList); + + coalesce = new Timer(1000, e -> refresh()); + coalesce.setRepeats(false); + ticker = new Timer(30_000, e -> refresh()); + ticker.start(); + timeFilter.addListener(rangeListener); + refresh(); + } + + public void dispose() + { + ticker.stop(); + coalesce.stop(); + timeFilter.removeListener(rangeListener); + } + + /** Coalesces bursts of xp drops into one repaint per second. */ + public void refreshLater() + { + if (!coalesce.isRunning()) + { + coalesce.start(); + } + } + + private void selectSkill(int skill) + { + selectedSkill = selectedSkill == skill ? -1 : skill; + refresh(); + } + + /** Resolves which character to show: explicit choice, else the logged-in one, else the latest. */ + static SkillHistory resolve(SkillStore store, String preferred, String active) + { + SkillHistory h = store.get(preferred); + if (h == null || h.latest() == null) + { + h = store.get(active); + } + if (h == null || h.latest() == null) + { + List all = store.all(); + h = all.isEmpty() ? null : all.get(0); + } + return h; + } + + /** Fills a summary card (used by the overview too). */ + static void fillCard(SkillsCard card, SkillHistory h, TimeFilter filter, String title) + { + if (h == null) + { + card.update(title, "", false, 0, 0, filter.getLabel(), "", new long[0], new long[0]); + return; + } + long start = h.start(filter); + long rateStart = h.rateStart(filter); + long[] gained = h.gained(filter); + long total = 0; + for (long g : gained) + { + total += g; + } + int levels = 0; + for (SkillHistory.LevelUp lu : h.levelUps(start)) + { + levels += gained[lu.skill] > 0 ? lu.to - lu.from : 0; + } + long rateGained = h.gainedTotalSince(rateStart); + long active = h.activeMillis(rateStart, -1); + String xph = active >= Ui.MINUTE && rateGained > 0 ? Ui.gp(rateGained * Ui.HOUR / active) + " xp/h" : ""; + + List samples = h.range(start); + long[] t = new long[samples.size() + 1]; + long[] v = new long[samples.size() + 1]; + long base = samples.isEmpty() ? 0 : samples.get(0).total(); + for (int i = 0; i < samples.size(); i++) + { + t[i] = Math.max(samples.get(i).t, start); + v[i] = samples.get(i).total() - base; + } + if (samples.isEmpty()) + { + t = new long[0]; + v = new long[0]; + } + else + { + t[samples.size()] = System.currentTimeMillis(); + v[samples.size()] = v[samples.size() - 1]; + } + card.update(title, h.name == null ? "" : h.name, true, total, levels, filter.getLabel(), xph, t, v); + XpSample latest = h.latest(); + int totalLevel = 0; + if (latest != null) + { + for (int s = 0; s < SkillHistory.SKILLS.length; s++) + { + totalLevel += SkillHistory.level(latest.get(s)); + } + } + card.setTotals(latest == null ? 0 : latest.total(), totalLevel); + card.setSkillGains(gained); + } + + private void refresh() + { + long now = System.currentTimeMillis(); + String active = plugin.getSkillKey(); + SkillHistory h = resolve(store, selectedKey, active); + updateCombo(h); + hudBox.setSelected(plugin.isSkillsHudEnabled()); + + shownKey = h == null ? null : h.key; + fillCard(card, h, timeFilter, "Skills"); + String label = timeFilter.getLabel(); + if (h == null) + { + chartTitle.setText(""); + showAll.setVisible(false); + cumulative.setData(null, null, -1, now - Ui.HOUR, now, Ui.GOLD, "Log in to start tracking xp"); + perPeriod.setData(null, null, -1, now - Ui.HOUR, now, Ui.GOLD, "No data yet"); + stats.update(new ArrayList<>()); + skillList.removeAll(); + rowOrder.clear(); + levelList.removeAll(); + revalidate(); + repaint(); + return; + } + + long start = h.start(timeFilter); + long[] starts = h.starts(timeFilter); + long rateStart = h.rateStart(timeFilter); + long[] rateStarts = h.rateStarts(timeFilter); + List samples = h.range(start); + long chartStart = samples.isEmpty() ? now - Ui.HOUR : Math.max(start, samples.get(0).t); + if (timeFilter.getRange() != TimeRange.ALL) + { + chartStart = start; + } + long[] gained = h.gained(timeFilter); + List levelUps = h.levelUps(start); + levelUps.removeIf(lu -> gained[lu.skill] <= 0); + XpSample last = h.latest(); + + // ---- chart + stats for the selection + int sel = selectedSkill; + Color color = sel < 0 ? Ui.GOLD : skillColor(SkillHistory.SKILLS[sel]); + chartTitle.setText(sel < 0 ? "All skills" : SkillHistory.SKILLS[sel].getName()); + chartTitle.setForeground(sel < 0 ? Ui.TEXT : color); + showAll.setVisible(sel >= 0); + String empty = "No xp gained " + timeFilter.inLabel(); + cumulative.setData(samples, levelUps, sel, chartStart, now, color, empty); + perPeriod.setData(samples, levelUps, sel, chartStart, now, color, empty); + + long total = 0; + int trained = 0; + for (long g : gained) + { + total += g; + trained += g > 0 ? 1 : 0; + } + long selGained = sel < 0 ? total : gained[sel]; + long rateSelStart = sel < 0 ? rateStart : rateStarts[sel]; + long selRateGained = sel < 0 ? h.gainedTotalSince(rateStart) : h.gainedSince(rateStarts[sel], sel); + long activeMs = h.activeMillis(rateSelStart, sel); + int levels = 0; + for (SkillHistory.LevelUp lu : levelUps) + { + if (sel < 0 || lu.skill == sel) + { + levels += lu.to - lu.from; + } + } + List cells = new ArrayList<>(); + cells.add(new StatsView.Cell("XP gained", "+" + Ui.gp(selGained), label, selGained > 0 ? Ui.UP : Ui.FAINT, + String.format(Locale.US, "%,d xp", selGained))); + cells.add(new StatsView.Cell("XP / hour", activeMs >= Ui.MINUTE && selRateGained > 0 ? Ui.gp(selRateGained * Ui.HOUR / activeMs) : "-", + "while training", Ui.TEXT, "Xp per hour of active training time")); + cells.add(new StatsView.Cell("Levels", "+" + levels, null, levels > 0 ? Ui.UP : Ui.TEXT, "Levels gained in " + label)); + cells.add(new StatsView.Cell("Active", duration(activeMs), null, Ui.TEXT, "Time spent gaining xp (gaps under 5 minutes)")); + if (sel < 0) + { + int totalLevel = 0; + for (int i = 0; i < SkillHistory.SKILLS.length; i++) + { + totalLevel += Math.min(99, SkillHistory.level(last.get(i))); + } + cells.add(new StatsView.Cell("Skills trained", String.valueOf(trained), null, Ui.TEXT, null)); + cells.add(new StatsView.Cell("Total level", String.valueOf(totalLevel), null, Ui.GOLD, null)); + } + else + { + int xp = last.get(sel); + int lvl = SkillHistory.level(xp); + String toNext = lvl >= Experience.MAX_VIRT_LEVEL ? "-" : Ui.gp(Experience.getXpForLevel(lvl + 1) - xp); + cells.add(new StatsView.Cell("Level", String.valueOf(lvl), Ui.gp(xp) + " xp", color, String.format(Locale.US, "%,d xp", xp))); + cells.add(new StatsView.Cell("To level " + Math.min(lvl + 1, Experience.MAX_VIRT_LEVEL), toNext, null, Ui.TEXT, null)); + SkillGoals.Goal goal = goal(sel); + if (goal != null) + { + int left = goal.xpLeft(xp); + long selXph = activeMs >= Ui.MINUTE && selGained > 0 ? selGained * Ui.HOUR / activeMs : 0; + cells.add(new StatsView.Cell("Goal " + goal.level, percent(goal.progress(xp)), left == 0 ? "reached" : Ui.gp(left) + " xp left", + left == 0 ? Ui.UP : Ui.GOLD, String.format(Locale.US, "%,d / %,d xp", xp, goal.targetXp()))); + cells.add(new StatsView.Cell("Time to goal", left == 0 ? "-" : selXph > 0 ? duration(left * Ui.HOUR / selXph) : "-", + selXph > 0 ? "at " + Ui.gp(selXph) + "/h" : "no xp/h yet", Ui.TEXT, null)); + } + } + stats.update(cells); + + // ---- skill rows (trained skills by xp, then the rest in game order) + List order = new ArrayList<>(); + for (int i = 0; i < gained.length; i++) + { + order.add(i); + } + order.sort((a, b) -> Long.compare(gained[b], gained[a])); + long maxGain = gained.length == 0 ? 0 : gained[order.get(0)]; + for (int i = 0; i < rows.length; i++) + { + int xp = last.get(i); + int from = gained[i] > 0 ? SkillHistory.level(h.at(starts[i]).get(i)) : SkillHistory.level(xp); + long skillRateStart = rateStarts[i]; + long skillRateGained = h.gainedSince(skillRateStart, i); + long skillActive = skillRateGained > 0 ? h.activeMillis(skillRateStart, i) : 0; + long xph = skillActive >= Ui.MINUTE ? skillRateGained * Ui.HOUR / skillActive : 0; + rows[i].update(xp, SkillHistory.level(xp) - from, gained[i], xph, maxGain, i == sel, goal(i)); + } + if (!order.equals(rowOrder)) + { + rowOrder.clear(); + rowOrder.addAll(order); + skillList.removeAll(); + for (int i : order) + { + skillList.add(rows[i]); + } + skillList.revalidate(); + } + + // ---- level ups, newest first + levelList.removeAll(); + if (levelUps.isEmpty()) + { + levelEmpty.setText("No level ups " + timeFilter.inLabel()); + levelList.add(levelEmpty); + } + for (int i = levelUps.size() - 1; i >= Math.max(0, levelUps.size() - LEVEL_UP_LIMIT); i--) + { + levelList.add(new LevelUpRow(levelUps.get(i))); + } + levelList.revalidate(); + + revalidate(); + repaint(); + } + + private SkillGoals.Goal goal(int skill) + { + return plugin.getSkillGoals().get(shownKey, SkillHistory.SKILLS[skill].getName()); + } + + private static String percent(double ratio) + { + double p = ratio * 100; + return (p >= 100 ? "100" : String.format(Locale.US, "%.1f", p)) + "%"; + } + + /** Goal editor for one skill: target level with quick picks, set and clear. */ + private void showGoalEditor(int skill, JComponent anchor, int x, int y) + { + if (shownKey == null) + { + return; + } + Skill s = SkillHistory.SKILLS[skill]; + SkillHistory h = store.get(shownKey); + XpSample last = h == null ? null : h.latest(); + int xp = last == null ? 0 : last.get(skill); + int level = SkillHistory.level(xp); + SkillGoals.Goal current = goal(skill); + int max = Experience.MAX_VIRT_LEVEL; + int initial = current != null ? current.level : level < 99 ? nextMilestone(level) : level + 1; + + JPopupMenu menu = new JPopupMenu(); + JPanel panel = new JPanel(new DynamicGridLayout(0, 1, 0, 6)); + panel.setBackground(Ui.CARD); + panel.setBorder(BorderFactory.createEmptyBorder(8, 8, 8, 8)); + + JLabel title = new JLabel(s.getName() + " goal"); + title.setFont(FontManager.getRunescapeBoldFont()); + title.setForeground(skillColor(s)); + panel.add(title); + JLabel now = new JLabel(String.format(Locale.US, "Now level %d (%,d xp)", level, xp)); + now.setFont(FontManager.getRunescapeSmallFont()); + now.setForeground(Ui.MUTED); + panel.add(now); + + SpinnerNumberModel model = new SpinnerNumberModel(Math.max(2, Math.min(max, initial)), 2, max, 1); + JSpinner spinner = new JSpinner(model); + spinner.setFont(FontManager.getRunescapeFont()); + JLabel preview = new JLabel(); + preview.setFont(FontManager.getRunescapeSmallFont()); + preview.setForeground(Ui.TEXT); + Runnable updatePreview = () -> + { + int target = Experience.getXpForLevel((Integer) model.getValue()); + int left = Math.max(0, target - xp); + preview.setText(left == 0 ? "Already reached" : String.format(Locale.US, "%,d xp needed", left)); + }; + spinner.addChangeListener(e -> updatePreview.run()); + updatePreview.run(); + JPanel levelRow = new JPanel(new BorderLayout(6, 0)); + levelRow.setOpaque(false); + JLabel lvLabel = new JLabel("Target level"); + lvLabel.setFont(FontManager.getRunescapeSmallFont()); + lvLabel.setForeground(Ui.TEXT); + levelRow.add(lvLabel, BorderLayout.WEST); + levelRow.add(spinner, BorderLayout.CENTER); + panel.add(levelRow); + + JPanel quick = new JPanel(new java.awt.GridLayout(1, 4, 3, 0)); + quick.setOpaque(false); + for (int[] pick : new int[][]{{1, 0}, {5, 0}, {0, 70}, {0, 99}}) + { + String label = pick[0] > 0 ? "+" + pick[0] : String.valueOf(pick[1]); + quick.add(button(label, () -> model.setValue(Math.max(2, Math.min(max, pick[0] > 0 ? level + pick[0] : pick[1]))))); + } + panel.add(quick); + panel.add(preview); + + JPanel actions = new JPanel(new java.awt.GridLayout(1, 2, 6, 0)); + actions.setOpaque(false); + JButton set = button("Set goal", () -> + { + plugin.getSkillGoals().set(shownKey, s.getName(), (Integer) model.getValue(), xp); + menu.setVisible(false); + refresh(); + }); + set.setForeground(Ui.ACCENT); + actions.add(set); + JButton clear = button("Clear", () -> + { + plugin.getSkillGoals().clear(shownKey, s.getName()); + menu.setVisible(false); + refresh(); + }); + clear.setEnabled(current != null); + actions.add(clear); + panel.add(actions); + + panel.setPreferredSize(new Dimension(190, panel.getPreferredSize().height)); + menu.add(panel); + menu.show(anchor, Math.max(0, Math.min(x, anchor.getWidth() - 190)), y); + } + + /** Next round level worth aiming for: 10, 20 .. 80, 85, 90, 99. */ + private static int nextMilestone(int level) + { + for (int m : new int[]{10, 20, 30, 40, 50, 60, 70, 80, 85, 90, 99}) + { + if (m > level) + { + return m; + } + } + return level + 1; + } + + private void updateCombo(SkillHistory shown) + { + List all = store.all(); + List choices = new ArrayList<>(); + for (SkillHistory h : all) + { + choices.add(new Choice(h.key, h.name == null ? h.key : h.name)); + } + List current = new ArrayList<>(); + for (int i = 0; i < characterBox.getItemCount(); i++) + { + current.add(characterBox.getItemAt(i)); + } + updatingCombo = true; + if (!choices.equals(current)) + { + characterBox.removeAllItems(); + choices.forEach(characterBox::addItem); + } + for (Choice c : choices) + { + if (shown != null && c.key.equals(shown.key)) + { + characterBox.setSelectedItem(c); + } + } + updatingCombo = false; + boolean visible = choices.size() > 1; + if (characterBox.isVisible() != visible) + { + characterBox.setVisible(visible); + revalidate(); + } + } + + private BufferedImage icon(Skill skill) + { + return icons.computeIfAbsent(skill, s -> + { + BufferedImage img = plugin.getSkillIconManager().getSkillImage(s, true); + return img == null ? null : ImageUtil.resizeImage(img, 16, 16); + }); + } + + private static String duration(long ms) + { + if (ms < Ui.MINUTE) + { + return "-"; + } + long h = ms / Ui.HOUR; + long m = (ms % Ui.HOUR) / Ui.MINUTE; + return h > 0 ? h + "h " + m + "m" : m + "m"; + } + + static Color skillColor(Skill skill) + { + switch (skill.name()) + { + case "ATTACK": return new Color(220, 80, 60); + case "DEFENCE": return new Color(120, 150, 230); + case "STRENGTH": return new Color(60, 190, 120); + case "HITPOINTS": return new Color(230, 90, 90); + case "RANGED": return new Color(150, 200, 60); + case "PRAYER": return new Color(235, 225, 170); + case "MAGIC": return new Color(110, 140, 255); + case "COOKING": return new Color(180, 100, 210); + case "WOODCUTTING": return new Color(110, 180, 80); + case "FLETCHING": return new Color(40, 190, 170); + case "FISHING": return new Color(130, 170, 220); + case "FIREMAKING": return new Color(250, 150, 40); + case "CRAFTING": return new Color(200, 150, 100); + case "SMITHING": return new Color(170, 170, 140); + case "MINING": return new Color(120, 180, 210); + case "HERBLORE": return new Color(60, 200, 70); + case "AGILITY": return new Color(110, 115, 220); + case "THIEVING": return new Color(190, 110, 170); + case "SLAYER": return new Color(200, 70, 70); + case "FARMING": return new Color(130, 200, 90); + case "RUNECRAFT": return new Color(230, 200, 70); + case "HUNTER": return new Color(170, 160, 110); + case "CONSTRUCTION": return new Color(200, 180, 140); + case "SAILING": return new Color(70, 170, 230); + default: return Ui.GOLD; + } + } + + 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; + } + + private static final class Choice + { + final String key; + final String name; + + Choice(String key, String name) + { + this.key = key; + this.name = name; + } + + @Override + public boolean equals(Object o) + { + return o instanceof Choice && ((Choice) o).key.equals(key) && Objects.equals(((Choice) o).name, name); + } + + @Override + public int hashCode() + { + return key.hashCode(); + } + + @Override + public String toString() + { + return name; + } + } + + /** width of the goal cog hit area at the right of a skill row */ + private static final int GEAR_HIT = 20; + + /** One skill: icon, level (+gained), xp gained, xp/h, progress to the next level and the goal, if set. */ + private final class SkillRow extends JComponent + { + private final int skill; + private int xp; + private int levelsGained; + private long gained; + private long xph; + private long maxGain; + private boolean selected; + private SkillGoals.Goal goal; + private boolean hover; + private boolean gearHover; + + SkillRow(int skill) + { + this.skill = skill; + setCursor(Cursor.getPredefinedCursor(Cursor.HAND_CURSOR)); + MouseAdapter mouse = new MouseAdapter() + { + @Override + public void mouseEntered(MouseEvent e) + { + hover = true; + repaint(); + } + + @Override + public void mouseExited(MouseEvent e) + { + hover = false; + gearHover = false; + repaint(); + } + + @Override + public void mouseMoved(MouseEvent e) + { + boolean g = onGear(e.getX(), e.getY()); + if (g != gearHover) + { + gearHover = g; + setToolTipText(g ? "Set a level goal" : tooltip()); + repaint(); + } + } + + @Override + public void mousePressed(MouseEvent e) + { + if (SwingUtilities.isRightMouseButton(e) || onGear(e.getX(), e.getY())) + { + showGoalEditor(skill, SkillRow.this, e.getX(), e.getY()); + } + else + { + selectSkill(skill); + } + } + }; + addMouseListener(mouse); + addMouseMotionListener(mouse); + } + + private boolean onGear(int x, int y) + { + return x >= getWidth() - GEAR_HIT && y <= 20; + } + + private String tooltip() + { + String t = String.format(Locale.US, "%s
%,d xp
+%,d xp gained", SkillHistory.SKILLS[skill].getName(), xp, gained); + if (goal != null) + { + t += String.format(Locale.US, "
Goal %d: %,d xp left", goal.level, goal.xpLeft(xp)); + } + return t + "
Right-click or the cog to set a goal"; + } + + void update(int xp, int levelsGained, long gained, long xph, long maxGain, boolean selected, SkillGoals.Goal goal) + { + boolean resize = (goal == null) != (this.goal == null); + this.goal = goal; + this.xp = xp; + this.levelsGained = levelsGained; + this.gained = gained; + this.xph = xph; + this.maxGain = maxGain; + this.selected = selected; + setToolTipText(tooltip()); + if (resize) + { + revalidate(); + } + repaint(); + } + + @Override + public Dimension getPreferredSize() + { + if (goal == null) + { + return new Dimension(10, 34); + } + int asc = getFontMetrics(FontManager.getRunescapeSmallFont()).getAscent(); + // goal text baseline at 20 + 3 * asc, then gap, 4px bar, bottom padding + return new Dimension(10, 4 + 3 * asc + 16 + 14); + } + + @Override + protected void paintComponent(Graphics g0) + { + Graphics2D g = Ui.prepare(g0); + int w = getWidth(); + int h = getHeight(); + Skill s = SkillHistory.SKILLS[skill]; + Color color = skillColor(s); + boolean trained = gained > 0; + + g.setColor(selected ? Ui.CARD_SELECTED : hover ? Ui.CARD_HOVER : Ui.CARD); + g.fillRoundRect(0, 0, w, h, 6, 6); + if (selected) + { + g.setColor(color); + g.fillRoundRect(0, 0, 3, h, 3, 3); + } + // share of the top skill's gain as a faint background bar + if (trained && maxGain > 0) + { + g.setColor(Ui.alpha(color, 26)); + g.fillRoundRect(0, 0, (int) (w * (double) gained / maxGain), h, 6, 6); + } + + BufferedImage img = icon(s); + if (img != null) + { + g.drawImage(img, 6, (h - 16) / 2 - 2, null); + } + + int level = SkillHistory.level(xp); + g.setFont(FontManager.getRunescapeSmallFont()); + FontMetrics fm = g.getFontMetrics(); + int x = 28; + int base1 = 4 + fm.getAscent(); + int base2 = base1 + fm.getAscent() + 2; + g.setColor(trained ? Ui.TEXT : Ui.MUTED); + g.drawString(s.getName(), x, base1); + String lv = "Lv " + level; + g.setColor(Ui.MUTED); + g.drawString(lv, x, base2); + if (levelsGained > 0) + { + g.setColor(Ui.UP); + g.drawString(" +" + levelsGained, x + fm.stringWidth(lv), base2); + } + + paintGear(g, w - 11, 10, gearHover ? Ui.ACCENT : goal != null ? Ui.GOLD : hover ? Ui.MUTED : Ui.FAINT); + int right = w - GEAR_HIT - 2; + g.setColor(trained ? Ui.UP : Ui.FAINT); + Ui.drawRight(g, trained ? "+" + Ui.gp(gained) + " xp" : "-", right, base1); + if (xph > 0) + { + g.setColor(Ui.MUTED); + Ui.drawRight(g, Ui.gp(xph) + "/h", right, base2); + } + + // progress to next level + int levelBarY = goal == null ? h - 4 : base2 + 6; + if (level < Experience.MAX_VIRT_LEVEL) + { + int lo = Experience.getXpForLevel(level); + int hi = Experience.getXpForLevel(level + 1); + double p = hi > lo ? (double) (xp - lo) / (hi - lo) : 0; + int bx = x; + int bw = w - x - 7; + g.setColor(Ui.alpha(Color.WHITE, 18)); + g.fillRect(bx, levelBarY, bw, 2); + g.setColor(Ui.alpha(color, trained ? 220 : 110)); + g.fillRect(bx, levelBarY, (int) (bw * Math.max(0, Math.min(1, p))), 2); + } + + if (goal != null) + { + int left = goal.xpLeft(xp); + boolean reached = left == 0; + int base3 = levelBarY + 2 + 6 + fm.getAscent(); + String head = "Goal " + goal.level + " "; + g.setColor(Ui.MUTED); + g.drawString(head, x, base3); + g.setColor(reached ? Ui.UP : Ui.GOLD); + g.drawString(percent(goal.progress(xp)), x + fm.stringWidth(head), base3); + String info = reached ? "reached!" : Ui.gp(left) + " xp left" + (xph > 0 ? " ~" + duration(left * Ui.HOUR / xph) : ""); + g.setColor(reached ? Ui.UP : Ui.TEXT); + Ui.drawRight(g, info, w - 7, base3); + int bx = x; + int bw = w - x - 7; + int by = base3 + 5; + g.setColor(Ui.alpha(Color.WHITE, 18)); + g.fillRoundRect(bx, by, bw, 4, 4, 4); + g.setColor(reached ? Ui.UP : Ui.GOLD); + g.fillRoundRect(bx, by, Math.max(4, (int) (bw * goal.progress(xp))), 4, 4, 4); + } + g.dispose(); + } + + /** Small cog: ring with eight teeth. */ + private void paintGear(Graphics2D g, int cx, int cy, Color c) + { + Graphics2D g2 = (Graphics2D) g.create(); + g2.translate(cx, cy); + g2.setColor(c); + for (int i = 0; i < 8; i++) + { + g2.fillRect(-1, -6, 3, 3); + g2.rotate(Math.PI / 4); + } + g2.fillOval(-4, -4, 9, 9); + g2.setColor(selected ? Ui.CARD_SELECTED : hover ? Ui.CARD_HOVER : Ui.CARD); + g2.fillOval(-1, -1, 3, 3); + g2.dispose(); + } + } + + /** "Attack 70 → 71" with the time it happened. */ + private final class LevelUpRow extends JComponent + { + private final SkillHistory.LevelUp levelUp; + + LevelUpRow(SkillHistory.LevelUp levelUp) + { + this.levelUp = levelUp; + setToolTipText(Instant.ofEpochMilli(levelUp.t).atZone(ZoneId.systemDefault()).format(WHEN)); + } + + @Override + public Dimension getPreferredSize() + { + return new Dimension(10, 22); + } + + @Override + protected void paintComponent(Graphics g0) + { + Graphics2D g = Ui.prepare(g0); + int w = getWidth(); + int h = getHeight(); + Skill s = SkillHistory.SKILLS[levelUp.skill]; + g.setColor(Ui.CARD); + g.fillRoundRect(0, 0, w, h, 6, 6); + BufferedImage img = icon(s); + if (img != null) + { + g.drawImage(img, 5, (h - 16) / 2, null); + } + g.setFont(FontManager.getRunescapeSmallFont()); + FontMetrics fm = g.getFontMetrics(); + int base = (h + fm.getAscent()) / 2 - 1; + g.setColor(Ui.TEXT); + String name = s.getName() + " "; + g.drawString(name, 26, base); + g.setColor(skillColor(s)); + String lv = levelUp.to - levelUp.from > 1 ? levelUp.from + " > " + levelUp.to : String.valueOf(levelUp.to); + g.drawString(lv, 26 + fm.stringWidth(name), base); + g.setColor(Ui.MUTED); + String when = Ui.ago(levelUp.t); + if (System.currentTimeMillis() - levelUp.t >= Ui.DAY) + { + when = Instant.ofEpochMilli(levelUp.t).atZone(ZoneId.systemDefault()).format(WHEN); + } + Ui.drawRight(g, when, w - 7, base); + g.dispose(); + } + } + + private static final String[][] HUD_OPTIONS = { + {"skillsHudLevel", "Level"}, + {"skillsHudPercent", "Progress %"}, + {"skillsHudXpLeft", "Xp to level"}, + {"skillsHudTimeToLevel", "Time to level"}, + {"skillsHudXpHour", "Xp per hour"}, + {"skillsHudProgressBar", "Progress bar"}, + }; + + private void showHudOptions(JComponent anchor) + { + JPopupMenu menu = new JPopupMenu(); + for (String[] option : HUD_OPTIONS) + { + JCheckBoxMenuItem item = new JCheckBoxMenuItem(option[1], plugin.isHudOption(option[0])); + item.addActionListener(e -> plugin.setHudOption(option[0], item.isSelected())); + menu.add(item); + } + menu.show(anchor, 0, anchor.getHeight()); + } +} diff --git a/src/main/java/com/example/essentialsplus/ui/StatsView.java b/src/main/java/com/example/essentialsplus/ui/StatsView.java new file mode 100644 index 0000000..e34dda4 --- /dev/null +++ b/src/main/java/com/example/essentialsplus/ui/StatsView.java @@ -0,0 +1,106 @@ +package com.example.essentialsplus.ui; + +import java.awt.Color; +import java.awt.Dimension; +import java.awt.FontMetrics; +import java.awt.Graphics; +import java.awt.Graphics2D; +import java.awt.event.MouseEvent; +import java.util.ArrayList; +import java.util.List; +import javax.swing.JComponent; +import net.runelite.client.ui.FontManager; + +/** Grid of key figures. */ +public class StatsView extends JComponent +{ + private static final int CELL_H = 36; + private static final int GAP = 4; + + public static final class Cell + { + public final String label; + public final String value; + public final String hint; + public final Color color; + public final String tooltip; + + public Cell(String label, String value, String hint, Color color, String tooltip) + { + this.label = label; + this.value = value; + this.hint = hint == null ? "" : hint; + this.color = color; + this.tooltip = tooltip; + } + } + + private final int columns; + private List cells = new ArrayList<>(); + + public StatsView(int columns) + { + this.columns = columns; + setToolTipText(""); + } + + public void update(List cells) + { + boolean resize = cells.size() != this.cells.size(); + this.cells = cells; + if (resize) + { + revalidate(); + } + repaint(); + } + + @Override + public String getToolTipText(MouseEvent e) + { + int cw = (getWidth() - GAP * (columns - 1)) / columns; + int col = Math.min(columns - 1, e.getX() / (cw + GAP)); + int row = e.getY() / (CELL_H + GAP); + int i = row * columns + col; + return i >= 0 && i < cells.size() ? cells.get(i).tooltip : null; + } + + @Override + public Dimension getPreferredSize() + { + int rows = Math.max(1, (cells.size() + columns - 1) / columns); + return new Dimension(10, rows * CELL_H + (rows - 1) * GAP); + } + + @Override + protected void paintComponent(Graphics g0) + { + Graphics2D g = Ui.prepare(g0); + int cw = (getWidth() - GAP * (columns - 1)) / columns; + for (int i = 0; i < cells.size(); i++) + { + Cell cell = cells.get(i); + int x = (i % columns) * (cw + GAP); + int y = (i / columns) * (CELL_H + GAP); + g.setColor(Ui.CARD); + g.fillRoundRect(x, y, cw, CELL_H, 8, 8); + + g.setFont(FontManager.getRunescapeSmallFont()); + FontMetrics sfm = g.getFontMetrics(); + int hintW = cell.hint.isEmpty() ? 0 : sfm.stringWidth(cell.hint) + 6; + g.setColor(Ui.MUTED); + g.drawString(Ui.ellipsize(sfm, cell.label, cw - 14 - hintW), x + 8, y + 4 + sfm.getAscent()); + if (hintW > 0) + { + g.setColor(Ui.alpha(cell.color == Ui.TEXT ? Ui.MUTED : cell.color, 210)); + Ui.drawRight(g, cell.hint, x + cw - 7, y + 4 + sfm.getAscent()); + } + + g.setFont(FontManager.getRunescapeBoldFont()); + FontMetrics fm = g.getFontMetrics(); + g.setColor(cell.color); + g.drawString(Ui.ellipsize(fm, cell.value, cw - 14), x + 8, y + CELL_H - 6); + } + g.dispose(); + } +} diff --git a/src/main/java/com/example/essentialsplus/ui/TimeFilter.java b/src/main/java/com/example/essentialsplus/ui/TimeFilter.java new file mode 100644 index 0000000..e9011ca --- /dev/null +++ b/src/main/java/com/example/essentialsplus/ui/TimeFilter.java @@ -0,0 +1,169 @@ +package com.example.essentialsplus.ui; + +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.function.Consumer; +import java.util.function.IntPredicate; +import java.util.function.IntToLongFunction; + +/** Global time range shared by every Essentials Plus view (wealth, loot, skills, HUD). */ +public final class TimeFilter +{ + /** "Now" drops an activity once nothing was gained for this long */ + public static final long ACTIVE_GAP = 5 * Ui.MINUTE; + /** login time of the current game session (hopping keeps it) */ + private volatile long sessionStart = System.currentTimeMillis(); + + private volatile TimeRange range; + private final Consumer onPersist; + private final List listeners = new CopyOnWriteArrayList<>(); + + public TimeFilter(TimeRange initial, Consumer onPersist) + { + this.range = initial == null ? TimeRange.DAY : initial; + this.onPersist = onPersist; + } + + public static TimeRange parse(String name) + { + if (name != null) + { + for (TimeRange r : TimeRange.values()) + { + if (r.name().equals(name)) + { + return r; + } + } + } + return TimeRange.DAY; + } + + public TimeRange getRange() + { + return range; + } + + public String getLabel() + { + return range.getLabel(); + } + + /** Label for "nothing in ..." texts. */ + public String inLabel() + { + switch (range) + { + case CURRENT: + return "right now"; + case SESSION: + return "this session"; + case ALL: + return "total"; + default: + return "in " + range.getLabel(); + } + } + + public long getSessionStart() + { + return sessionStart; + } + + /** Starts the "Sess" range at a new login. */ + public void setSessionStart(long t) + { + sessionStart = t; + for (Runnable l : listeners) + { + l.run(); + } + } + + public boolean isCurrent() + { + return range == TimeRange.CURRENT; + } + + /** + * Start of the range in epoch millis; 0 for "All". For "Now" this is only a fallback: views work out + * the start of each activity with {@link #streakStart}. + */ + public long start() + { + long now = System.currentTimeMillis(); + switch (range) + { + case ALL: + return 0; + case SESSION: + return sessionStart; + case CURRENT: + return now - ACTIVE_GAP; + default: + return now - range.getMillis(); + } + } + + /** + * Start of the current activity streak in a time-ordered list: the newest gain must be at most + * {@link #ACTIVE_GAP} old and each earlier gain at most that far from the next one. + * + * @param time time of entry {@code i} + * @param gain whether entry {@code i} is progress (e.g. more xp than entry {@code i - 1}) + * @return a start such that the entry before the streak is the baseline, or {@link Long#MAX_VALUE} when idle + */ + public static long streakStart(int size, IntToLongFunction time, IntPredicate gain, long now) + { + int first = -1; + long firstTime = now; + for (int i = size - 1; i >= 0; i--) + { + long t = time.applyAsLong(i); + if (firstTime - t > ACTIVE_GAP) + { + break; + } + if (gain.test(i)) + { + first = i; + firstTime = t; + } + } + if (first < 0) + { + return Long.MAX_VALUE; + } + long start = firstTime - Ui.MINUTE; + return first > 0 ? Math.max(time.applyAsLong(first - 1), start) : start; + } + + public void set(TimeRange r) + { + if (r == null) + { + return; + } + boolean changed = r != range; + range = r; + if (changed && onPersist != null) + { + onPersist.accept(r); + } + // always notify: re-selecting the active range leaves e.g. the wealth session view + for (Runnable l : listeners) + { + l.run(); + } + } + + public void addListener(Runnable r) + { + listeners.add(r); + } + + public void removeListener(Runnable r) + { + listeners.remove(r); + } +} diff --git a/src/main/java/com/example/essentialsplus/ui/TimeRange.java b/src/main/java/com/example/essentialsplus/ui/TimeRange.java new file mode 100644 index 0000000..f3f8991 --- /dev/null +++ b/src/main/java/com/example/essentialsplus/ui/TimeRange.java @@ -0,0 +1,22 @@ +package com.example.essentialsplus.ui; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum TimeRange +{ + /** only what is being gained right now: resets after {@link TimeFilter#ACTIVE_GAP} without progress */ + CURRENT("Now", -1), + /** since the client was started */ + SESSION("Sess", -1), + HOUR("1H", Ui.HOUR), + DAY("1D", Ui.DAY), + TWO_WEEKS("2W", 14 * Ui.DAY), + QUARTER("3M", 91 * Ui.DAY), + ALL("All", -1); + + private final String label; + private final long millis; +} diff --git a/src/main/java/com/example/essentialsplus/ui/TopItemsView.java b/src/main/java/com/example/essentialsplus/ui/TopItemsView.java new file mode 100644 index 0000000..304114d --- /dev/null +++ b/src/main/java/com/example/essentialsplus/ui/TopItemsView.java @@ -0,0 +1,147 @@ +package com.example.essentialsplus.ui; + +import com.example.essentialsplus.data.TopItem; +import java.awt.Dimension; +import java.awt.FontMetrics; +import java.awt.Graphics; +import java.awt.Graphics2D; +import java.awt.RenderingHints; +import java.awt.event.MouseAdapter; +import java.awt.event.MouseEvent; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import javax.swing.JComponent; +import net.runelite.client.ui.FontManager; + +/** Most valuable item stacks. */ +class TopItemsView extends JComponent +{ + private static final int ROW = 34; + + private final ItemIcons icons; + private final int limit; + private List items = new ArrayList<>(); + private long total; + private int hover = -1; + + TopItemsView(ItemIcons icons, int limit) + { + this.icons = icons; + this.limit = limit; + setToolTipText(""); + MouseAdapter mouse = new MouseAdapter() + { + @Override + public void mouseMoved(MouseEvent e) + { + int h = (e.getY() - 4) / ROW; + if (h != hover) + { + hover = h; + repaint(); + } + } + + @Override + public void mouseExited(MouseEvent e) + { + hover = -1; + repaint(); + } + }; + addMouseListener(mouse); + addMouseMotionListener(mouse); + } + + void update(List items, long total) + { + List list = items.size() > limit ? new ArrayList<>(items.subList(0, limit)) : items; + boolean sizeChanged = list.size() != this.items.size(); + this.items = list; + this.total = total; + if (sizeChanged) + { + revalidate(); + } + repaint(); + } + + @Override + public String getToolTipText(MouseEvent e) + { + int i = (e.getY() - 4) / ROW; + if (i < 0 || i >= items.size()) + { + return null; + } + TopItem it = items.get(i); + long each = it.qty > 0 ? it.value / it.qty : 0; + return "" + it.name + "
" + Ui.exact(it.value) + + "
" + String.format(Locale.US, "%,d", it.qty) + " x " + Ui.exact(each) + ""; + } + + @Override + public Dimension getPreferredSize() + { + return new Dimension(10, items.isEmpty() ? 40 : items.size() * ROW + 8); + } + + @Override + protected void paintComponent(Graphics g0) + { + Graphics2D g = Ui.prepare(g0); + int w = getWidth(); + g.setColor(Ui.CARD); + g.fillRoundRect(0, 0, w, getHeight(), 8, 8); + g.setFont(FontManager.getRunescapeSmallFont()); + FontMetrics sfm = g.getFontMetrics(); + + if (items.isEmpty()) + { + g.setColor(Ui.MUTED); + String s = "No items yet"; + g.drawString(s, (w - sfm.stringWidth(s)) / 2, 20 + sfm.getAscent() / 2); + g.dispose(); + return; + } + + g.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR); + int pad = 6; + for (int i = 0; i < items.size(); i++) + { + TopItem it = items.get(i); + int y = 4 + i * ROW; + if (i == hover) + { + g.setColor(Ui.CARD_HOVER.brighter()); + g.fillRoundRect(3, y, w - 6, ROW, 6, 6); + } + g.drawImage(icons.icon(it.id, this), pad, y + 1, 36, 32, null); + + String val = Ui.gp(it.value); + g.setFont(FontManager.getRunescapeBoldFont()); + FontMetrics fm = g.getFontMetrics(); + int vw = fm.stringWidth(val); + g.setColor(Ui.GOLD); + Ui.drawRight(g, val, w - pad - 2, y + 7 + fm.getAscent()); + + int textX = pad + 40; + g.setFont(FontManager.getRunescapeFont()); + FontMetrics nfm = g.getFontMetrics(); + g.setColor(Ui.TEXT); + String name = it.name != null ? it.name : icons.name(it.id); + g.drawString(Ui.ellipsize(nfm, name, w - textX - vw - pad - 6), textX, y + 4 + nfm.getAscent()); + + g.setFont(FontManager.getRunescapeSmallFont()); + g.setColor(Ui.MUTED); + String sub = "x " + Ui.gp(it.qty); + if (total > 0) + { + sub += " " + String.format(Locale.US, "%.1f%%", it.value * 100.0 / total); + } + g.drawString(sub, textX, y + ROW - 6); + } + g.dispose(); + } +} diff --git a/src/main/java/com/example/essentialsplus/ui/Ui.java b/src/main/java/com/example/essentialsplus/ui/Ui.java new file mode 100644 index 0000000..01b4be7 --- /dev/null +++ b/src/main/java/com/example/essentialsplus/ui/Ui.java @@ -0,0 +1,171 @@ +package com.example.essentialsplus.ui; + +import java.awt.Color; +import java.awt.FontMetrics; +import java.awt.Graphics; +import java.awt.Graphics2D; +import java.awt.RenderingHints; +import java.text.NumberFormat; +import java.util.Locale; +import net.runelite.client.ui.ColorScheme; + +/** Shared colours, formatting and painting helpers. */ +public final class Ui +{ + public static final Color BG = ColorScheme.DARK_GRAY_COLOR; + public static final Color CARD = ColorScheme.DARKER_GRAY_COLOR; + public static final Color CARD_HOVER = new Color(38, 38, 38); + public static final Color CARD_SELECTED = new Color(44, 40, 34); + public static final Color TEXT = new Color(232, 232, 232); + public static final Color MUTED = new Color(155, 155, 155); + public static final Color FAINT = new Color(105, 105, 105); + public static final Color GRID = new Color(255, 255, 255, 16); + public static final Color GOLD = new Color(255, 194, 64); + public static final Color UP = new Color(72, 208, 130); + public static final Color DOWN = new Color(236, 96, 96); + public static final Color WARN = new Color(255, 152, 0); + public static final Color ACCENT = ColorScheme.BRAND_ORANGE; + + public static final long MINUTE = 60_000L; + public static final long HOUR = 60 * MINUTE; + public static final long DAY = 24 * HOUR; + + private Ui() + { + } + + public static Graphics2D prepare(Graphics g) + { + Graphics2D g2 = (Graphics2D) g.create(); + g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); + g2.setRenderingHint(RenderingHints.KEY_STROKE_CONTROL, RenderingHints.VALUE_STROKE_PURE); + g2.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_OFF); + return g2; + } + + public static Color alpha(Color c, int a) + { + return new Color(c.getRed(), c.getGreen(), c.getBlue(), Math.max(0, Math.min(255, a))); + } + + public static Color deltaColor(long delta) + { + return delta > 0 ? UP : delta < 0 ? DOWN : MUTED; + } + + /** Short RS-style amount: 999, 12.3K, 117.7M, 1.23B. */ + public static String gp(long v) + { + long a = Math.abs(v); + String sign = v < 0 ? "-" : ""; + if (a < 1_000) + { + return sign + a; + } + if (a < 1_000_000) + { + return sign + trim(a / 1_000.0, a < 100_000 ? 1 : 0) + "K"; + } + if (a < 1_000_000_000) + { + return sign + trim(a / 1_000_000.0, a < 100_000_000 ? (a < 10_000_000 ? 2 : 1) : 1) + "M"; + } + return sign + trim(a / 1_000_000_000.0, a < 10_000_000_000L ? 2 : 1) + "B"; + } + + static String signedGp(long v) + { + return (v > 0 ? "+" : "") + gp(v); + } + + static String exact(long v) + { + return NumberFormat.getIntegerInstance(Locale.US).format(v) + " gp"; + } + + static String pct(double ratio) + { + if (Double.isNaN(ratio) || Double.isInfinite(ratio)) + { + return ""; + } + double p = ratio * 100; + String s = Math.abs(p) >= 100 ? String.format(Locale.US, "%.0f", p) : String.format(Locale.US, "%.1f", p); + return (p > 0 ? "+" : "") + s + "%"; + } + + static String arrow(long delta) + { + return delta > 0 ? "▲ " : delta < 0 ? "▼ " : ""; + } + + public static String ago(long timestamp) + { + if (timestamp <= 0) + { + return "never"; + } + long d = System.currentTimeMillis() - timestamp; + if (d < MINUTE) + { + return "just now"; + } + if (d < HOUR) + { + return (d / MINUTE) + "m ago"; + } + if (d < DAY) + { + return (d / HOUR) + "h ago"; + } + return (d / DAY) + "d ago"; + } + + private static String trim(double v, int decimals) + { + String s = String.format(Locale.US, "%." + decimals + "f", v); + if (s.contains(".")) + { + s = s.replaceAll("0+$", "").replaceAll("\\.$", ""); + } + return s; + } + + public static String ellipsize(FontMetrics fm, String s, int maxWidth) + { + if (s == null) + { + return ""; + } + if (fm.stringWidth(s) <= maxWidth) + { + return s; + } + String dots = ".."; + for (int i = s.length() - 1; i > 0; i--) + { + String t = s.substring(0, i) + dots; + if (fm.stringWidth(t) <= maxWidth) + { + return t; + } + } + return dots; + } + + /** Accent outline on an overview card while it is dragged to a new position. */ + public static void paintDragOutline(Graphics2D g, javax.swing.JComponent card) + { + if (Boolean.TRUE.equals(card.getClientProperty("cardDragging"))) + { + g.setColor(ACCENT); + g.setStroke(new java.awt.BasicStroke(2f)); + g.drawRoundRect(1, 1, card.getWidth() - 3, card.getHeight() - 3, 8, 8); + } + } + + public static void drawRight(Graphics2D g, String s, int rightX, int baseline) + { + g.drawString(s, rightX - g.getFontMetrics().stringWidth(s), baseline); + } +} diff --git a/src/main/java/com/example/essentialsplus/ui/WealthChart.java b/src/main/java/com/example/essentialsplus/ui/WealthChart.java new file mode 100644 index 0000000..880f795 --- /dev/null +++ b/src/main/java/com/example/essentialsplus/ui/WealthChart.java @@ -0,0 +1,963 @@ +package com.example.essentialsplus.ui; + +import com.example.essentialsplus.WealthCategory; +import com.example.essentialsplus.data.Snapshot; +import java.awt.BasicStroke; +import java.awt.Color; +import java.awt.Dimension; +import java.awt.Font; +import java.awt.FontMetrics; +import java.awt.GradientPaint; +import java.awt.Graphics; +import java.awt.Graphics2D; +import java.awt.Stroke; +import java.awt.event.MouseAdapter; +import java.awt.event.MouseEvent; +import java.awt.geom.Ellipse2D; +import java.awt.geom.Path2D; +import java.awt.geom.Rectangle2D; +import java.time.Instant; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Locale; +import java.util.function.DoubleUnaryOperator; +import javax.swing.JComponent; +import net.runelite.client.ui.FontManager; + +/** + * Interactive timeline. + *
    + *
  • LINE: total wealth (min/max band where data is aggregated)
  • + *
  • CANDLES: OHLC candles, bucket size adapts to range and width
  • + *
  • BARS: change per day (per hour for short ranges, per week for long ones)
  • + *
  • STACKED: composition by category
  • + *
+ */ +public class WealthChart extends JComponent +{ + public enum Mode + { + LINE, CANDLES, BARS, STACKED + } + + private static final Stroke LINE_STROKE = new BasicStroke(1.8f, BasicStroke.CAP_ROUND, BasicStroke.JOIN_ROUND); + private static final Stroke THIN = new BasicStroke(1f); + private static final Stroke DASHED = new BasicStroke(1f, BasicStroke.CAP_BUTT, BasicStroke.JOIN_MITER, 10f, new float[]{3f, 3f}, 0f); + private static final DateTimeFormatter TIP_TIME = DateTimeFormatter.ofPattern("EEE d MMM, HH:mm", Locale.ENGLISH); + private static final DateTimeFormatter TIP_HOUR = DateTimeFormatter.ofPattern("EEE d MMM, HH:00", Locale.ENGLISH); + private static final DateTimeFormatter TIP_DAY = DateTimeFormatter.ofPattern("EEE d MMM yyyy", Locale.ENGLISH); + + private Mode mode; + private final int prefHeight; + private List points = Collections.emptyList(); + private List candles = Collections.emptyList(); + private long bucket; + private long start; + private long end; + private String emptyText = "No history yet"; + private int hoverX = -1; + private boolean large; + + public WealthChart(Mode mode, int prefHeight) + { + this.mode = mode; + this.prefHeight = prefHeight; + setOpaque(false); + MouseAdapter mouse = new MouseAdapter() + { + @Override + public void mouseMoved(MouseEvent e) + { + hoverX = e.getX(); + repaint(); + } + + @Override + public void mouseDragged(MouseEvent e) + { + mouseMoved(e); + } + + @Override + public void mouseExited(MouseEvent e) + { + hoverX = -1; + repaint(); + } + }; + addMouseListener(mouse); + addMouseMotionListener(mouse); + } + + public void setLarge(boolean large) + { + this.large = large; + } + + public void setMode(Mode mode) + { + this.mode = mode; + repaint(); + } + + public void setData(List points, long start, long end, String emptyText) + { + this.points = points == null ? Collections.emptyList() : points; + this.start = start; + this.end = Math.max(end, start + Ui.MINUTE); + this.emptyText = emptyText; + this.candles = Collections.emptyList(); + this.bucket = 0; + repaint(); + } + + @Override + public Dimension getPreferredSize() + { + return new Dimension(10, prefHeight); + } + + private void ensureCandles(int plotWidth) + { + long b = mode == Mode.BARS + ? Analysis.changeBucket(end - start) + : Analysis.pickBucket(end - start, Math.max(8, plotWidth / (large ? 9 : 6))); + if (b != bucket || candles.isEmpty()) + { + bucket = b; + candles = Analysis.candles(points, start, end, b); + } + } + + @Override + protected void paintComponent(Graphics g0) + { + Graphics2D g = Ui.prepare(g0); + int w = getWidth(); + int h = getHeight(); + g.setColor(Ui.CARD); + g.fillRoundRect(0, 0, w, h, 8, 8); + + Font font = large ? FontManager.getRunescapeFont() : FontManager.getRunescapeSmallFont(); + g.setFont(font); + FontMetrics fm = g.getFontMetrics(); + + if (points.isEmpty()) + { + paintEmpty(g, fm, w, h); + g.dispose(); + return; + } + + // rough plot width before labels are known (needed to pick the candle size) + int roughPw = w - 50; + if (mode == Mode.CANDLES || mode == Mode.BARS) + { + ensureCandles(roughPw); + } + + // ---- value range + double lo = Double.MAX_VALUE; + double hi = -Double.MAX_VALUE; + switch (mode) + { + case LINE: + for (Snapshot p : points) + { + lo = Math.min(lo, p.low()); + hi = Math.max(hi, p.high()); + } + break; + case CANDLES: + for (Analysis.Candle c : candles) + { + lo = Math.min(lo, c.l); + hi = Math.max(hi, c.h); + } + break; + case BARS: + lo = 0; + hi = 0; + for (Analysis.Candle c : candles) + { + lo = Math.min(lo, c.change()); + hi = Math.max(hi, c.change()); + } + break; + default: + lo = 0; + for (Snapshot p : points) + { + hi = Math.max(hi, p.total()); + } + } + if (lo == Double.MAX_VALUE) + { + paintEmpty(g, fm, w, h); + g.dispose(); + return; + } + + if (mode == Mode.STACKED) + { + hi = Math.max(hi * 1.08, 1000); + } + else if (mode == Mode.BARS) + { + double m = Math.max(Math.max(Math.abs(lo), Math.abs(hi)), 1000); + lo = lo < 0 ? lo - m * 0.1 : -m * 0.05; + hi = hi > 0 ? hi + m * 0.1 : m * 0.05; + } + else + { + double span = hi - lo; + if (span <= 0) + { + span = Math.max(Math.abs(hi) * 0.04, 1000); + } + lo = lo >= 0 ? Math.max(0, lo - span * 0.12) : lo - span * 0.12; + hi = hi + span * 0.15; + } + + double step = niceStep((hi - lo) / (large ? 5 : 3)); + double firstTick = Math.ceil(lo / step) * step; + List ticks = new ArrayList<>(); + for (double v = firstTick; v <= hi + step * 0.001; v += step) + { + ticks.add(Math.abs(v) < step * 1e-6 ? 0 : v); + } + + int labelW = 0; + for (double v : ticks) + { + labelW = Math.max(labelW, fm.stringWidth(axisLabel(v))); + } + + int px = labelW + 10; + int py = 10; + int pw = w - px - 8; + int ph = h - py - fm.getHeight() - 6; + if (pw < 20 || ph < 20) + { + g.dispose(); + return; + } + + final double fLo = lo; + final double fHi = hi; + DoubleUnaryOperator yOf = v -> py + ph - (v - fLo) / (fHi - fLo) * ph; + + for (double v : ticks) + { + int y = (int) Math.round(yOf.applyAsDouble(v)); + g.setColor(mode == Mode.BARS && v == 0 ? Ui.alpha(Ui.TEXT, 60) : Ui.GRID); + g.setStroke(THIN); + g.drawLine(px, y, px + pw, y); + g.setColor(Ui.FAINT); + Ui.drawRight(g, axisLabel(v), px - 5, y + fm.getAscent() / 2 - 1); + } + drawTimeAxis(g, fm, px, py + ph, pw); + + switch (mode) + { + case LINE: + paintLine(g, fm, px, py, pw, ph, yOf); + paintLineHover(g, fm, px, py, pw, ph, yOf); + break; + case STACKED: + paintStacked(g, px, py, pw, ph, yOf); + paintStackedHover(g, fm, px, py, pw, ph); + break; + case CANDLES: + paintCandles(g, px, pw, yOf); + paintCandleHover(g, fm, px, py, pw, ph); + break; + case BARS: + paintBars(g, px, pw, yOf); + paintCandleHover(g, fm, px, py, pw, ph); + break; + } + g.dispose(); + } + + private String axisLabel(double v) + { + long r = Math.round(v); + return mode == Mode.BARS ? Ui.signedGp(r) : Ui.gp(r); + } + + private void paintEmpty(Graphics2D g, FontMetrics fm, int w, int h) + { + g.setColor(Ui.MUTED); + String[] lines = emptyText.split("\n"); + int y = h / 2 - (lines.length - 1) * fm.getHeight() / 2 + fm.getAscent() / 2; + for (String line : lines) + { + g.drawString(line, (w - fm.stringWidth(line)) / 2, y); + y += fm.getHeight(); + } + } + + private double xOf(long t, int px, int pw) + { + return px + (double) (t - start) / (end - start) * pw; + } + + private long tAt(int x, int px, int pw) + { + return start + Math.round((double) (x - px) / pw * (end - start)); + } + + // ------------------------------------------------------------------ line + + private void paintLine(Graphics2D g, FontMetrics fm, int px, int py, int pw, int ph, DoubleUnaryOperator yOf) + { + List pts = downsample(points, pw); + long first = points.get(0).total(); + long last = points.get(points.size() - 1).total(); + Color color = last > first ? Ui.UP : last < first ? Ui.DOWN : Ui.GOLD; + + boolean anyAgg = false; + for (Snapshot p : pts) + { + anyAgg |= p.isAggregate(); + } + if (anyAgg) + { + Path2D band = new Path2D.Double(); + for (int i = 0; i < pts.size(); i++) + { + Snapshot p = pts.get(i); + double x = xOf(p.t, px, pw); + double y = yOf.applyAsDouble(p.high()); + if (i == 0) + { + band.moveTo(x, y); + } + else + { + band.lineTo(x, y); + } + } + for (int i = pts.size() - 1; i >= 0; i--) + { + Snapshot p = pts.get(i); + band.lineTo(xOf(p.t, px, pw), yOf.applyAsDouble(p.low())); + } + band.closePath(); + g.setColor(Ui.alpha(color, 45)); + g.fill(band); + } + + Path2D line = new Path2D.Double(); + double firstX = xOf(pts.get(0).t, px, pw); + double lastX = firstX; + for (int i = 0; i < pts.size(); i++) + { + Snapshot p = pts.get(i); + double x = xOf(p.t, px, pw); + double y = yOf.applyAsDouble(p.total()); + if (i == 0) + { + line.moveTo(x, y); + } + else + { + // raw data: value holds until the next change; aggregates: straight line + if (!p.isAggregate() && !pts.get(i - 1).isAggregate()) + { + line.lineTo(x, yOf.applyAsDouble(pts.get(i - 1).total())); + } + line.lineTo(x, y); + } + lastX = x; + } + if (pts.size() == 1) + { + line.lineTo(px + pw, yOf.applyAsDouble(pts.get(0).total())); + lastX = px + pw; + } + + Path2D area = new Path2D.Double(line); + area.lineTo(lastX, py + ph); + area.lineTo(firstX, py + ph); + area.closePath(); + g.setPaint(new GradientPaint(0, py, Ui.alpha(color, 95), 0, py + ph, Ui.alpha(color, 0))); + g.fill(area); + + int baseY = (int) Math.round(yOf.applyAsDouble(first)); + g.setStroke(DASHED); + g.setColor(Ui.alpha(Ui.MUTED, 90)); + g.drawLine(px, baseY, px + pw, baseY); + + g.setStroke(LINE_STROKE); + g.setColor(color); + g.draw(line); + + int hiIdx = 0; + int loIdx = 0; + for (int i = 1; i < points.size(); i++) + { + if (points.get(i).high() > points.get(hiIdx).high()) + { + hiIdx = i; + } + if (points.get(i).low() < points.get(loIdx).low()) + { + loIdx = i; + } + } + if (points.get(hiIdx).high() != points.get(loIdx).low()) + { + marker(g, fm, px, pw, points.get(hiIdx).t, yOf.applyAsDouble(points.get(hiIdx).high()), points.get(hiIdx).high(), true); + marker(g, fm, px, pw, points.get(loIdx).t, yOf.applyAsDouble(points.get(loIdx).low()), points.get(loIdx).low(), false); + } + + double ly = yOf.applyAsDouble(last); + g.setColor(Ui.alpha(color, 60)); + g.fill(new Ellipse2D.Double(lastX - 5, ly - 5, 10, 10)); + g.setColor(color); + g.fill(new Ellipse2D.Double(lastX - 2.5, ly - 2.5, 5, 5)); + } + + private void marker(Graphics2D g, FontMetrics fm, int px, int pw, long t, double y, long value, boolean top) + { + double x = xOf(t, px, pw); + g.setColor(Ui.TEXT); + g.fill(new Ellipse2D.Double(x - 2, y - 2, 4, 4)); + String s = Ui.gp(value); + int sw = fm.stringWidth(s); + int tx = (int) Math.round(Math.max(px + 1, Math.min(px + pw - sw - 1, x - sw / 2.0))); + int ty = top ? (int) y - 5 : (int) y + fm.getAscent() + 3; + g.setColor(Ui.alpha(Ui.TEXT, 190)); + g.drawString(s, tx, ty); + } + + private void paintLineHover(Graphics2D g, FontMetrics fm, int px, int py, int pw, int ph, DoubleUnaryOperator yOf) + { + if (hoverX < px || hoverX > px + pw) + { + return; + } + int idx = nearestIndex(tAt(hoverX, px, pw)); + if (idx < 0) + { + return; + } + Snapshot p = points.get(idx); + int x = (int) Math.round(xOf(p.t, px, pw)); + crosshair(g, x, py, ph); + + long total = p.total(); + double y = yOf.applyAsDouble(total); + g.setColor(Ui.TEXT); + g.fill(new Ellipse2D.Double(x - 3, y - 3, 6, 6)); + + Tip tip = new Tip(title(p)); + if (p.isAggregate()) + { + tip.row("Open", Ui.gp(p.open()), Ui.TEXT); + tip.row("High", Ui.gp(p.high()), Ui.UP); + tip.row("Low", Ui.gp(p.low()), Ui.DOWN); + tip.row("Close", Ui.gp(p.close()), Ui.TEXT); + tip.row("Average", Ui.gp(total), Ui.GOLD); + } + else + { + tip.row("Total", Ui.gp(total), Ui.GOLD); + } + long first = points.get(0).total(); + if (idx > 0) + { + long d = total - first; + tip.row("vs start", Ui.signedGp(d) + " " + Ui.pct(first != 0 ? (double) d / first : Double.NaN), Ui.deltaColor(d)); + long prev = points.get(idx - 1).total(); + if (prev != total) + { + tip.row("vs previous", Ui.signedGp(total - prev), Ui.deltaColor(total - prev)); + } + } + tip.paint(g, fm, x, px, pw, py); + } + + private static String title(Snapshot p) + { + java.time.ZonedDateTime z = Instant.ofEpochMilli(p.t).atZone(ZoneId.systemDefault()); + if (Snapshot.HOUR.equals(p.g)) + { + return z.format(TIP_HOUR) + " (hour)"; + } + if (p.isAggregate()) + { + return z.format(TIP_DAY) + " (day)"; + } + return z.format(TIP_TIME); + } + + // ------------------------------------------------------------------ candles & bars + + private void paintCandles(Graphics2D g, int px, int pw, DoubleUnaryOperator yOf) + { + for (Analysis.Candle c : candles) + { + double x0 = Math.max(px, xOf(c.t0, px, pw)); + double x1 = Math.min(px + pw, xOf(c.t1, px, pw)); + if (x1 <= x0) + { + continue; + } + double cw = Math.max(1, (x1 - x0) * 0.68); + double cx = (x0 + x1) / 2; + Color color = c.c > c.o ? Ui.UP : c.c < c.o ? Ui.DOWN : Ui.MUTED; + if (c.empty) + { + color = Ui.alpha(Ui.MUTED, 110); + } + g.setColor(color); + g.setStroke(THIN); + g.draw(new java.awt.geom.Line2D.Double(cx, yOf.applyAsDouble(c.h), cx, yOf.applyAsDouble(c.l))); + double yo = yOf.applyAsDouble(c.o); + double yc = yOf.applyAsDouble(c.c); + double top = Math.min(yo, yc); + double bh = Math.max(1.2, Math.abs(yo - yc)); + Rectangle2D body = new Rectangle2D.Double(cx - cw / 2, top, cw, bh); + g.setColor(c.c < c.o ? color : Ui.alpha(color, c.empty ? 110 : 200)); + g.fill(body); + } + } + + private void paintBars(Graphics2D g, int px, int pw, DoubleUnaryOperator yOf) + { + double zero = yOf.applyAsDouble(0); + for (Analysis.Candle c : candles) + { + long ch = c.change(); + if (ch == 0) + { + continue; + } + double x0 = Math.max(px, xOf(c.t0, px, pw)); + double x1 = Math.min(px + pw, xOf(c.t1, px, pw)); + if (x1 <= x0) + { + continue; + } + double bw = Math.max(1, (x1 - x0) * 0.72); + double cx = (x0 + x1) / 2; + double y = yOf.applyAsDouble(ch); + Color color = ch > 0 ? Ui.UP : Ui.DOWN; + g.setPaint(new GradientPaint(0, (float) y, Ui.alpha(color, 230), 0, (float) zero, Ui.alpha(color, 130))); + g.fill(new Rectangle2D.Double(cx - bw / 2, Math.min(y, zero), bw, Math.max(1, Math.abs(zero - y)))); + } + } + + private void paintCandleHover(Graphics2D g, FontMetrics fm, int px, int py, int pw, int ph) + { + if (hoverX < px || hoverX > px + pw || candles.isEmpty()) + { + return; + } + long t = tAt(hoverX, px, pw); + Analysis.Candle hit = null; + for (Analysis.Candle c : candles) + { + if (t >= c.t0 && t < c.t1) + { + hit = c; + break; + } + } + if (hit == null) + { + return; + } + double x0 = Math.max(px, xOf(hit.t0, px, pw)); + double x1 = Math.min(px + pw, xOf(hit.t1, px, pw)); + g.setColor(Ui.alpha(Color.WHITE, 16)); + g.fill(new Rectangle2D.Double(x0, py, Math.max(1, x1 - x0), ph)); + + java.time.ZonedDateTime z = Instant.ofEpochMilli(hit.t0).atZone(ZoneId.systemDefault()); + String title; + if (bucket >= 30 * Ui.DAY) + { + title = z.format(DateTimeFormatter.ofPattern("MMMM yyyy", Locale.ENGLISH)); + } + else if (bucket >= 7 * Ui.DAY) + { + title = "Week of " + z.format(DateTimeFormatter.ofPattern("d MMM yyyy", Locale.ENGLISH)); + } + else if (bucket >= Ui.DAY) + { + title = z.format(TIP_DAY); + } + else + { + DateTimeFormatter hm = DateTimeFormatter.ofPattern("HH:mm"); + title = z.format(TIP_TIME) + " - " + Instant.ofEpochMilli(hit.t1).atZone(ZoneId.systemDefault()).format(hm); + } + + Tip tip = new Tip(title); + long ch = hit.change(); + if (mode == Mode.CANDLES) + { + tip.row("Open", Ui.gp(hit.o), Ui.TEXT); + tip.row("High", Ui.gp(hit.h), Ui.UP); + tip.row("Low", Ui.gp(hit.l), Ui.DOWN); + tip.row("Close", Ui.gp(hit.c), Ui.GOLD); + } + tip.row("Change", Ui.signedGp(ch) + " " + Ui.pct(hit.o != 0 ? (double) ch / hit.o : Double.NaN), Ui.deltaColor(ch)); + if (mode == Mode.BARS) + { + tip.row("Close", Ui.gp(hit.c), Ui.GOLD); + } + if (hit.empty) + { + tip.row("", "no data", Ui.FAINT); + } + tip.paint(g, fm, (int) Math.round((x0 + x1) / 2), px, pw, py); + } + + // ------------------------------------------------------------------ stacked + + private void paintStacked(Graphics2D g, int px, int py, int pw, int ph, DoubleUnaryOperator yOf) + { + List pts = downsample(points, pw); + int n = pts.size(); + WealthCategory[] cats = WealthCategory.values(); + double[][] cum = new double[n][cats.length + 1]; + boolean[] used = new boolean[cats.length]; + for (int i = 0; i < n; i++) + { + for (int c = 0; c < cats.length; c++) + { + long v = pts.get(i).get(cats[c]); + cum[i][c + 1] = cum[i][c] + v; + used[c] |= v != 0; + } + } + + double[] xs = new double[n]; + for (int i = 0; i < n; i++) + { + xs[i] = xOf(pts.get(i).t, px, pw); + } + double endX = n == 1 ? px + pw : xs[n - 1]; + + for (int c = 0; c < cats.length; c++) + { + if (!used[c]) + { + continue; + } + List upper = stepEdge(xs, cum, c + 1, endX, yOf, pts); + List lower = stepEdge(xs, cum, c, endX, yOf, pts); + Path2D poly = new Path2D.Double(); + Path2D top = new Path2D.Double(); + for (int i = 0; i < upper.size(); i++) + { + double[] pt = upper.get(i); + if (i == 0) + { + poly.moveTo(pt[0], pt[1]); + top.moveTo(pt[0], pt[1]); + } + else + { + poly.lineTo(pt[0], pt[1]); + top.lineTo(pt[0], pt[1]); + } + } + for (int i = lower.size() - 1; i >= 0; i--) + { + poly.lineTo(lower.get(i)[0], lower.get(i)[1]); + } + poly.closePath(); + + Color color = cats[c].getColor(); + g.setPaint(new GradientPaint(0, py, Ui.alpha(color, 215), 0, py + ph, Ui.alpha(color, 150))); + g.fill(poly); + g.setStroke(THIN); + g.setColor(color.brighter()); + g.draw(top); + } + } + + private static List stepEdge(double[] xs, double[][] cum, int level, double endX, DoubleUnaryOperator yOf, List src) + { + List pts = new ArrayList<>(xs.length * 2); + for (int i = 0; i < xs.length; i++) + { + double y = yOf.applyAsDouble(cum[i][level]); + if (i > 0 && !src.get(i).isAggregate() && !src.get(i - 1).isAggregate()) + { + pts.add(new double[]{xs[i], yOf.applyAsDouble(cum[i - 1][level])}); + } + pts.add(new double[]{xs[i], y}); + } + if (xs.length == 1) + { + pts.add(new double[]{endX, yOf.applyAsDouble(cum[0][level])}); + } + return pts; + } + + private void paintStackedHover(Graphics2D g, FontMetrics fm, int px, int py, int pw, int ph) + { + if (hoverX < px || hoverX > px + pw) + { + return; + } + int idx = nearestIndex(tAt(hoverX, px, pw)); + if (idx < 0) + { + return; + } + Snapshot p = points.get(idx); + int x = (int) Math.round(xOf(p.t, px, pw)); + crosshair(g, x, py, ph); + + Tip tip = new Tip(title(p)); + tip.swatch = true; + WealthCategory[] cats = WealthCategory.values(); + long total = p.total(); + for (int c = cats.length - 1; c >= 0; c--) + { + long v = p.get(cats[c]); + if (v != 0) + { + String share = total > 0 ? String.format(Locale.US, " %.0f%%", v * 100.0 / total) : ""; + tip.row(cats[c].getLabel(), Ui.gp(v) + share, cats[c].getColor()); + } + } + tip.row("Total", Ui.gp(total), null); + tip.paint(g, fm, x, px, pw, py); + } + + // ------------------------------------------------------------------ shared + + private static void crosshair(Graphics2D g, int x, int py, int ph) + { + g.setStroke(DASHED); + g.setColor(Ui.alpha(Ui.TEXT, 110)); + g.drawLine(x, py, x, py + ph); + } + + /** Tooltip box painted inside the chart. */ + private final class Tip + { + final String title; + final List rows = new ArrayList<>(); + final List colors = new ArrayList<>(); + boolean swatch; + + Tip(String title) + { + this.title = title; + } + + void row(String key, String value, Color color) + { + rows.add(new String[]{key, value}); + colors.add(color); + } + + void paint(Graphics2D g, FontMetrics fm, int x, int px, int pw, int py) + { + int lh = fm.getHeight(); + int keyW = 0; + int valW = 0; + for (String[] r : rows) + { + keyW = Math.max(keyW, fm.stringWidth(r[0])); + valW = Math.max(valW, fm.stringWidth(r[1])); + } + int contentW = Math.max(fm.stringWidth(title), (swatch ? 10 : 0) + keyW + 10 + valW); + int bw = contentW + 12; + int bh = (rows.size() + 1) * lh + 8; + int bx = x + 10; + if (bx + bw > px + pw) + { + bx = x - 10 - bw; + } + bx = Math.max(2, Math.min(getWidth() - bw - 2, bx)); + int by = py; + + g.setColor(new Color(18, 18, 18, 238)); + g.fillRoundRect(bx, by, bw, bh, 8, 8); + g.setStroke(THIN); + g.setColor(new Color(255, 255, 255, 35)); + g.drawRoundRect(bx, by, bw, bh, 8, 8); + + int ty = by + 4 + fm.getAscent(); + g.setColor(Ui.MUTED); + g.drawString(title, bx + 6, ty); + for (int i = 0; i < rows.size(); i++) + { + ty += lh; + String[] r = rows.get(i); + int kx = bx + 6; + Color rc = colors.get(i); + if (swatch) + { + if (rc != null) + { + g.setColor(rc); + g.fillRoundRect(kx, ty - fm.getAscent() + 2, 7, 7, 2, 2); + } + kx += 10; + } + g.setColor(Ui.TEXT); + g.drawString(r[0], kx, ty); + g.setColor(swatch ? (rc == null ? Ui.GOLD : Ui.TEXT) : rc); + Ui.drawRight(g, r[1], bx + bw - 6, ty); + } + } + } + + private int nearestIndex(long t) + { + if (points.isEmpty()) + { + return -1; + } + int lo = 0; + int hi = points.size() - 1; + while (lo < hi) + { + int mid = (lo + hi) >>> 1; + if (points.get(mid).t < t) + { + lo = mid + 1; + } + else + { + hi = mid; + } + } + if (lo > 0 && Math.abs(points.get(lo - 1).t - t) <= Math.abs(points.get(lo).t - t)) + { + return lo - 1; + } + return lo; + } + + private void drawTimeAxis(Graphics2D g, FontMetrics fm, int px, int baseY, int pw) + { + long span = end - start; + DateTimeFormatter f; + if (span <= 2 * Ui.DAY) + { + f = DateTimeFormatter.ofPattern("HH:mm", Locale.ENGLISH); + } + else if (span <= 10 * Ui.DAY) + { + f = DateTimeFormatter.ofPattern("EEE d", Locale.ENGLISH); + } + else if (span <= 200 * Ui.DAY) + { + f = DateTimeFormatter.ofPattern("MMM d", Locale.ENGLISH); + } + else + { + f = DateTimeFormatter.ofPattern("MMM yy", Locale.ENGLISH); + } + + int count = Math.max(2, Math.min(8, pw / (fm.stringWidth("Mmm 00") + 18))); + g.setColor(Ui.FAINT); + int lastRight = Integer.MIN_VALUE; + int y = baseY + fm.getAscent() + 4; + for (int i = 0; i <= count; i++) + { + long t = start + span * i / count; + String s = Instant.ofEpochMilli(t).atZone(ZoneId.systemDefault()).format(f); + int sw = fm.stringWidth(s); + int x = px + pw * i / count - sw / 2; + x = Math.max(px - 4, Math.min(px + pw - sw, x)); + if (x < lastRight + 6) + { + continue; + } + g.drawString(s, x, y); + lastRight = x + sw; + } + } + + /** Keeps at most ~2 points per pixel column (min/max) so large ranges stay fast. */ + private List downsample(List src, int pixels) + { + if (src.size() <= pixels * 2) + { + return src; + } + List out = new ArrayList<>(pixels * 2 + 2); + int col = -1; + Snapshot min = null; + Snapshot max = null; + for (Snapshot s : src) + { + int c = (int) ((s.t - start) * pixels / Math.max(1, end - start)); + if (c != col) + { + addPair(out, min, max); + col = c; + min = s; + max = s; + } + else + { + if (s.total() < min.total()) + { + min = s; + } + if (s.total() > max.total()) + { + max = s; + } + } + } + addPair(out, min, max); + Snapshot last = src.get(src.size() - 1); + if (out.get(out.size() - 1) != last) + { + out.add(last); + } + return out; + } + + private static void addPair(List out, Snapshot a, Snapshot b) + { + if (a == null) + { + return; + } + if (a == b) + { + out.add(a); + } + else if (a.t <= b.t) + { + out.add(a); + out.add(b); + } + else + { + out.add(b); + out.add(a); + } + } + + private static double niceStep(double raw) + { + if (raw <= 0 || Double.isNaN(raw)) + { + return 1; + } + double exp = Math.pow(10, Math.floor(Math.log10(raw))); + double f = raw / exp; + double nice = f < 1.5 ? 1 : f < 3 ? 2 : f < 7 ? 5 : 10; + return nice * exp; + } +} diff --git a/src/main/java/com/example/essentialsplus/ui/WealthPanel.java b/src/main/java/com/example/essentialsplus/ui/WealthPanel.java new file mode 100644 index 0000000..7caa6b5 --- /dev/null +++ b/src/main/java/com/example/essentialsplus/ui/WealthPanel.java @@ -0,0 +1,1070 @@ +package com.example.essentialsplus.ui; + +import com.example.essentialsplus.WealthStore; +import com.example.essentialsplus.EssentialsPlusPlugin; +import com.example.essentialsplus.data.AccountData; +import com.example.essentialsplus.data.ItemLedger; +import com.example.essentialsplus.data.Snapshot; +import com.example.essentialsplus.data.SourceValue; +import com.example.essentialsplus.data.TopItem; +import java.awt.BorderLayout; +import java.awt.Cursor; +import java.awt.GridLayout; +import java.awt.event.MouseAdapter; +import java.awt.event.MouseEvent; +import java.io.File; +import java.time.Instant; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.HashMap; +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.atomic.AtomicBoolean; +import javax.swing.BorderFactory; +import javax.swing.JButton; +import javax.swing.JLabel; +import javax.swing.JMenuItem; +import javax.swing.JOptionPane; +import javax.swing.JPanel; +import javax.swing.JPopupMenu; +import javax.swing.SwingUtilities; +import javax.swing.Timer; +import net.runelite.client.ui.DynamicGridLayout; +import net.runelite.client.ui.FontManager; +import net.runelite.client.ui.PluginPanel; +import net.runelite.client.util.LinkBrowser; + +public class WealthPanel extends PluginPanel +{ + static final String ALL = "__all_characters__"; + private static final DateTimeFormatter SHORT_DATE = DateTimeFormatter.ofPattern("d MMM", Locale.ENGLISH); + + private final EssentialsPlusPlugin plugin; + private final WealthStore store; + private final ItemIcons icons; + private final AtomicBoolean refreshQueued = new AtomicBoolean(); + private final Set namesRequested = new HashSet<>(); + private final Timer ticker; + + private final AccountCard summaryCard; + private final JPanel accountList = new JPanel(new DynamicGridLayout(0, 1, 0, 4)); + private final Map cards = new LinkedHashMap<>(); + private final TimeFilter timeFilter; + private final Runnable rangeListener = this::onRangeChanged; + private final DetailView detail; + private java.util.function.Consumer onSummaryUpdated; + /** total for the overview card, limited to {@link #overviewKeys} */ + private final AccountCard overviewSummary = new AccountCard(true, () -> { }, null); + /** characters counted on the overview card; empty = all */ + private volatile Set overviewKeys = new HashSet<>(); + + private String selected = ALL; + private WealthChart.Mode mainMode = WealthChart.Mode.LINE; + private WealthPopout popout; + private boolean sessionView; + private final JLabel sessionLabel = new JLabel(); + private final Map itemWindows = new HashMap<>(); + private final Map geWindows = new HashMap<>(); + + private long globalResetHourlyNowAt; + private long globalResetHourlySessionAt; + + public void resetHourly(boolean isSession, long now) + { + if (isSession) + { + globalResetHourlySessionAt = now; + } + else + { + globalResetHourlyNowAt = now; + } + } + + public WealthPanel(EssentialsPlusPlugin plugin) + { + this(plugin, null); + } + + public WealthPanel(EssentialsPlusPlugin plugin, Runnable onBack) + { + super(true); + this.plugin = plugin; + this.store = plugin.getStore(); + this.icons = new ItemIcons(plugin.getItemManager()); + this.timeFilter = plugin.getTimeFilter(); + + setBackground(Ui.BG); + setLayout(new DynamicGridLayout(0, 1, 0, 6)); + + if (onBack != null) + { + JPanel backRow = new JPanel(new BorderLayout(6, 0)); + backRow.setOpaque(false); + backRow.setBorder(BorderFactory.createEmptyBorder(0, 0, 4, 0)); + + JButton backBtn = button("← Overview", onBack); + backBtn.setForeground(Ui.ACCENT); + backRow.add(backBtn, BorderLayout.WEST); + + JLabel titleLabel = new JLabel("Wealth Tracker"); + titleLabel.setFont(FontManager.getRunescapeBoldFont()); + titleLabel.setForeground(Ui.ACCENT); + titleLabel.setHorizontalAlignment(JLabel.RIGHT); + backRow.add(titleLabel, BorderLayout.CENTER); + + add(backRow); + } + + summaryCard = new AccountCard(true, () -> select(ALL), null, () -> { + plugin.resetWealthHourly(ALL); + refreshLater(); + }); + add(summaryCard); + + accountList.setOpaque(false); + add(accountList); + + JPanel sessionRow = new JPanel(new GridLayout(1, 2, 4, 0)); + sessionRow.setOpaque(false); + sessionRow.add(button("Session", this::showSession)); + sessionRow.add(button("New session", this::newSession)); + add(sessionRow); + sessionLabel.setFont(FontManager.getRunescapeSmallFont()); + sessionLabel.setForeground(Ui.MUTED); + sessionLabel.setHorizontalAlignment(JLabel.CENTER); + add(sessionLabel); + + detail = new DetailView(icons, false); + MouseAdapter chartRightClick = new MouseAdapter() + { + @Override + public void mousePressed(MouseEvent e) + { + if (e.isPopupTrigger() || SwingUtilities.isRightMouseButton(e)) + { + showPopup(e); + } + } + + @Override + public void mouseReleased(MouseEvent e) + { + if (e.isPopupTrigger()) + { + showPopup(e); + } + } + + private void showPopup(MouseEvent e) + { + if (!isHourly()) + { + return; + } + JPopupMenu menu = new JPopupMenu(); + JMenuItem reset = new JMenuItem("Reset Hourly"); + reset.addActionListener(a -> + { + plugin.resetWealthHourly(selected); + refreshLater(); + }); + menu.add(reset); + menu.show((java.awt.Component) e.getSource(), e.getX(), e.getY()); + } + }; + detail.main.addMouseListener(chartRightClick); + detail.stats.addMouseListener(chartRightClick); + + add(sectionHeader("NET WORTH", detail.title, "Expand", this::openPopout)); + add(detail.main); + add(detail.stats); + add(sectionHeader("CHANGE PER PERIOD", null, null, null)); + add(detail.bars); + add(sectionHeader("COMPOSITION", null, null, null)); + add(detail.stacked); + JPanel viewButtons = new JPanel(new GridLayout(2, 2, 4, 4)); + viewButtons.setOpaque(false); + viewButtons.add(button("Bank", () -> openItems("bank", "Bank"))); + viewButtons.add(button("Inventory", () -> openItems("inventory", "Inventory"))); + viewButtons.add(button("Equipment", () -> openItems("equipment", "Equipment"))); + viewButtons.add(button("Grand Exchange", () -> openItems("ge", "Grand Exchange"))); + add(viewButtons); + add(detail.breakdown); + detail.breakdown.setOnOpen(this::openItems); + add(sectionHeader("WHAT CHANGED", null, null, null)); + add(detail.movers); + add(sectionHeader("MOST VALUABLE", null, null, null)); + add(detail.topItems); + + add(button("Export CSV", this::export)); + + JLabel hint = new JLabel("
Right-click a character to delete its data
"); + hint.setFont(FontManager.getRunescapeSmallFont()); + hint.setForeground(Ui.FAINT); + hint.setHorizontalAlignment(JLabel.CENTER); + add(hint); + + ticker = new Timer(30_000, e -> refresh()); + ticker.start(); + timeFilter.addListener(rangeListener); + } + + private void onRangeChanged() + { + sessionView = false; + if (popout != null) + { + popout.setRange(timeFilter.getRange().ordinal()); + } + refreshLater(); + } + + public void dispose() + { + ticker.stop(); + timeFilter.removeListener(rangeListener); + new ArrayList<>(itemWindows.values()).forEach(ItemsWindow::dispose); + new ArrayList<>(geWindows.values()).forEach(GeWindow::dispose); + if (popout != null) + { + popout.dispose(); + popout = null; + } + } + + public void setOnSummaryUpdated(java.util.function.Consumer onSummaryUpdated) + { + this.onSummaryUpdated = onSummaryUpdated; + if (onSummaryUpdated != null) + { + onSummaryUpdated.accept(overviewSummary); + } + } + + /** Characters summed on the overview card (keys); empty = all characters. */ + public void setOverviewKeys(Set keys) + { + overviewKeys = new HashSet<>(keys); + refreshLater(); + } + + public AccountCard getSummaryCard() + { + return summaryCard; + } + + public void refreshLater() + { + if (refreshQueued.compareAndSet(false, true)) + { + SwingUtilities.invokeLater(() -> + { + refreshQueued.set(false); + refresh(); + }); + } + } + + void setRange(TimeRange r) + { + timeFilter.set(r); + } + + /** Shows everything since the session start (starts a session if there is none). */ + void showSession() + { + if (plugin.getSessionStart() <= 0) + { + plugin.startSession(); + } + sessionView = true; + if (popout != null) + { + popout.setRange(-1); + } + refresh(); + } + + /** Resets the session to now, e.g. when starting a new activity. */ + void newSession() + { + plugin.startSession(); + long now = System.currentTimeMillis(); + resetHourly(true, now); + for (com.example.essentialsplus.data.AccountData a : store.all()) + { + a.resetHourlySessionAt = now; + } + showSession(); + } + + long sessionStart() + { + return plugin.getSessionStart(); + } + + public boolean isCustomSessionView() + { + return sessionView; + } + + public boolean isSessionView() + { + return sessionView || (timeFilter != null && timeFilter.getRange() == TimeRange.SESSION); + } + + public boolean isHourly() + { + return isSessionView() || (timeFilter != null && timeFilter.isCurrent()); + } + + public void resetHourly(String key) + { + plugin.resetWealthHourly(key); + } + + TimeRange getRange() + { + return timeFilter.getRange(); + } + + void setMainMode(WealthChart.Mode mode) + { + mainMode = mode; + detail.main.setMode(mode); + if (popout != null) + { + popout.setMainMode(mode); + } + } + + WealthChart.Mode getMainMode() + { + return mainMode; + } + + ItemIcons getIcons() + { + return icons; + } + + /** Opens (or focuses) the item window for a view of the side panel's selection. */ + void openItems(String view, String label) + { + openItems(view, label, selected); + } + + /** Opens (or focuses) the item window for a view of a character (or ALL). */ + void openItems(String view, String label, String selection) + { + if ("ge".equals(view)) + { + GeWindow gw = geWindows.get(selection); + if (gw == null) + { + gw = new GeWindow(icons, () -> geWindows.remove(selection)); + gw.selection = selection; + gw.setLocationRelativeTo(this); + geWindows.put(selection, gw); + } + gw.setVisible(true); + gw.toFront(); + loadGe(gw); + return; + } + String id = view + "|" + selection; + ItemsWindow w = itemWindows.get(id); + if (w == null) + { + w = new ItemsWindow(icons, () -> itemWindows.remove(id)); + w.setLocationRelativeTo(this); + w.view = view; + w.label = label; + w.selection = selection; + itemWindows.put(id, w); + } + w.setVisible(true); + w.toFront(); + loadItems(w); + } + + private void loadItems(ItemsWindow w) + { + List accounts = selectedAccounts(w.selection); + String who = ALL.equals(w.selection) ? "All characters" : (accounts.isEmpty() ? "" : accounts.get(0).name); + w.setTitleText(w.label + " - " + who); + w.lastLoad = System.currentTimeMillis(); + plugin.loadItems(accounts, w.view, w::setRows); + } + + private void loadGe(GeWindow w) + { + List accounts = selectedAccounts(w.selection); + w.setTitleText(ALL.equals(w.selection) ? "All characters" : (accounts.isEmpty() ? "" : accounts.get(0).name)); + w.lastLoad = System.currentTimeMillis(); + plugin.loadGe(accounts, w::setRows); + } + + private List selectedAccounts(String selection) + { + List accounts = store.all(); + if (ALL.equals(selection)) + { + accounts.removeIf(a -> !a.bankKnown && a.containers.isEmpty()); + } + else + { + accounts.removeIf(a -> !a.key.equals(selection)); + } + return accounts; + } + + private void select(String key) + { + selected = key; + refresh(); + } + + private void openPopout() + { + if (popout == null) + { + popout = new WealthPopout(this, timeFilter.getRange(), () -> popout = null); + } + popout.setVisible(true); + popout.toFront(); + refresh(); + } + + private void export() + { + List accounts = store.all(); + if (!ALL.equals(selected)) + { + accounts.removeIf(a -> !a.key.equals(selected)); + } + plugin.exportCsv(accounts, () -> + { + File dir = new File(WealthStore.DIR, "export"); + LinkBrowser.open(dir.getAbsolutePath()); + }); + } + + private void refresh() + { + long now = System.currentTimeMillis(); + List accounts = store.all(); + String active = plugin.getActiveKey(); + TimeRange range = timeFilter.getRange(); + + if (!ALL.equals(selected) && accounts.stream().noneMatch(a -> a.key.equals(selected))) + { + selected = ALL; + } + + boolean isSession = isSessionView(); + long pluginSessionStart = plugin.getSessionStart(); + long sessionStart = sessionView + ? (pluginSessionStart > 0 ? pluginSessionStart : timeFilter.getSessionStart()) + : (timeFilter.getSessionStart() > 0 ? timeFilter.getSessionStart() : pluginSessionStart); + long start; + String rangeLabel; + if (sessionView && sessionStart > 0) + { + start = sessionStart; + rangeLabel = "session"; + } + else + { + start = range == TimeRange.ALL ? Series.earliest(accounts) : timeFilter.start(); + rangeLabel = range.getLabel(); + } + sessionLabel.setText(sessionStart <= 0 ? "No session started" + : "Session since " + Instant.ofEpochMilli(sessionStart).atZone(ZoneId.systemDefault()).format(DateTimeFormatter.ofPattern("d MMM HH:mm", Locale.ENGLISH)) + + " (" + Ui.ago(sessionStart).replace(" ago", "") + ")"); + sessionLabel.setForeground(isSession ? Ui.ACCENT : Ui.MUTED); + + // ---- character cards + List keys = new ArrayList<>(); + accounts.forEach(a -> keys.add(a.key)); + if (!keys.equals(new ArrayList<>(cards.keySet()))) + { + accountList.removeAll(); + cards.clear(); + for (AccountData a : accounts) + { + String key = a.key; + AccountCard card = new AccountCard(false, () -> select(key), () -> confirmDelete(key, a.name), () -> { + plugin.resetWealthHourly(key); + refreshLater(); + }); + cards.put(key, card); + accountList.add(card); + } + accountList.revalidate(); + } + + List> allSeries = new ArrayList<>(); + long currentStart = now; + // price movements of held items only count as profit/loss over all time + boolean ignorePrices = isSession || range != TimeRange.ALL; + boolean isHourly = isHourly(); + int needBank = 0; + long totalNow = 0; + Map charGphMap = new HashMap<>(); + for (AccountData a : accounts) + { + for (TopItem t : a.topItems) + { + icons.putName(t.id, t.name); + } + long accountStart = start; + if (timeFilter.isCurrent() && !isSession) + { + List h = a.history; + accountStart = computeActiveStreakStart(h, a.lastUpdated, now); + } + currentStart = Math.min(currentStart, accountStart); + List s = Series.forAccount(a, accountStart, now, ignorePrices); + allSeries.add(s); + long delta = s.size() > 1 ? s.get(s.size() - 1).total() - s.get(0).total() : 0; + boolean live = a.key.equals(active); + + String status; + java.awt.Color statusColor; + if (!a.bankKnown) + { + status = "Open your bank in-game"; + statusColor = Ui.WARN; + needBank++; + } + else if (live) + { + status = "Live"; + statusColor = Ui.UP; + } + else + { + status = Ui.ago(a.lastUpdated); + statusColor = Ui.MUTED; + } + if (a.bankKnown) + { + totalNow += a.currentTotal(); + } + + String charGph = ""; + if (isHourly) + { + long accountRateStart = isSession + ? Math.max(start, Math.max(a.resetHourlySessionAt, globalResetHourlySessionAt)) + : Math.max(accountStart, Math.max(a.resetHourlyNowAt, globalResetHourlyNowAt)); + List rateSeries = accountRateStart > accountStart + ? Series.forAccount(a, accountRateStart, now, ignorePrices) + : s; + Long rate = calculateHourlyRate(rateSeries, accountRateStart, now); + charGph = formatHourlyRate(rate); + } + charGphMap.put(a.key, charGph); + + cards.get(a.key).update(a.name, "", a.bankKnown ? a.currentTotal() : null, delta, Double.NaN, rangeLabel, + status, statusColor, live, s, a.key.equals(selected), charGph); + } + + List combined = allSeries.isEmpty() ? new ArrayList<>() : Series.combine(allSeries); + long allDelta = combined.size() > 1 ? combined.get(combined.size() - 1).total() - combined.get(0).total() : 0; + double allRatio = combined.size() > 1 && combined.get(0).total() != 0 ? (double) allDelta / combined.get(0).total() : Double.NaN; + String sub = accounts.size() == 1 ? "1 character" : accounts.size() + " characters"; + String summaryStatus = accounts.isEmpty() ? "Log in to start" : needBank > 0 ? needBank + " need bank" : ""; + + String totalGph = ""; + if (isHourly) + { + long totRateStart = isSession + ? Math.max(start, globalResetHourlySessionAt) + : Math.max(currentStart, globalResetHourlyNowAt); + List> rateSeriesList = new ArrayList<>(); + for (int i = 0; i < accounts.size(); i++) + { + AccountData a = accounts.get(i); + long aRateStart = isSession + ? Math.max(start, Math.max(a.resetHourlySessionAt, globalResetHourlySessionAt)) + : Math.max(totRateStart, Math.max(a.resetHourlyNowAt, globalResetHourlyNowAt)); + rateSeriesList.add(Series.forAccount(a, aRateStart, now, ignorePrices)); + } + List combRateSeries = rateSeriesList.isEmpty() ? new ArrayList<>() : Series.combine(rateSeriesList); + Long r = calculateHourlyRate(combRateSeries, totRateStart, now); + totalGph = formatHourlyRate(r); + } + + summaryCard.update("Total wealth", sub, accounts.isEmpty() ? null : totalNow, allDelta, allRatio, rangeLabel, + summaryStatus, Ui.WARN, false, combined, ALL.equals(selected), totalGph); + if (onSummaryUpdated != null) + { + Set only = overviewKeys; + List> subSeries = new ArrayList<>(); + long subNow = 0; + int subCount = 0; + int subNeedBank = 0; + for (int i = 0; i < accounts.size(); i++) + { + AccountData a = accounts.get(i); + if (!only.isEmpty() && !only.contains(a.key)) + { + continue; + } + subCount++; + subSeries.add(allSeries.get(i)); + if (a.bankKnown) + { + subNow += a.currentTotal(); + } + else + { + subNeedBank++; + } + } + if (only.isEmpty() || subCount == 0) + { + overviewSummary.copyFrom(summaryCard); + } + else + { + List subCombined = Series.combine(subSeries); + long subDelta = subCombined.size() > 1 ? subCombined.get(subCombined.size() - 1).total() - subCombined.get(0).total() : 0; + double subRatio = subCombined.size() > 1 && subCombined.get(0).total() != 0 ? (double) subDelta / subCombined.get(0).total() : Double.NaN; + String title = subCount == 1 ? "Wealth" : "Total wealth"; + String subtitle = subCount == 1 + ? accounts.stream().filter(a -> only.contains(a.key)).map(a -> a.name).findFirst().orElse("") + : subCount + " of " + accounts.size() + " characters"; + overviewSummary.update(title, subtitle, subNow, subDelta, subRatio, rangeLabel, + subNeedBank > 0 ? subNeedBank + " need bank" : "", Ui.WARN, false, subCombined, false, totalGph); + } + onSummaryUpdated.accept(overviewSummary); + } + + if (timeFilter.isCurrent() && !isSession) + { + start = currentStart; + } + + // ---- details for the selection + String selectedGph = isHourly ? (ALL.equals(selected) ? totalGph : charGphMap.getOrDefault(selected, "")) : null; + Model m = buildModel(selected, accounts, allSeries, combined, start, now, active, selectedGph); + detail.update(m, 5); + if (popout != null) + { + String key = popout.resolveSelection(accounts, active); + String popoutGph = isHourly ? (ALL.equals(key) ? totalGph : charGphMap.getOrDefault(key, "")) : null; + popout.update(buildModel(key, accounts, allSeries, combined, start, now, active, popoutGph), accounts, key); + } + resolveNames(detail.lastMovers); + long nowMs = System.currentTimeMillis(); + for (ItemsWindow w : new ArrayList<>(itemWindows.values())) + { + if (nowMs - w.lastLoad > 3000) + { + loadItems(w); + } + } + for (GeWindow w : new ArrayList<>(geWindows.values())) + { + if (nowMs - w.lastLoad > 3000) + { + loadGe(w); + } + } + revalidate(); + repaint(); + } + + private Model buildModel(String selection, List accounts, List> allSeries, + List combined, long start, long now, String active, String hourlyRate) + { + Model m = new Model(); + m.hourlyRate = hourlyRate; + m.start = start; + m.ignorePrices = isSessionView() || timeFilter.getRange() != TimeRange.ALL; + m.end = now; + m.ledgers = new ArrayList<>(); + m.selection = selection; + if (ALL.equals(selection) || accounts.isEmpty()) + { + m.title = "All characters"; + m.points = combined; + m.current = Series.sumCurrent(accounts); + m.sources = new ArrayList<>(); + Map items = new HashMap<>(); + for (AccountData a : accounts) + { + if (!a.bankKnown) + { + continue; + } + m.sources.addAll(a.sources); + m.ledgers.add(store.ledger(a.key, a.key.equals(active))); + for (TopItem t : a.topItems) + { + items.merge(t.id, new TopItem(t.id, t.name, t.qty, t.value), + (x, y) -> new TopItem(x.id, x.name, x.qty + y.qty, x.value + y.value)); + } + } + m.topItems = new ArrayList<>(items.values()); + m.topItems.sort((x, y) -> Long.compare(y.value, x.value)); + m.emptyText = accounts.isEmpty() ? "Log in and open your bank\nto start tracking" : "No history in this range"; + } + else + { + int idx = 0; + while (idx < accounts.size() - 1 && !accounts.get(idx).key.equals(selection)) + { + idx++; + } + AccountData a = accounts.get(idx); + m.title = a.name; + m.points = allSeries.get(idx); + m.current = a.current; + m.sources = a.sources; + m.topItems = a.topItems; + m.ledgers.add(store.ledger(a.key, a.key.equals(active))); + m.emptyText = a.bankKnown ? "No history in this range" : "Open your bank in-game\nto start tracking"; + } + m.stats = Analysis.stats(m.points); + return m; + } + + /** Fetches names of items that only appear in the item history. */ + void resolveNames(Analysis.Movers movers) + { + if (movers == null) + { + return; + } + Set missing = new HashSet<>(); + for (List list : List.of(movers.gainers, movers.losers)) + { + for (Analysis.Mover mv : list) + { + if (!icons.hasName(mv.id) && namesRequested.add(mv.id)) + { + missing.add(mv.id); + } + } + } + if (!missing.isEmpty()) + { + plugin.resolveItemNames(missing, names -> + { + names.forEach(icons::putName); + detail.movers.repaint(); + if (popout != null) + { + popout.repaint(); + } + }); + } + } + + private void confirmDelete(String key, String name) + { + int r = JOptionPane.showConfirmDialog(this, + "Delete all wealth history of " + name + "?\nThis cannot be undone.", + "Wealth Tracker", JOptionPane.OK_CANCEL_OPTION, JOptionPane.WARNING_MESSAGE); + if (r == JOptionPane.OK_OPTION) + { + if (key.equals(selected)) + { + selected = ALL; + } + plugin.deleteAccount(key); + } + } + + static JPanel sectionHeader(String text, JLabel extra, String action, Runnable onAction) + { + 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.setForeground(Ui.TEXT); + extra.setHorizontalAlignment(JLabel.RIGHT); + p.add(extra, BorderLayout.CENTER); + } + if (action != null) + { + JLabel a = new JLabel(action); + a.setFont(FontManager.getRunescapeSmallFont()); + a.setForeground(Ui.ACCENT); + a.setCursor(Cursor.getPredefinedCursor(Cursor.HAND_CURSOR)); + a.addMouseListener(new MouseAdapter() + { + @Override + public void mousePressed(MouseEvent e) + { + onAction.run(); + } + + @Override + public void mouseEntered(MouseEvent e) + { + a.setForeground(Ui.ACCENT.brighter()); + } + + @Override + public void mouseExited(MouseEvent e) + { + a.setForeground(Ui.ACCENT); + } + }); + p.add(a, BorderLayout.EAST); + } + return p; + } + + 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; + } + + /** Everything the detail views need for the current selection. */ + static final class Model + { + String selection; + String title; + List points; + long start; + long end; + long[] current; + List sources; + List topItems; + List ledgers; + boolean ignorePrices; + Analysis.Stats stats; + String emptyText; + String hourlyRate; + } + + public static Long calculateHourlyRate(List series, long rateStart, long now) + { + if (series == null || series.isEmpty() || now <= rateStart) + { + return null; + } + long startVal = series.get(0).total(); + long endVal = series.get(series.size() - 1).total(); + long delta = endVal - startVal; + + long activeMs = calculateActiveMillis(series, rateStart, now); + if (activeMs < Ui.MINUTE) + { + return null; + } + + return Math.round((double) delta * (double) Ui.HOUR / (double) activeMs); + } + + public static long calculateActiveMillis(List series, long rateStart, long now) + { + if (series == null || series.isEmpty()) + { + return 0; + } + if (series.size() == 1) + { + long span = now - rateStart; + return (span > 0 && span <= 10 * Ui.MINUTE) ? span : 0; + } + long active = 0; + for (int i = 1; i < series.size(); i++) + { + Snapshot a = series.get(i - 1); + Snapshot b = series.get(i); + long tA = Math.max(a.t, rateStart); + long tB = Math.max(b.t, rateStart); + long gap = tB - tA; + if (gap > 0 && gap <= 10 * Ui.MINUTE) + { + active += gap; + } + } + long lastT = Math.max(series.get(series.size() - 1).t, rateStart); + long tail = now - lastT; + if (tail > 0 && tail <= 10 * Ui.MINUTE) + { + active += tail; + } + return active; + } + + public static String formatHourlyRate(Long rate) + { + if (rate == null) + { + return ""; + } + return Ui.signedGp(rate) + " gp/h"; + } + + /** Charts + analysis; used in the side panel and in the pop-out window. */ + public static final class DetailView + { + final JLabel title = new JLabel(); + final WealthChart main; + final WealthChart bars; + final WealthChart stacked; + final StatsView stats; + final BreakdownView breakdown = new BreakdownView(); + final MoversView movers; + final TopItemsView topItems; + final boolean large; + Analysis.Movers lastMovers; + + DetailView(ItemIcons icons, boolean large) + { + this.large = large; + main = new WealthChart(WealthChart.Mode.LINE, 170); + bars = new WealthChart(WealthChart.Mode.BARS, 100); + stacked = new WealthChart(WealthChart.Mode.STACKED, 120); + main.setLarge(large); + bars.setLarge(large); + stacked.setLarge(large); + stats = new StatsView(2); + movers = new MoversView(icons); + topItems = new TopItemsView(icons, large ? 25 : 10); + } + + void update(Model m, int moverLimit) + { + title.setText(m.title); + main.setData(m.points, m.start, m.end, m.emptyText); + bars.setData(m.points, m.start, m.end, m.emptyText); + stacked.setData(m.points, m.start, m.end, m.emptyText); + stats.update(cells(m.stats, large, m.hourlyRate)); + + long[] startValues = m.points.isEmpty() ? null : m.points.get(0).v; + breakdown.update(m.current, m.sources == null ? new ArrayList<>() : m.sources, startValues); + + lastMovers = Analysis.movers(m.ledgers, m.start, moverLimit, m.ignorePrices); + movers.update(lastMovers); + + long total = m.current == null ? 0 : Series.sum(m.current); + topItems.update(m.topItems == null ? new ArrayList<>() : m.topItems, total); + } + + private static String date(long t) + { + return Instant.ofEpochMilli(t).atZone(ZoneId.systemDefault()).format(SHORT_DATE); + } + + static List cells(Analysis.Stats s, boolean full) + { + return cells(s, full, null); + } + + public static List cells(Analysis.Stats s, boolean full, String hourlyRate) + { + List cells = new ArrayList<>(); + if (!s.has) + { + for (String l : new String[]{"Change", hourlyRate != null ? "Per hour" : "Per day", "Peak", "Low"}) + { + cells.add(new StatsView.Cell(l, "-", null, Ui.FAINT, null)); + } + return cells; + } + String period = s.bucket >= 7 * Ui.DAY ? "week" : s.bucket >= Ui.DAY ? "day" : s.bucket >= Ui.HOUR ? "hour" : (s.bucket / Ui.MINUTE) + "min"; + + cells.add(new StatsView.Cell("Change", Ui.signedGp(s.change), Ui.pct(s.ratio), Ui.deltaColor(s.change), + "Change over the range: " + Ui.exact(s.change))); + + if (hourlyRate != null) + { + cells.add(new StatsView.Cell("Per hour", hourlyRate.isEmpty() ? "-" : hourlyRate, null, Ui.deltaColor(s.change), + "Rate of change per hour")); + } + else + { + cells.add(new StatsView.Cell("Per day", Ui.signedGp(s.perDay), null, Ui.deltaColor(s.perDay), + "Average change per day: " + Ui.exact(s.perDay))); + } + cells.add(new StatsView.Cell("Peak", Ui.gp(s.peak), date(s.peakT), Ui.TEXT, "Highest value: " + Ui.exact(s.peak))); + cells.add(new StatsView.Cell("Low", Ui.gp(s.low), date(s.lowT), Ui.TEXT, "Lowest value: " + Ui.exact(s.low))); + cells.add(new StatsView.Cell("Best " + period, s.best == null ? "-" : Ui.signedGp(s.best.change()), + s.best == null ? null : date(s.best.t0), s.best == null ? Ui.FAINT : Ui.UP, + s.best == null ? null : "Biggest gain in one " + period + ": " + Ui.exact(s.best.change()))); + cells.add(new StatsView.Cell("Worst " + period, s.worst == null ? "-" : Ui.signedGp(s.worst.change()), + s.worst == null ? null : date(s.worst.t0), s.worst == null ? Ui.FAINT : Ui.DOWN, + s.worst == null ? null : "Biggest loss in one " + period + ": " + Ui.exact(s.worst.change()))); + cells.add(new StatsView.Cell("Max drawdown", s.maxDrawdown <= 0 ? "0%" : String.format(Locale.US, "-%.1f%%", s.maxDrawdown * 100), + null, s.maxDrawdown > 0 ? Ui.DOWN : Ui.TEXT, "Largest drop from a previous peak")); + cells.add(new StatsView.Cell("Up/down", s.up + " / " + s.down, s.periods + (s.bucket < Ui.HOUR ? "x" : period.substring(0, 1)), + s.up >= s.down ? Ui.UP : Ui.DOWN, "Periods with gains / losses")); + if (full) + { + cells.add(new StatsView.Cell("Volatility", Double.isNaN(s.volatility) ? "-" : String.format(Locale.US, "±%.2f%%", s.volatility * 100), + "per " + period, Ui.TEXT, "Standard deviation of the change per " + period)); + double hitRate = s.up + s.down > 0 ? (double) s.up / (s.up + s.down) : Double.NaN; + cells.add(new StatsView.Cell("Win rate", Double.isNaN(hitRate) ? "-" : String.format(Locale.US, "%.0f%%", hitRate * 100), + null, Ui.TEXT, "Share of " + period + "s that ended with a gain")); + } + return cells; + } + } + + private static long computeActiveStreakStart(List h, long lastUpdated, long now) + { + if (h == null || h.isEmpty()) + { + return now - TimeFilter.ACTIVE_GAP; + } + + long latest = Math.max(lastUpdated, h.get(h.size() - 1).t); + if (now - latest > TimeFilter.ACTIVE_GAP) + { + // Character has been idle / logged out for more than 5 minutes + return now - TimeFilter.ACTIVE_GAP; + } + + // Trace continuous active session backwards: consecutive snapshots within 10 minutes of each other + long streakStart = h.get(h.size() - 1).t; + int firstIdx = h.size() - 1; + for (int i = h.size() - 1; i > 0; i--) + { + long gap = h.get(i).t - h.get(i - 1).t; + if (gap <= 10 * Ui.MINUTE) + { + firstIdx = i - 1; + streakStart = h.get(i - 1).t; + } + else + { + break; + } + } + + // Baseline is right before the first active point of this session + if (firstIdx > 0) + { + return Math.max(h.get(firstIdx - 1).t, streakStart - Ui.MINUTE); + } + return streakStart - Ui.MINUTE; + } +} diff --git a/src/main/java/com/example/essentialsplus/ui/WealthPopout.java b/src/main/java/com/example/essentialsplus/ui/WealthPopout.java new file mode 100644 index 0000000..8e234ca --- /dev/null +++ b/src/main/java/com/example/essentialsplus/ui/WealthPopout.java @@ -0,0 +1,339 @@ +package com.example.essentialsplus.ui; + +import com.example.essentialsplus.data.AccountData; +import java.awt.BorderLayout; +import java.awt.Component; +import java.awt.Cursor; +import java.awt.Dimension; +import java.awt.FlowLayout; +import java.awt.GridBagConstraints; +import java.awt.GridBagLayout; +import java.awt.Insets; +import java.awt.event.WindowAdapter; +import java.awt.event.WindowEvent; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import javax.swing.BorderFactory; +import javax.swing.DefaultListCellRenderer; +import javax.swing.JButton; +import javax.swing.JComboBox; +import javax.swing.JComponent; +import javax.swing.JFrame; +import javax.swing.JLabel; +import javax.swing.JList; +import javax.swing.JPanel; +import javax.swing.JScrollPane; +import javax.swing.ScrollPaneConstants; +import javax.swing.WindowConstants; +import net.runelite.client.ui.DynamicGridLayout; +import net.runelite.client.ui.FontManager; + +/** Large resizable analysis window with its own character selection. */ +class WealthPopout extends JFrame +{ + static final String AUTO = "__logged_in__"; + + private final WealthPanel panel; + private final WealthPanel.DetailView view; + private final Segmented rangeBar; + private final Segmented styleBar; + private final JComboBox characterBox = new JComboBox<>(); + private final JLabel sessionLabel = new JLabel(); + private String choice = AUTO; + private String resolved = WealthPanel.ALL; + private List comboKeys = new ArrayList<>(); + private boolean updatingCombo; + + private static final class Choice + { + final String key; + final String label; + + Choice(String key, String label) + { + this.key = key; + this.label = label; + } + + @Override + public String toString() + { + return label; + } + } + + WealthPopout(WealthPanel panel, TimeRange range, Runnable onClose) + { + super("Wealth Tracker"); + this.panel = panel; + setDefaultCloseOperation(WindowConstants.DISPOSE_ON_CLOSE); + addWindowListener(new WindowAdapter() + { + @Override + public void windowClosed(WindowEvent e) + { + onClose.run(); + } + }); + + view = new WealthPanel.DetailView(panel.getIcons(), true); + view.main.setMode(panel.getMainMode()); + view.breakdown.setOnOpen((v, label) -> panel.openItems(v, label, resolved)); + rangeBar = Segmented.ranges(range, panel::setRange); + rangeBar.setSelected(panel.isCustomSessionView() ? -1 : range.ordinal()); + styleBar = new Segmented(new String[]{"Line", "Candles"}, panel.getMainMode() == WealthChart.Mode.CANDLES ? 1 : 0, + i -> panel.setMainMode(i == 1 ? WealthChart.Mode.CANDLES : WealthChart.Mode.LINE)); + + java.awt.event.MouseAdapter popoutRightClick = new java.awt.event.MouseAdapter() + { + @Override + public void mousePressed(java.awt.event.MouseEvent e) + { + if (e.isPopupTrigger()) + { + showPopup(e); + } + } + + @Override + public void mouseReleased(java.awt.event.MouseEvent e) + { + if (e.isPopupTrigger()) + { + showPopup(e); + } + } + + private void showPopup(java.awt.event.MouseEvent e) + { + if (!panel.isHourly()) + { + return; + } + javax.swing.JPopupMenu menu = new javax.swing.JPopupMenu(); + javax.swing.JMenuItem reset = new javax.swing.JMenuItem("Reset Hourly"); + reset.addActionListener(a -> + { + panel.resetHourly(resolved); + panel.refreshLater(); + }); + menu.add(reset); + menu.show((java.awt.Component) e.getSource(), e.getX(), e.getY()); + } + }; + view.main.addMouseListener(popoutRightClick); + view.stats.addMouseListener(popoutRightClick); + + characterBox.setFont(FontManager.getRunescapeBoldFont()); + characterBox.setBackground(Ui.CARD); + characterBox.setForeground(Ui.TEXT); + characterBox.setFocusable(false); + characterBox.setPreferredSize(new Dimension(260, 30)); + characterBox.setRenderer(new DefaultListCellRenderer() + { + @Override + public Component getListCellRendererComponent(JList list, Object value, int index, boolean isSelected, boolean cellHasFocus) + { + JLabel l = (JLabel) super.getListCellRendererComponent(list, value, index, isSelected, cellHasFocus); + l.setFont(FontManager.getRunescapeBoldFont()); + l.setBorder(BorderFactory.createEmptyBorder(3, 8, 3, 8)); + return l; + } + }); + characterBox.addActionListener(e -> + { + if (updatingCombo) + { + return; + } + Choice c = (Choice) characterBox.getSelectedItem(); + if (c != null) + { + choice = c.key; + panel.refreshLater(); + } + }); + + JPanel root = new JPanel(new BorderLayout(12, 10)); + root.setBackground(Ui.BG); + root.setBorder(BorderFactory.createEmptyBorder(12, 12, 12, 12)); + + // ---- top bar + JPanel top = new JPanel(new BorderLayout(12, 0)); + top.setOpaque(false); + JPanel left = new JPanel(new FlowLayout(FlowLayout.LEFT, 12, 0)); + left.setOpaque(false); + view.title.setFont(FontManager.getRunescapeBoldFont().deriveFont(24f)); + view.title.setForeground(Ui.ACCENT); + left.add(view.title); + left.add(characterBox); + top.add(left, BorderLayout.WEST); + + JPanel controls = new JPanel(new FlowLayout(FlowLayout.RIGHT, 10, 0)); + controls.setOpaque(false); + sessionLabel.setFont(FontManager.getRunescapeFont()); + sessionLabel.setForeground(Ui.MUTED); + controls.add(sessionLabel); + controls.add(button("Session", panel::showSession)); + controls.add(button("New session", panel::newSession)); + styleBar.setPreferredSize(new Dimension(150, 30)); + rangeBar.setPreferredSize(new Dimension(380, 30)); + controls.add(styleBar); + controls.add(rangeBar); + top.add(controls, BorderLayout.EAST); + root.add(top, BorderLayout.NORTH); + + // ---- charts + JPanel charts = new JPanel(new GridBagLayout()); + charts.setOpaque(false); + int row = 0; + row = addChart(charts, row, "NET WORTH", view.main, 0.5); + row = addChart(charts, row, "CHANGE PER PERIOD", view.bars, 0.2); + addChart(charts, row, "COMPOSITION", view.stacked, 0.3); + root.add(charts, BorderLayout.CENTER); + + // ---- side column + JPanel side = new JPanel(new DynamicGridLayout(0, 1, 0, 6)); + side.setBackground(Ui.BG); + side.add(label("STATISTICS")); + side.add(view.stats); + side.add(label("BREAKDOWN (click to view items)")); + JPanel viewButtons = new JPanel(new java.awt.GridLayout(1, 4, 4, 0)); + viewButtons.setOpaque(false); + viewButtons.add(button("Bank", () -> panel.openItems("bank", "Bank", resolved))); + viewButtons.add(button("Inventory", () -> panel.openItems("inventory", "Inventory", resolved))); + viewButtons.add(button("Equipment", () -> panel.openItems("equipment", "Equipment", resolved))); + viewButtons.add(button("GE", () -> panel.openItems("ge", "Grand Exchange", resolved))); + side.add(viewButtons); + side.add(view.breakdown); + side.add(label("WHAT CHANGED")); + side.add(view.movers); + side.add(label("MOST VALUABLE")); + side.add(view.topItems); + + JPanel sideWrap = new JPanel(new BorderLayout()); + sideWrap.setBackground(Ui.BG); + sideWrap.add(side, BorderLayout.NORTH); + JScrollPane scroll = new JScrollPane(sideWrap, ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED, ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER); + scroll.setBorder(null); + scroll.getVerticalScrollBar().setUnitIncrement(16); + scroll.setPreferredSize(new Dimension(340, 10)); + root.add(scroll, BorderLayout.EAST); + + setContentPane(root); + setSize(1600, 900); + setMinimumSize(new Dimension(900, 560)); + setLocationRelativeTo(panel); + } + + private static int addChart(JPanel charts, int row, String title, JComponent chart, double weight) + { + GridBagConstraints c = new GridBagConstraints(); + c.gridx = 0; + c.fill = GridBagConstraints.BOTH; + c.weightx = 1; + c.gridy = row; + c.weighty = 0; + c.insets = new Insets(row == 0 ? 0 : 8, 0, 4, 0); + charts.add(label(title), c); + c.gridy = row + 1; + c.weighty = weight; + c.insets = new Insets(0, 0, 0, 0); + chart.setPreferredSize(new Dimension(10, 60)); + charts.add(chart, c); + return row + 2; + } + + /** Character shown in this window: the logged-in one by default, or the one picked in the box. */ + String resolveSelection(List accounts, String activeKey) + { + if (AUTO.equals(choice)) + { + if (activeKey != null && accounts.stream().anyMatch(a -> a.key.equals(activeKey))) + { + resolved = activeKey; + } + else if (accounts.stream().noneMatch(a -> a.key.equals(resolved))) + { + resolved = WealthPanel.ALL; + } + } + else if (WealthPanel.ALL.equals(choice) || accounts.stream().anyMatch(a -> a.key.equals(choice))) + { + resolved = choice; + } + else + { + choice = AUTO; + resolved = WealthPanel.ALL; + } + return resolved; + } + + void setRange(int index) + { + rangeBar.setSelected(index); + } + + void setMainMode(WealthChart.Mode mode) + { + styleBar.setSelected(mode == WealthChart.Mode.CANDLES ? 1 : 0); + view.main.setMode(mode); + } + + void update(WealthPanel.Model m, List accounts, String selectionKey) + { + List keys = new ArrayList<>(); + accounts.forEach(a -> keys.add(a.key + "=" + a.name)); + if (!keys.equals(comboKeys)) + { + comboKeys = keys; + updatingCombo = true; + characterBox.removeAllItems(); + characterBox.addItem(new Choice(AUTO, "Logged-in character")); + characterBox.addItem(new Choice(WealthPanel.ALL, "All characters")); + for (AccountData a : accounts) + { + characterBox.addItem(new Choice(a.key, a.name)); + } + updatingCombo = false; + } + updatingCombo = true; + for (int i = 0; i < characterBox.getItemCount(); i++) + { + if (Objects.equals(characterBox.getItemAt(i).key, choice)) + { + characterBox.setSelectedIndex(i); + break; + } + } + updatingCombo = false; + + long sessionStart = panel.sessionStart(); + sessionLabel.setText(panel.isSessionView() ? "Since session start" : sessionStart > 0 ? "Session: " + Ui.ago(sessionStart).replace(" ago", "") : ""); + sessionLabel.setForeground(panel.isSessionView() ? Ui.ACCENT : Ui.MUTED); + + view.update(m, 10); + panel.resolveNames(view.lastMovers); + } + + private static JButton button(String text, Runnable action) + { + JButton b = new JButton(text); + b.setFont(FontManager.getRunescapeSmallFont()); + b.setFocusPainted(false); + b.setCursor(Cursor.getPredefinedCursor(Cursor.HAND_CURSOR)); + b.setPreferredSize(new Dimension(Math.max(70, text.length() * 8 + 20), 30)); + b.addActionListener(e -> action.run()); + return b; + } + + private static JLabel label(String text) + { + JLabel l = new JLabel(text); + l.setFont(FontManager.getRunescapeSmallFont()); + l.setForeground(Ui.MUTED); + return l; + } +} diff --git a/src/main/java/com/example/essentialsplus/ui/XpChart.java b/src/main/java/com/example/essentialsplus/ui/XpChart.java new file mode 100644 index 0000000..d97430a --- /dev/null +++ b/src/main/java/com/example/essentialsplus/ui/XpChart.java @@ -0,0 +1,452 @@ +package com.example.essentialsplus.ui; + +import com.example.essentialsplus.skills.SkillHistory; +import com.example.essentialsplus.skills.XpSample; +import java.awt.BasicStroke; +import java.awt.Color; +import java.awt.Dimension; +import java.awt.FontMetrics; +import java.awt.GradientPaint; +import java.awt.Graphics; +import java.awt.Graphics2D; +import java.awt.event.MouseAdapter; +import java.awt.event.MouseEvent; +import java.awt.geom.Path2D; +import java.time.Instant; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Locale; +import javax.swing.JComponent; +import net.runelite.client.ui.FontManager; + +/** + * Experience chart: cumulative xp gained over the range (with level-up markers) or xp per period as bars. + */ +class XpChart extends JComponent +{ + enum Mode + { + CUMULATIVE, + BARS + } + + private static final BasicStroke THIN = new BasicStroke(1f); + private static final BasicStroke LINE = new BasicStroke(1.6f, BasicStroke.CAP_ROUND, BasicStroke.JOIN_ROUND); + + private final Mode mode; + private final int prefHeight; + private List samples = Collections.emptyList(); + private List levelUps = Collections.emptyList(); + private int skill = -1; + private long start; + private long end; + private Color color = Ui.GOLD; + private String emptyText = ""; + private int hoverX = -1; + + /** bars: bucket start, bucket end, xp */ + private final List bars = new ArrayList<>(); + private long bucket; + + XpChart(Mode mode, int prefHeight) + { + this.mode = mode; + this.prefHeight = prefHeight; + MouseAdapter mouse = new MouseAdapter() + { + @Override + public void mouseMoved(MouseEvent e) + { + hoverX = e.getX(); + repaint(); + } + + @Override + public void mouseExited(MouseEvent e) + { + hoverX = -1; + repaint(); + } + }; + addMouseListener(mouse); + addMouseMotionListener(mouse); + } + + void setData(List samples, List levelUps, int skill, long start, long end, Color color, String emptyText) + { + this.samples = samples == null ? Collections.emptyList() : samples; + this.levelUps = levelUps == null ? Collections.emptyList() : levelUps; + this.skill = skill; + this.start = start; + this.end = Math.max(end, start + Ui.MINUTE); + this.color = color; + this.emptyText = emptyText; + if (mode == Mode.BARS) + { + buildBars(); + } + repaint(); + } + + @Override + public Dimension getPreferredSize() + { + return new Dimension(10, prefHeight); + } + + static long barBucket(long span) + { + if (span <= 3 * Ui.HOUR) + { + return 5 * Ui.MINUTE; + } + if (span <= 2 * Ui.DAY) + { + return Ui.HOUR; + } + if (span <= 10 * Ui.DAY) + { + return 6 * Ui.HOUR; + } + if (span <= 120 * Ui.DAY) + { + return Ui.DAY; + } + if (span <= 400 * Ui.DAY) + { + return 7 * Ui.DAY; + } + return 30 * Ui.DAY; + } + + private void buildBars() + { + bars.clear(); + bucket = barBucket(end - start); + if (samples.size() < 2) + { + return; + } + long t = Analysis.bucketStart(start, bucket); + int idx = 0; + long prev = samples.get(0).value(skill); + while (t < end) + { + long t1 = Analysis.bucketEnd(t, bucket); + long last = prev; + while (idx < samples.size() && samples.get(idx).t < t1) + { + last = Math.max(last, samples.get(idx).value(skill)); + idx++; + } + bars.add(new long[]{t, t1, Math.max(0, last - prev)}); + prev = last; + t = t1; + } + } + + private long gainedAt(int i) + { + return Math.max(0, samples.get(i).value(skill) - samples.get(0).value(skill)); + } + + @Override + protected void paintComponent(Graphics g0) + { + Graphics2D g = Ui.prepare(g0); + int w = getWidth(); + int h = getHeight(); + g.setColor(Ui.CARD); + g.fillRoundRect(0, 0, w, h, 8, 8); + g.setFont(FontManager.getRunescapeSmallFont()); + FontMetrics fm = g.getFontMetrics(); + + long max = 0; + if (mode == Mode.BARS) + { + for (long[] b : bars) + { + max = Math.max(max, b[2]); + } + } + else if (!samples.isEmpty()) + { + max = gainedAt(samples.size() - 1); + } + + if (samples.size() < 2 || max <= 0) + { + g.setColor(Ui.MUTED); + int y = h / 2 + fm.getAscent() / 2; + g.drawString(emptyText, (w - fm.stringWidth(emptyText)) / 2, y); + g.dispose(); + return; + } + + double hi = max * 1.12; + double step = niceStep(hi / 3); + int labelW = 0; + for (double v = 0; v <= hi; v += step) + { + labelW = Math.max(labelW, fm.stringWidth(Ui.gp(Math.round(v)))); + } + int px = labelW + 10; + int py = 10; + int pw = w - px - 8; + int ph = h - py - fm.getHeight() - 6; + if (pw < 20 || ph < 20) + { + g.dispose(); + return; + } + + for (double v = 0; v <= hi; v += step) + { + int y = (int) Math.round(py + ph - v / hi * ph); + g.setColor(v == 0 ? Ui.alpha(Ui.TEXT, 50) : Ui.GRID); + g.setStroke(THIN); + g.drawLine(px, y, px + pw, y); + g.setColor(Ui.FAINT); + Ui.drawRight(g, Ui.gp(Math.round(v)), px - 5, y + fm.getAscent() / 2 - 1); + } + drawTimeAxis(g, fm, px, py + ph, pw); + + if (mode == Mode.BARS) + { + paintBars(g, fm, px, py, pw, ph, hi); + } + else + { + paintCumulative(g, fm, px, py, pw, ph, hi); + } + g.dispose(); + } + + private double xOf(long t, int px, int pw) + { + return px + (double) (Math.max(start, Math.min(end, t)) - start) / (end - start) * pw; + } + + private void paintCumulative(Graphics2D g, FontMetrics fm, int px, int py, int pw, int ph, double hi) + { + Path2D line = new Path2D.Double(); + double lastY = py + ph; + line.moveTo(px, lastY); + for (int i = 1; i < samples.size(); i++) + { + double x = xOf(samples.get(i).t, px, pw); + double y = py + ph - gainedAt(i) / hi * ph; + line.lineTo(x, lastY); + line.lineTo(x, y); + lastY = y; + } + line.lineTo(px + pw, lastY); + + Path2D area = new Path2D.Double(line); + area.lineTo(px + pw, py + ph); + area.lineTo(px, py + ph); + area.closePath(); + g.setPaint(new GradientPaint(0, py, Ui.alpha(color, 90), 0, py + ph, Ui.alpha(color, 0))); + g.fill(area); + g.setStroke(LINE); + g.setColor(color); + g.draw(line); + + // level-up markers + long base = samples.get(0).value(skill); + for (SkillHistory.LevelUp lu : levelUps) + { + if (skill >= 0 && lu.skill != skill) + { + continue; + } + int i = indexAt(lu.t); + double x = xOf(lu.t, px, pw); + double y = py + ph - Math.max(0, samples.get(i).value(skill) - base) / hi * ph; + g.setColor(Ui.alpha(Ui.UP, 70)); + g.fillOval((int) x - 5, (int) y - 5, 10, 10); + g.setColor(Ui.UP); + g.fillOval((int) x - 3, (int) y - 3, 6, 6); + } + + if (hoverX >= px && hoverX <= px + pw) + { + long t = start + (long) ((double) (hoverX - px) / pw * (end - start)); + int i = indexAt(t); + g.setStroke(THIN); + g.setColor(Ui.alpha(Ui.TEXT, 70)); + g.drawLine(hoverX, py, hoverX, py + ph); + + List rows = new ArrayList<>(); + rows.add(new String[]{"Gained", "+" + Ui.gp(gainedAt(i))}); + List ups = new ArrayList<>(); + for (SkillHistory.LevelUp lu : levelUps) + { + double lx = xOf(lu.t, px, pw); + if ((skill < 0 || lu.skill == skill) && Math.abs(lx - hoverX) <= 4) + { + ups.add(SkillHistory.SKILLS[lu.skill].getName() + " " + lu.to); + } + } + for (String u : ups) + { + rows.add(new String[]{"Level", u}); + } + paintTip(g, fm, hoverX, py, pw, px, formatTime(t), rows); + } + } + + private int indexAt(long t) + { + int idx = 0; + for (int i = 0; i < samples.size(); i++) + { + if (samples.get(i).t <= t) + { + idx = i; + } + else + { + break; + } + } + return idx; + } + + private void paintBars(Graphics2D g, FontMetrics fm, int px, int py, int pw, int ph, double hi) + { + int hovered = -1; + for (int i = 0; i < bars.size(); i++) + { + long[] b = bars.get(i); + double x0 = xOf(b[0], px, pw); + double x1 = xOf(b[1], px, pw); + int bw = (int) Math.max(1, x1 - x0 - (x1 - x0 > 4 ? 2 : 0)); + boolean hover = hoverX >= x0 && hoverX < x1; + if (hover) + { + hovered = i; + g.setColor(Ui.alpha(Ui.TEXT, 18)); + g.fillRect((int) x0, py, (int) Math.max(1, x1 - x0), ph); + } + if (b[2] <= 0) + { + continue; + } + int bh = (int) Math.max(1, Math.round(b[2] / hi * ph)); + g.setColor(hover ? color.brighter() : Ui.alpha(color, 210)); + g.fillRect((int) Math.round(x0) + 1, py + ph - bh, bw, bh); + } + if (hovered >= 0) + { + long[] b = bars.get(hovered); + List rows = new ArrayList<>(); + rows.add(new String[]{"XP", "+" + Ui.gp(b[2])}); + if (bucket < Ui.DAY) + { + rows.add(new String[]{"Per hour", Ui.gp(b[2] * Ui.HOUR / bucket)}); + } + paintTip(g, fm, hoverX, py, pw, px, formatTime(b[0]) + " - " + formatTime(b[1]), rows); + } + } + + private String formatTime(long t) + { + long span = end - start; + String pattern = span <= 2 * Ui.DAY ? "HH:mm" : span <= 120 * Ui.DAY ? "d MMM HH:mm" : "d MMM yy"; + return Instant.ofEpochMilli(t).atZone(ZoneId.systemDefault()).format(DateTimeFormatter.ofPattern(pattern, Locale.ENGLISH)); + } + + private void paintTip(Graphics2D g, FontMetrics fm, int x, int py, int pw, int px, String title, List rows) + { + int lh = fm.getHeight(); + int keyW = 0; + int valW = 0; + for (String[] r : rows) + { + keyW = Math.max(keyW, fm.stringWidth(r[0])); + valW = Math.max(valW, fm.stringWidth(r[1])); + } + int bw = Math.max(fm.stringWidth(title), keyW + 10 + valW) + 12; + int bh = (rows.size() + 1) * lh + 8; + int bx = x + 10; + if (bx + bw > px + pw) + { + bx = x - 10 - bw; + } + bx = Math.max(2, Math.min(getWidth() - bw - 2, bx)); + + g.setColor(new Color(18, 18, 18, 238)); + g.fillRoundRect(bx, py, bw, bh, 8, 8); + g.setStroke(THIN); + g.setColor(new Color(255, 255, 255, 35)); + g.drawRoundRect(bx, py, bw, bh, 8, 8); + + int ty = py + 4 + fm.getAscent(); + g.setColor(Ui.MUTED); + g.drawString(title, bx + 6, ty); + for (String[] r : rows) + { + ty += lh; + g.setColor(Ui.MUTED); + g.drawString(r[0], bx + 6, ty); + g.setColor("Level".equals(r[0]) ? Ui.UP : Ui.TEXT); + Ui.drawRight(g, r[1], bx + bw - 6, ty); + } + } + + private void drawTimeAxis(Graphics2D g, FontMetrics fm, int px, int baseY, int pw) + { + long span = end - start; + DateTimeFormatter f; + if (span <= 2 * Ui.DAY) + { + f = DateTimeFormatter.ofPattern("HH:mm", Locale.ENGLISH); + } + else if (span <= 10 * Ui.DAY) + { + f = DateTimeFormatter.ofPattern("EEE d", Locale.ENGLISH); + } + else if (span <= 200 * Ui.DAY) + { + f = DateTimeFormatter.ofPattern("MMM d", Locale.ENGLISH); + } + else + { + f = DateTimeFormatter.ofPattern("MMM yy", Locale.ENGLISH); + } + int count = Math.max(2, Math.min(8, pw / (fm.stringWidth("Mmm 00") + 18))); + g.setColor(Ui.FAINT); + int lastRight = Integer.MIN_VALUE; + int y = baseY + fm.getAscent() + 4; + for (int i = 0; i <= count; i++) + { + long t = start + span * i / count; + String s = Instant.ofEpochMilli(t).atZone(ZoneId.systemDefault()).format(f); + int sw = fm.stringWidth(s); + int x = px + pw * i / count - sw / 2; + x = Math.max(px - 4, Math.min(px + pw - sw, x)); + if (x < lastRight + 6) + { + continue; + } + g.drawString(s, x, y); + lastRight = x + sw; + } + } + + private static double niceStep(double raw) + { + if (raw <= 0) + { + return 1; + } + double mag = Math.pow(10, Math.floor(Math.log10(raw))); + double n = raw / mag; + double nice = n <= 1 ? 1 : n <= 2 ? 2 : n <= 5 ? 5 : 10; + return Math.max(1, nice * mag); + } +} diff --git a/src/main/java/com/example/essentialsplus/wiki/WikiApi.java b/src/main/java/com/example/essentialsplus/wiki/WikiApi.java new file mode 100644 index 0000000..f4aaf9e --- /dev/null +++ b/src/main/java/com/example/essentialsplus/wiki/WikiApi.java @@ -0,0 +1,380 @@ +package com.example.essentialsplus.wiki; + +import com.example.essentialsplus.loot.droptable.DropLine; +import com.example.essentialsplus.loot.droptable.DropTable; +import com.google.gson.Gson; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import java.awt.image.BufferedImage; +import java.io.IOException; +import java.io.InputStream; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import javax.imageio.ImageIO; +import lombok.extern.slf4j.Slf4j; +import okhttp3.Call; +import okhttp3.Callback; +import okhttp3.HttpUrl; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.Response; +import okhttp3.ResponseBody; + +/** Thin client for the Old School RuneScape Wiki: title search, full-text search, parsed pages and images. */ +@Slf4j +public class WikiApi +{ + public static final String BASE = "https://oldschool.runescape.wiki"; + private static final int MAX_IMAGE_BYTES = 2 * 1024 * 1024; + + private final OkHttpClient http; + private final Gson gson; + + public WikiApi(OkHttpClient http, Gson gson) + { + this.http = http; + this.gson = gson; + } + + /** One search hit; {@code snippet} may be empty, {@code thumbnail} null. */ + public static final class Result + { + public final String title; + public final String snippet; + public final String thumbnail; + + public Result(String title, String snippet, String thumbnail) + { + this.title = title; + this.snippet = snippet; + this.thumbnail = thumbnail; + } + } + + /** A parsed page: resolved title (after redirects) and its raw content html. */ + public static final class Page + { + public final String title; + public final String html; + + Page(String title, String html) + { + this.title = title; + this.html = html; + } + } + + public static String pageUrl(String title) + { + return BASE + "/w/" + encodeTitle(title); + } + + static String encodeTitle(String title) + { + return URLEncoder.encode(title.replace(' ', '_'), StandardCharsets.UTF_8) + .replace("%2F", "/").replace("%3A", ":").replace("+", "%20"); + } + + /** Search-as-you-type suggestions, the same list the wiki's search box shows. */ + public List suggest(String query, int limit) throws IOException + { + HttpUrl url = HttpUrl.parse(BASE + "/rest.php/v1/search/title").newBuilder() + .addQueryParameter("q", query) + .addQueryParameter("limit", String.valueOf(limit)) + .build(); + JsonObject root = getJson(url); + List out = new ArrayList<>(); + JsonArray pages = root.getAsJsonArray("pages"); + if (pages == null) + { + return out; + } + for (JsonElement el : pages) + { + JsonObject p = el.getAsJsonObject(); + String thumb = null; + if (p.has("thumbnail") && p.get("thumbnail").isJsonObject()) + { + thumb = string(p.getAsJsonObject("thumbnail"), "url"); + } + String desc = string(p, "description"); + out.add(new Result(string(p, "title"), desc == null ? "" : desc, thumb)); + } + return out; + } + + /** Full-text search ("pages containing ..."), like the wiki's Special:Search. */ + public List search(String query, int limit) throws IOException + { + HttpUrl url = HttpUrl.parse(BASE + "/api.php").newBuilder() + .addQueryParameter("action", "query") + .addQueryParameter("list", "search") + .addQueryParameter("srsearch", query) + .addQueryParameter("srlimit", String.valueOf(limit)) + .addQueryParameter("srprop", "snippet") + .addQueryParameter("format", "json") + .addQueryParameter("formatversion", "2") + .build(); + JsonObject root = getJson(url); + List out = new ArrayList<>(); + JsonObject q = root.getAsJsonObject("query"); + if (q == null || !q.has("search")) + { + return out; + } + for (JsonElement el : q.getAsJsonArray("search")) + { + JsonObject p = el.getAsJsonObject(); + String snippet = string(p, "snippet"); + out.add(new Result(string(p, "title"), snippet == null ? "" : WikiHtml.plainText(snippet), null)); + } + return out; + } + + /** + * Resolves a game entity to its wiki page via Special:Lookup (handles ids shared by several pages and + * variants). Returns the page url, or null when the wiki only offers a search. + * + * @param type "item", "npc" or "object" + */ + public String lookup(String type, int id, String name) throws IOException + { + HttpUrl url = HttpUrl.parse(BASE + "/w/Special:Lookup").newBuilder() + .addQueryParameter("type", type) + .addQueryParameter("id", String.valueOf(id)) + .addQueryParameter("name", name) + .build(); + try (Response response = http.newCall(new Request.Builder().url(url).build()).execute()) + { + String resolved = response.request().url().toString(); + if (!response.isSuccessful() || resolved.contains("Special:")) + { + return null; + } + return resolved; + } + } + + /** + * Drop table of a monster/source page. Falls back to the redirect target when the name has no rows + * (e.g. different capitalisation). Returns an empty table when the wiki has none. + */ + public DropTable fetchDropTable(String name) throws IOException + { + List lines = dropLines(name); + String page = name; + if (lines.isEmpty()) + { + String resolved = resolveTitle(name); + if (resolved != null && !resolved.equals(name)) + { + page = resolved; + lines = dropLines(resolved); + } + } + return new DropTable(page, lines); + } + + private List dropLines(String page) throws IOException + { + String escaped = page.replace("\\", "\\\\").replace("'", "\\'"); + HttpUrl url = HttpUrl.parse(BASE + "/api.php").newBuilder() + .addQueryParameter("action", "bucket") + .addQueryParameter("format", "json") + .addQueryParameter("query", "bucket('dropsline').select('drop_json').where('page_name','" + escaped + "').limit(5000).run()") + .build(); + JsonObject root = getJson(url); + List out = new ArrayList<>(); + JsonArray rows = root.getAsJsonArray("bucket"); + if (rows == null) + { + return out; + } + for (JsonElement el : rows) + { + String raw = string(el.getAsJsonObject(), "drop_json"); + if (raw == null) + { + continue; + } + try + { + JsonObject d = gson.fromJson(raw, JsonObject.class); + out.add(new DropLine(string(d, "Dropped from"), string(d, "Dropped item"), string(d, "Rarity"), + integer(d, "Rolls", 1), integer(d, "Quantity Low", 1), integer(d, "Quantity High", 1))); + } + catch (RuntimeException e) + { + log.debug("bad drop row on {}: {}", page, raw, e); + } + } + return out; + } + + /** Title after normalisation and redirects, or null if the page does not exist. */ + private String resolveTitle(String title) throws IOException + { + HttpUrl url = HttpUrl.parse(BASE + "/api.php").newBuilder() + .addQueryParameter("action", "query") + .addQueryParameter("titles", title) + .addQueryParameter("redirects", "1") + .addQueryParameter("format", "json") + .addQueryParameter("formatversion", "2") + .build(); + JsonObject q = getJson(url).getAsJsonObject("query"); + if (q == null || !q.has("pages") || q.getAsJsonArray("pages").size() == 0) + { + return null; + } + JsonObject p = q.getAsJsonArray("pages").get(0).getAsJsonObject(); + return p.has("missing") ? null : string(p, "title"); + } + + private static int integer(JsonObject o, String key, int fallback) + { + JsonElement e = o.get(key); + if (e == null || e.isJsonNull()) + { + return fallback; + } + try + { + return (int) Math.round(e.getAsDouble()); + } + catch (RuntimeException ex) + { + return fallback; + } + } + + public Page fetchPage(String title) throws IOException + { + HttpUrl url = HttpUrl.parse(BASE + "/api.php").newBuilder() + .addQueryParameter("action", "parse") + .addQueryParameter("page", title) + .addQueryParameter("prop", "text") + .addQueryParameter("redirects", "1") + .addQueryParameter("disableeditsection", "1") + .addQueryParameter("disabletoc", "1") + .addQueryParameter("format", "json") + .addQueryParameter("formatversion", "2") + .build(); + JsonObject root = getJson(url); + if (root.has("error")) + { + JsonObject err = root.getAsJsonObject("error"); + String code = string(err, "code"); + throw new IOException("missingtitle".equals(code) ? "Page does not exist" : string(err, "info")); + } + JsonObject parse = root.getAsJsonObject("parse"); + if (parse == null) + { + throw new IOException("Unexpected wiki response"); + } + String resolved = string(parse, "title"); + return new Page(resolved == null ? title : resolved, string(parse, "text")); + } + + /** Downloads the given images in parallel; failed or undecodable ones are left out. */ + public Map fetchImages(Collection urls, long timeoutMs) + { + Map out = new ConcurrentHashMap<>(); + CountDownLatch latch = new CountDownLatch(urls.size()); + for (String u : urls) + { + Request req = new Request.Builder().url(u).build(); + http.newCall(req).enqueue(new Callback() + { + @Override + public void onFailure(Call call, IOException e) + { + latch.countDown(); + } + + @Override + public void onResponse(Call call, Response response) + { + try (ResponseBody body = response.body()) + { + if (response.isSuccessful() && body != null && body.contentLength() <= MAX_IMAGE_BYTES) + { + BufferedImage img = read(body.byteStream()); + if (img != null) + { + out.put(u, img); + } + } + } + catch (Exception e) + { + log.debug("wiki image {} failed", u, e); + } + finally + { + latch.countDown(); + } + } + }); + } + try + { + latch.await(timeoutMs, TimeUnit.MILLISECONDS); + } + catch (InterruptedException e) + { + Thread.currentThread().interrupt(); + } + return new java.util.HashMap<>(out); + } + + public BufferedImage fetchImage(String url) throws IOException + { + try (Response response = http.newCall(new Request.Builder().url(url).build()).execute()) + { + ResponseBody body = response.body(); + if (!response.isSuccessful() || body == null) + { + throw new IOException("HTTP " + response.code()); + } + return read(body.byteStream()); + } + } + + private static BufferedImage read(InputStream in) throws IOException + { + return ImageIO.read(in); + } + + private JsonObject getJson(HttpUrl url) throws IOException + { + 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("Wiki returned HTTP " + response.code()); + } + JsonObject obj = gson.fromJson(body.charStream(), JsonObject.class); + if (obj == null) + { + throw new IOException("Empty wiki response"); + } + return obj; + } + } + + private static String string(JsonObject o, String key) + { + JsonElement e = o.get(key); + return e == null || e.isJsonNull() ? null : e.getAsString(); + } +} diff --git a/src/main/java/com/example/essentialsplus/wiki/WikiDocumentView.java b/src/main/java/com/example/essentialsplus/wiki/WikiDocumentView.java new file mode 100644 index 0000000..533a979 --- /dev/null +++ b/src/main/java/com/example/essentialsplus/wiki/WikiDocumentView.java @@ -0,0 +1,444 @@ +package com.example.essentialsplus.wiki; + +import java.awt.Color; +import java.awt.Font; +import java.awt.Graphics2D; +import java.awt.Rectangle; +import java.awt.RenderingHints; +import java.awt.geom.Point2D; +import java.awt.geom.Rectangle2D; +import java.awt.image.BufferedImage; +import java.io.IOException; +import java.io.StringReader; +import java.net.URL; +import java.util.Hashtable; +import java.util.Map; +import javax.swing.JEditorPane; +import javax.swing.SizeRequirements; +import javax.swing.text.AttributeSet; +import javax.swing.text.BadLocationException; +import javax.swing.text.Element; +import javax.swing.text.StyleConstants; +import javax.swing.text.View; +import javax.swing.text.ViewFactory; +import javax.swing.text.html.BlockView; +import javax.swing.text.html.HTML; +import javax.swing.text.html.HTMLDocument; +import javax.swing.text.html.HTMLEditorKit; +import javax.swing.text.html.StyleSheet; +import net.runelite.client.ui.FontManager; + +/** + * Lays out a wiki page with an off-screen {@link JEditorPane} and paints the visible part into an image + * that the in-game overlay draws. All methods except {@link #buildDocument} must run on the Swing thread. + */ +final class WikiDocumentView +{ + static final Color TEXT = new Color(0xE4D8BC); + static final Color LINK = new Color(0xFFB83F); + static final Color HEADING = new Color(0xFF981F); + + private static final HTMLEditorKit KIT = new Kit(); + + private final JEditorPane pane = new JEditorPane(); + private int height; + private int contentWidth; + + WikiDocumentView() + { + pane.setEditable(false); + pane.setOpaque(false); + pane.setDoubleBuffered(false); + pane.setEditorKit(KIT); + } + + /** Builds a detached document; safe to call off the Swing thread. */ + static HTMLDocument buildDocument(String html, String base, Map images) throws IOException + { + StyleSheet styles = new StyleSheet(); + styles.addStyleSheet(KIT.getStyleSheet()); + addRules(styles); + HTMLDocument doc = new HTMLDocument(styles); + doc.setAsynchronousLoadPriority(-1); + doc.putProperty("IgnoreCharsetDirective", Boolean.TRUE); + doc.setBase(new URL(base)); + + Hashtable cache = new Hashtable<>(); + for (Map.Entry e : images.entrySet()) + { + try + { + cache.put(new URL(e.getKey()), e.getValue()); + } + catch (IOException ignored) + { + // malformed url, the sanitizer drops images that are not in the cache + } + } + doc.putProperty("imageCache", cache); + try + { + KIT.read(new StringReader(html), doc, 0); + } + catch (BadLocationException e) + { + throw new IOException(e); + } + return doc; + } + + private static void addRules(StyleSheet s) + { + String font = family(FontManager.getRunescapeFont()); + String small = family(FontManager.getRunescapeSmallFont()); + s.addRule("body { color: " + hex(TEXT) + "; font-family: " + font + "; font-size: 16pt; margin: 2px 4px 8px 2px; }"); + s.addRule("p { margin-top: 3px; margin-bottom: 5px; }"); + s.addRule("a { color: " + hex(LINK) + "; text-decoration: none; }"); + for (int i = 1; i <= 6; i++) + { + String color = i <= 2 ? hex(HEADING) : "#FFCF5C"; + s.addRule("h" + i + " { color: " + color + "; font-family: " + font + "; font-size: 16pt; font-weight: bold;" + + " margin-top: " + (i <= 2 ? 10 : 6) + "px; margin-bottom: 3px; }"); + s.addRule("h" + i + " a { color: " + color + "; }"); + } + s.addRule("h2 { border-bottom-width: 1px; border-bottom-style: solid; border-color: #5B5144; }"); + s.addRule("table { border-color: #5B5144; margin-top: 2px; margin-bottom: 6px; }"); + s.addRule("th { color: " + hex(HEADING) + "; background-color: #2A241D; font-weight: bold; text-align: left; }"); + s.addRule("th a { color: " + hex(HEADING) + "; }"); + s.addRule("td { color: " + hex(TEXT) + "; }"); + s.addRule("caption { color: " + hex(HEADING) + "; font-weight: bold; }"); + s.addRule("ul { margin-left: 16px; margin-top: 2px; margin-bottom: 4px; }"); + s.addRule("ol { margin-left: 20px; margin-top: 2px; margin-bottom: 4px; }"); + s.addRule("li { margin-bottom: 1px; }"); + s.addRule("dt { color: #FFCF5C; }"); + s.addRule("dd { margin-left: 12px; }"); + s.addRule("small { font-family: " + small + "; font-size: 16pt; }"); + s.addRule("sup { font-family: " + small + "; font-size: 16pt; }"); + s.addRule("sub { font-family: " + small + "; font-size: 16pt; }"); + s.addRule("pre { font-family: " + font + "; font-size: 16pt; color: #FFFFFF; }"); + s.addRule("code { font-family: " + font + "; font-size: 16pt; color: #FFFFFF; }"); + s.addRule("b { color: #FFFFFF; }"); + s.addRule("strong { color: #FFFFFF; }"); + s.addRule("blockquote { margin-left: 12px; }"); + } + + void setDocument(HTMLDocument doc, int width) + { + pane.setDocument(doc); + layout(width); + } + + /** + * Text wraps at {@code width}; tables that cannot shrink that far stick out to the right, which makes + * {@link #contentWidth()} wider than the viewport (scrolled horizontally). + */ + void layout(int width) + { + View root = pane.getUI().getRootView(pane); + BodyView body = findBody(root); + if (body != null) + { + body.wrapWidth = width; + } + root.setSize(width, Integer.MAX_VALUE / 4f); + contentWidth = body == null ? width : Math.max(width, body.naturalMinimum); + root.setSize(contentWidth, Integer.MAX_VALUE / 4f); + height = (int) Math.ceil(root.getPreferredSpan(View.Y_AXIS)); + pane.setSize(contentWidth, height); + } + + private static BodyView findBody(View v) + { + if (v instanceof BodyView) + { + return (BodyView) v; + } + for (int i = 0; v != null && i < v.getViewCount(); i++) + { + BodyView b = findBody(v.getView(i)); + if (b != null) + { + return b; + } + } + return null; + } + + int height() + { + return height; + } + + int contentWidth() + { + return contentWidth; + } + + /** Paints the page area at ({@code scrollX}, {@code scrollY}) with the given size into a transparent image. */ + BufferedImage paint(int w, int h, int scrollX, int scrollY) + { + if (w <= 0 || h <= 0) + { + return null; + } + BufferedImage img = new BufferedImage(w, h, BufferedImage.TYPE_INT_ARGB); + Graphics2D g = img.createGraphics(); + try + { + g.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_OFF); + g.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_NEAREST_NEIGHBOR); + g.setClip(0, 0, w, h); + g.translate(-scrollX, -scrollY); + pane.paint(g); + } + finally + { + g.dispose(); + } + return img; + } + + /** The href under a point in page coordinates, or null. */ + String linkAt(int x, int y) + { + int pos = pane.viewToModel2D(new Point2D.Float(x, y)); + if (pos < 0 || !(pane.getDocument() instanceof HTMLDocument)) + { + return null; + } + try + { + Rectangle2D r = pane.modelToView2D(pos); + Rectangle2D next = pos + 1 < pane.getDocument().getLength() ? pane.modelToView2D(pos + 1) : null; + if (r == null) + { + return null; + } + double left = r.getX(); + double right = next != null && Math.abs(next.getY() - r.getY()) < 1 ? next.getX() : r.getX() + 8; + if (right < left) + { + right = left + 8; + } + // viewToModel snaps to the nearest character; only count real hits + if (x < left - 2 || x > right + 2 || y < r.getY() - 2 || y > r.getY() + Math.max(r.getHeight(), 16) + 2) + { + return linkNearImage(pos, x, y); + } + String href = href(((HTMLDocument) pane.getDocument()).getCharacterElement(pos)); + return href != null ? href : linkNearImage(pos, x, y); + } + catch (BadLocationException e) + { + return null; + } + } + + /** Images are single characters whose view can be much larger than a text glyph. */ + private String linkNearImage(int pos, int x, int y) throws BadLocationException + { + HTMLDocument doc = (HTMLDocument) pane.getDocument(); + for (int p = Math.max(0, pos - 1); p <= Math.min(doc.getLength() - 1, pos + 1); p++) + { + Element el = doc.getCharacterElement(p); + if (el.getAttributes().getAttribute(StyleConstants.NameAttribute) != HTML.Tag.IMG) + { + continue; + } + Rectangle2D r = pane.modelToView2D(p); + View v = viewFor(el); + if (r != null && v != null) + { + Rectangle box = new Rectangle((int) r.getX(), (int) r.getY(), (int) v.getPreferredSpan(View.X_AXIS), + (int) v.getPreferredSpan(View.Y_AXIS)); + if (box.contains(x, y)) + { + return href(el); + } + } + } + return null; + } + + private View viewFor(Element el) + { + View v = pane.getUI().getRootView(pane); + while (v != null && v.getElement() != el) + { + int i = v.getViewIndex(el.getStartOffset(), javax.swing.text.Position.Bias.Forward); + if (i < 0) + { + return null; + } + v = v.getView(i); + } + return v; + } + + private static String href(Element el) + { + Object a = el.getAttributes().getAttribute(HTML.Tag.A); + if (a instanceof AttributeSet) + { + Object href = ((AttributeSet) a).getAttribute(HTML.Attribute.HREF); + return href == null ? null : href.toString(); + } + return null; + } + + /** The h2/h3 headings of the page with their anchors and positions, in page order. */ + java.util.List sections() + { + java.util.List out = new java.util.ArrayList<>(); + if (!(pane.getDocument() instanceof HTMLDocument)) + { + return out; + } + HTMLDocument doc = (HTMLDocument) pane.getDocument(); + java.util.Set seen = new java.util.HashSet<>(); + for (HTMLDocument.Iterator it = doc.getIterator(HTML.Tag.A); it.isValid(); it.next()) + { + Object n = it.getAttributes().getAttribute(HTML.Attribute.NAME); + if (n == null || !seen.add(n.toString())) + { + continue; + } + Element para = doc.getParagraphElement(it.getStartOffset()); + Object tag = para.getAttributes().getAttribute(StyleConstants.NameAttribute); + int level = tag == HTML.Tag.H2 ? 2 : tag == HTML.Tag.H3 ? 3 : 0; + if (level == 0) + { + continue; + } + try + { + String text = doc.getText(para.getStartOffset(), para.getEndOffset() - para.getStartOffset()).trim(); + Rectangle2D r = pane.modelToView2D(it.getStartOffset()); + if (!text.isEmpty() && r != null) + { + out.add(new WikiWindow.Section(text, n.toString(), level, (int) r.getY())); + } + } + catch (BadLocationException ignored) + { + // heading vanished + } + } + out.sort(java.util.Comparator.comparingInt(sec -> sec.y)); + return out; + } + + /** Y position of a named anchor ({@code }), or -1. */ + int anchorY(String name) + { + if (!(pane.getDocument() instanceof HTMLDocument)) + { + return -1; + } + HTMLDocument doc = (HTMLDocument) pane.getDocument(); + for (HTMLDocument.Iterator it = doc.getIterator(HTML.Tag.A); it.isValid(); it.next()) + { + Object n = it.getAttributes().getAttribute(HTML.Attribute.NAME); + if (n != null && n.toString().equals(name)) + { + try + { + Rectangle2D r = pane.modelToView2D(it.getStartOffset()); + return r == null ? -1 : (int) r.getY(); + } + catch (BadLocationException e) + { + return -1; + } + } + } + return -1; + } + + private static final class Kit extends HTMLEditorKit + { + private static final java.util.Set LENIENT = java.util.Set.of(HTML.Tag.DIV, HTML.Tag.CENTER, + HTML.Tag.BLOCKQUOTE, HTML.Tag.DL, HTML.Tag.DD, HTML.Tag.DT); + + private final ViewFactory factory = new HTMLFactory() + { + @Override + public View create(Element el) + { + Object tag = el.getAttributes().getAttribute(StyleConstants.NameAttribute); + if (tag == HTML.Tag.BODY) + { + return new BodyView(el); + } + if (tag instanceof HTML.Tag && LENIENT.contains(tag)) + { + return new LenientBlockView(el); + } + return super.create(el); + } + }; + + @Override + public ViewFactory getViewFactory() + { + return factory; + } + } + + /** + * Block that reports no minimum width, so a wide table inside it cannot widen the surrounding text; + * the real minimum is kept in {@link #naturalMinimum} to size horizontal scrolling. + */ + private static class LenientBlockView extends BlockView + { + int naturalMinimum; + + LenientBlockView(Element el) + { + super(el, View.Y_AXIS); + } + + @Override + protected SizeRequirements calculateMinorAxisRequirements(int axis, SizeRequirements r) + { + SizeRequirements req = super.calculateMinorAxisRequirements(axis, r); + int natural = 0; + for (int i = 0; i < getViewCount(); i++) + { + View child = getView(i); + int min = child instanceof LenientBlockView ? ((LenientBlockView) child).naturalMinimum : (int) child.getMinimumSpan(axis); + natural = Math.max(natural, min); + } + naturalMinimum = natural + (int) (axis == View.X_AXIS ? getLeftInset() + getRightInset() : getTopInset() + getBottomInset()); + req.minimum = 0; + return req; + } + } + + /** Body that wraps its text at {@link #wrapWidth} even when a wide table gets a larger allocation. */ + private static final class BodyView extends LenientBlockView + { + int wrapWidth = Integer.MAX_VALUE; + + BodyView(Element el) + { + super(el); + } + + @Override + protected void layoutMinorAxis(int targetSpan, int axis, int[] offsets, int[] spans) + { + super.layoutMinorAxis(Math.min(targetSpan, wrapWidth), axis, offsets, spans); + } + } + + private static String family(Font f) + { + return "\"" + f.getFamily() + "\""; + } + + private static String hex(Color c) + { + return String.format("#%02X%02X%02X", c.getRed(), c.getGreen(), c.getBlue()); + } +} diff --git a/src/main/java/com/example/essentialsplus/wiki/WikiHtml.java b/src/main/java/com/example/essentialsplus/wiki/WikiHtml.java new file mode 100644 index 0000000..064cc48 --- /dev/null +++ b/src/main/java/com/example/essentialsplus/wiki/WikiHtml.java @@ -0,0 +1,344 @@ +package com.example.essentialsplus.wiki; + +import java.awt.image.BufferedImage; +import java.util.ArrayDeque; +import java.util.Deque; +import java.util.LinkedHashSet; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.HashMap; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Rewrites wiki page html into the small HTML 3.2 subset Swing can render: drops scripts, styles, + * navboxes and JS-only widgets, makes links and images absolute and strips unsupported attributes. + */ +final class WikiHtml +{ + private static final Pattern COMMENT = Pattern.compile("", Pattern.DOTALL); + private static final Pattern TAG = Pattern.compile("<(/?)([a-zA-Z][a-zA-Z0-9]*)((?:\"[^\"]*\"|'[^']*'|[^'\">])*)>"); + private static final Pattern ATTR = Pattern.compile("([a-zA-Z_:][-a-zA-Z0-9_:.]*)\\s*=\\s*(?:\"([^\"]*)\"|'([^']*)'|([^\\s>]+))"); + private static final Pattern ANY_TAG = Pattern.compile("<[^>]*>"); + private static final Pattern EMPTY_ROW = Pattern.compile("\\s*"); + + private static final Set VOID = Set.of("img", "br", "hr", "input", "meta", "link", "col", "wbr", "source", "area", "track", "embed"); + /** Dropped together with everything inside. */ + private static final Set DROP_TAGS = Set.of("script", "style", "noscript", "audio", "video", "svg", "math", + "button", "select", "textarea", "form", "iframe", "object", "template", "input", "link", "meta", "colgroup", "col"); + private static final Set DROP_CLASSES = Set.of("navbox", "noprint", "mw-editsection", "reference", "references", + "mw-references-wrap", "reflist", "toc", "rs-external-header-links", "navigation-not-searchable", "hidden", "printfooter", + "catlinks", "infobox-buttons", "mw-empty-elt", "mw-cite-backlink", "mbox-image", "noexcerpt-hidden", "GEChartBox", + "GEdatachart", "rsw-chart", "embed-audio", "audio-player", "sm2-container", "tabbernav", "mw-collapsible-toggle", + "skinchooser", "external-header-links", "infobox-padding", "mw-headline-number", "sortkey"); + /** Kept as they are (with filtered attributes). */ + private static final Set KEEP = Set.of("a", "b", "strong", "i", "em", "u", "s", "strike", "p", "br", "hr", + "h1", "h2", "h3", "h4", "h5", "h6", "ul", "ol", "li", "dl", "dt", "dd", "table", "tr", "td", "th", "caption", "img", + "div", "span", "pre", "code", "sup", "sub", "center", "blockquote", "small", "big", "tt", "kbd", "var", "cite"); + /** Unknown to Swing, rendered as another tag. */ + private static final Map RENAME = Map.of("figure", "div", "figcaption", "div", "section", "div", + "article", "div", "aside", "div", "header", "div", "footer", "div", "abbr", "span", "q", "i", "del", "s"); + + private WikiHtml() + { + } + + static final class Result + { + final String html; + final Set images; + + Result(String html, Set images) + { + this.html = html; + this.images = images; + } + } + + /** + * @param maxImageWidth wider images are scaled down to this width + * @param loaded downloaded images by absolute url; only these stay in the output. {@code null} keeps all + * (first pass, to collect the urls) + */ + static Result sanitize(String raw, int maxImageWidth, Map loaded) + { + String html = COMMENT.matcher(raw == null ? "" : raw).replaceAll(""); + StringBuilder out = new StringBuilder(html.length()); + Set images = new LinkedHashSet<>(); + Deque closers = new ArrayDeque<>(); + int skip = 0; + Matcher m = TAG.matcher(html); + int last = 0; + while (m.find()) + { + if (skip == 0) + { + out.append(html, last, m.start()); + } + last = m.end(); + + boolean closing = !m.group(1).isEmpty(); + String name = m.group(2).toLowerCase(Locale.ENGLISH); + String attrText = m.group(3); + boolean selfClosing = attrText.endsWith("/") || VOID.contains(name); + + if (skip > 0) + { + if (!selfClosing) + { + skip += closing ? -1 : 1; + } + continue; + } + + if (closing) + { + if (VOID.contains(name)) + { + continue; + } + String closer = closers.poll(); + if (closer != null) + { + out.append(closer); + } + continue; + } + + Map attrs = attributes(attrText); + if (shouldDrop(name, attrs)) + { + if (!selfClosing) + { + skip = 1; + } + continue; + } + + String tag = KEEP.contains(name) ? name : RENAME.get(name); + if (tag == null) + { + // unknown wrapper (tbody, thead, bdi, ...): drop the tag, keep its content + if (!selfClosing) + { + closers.push(""); + } + continue; + } + + if (tag.equals("img")) + { + appendImage(out, attrs, maxImageWidth, loaded, images); + continue; + } + if (selfClosing) + { + out.append('<').append(tag).append('>'); + continue; + } + + StringBuilder open = new StringBuilder("<").append(tag); + String closer = ""; + switch (tag) + { + case "a": + String href = link(attrs.get("href"), attrs.get("class")); + if (href != null) + { + open.append(" href=\"").append(escape(href)).append('"'); + String title = attrs.get("title"); + if (title != null) + { + open.append(" title=\"").append(escape(title)).append('"'); + } + } + break; + case "table": + String cls = attrs.getOrDefault("class", ""); + boolean bordered = cls.contains("wikitable") || cls.contains("infobox") || cls.contains("lighttable"); + open.append(bordered ? " border=\"1\" cellspacing=\"0\" cellpadding=\"3\"" : " cellspacing=\"0\" cellpadding=\"2\""); + if (cls.contains("infobox")) + { + open.append(" class=\"infobox\""); + } + break; + case "td": + case "th": + copy(attrs, open, "colspan", "rowspan", "align", "valign"); + break; + case "div": + case "p": + String style = attrs.getOrDefault("style", "").toLowerCase(Locale.ENGLISH); + if (style.contains("text-align:center") || style.contains("text-align: center")) + { + open.append(" align=\"center\""); + } + break; + default: + break; + } + open.append('>'); + + // headings keep their id as an anchor so "#section" links can scroll to them + String id = attrs.get("id"); + if (id != null && (tag.charAt(0) == 'h' && tag.length() == 2 || tag.equals("span"))) + { + open.append(""); + closer = "" + closer; + } + out.append(open); + closers.push(closer); + } + if (skip == 0) + { + out.append(html.substring(last)); + } + while (!closers.isEmpty()) + { + out.append(closers.pop()); + } + String body = EMPTY_ROW.matcher(out).replaceAll(""); + return new Result("" + body + "", images); + } + + private static boolean shouldDrop(String name, Map attrs) + { + if (DROP_TAGS.contains(name)) + { + return true; + } + String style = attrs.get("style"); + if (style != null && style.replace(" ", "").toLowerCase(Locale.ENGLISH).contains("display:none")) + { + return true; + } + String cls = attrs.get("class"); + if (cls != null) + { + for (String c : cls.split("\\s+")) + { + if (DROP_CLASSES.contains(c)) + { + return true; + } + } + } + return false; + } + + private static void appendImage(StringBuilder out, Map attrs, int maxWidth, Map loaded, Set images) + { + String src = absolute(attrs.get("src")); + if (src == null || src.toLowerCase(Locale.ENGLISH).contains(".svg")) + { + return; + } + images.add(src); + BufferedImage img = loaded == null ? null : loaded.get(src); + if (loaded != null && img == null) + { + return; + } + int w = parseInt(attrs.get("width")); + int h = parseInt(attrs.get("height")); + if ((w <= 0 || h <= 0) && img != null) + { + w = img.getWidth(); + h = img.getHeight(); + } + if (w > maxWidth && maxWidth > 0 && h > 0) + { + h = Math.max(1, h * maxWidth / w); + w = maxWidth; + } + out.append(" 0 && h > 0) + { + out.append(" width=\"").append(w).append("\" height=\"").append(h).append('"'); + } + out.append(" border=\"0\">"); + } + + /** Absolute link target, or null for links that lead nowhere useful (red links, edit links). */ + private static String link(String href, String cls) + { + if (href == null || (cls != null && (" " + cls + " ").contains(" new ")) || href.contains("action=edit")) + { + return null; + } + if (href.startsWith("#")) + { + return href; + } + return absolute(href); + } + + static String absolute(String url) + { + if (url == null || url.isEmpty()) + { + return null; + } + url = url.replace("&", "&"); + if (url.startsWith("//")) + { + return "https:" + url; + } + if (url.startsWith("/")) + { + return WikiApi.BASE + url; + } + return url; + } + + private static void copy(Map attrs, StringBuilder open, String... names) + { + for (String n : names) + { + String v = attrs.get(n); + if (v != null && !v.isEmpty()) + { + open.append(' ').append(n).append("=\"").append(escape(v)).append('"'); + } + } + } + + private static Map attributes(String text) + { + Map map = new HashMap<>(); + Matcher m = ATTR.matcher(text); + while (m.find()) + { + String v = m.group(2) != null ? m.group(2) : m.group(3) != null ? m.group(3) : m.group(4); + map.putIfAbsent(m.group(1).toLowerCase(Locale.ENGLISH), v); + } + return map; + } + + private static int parseInt(String s) + { + try + { + return s == null ? 0 : Integer.parseInt(s.trim()); + } + catch (NumberFormatException e) + { + return 0; + } + } + + private static String escape(String s) + { + return s.replace("&", "&").replace("\"", """).replace("<", "<").replace(">", ">"); + } + + /** Tags removed and the common entities decoded, for search snippets. */ + static String plainText(String html) + { + return ANY_TAG.matcher(html).replaceAll("") + .replace(""", "\"").replace("'", "'").replace("'", "'") + .replace("<", "<").replace(">", ">").replace(" ", " ").replace("&", "&") + .replaceAll("\\s+", " ").trim(); + } +} diff --git a/src/main/java/com/example/essentialsplus/wiki/WikiOverlay.java b/src/main/java/com/example/essentialsplus/wiki/WikiOverlay.java new file mode 100644 index 0000000..488dc8c --- /dev/null +++ b/src/main/java/com/example/essentialsplus/wiki/WikiOverlay.java @@ -0,0 +1,582 @@ +package com.example.essentialsplus.wiki; + +import java.awt.Color; +import java.awt.Dimension; +import java.awt.Font; +import java.awt.FontMetrics; +import java.awt.Graphics2D; +import java.awt.Point; +import java.awt.Rectangle; +import java.awt.RenderingHints; +import java.awt.Shape; +import java.awt.image.BufferedImage; +import net.runelite.api.gameval.SpriteID; +import net.runelite.client.game.SpriteManager; +import net.runelite.client.ui.FontManager; +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; + +/** Draws the wiki window in the style of the game's own interfaces (steel border, dark backing, stone buttons). */ +public class WikiOverlay extends Overlay +{ + private static final Color BACKING = new Color(0x3E3529); + private static final Color BORDER_DARK = new Color(0x1A1612); + private static final Color BORDER_LIGHT = new Color(0x6B5F4D); + private static final Color ORANGE = new Color(0xFF981F); + private static final Color SHADOW = Color.BLACK; + private static final Color BUTTON = new Color(0x4A4034); + private static final Color BUTTON_HOVER = new Color(0x5C5042); + private static final Color BUTTON_DOWN = new Color(0x362F26); + private static final Color TRACK = new Color(0x2B251E); + private static final Color TOOLTIP_BG = new Color(0xFFFFA0); + + private static final int[] CORNER_SPRITES = { + SpriteID.Steelborder.TOP_LEFT, SpriteID.Steelborder.TOP_RIGHT, SpriteID.Steelborder.BOTTOM_LEFT, SpriteID.Steelborder.BOTTOM_RIGHT + }; + + private final SpriteManager spriteManager; + private final WikiWindows windows; + private final BufferedImage[] border = new BufferedImage[8]; + private BufferedImage backing; + private BufferedImage close; + private BufferedImage closeHover; + private BufferedImage arrowUp; + private BufferedImage arrowDown; + private BufferedImage thumbTop; + private BufferedImage thumbMiddle; + private BufferedImage thumbBottom; + private BufferedImage track; + + public WikiOverlay(SpriteManager spriteManager, WikiWindows windows) + { + this.spriteManager = spriteManager; + this.windows = windows; + setPosition(OverlayPosition.DYNAMIC); + setLayer(OverlayLayer.ALWAYS_ON_TOP); + setPriority(Overlay.PRIORITY_HIGHEST); + setMovable(false); + setResettable(false); + } + + @Override + public Dimension render(Graphics2D g) + { + java.util.List open = windows.windows(); + if (open.isEmpty()) + { + return null; + } + loadSprites(); + for (WikiWindow window : open) + { + if (window.isOpen()) + { + drawWindow(g, window); + } + } + return null; + } + + private void drawWindow(Graphics2D g, WikiWindow window) + { + window.ensureOnScreen(); + window.lastDrawn = System.currentTimeMillis(); + + Font small = FontManager.getRunescapeSmallFont(); + g.setFont(small); + window.externalWidth = g.getFontMetrics().stringWidth("Open in browser") + 14; + if (border[0] != null && border[4] != null && border[5] != null) + { + window.border = Math.max(4, Math.min(24, Math.max(border[4].getWidth(), border[5].getHeight()))); + window.corner = Math.min(40, border[0].getWidth()); + } + + g.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_OFF); + g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_OFF); + + WikiWindow.Layout l = window.layout(); + Rectangle b = l.bounds; + WikiWindow.Part hover = window.hover; + WikiWindow.Part pressed = window.pressed; + + // backing + Shape oldClip = g.getClip(); + g.setColor(BACKING); + g.fillRect(b.x, b.y, b.width, b.height); + if (backing != null) + { + g.clipRect(b.x, b.y, b.width, b.height); + tile(g, backing, b.x, b.y, b.width, b.height); + g.setClip(oldClip); + } + + // title bar + g.setColor(BORDER_DARK); + g.drawLine(l.title.x, l.title.y + l.title.height, l.title.x + l.title.width - 1, l.title.y + l.title.height); + g.setColor(BORDER_LIGHT); + g.drawLine(l.title.x, l.title.y + l.title.height + 1, l.title.x + l.title.width - 1, l.title.y + l.title.height + 1); + + Font bold = FontManager.getRunescapeBoldFont(); + g.setFont(bold); + FontMetrics fm = g.getFontMetrics(); + int titleLeft = (window.canGoBack ? l.back.x + l.back.width : l.title.x) + 8; + int titleRight = l.external.x - 8; + String title = ellipsize(fm, window.title, titleRight - titleLeft); + int tx = Math.max(titleLeft, l.title.x + (l.title.width - fm.stringWidth(title)) / 2); + if (tx + fm.stringWidth(title) > titleRight) + { + tx = titleLeft; + } + text(g, title, tx, l.title.y + (l.title.height + fm.getAscent()) / 2 - 1, ORANGE); + + g.setFont(small); + if (window.canGoBack) + { + button(g, l.back, "Back", hover == WikiWindow.Part.BACK, pressed == WikiWindow.Part.BACK); + } + button(g, l.external, "Open in browser", hover == WikiWindow.Part.EXTERNAL, pressed == WikiWindow.Part.EXTERNAL); + BufferedImage closeImg = hover == WikiWindow.Part.CLOSE && closeHover != null ? closeHover : close; + if (closeImg != null) + { + g.drawImage(closeImg, l.close.x + (l.close.width - closeImg.getWidth()) / 2, l.close.y + (l.close.height - closeImg.getHeight()) / 2, null); + } + else + { + button(g, l.close, "X", hover == WikiWindow.Part.CLOSE, pressed == WikiWindow.Part.CLOSE); + } + + // content + g.setColor(new Color(0, 0, 0, 40)); + g.fillRect(l.content.x, l.content.y, l.content.width, l.content.height); + BufferedImage frame = window.frame; + String status = window.status; + if (frame != null && status == null) + { + g.clipRect(l.content.x, l.content.y, l.content.width, l.content.height); + g.drawImage(frame, l.content.x, l.content.y, null); + g.setClip(oldClip); + } + if (status != null) + { + g.setFont(FontManager.getRunescapeFont()); + FontMetrics sfm = g.getFontMetrics(); + String s = ellipsize(sfm, status, l.content.width - 20); + text(g, s, l.content.x + (l.content.width - sfm.stringWidth(s)) / 2, l.content.y + l.content.height / 2, ORANGE); + } + + drawToc(g, window, l); + + // scrollbars + drawVerticalBar(g, window, l); + if (l.hbar != null) + { + drawHorizontalBar(g, window, l); + } + + drawBorder(g, b); + drawGrip(g, l.grip, hover == WikiWindow.Part.GRIP); + + String link = window.hoverLink; + if (link != null && hover == WikiWindow.Part.CONTENT) + { + drawTooltip(g, describe(link), window.mouse, b); + } + } + + /** Contents bar: section chips below the title, the one being read highlighted. */ + private static void drawToc(Graphics2D g, WikiWindow window, WikiWindow.Layout l) + { + WikiWindow.Toc toc = window.toc; + if (toc.height <= 0) + { + return; + } + java.util.List sections = window.sections; + int current = WikiWindow.currentSection(sections, window.scrollY, true); + int currentMain = WikiWindow.currentSection(sections, window.scrollY, false); + int hoverChip = window.hoverChip; + Shape clip = g.getClip(); + g.clipRect(l.toc.x, l.toc.y, l.toc.width, l.toc.height); + g.setFont(FontManager.getRunescapeSmallFont()); + FontMetrics fm = g.getFontMetrics(); + for (int i = 0; i < toc.chips.size(); i++) + { + WikiWindow.Chip chip = toc.chips.get(i); + Rectangle r = new Rectangle(l.toc.x + chip.rect.x, l.toc.y + chip.rect.y, chip.rect.width, chip.rect.height); + boolean hover = i == hoverChip; + int ty = r.y + (r.height + fm.getAscent()) / 2 - 1; + if (chip.section == WikiWindow.CHIP_LABEL) + { + String name = "Contents"; + text(g, name, r.x + 2, ty, ORANGE); + String toggle = chip.label.substring(name.length()); + text(g, toggle, r.x + 2 + fm.stringWidth(name), ty, hover ? Color.WHITE : new Color(0xC8B48C)); + continue; + } + boolean active = chip.section >= 0 && (chip.section == current || chip.section == currentMain); + boolean sub = chip.section >= 0 && chip.section < sections.size() && sections.get(chip.section).level > 2; + g.setColor(active ? new Color(0x6A4F22) : hover ? BUTTON_HOVER : sub ? new Color(0x3A3229) : BUTTON); + g.fillRect(r.x, r.y, r.width, r.height); + bevel(g, r.x, r.y, r.width, r.height, active); + Color color = hover ? Color.WHITE : chip.section < 0 ? new Color(0xC8B48C) : sub ? new Color(0xE4D8BC) : ORANGE; + String label = ellipsize(fm, chip.label, r.width - 8); + text(g, label, r.x + (r.width - fm.stringWidth(label)) / 2, ty, active ? Color.WHITE : color); + } + g.setClip(clip); + } + + private void loadSprites() + { + if (spriteManager == null) + { + return; + } + for (int i = 0; i < CORNER_SPRITES.length; i++) + { + if (border[i] == null) + { + border[i] = spriteManager.getSprite(CORNER_SPRITES[i], 0); + } + } + // the game draws the bottom edge as the flipped top edge and the left edge as the flipped right edge + if (border[5] == null) + { + BufferedImage top = spriteManager.getSprite(SpriteID.Steelborder2.EDGE_TOP, 0); + if (top != null) + { + border[5] = top; + border[7] = ImageUtil.flipImage(top, false, true); + } + } + if (border[6] == null) + { + BufferedImage right = spriteManager.getSprite(SpriteID.Steelborder2.EDGE_RIGHT, 0); + if (right != null) + { + border[6] = right; + border[4] = ImageUtil.flipImage(right, true, false); + } + } + if (backing == null) + { + backing = spriteManager.getSprite(SpriteID.TRADEBACKING_DARK, 0); + } + if (close == null) + { + close = spriteManager.getSprite(SpriteID.CloseButtons.BUTTON, 0); + } + if (closeHover == null) + { + closeHover = spriteManager.getSprite(SpriteID.CloseButtons.HOVERED, 0); + } + if (arrowUp == null) + { + arrowUp = spriteManager.getSprite(SpriteID.ScrollbarV2.ARROW_UP, 0); + } + if (arrowDown == null) + { + arrowDown = spriteManager.getSprite(SpriteID.ScrollbarV2.ARROW_DOWN, 0); + } + if (thumbTop == null) + { + thumbTop = spriteManager.getSprite(SpriteID.ScrollbarDraggerV2.TOP, 0); + } + if (thumbMiddle == null) + { + thumbMiddle = spriteManager.getSprite(SpriteID.ScrollbarDraggerV2.MIDDLE, 0); + } + if (thumbBottom == null) + { + thumbBottom = spriteManager.getSprite(SpriteID.ScrollbarDraggerV2.BOTTOM, 0); + } + if (track == null) + { + track = spriteManager.getSprite(SpriteID.ScrollbarDraggerV2.TRACK, 0); + } + } + + private void drawBorder(Graphics2D g, Rectangle b) + { + BufferedImage tl = border[0]; + BufferedImage tr = border[1]; + BufferedImage bl = border[2]; + BufferedImage br = border[3]; + BufferedImage left = border[4]; + BufferedImage top = border[5]; + BufferedImage right = border[6]; + BufferedImage bottom = border[7]; + if (tl == null || tr == null || bl == null || br == null || left == null || top == null || right == null || bottom == null) + { + g.setColor(BORDER_DARK); + g.drawRect(b.x, b.y, b.width - 1, b.height - 1); + g.setColor(BORDER_LIGHT); + g.drawRect(b.x + 1, b.y + 1, b.width - 3, b.height - 3); + g.setColor(BORDER_DARK); + g.drawRect(b.x + 2, b.y + 2, b.width - 5, b.height - 5); + return; + } + Shape clip = g.getClip(); + g.clipRect(b.x, b.y, b.width, b.height); + + clipped(g, b.x + tl.getWidth(), b.y, b.width - tl.getWidth() - tr.getWidth(), top.getHeight(), + () -> tile(g, top, b.x + tl.getWidth(), b.y, b.width - tl.getWidth() - tr.getWidth(), top.getHeight())); + clipped(g, b.x + bl.getWidth(), b.y + b.height - bottom.getHeight(), b.width - bl.getWidth() - br.getWidth(), bottom.getHeight(), + () -> tile(g, bottom, b.x + bl.getWidth(), b.y + b.height - bottom.getHeight(), b.width - bl.getWidth() - br.getWidth(), bottom.getHeight())); + clipped(g, b.x, b.y + tl.getHeight(), left.getWidth(), b.height - tl.getHeight() - bl.getHeight(), + () -> tile(g, left, b.x, b.y + tl.getHeight(), left.getWidth(), b.height - tl.getHeight() - bl.getHeight())); + clipped(g, b.x + b.width - right.getWidth(), b.y + tr.getHeight(), right.getWidth(), b.height - tr.getHeight() - br.getHeight(), + () -> tile(g, right, b.x + b.width - right.getWidth(), b.y + tr.getHeight(), right.getWidth(), b.height - tr.getHeight() - br.getHeight())); + + g.drawImage(tl, b.x, b.y, null); + g.drawImage(tr, b.x + b.width - tr.getWidth(), b.y, null); + g.drawImage(bl, b.x, b.y + b.height - bl.getHeight(), null); + g.drawImage(br, b.x + b.width - br.getWidth(), b.y + b.height - br.getHeight(), null); + g.setClip(clip); + } + + private static void clipped(Graphics2D g, int x, int y, int w, int h, Runnable paint) + { + if (w <= 0 || h <= 0) + { + return; + } + Shape before = g.getClip(); + g.clipRect(x, y, w, h); + paint.run(); + g.setClip(before); + } + + private void drawVerticalBar(Graphics2D g, WikiWindow window, WikiWindow.Layout l) + { + Rectangle bar = l.vbar; + int trackStart = bar.y + WikiWindow.SCROLL_W; + int trackLen = bar.height - WikiWindow.SCROLL_W * 2; + paintTrack(g, bar.x, trackStart, bar.width, trackLen, true); + arrow(g, arrowUp, bar.x, bar.y, 0); + arrow(g, arrowDown, bar.x, bar.y + bar.height - WikiWindow.SCROLL_W, 2); + if (window.contentHeight > l.content.height) + { + WikiWindow.Thumb t = WikiWindow.thumb(trackStart, trackLen, l.content.height, window.contentHeight, window.scrollY); + paintThumb(g, bar.x, t.pos, bar.width, t.size, true, window.hover == WikiWindow.Part.THUMB); + } + } + + private void drawHorizontalBar(Graphics2D g, WikiWindow window, WikiWindow.Layout l) + { + Rectangle bar = l.hbar; + int trackStart = bar.x + WikiWindow.SCROLL_W; + int trackLen = bar.width - WikiWindow.SCROLL_W * 2; + paintTrack(g, trackStart, bar.y, trackLen, bar.height, false); + arrow(g, arrowUp, bar.x, bar.y, 3); + arrow(g, arrowDown, bar.x + bar.width - WikiWindow.SCROLL_W, bar.y, 1); + WikiWindow.Thumb t = WikiWindow.thumb(trackStart, trackLen, l.content.width, window.contentWidth, window.scrollX); + paintThumb(g, t.pos, bar.y, t.size, bar.height, false, window.hover == WikiWindow.Part.HTHUMB); + } + + private void paintTrack(Graphics2D g, int x, int y, int w, int h, boolean vertical) + { + if (w <= 0 || h <= 0) + { + return; + } + g.setColor(TRACK); + g.fillRect(x, y, w, h); + if (track != null && vertical) + { + Shape clip = g.getClip(); + g.clipRect(x, y, w, h); + tile(g, track, x, y, w, h); + g.setClip(clip); + } + } + + /** + * @param quarterTurns rotation of the "up" style arrow: 0 up, 1 right, 2 down, 3 left + */ + private static void arrow(Graphics2D g, BufferedImage sprite, int x, int y, int quarterTurns) + { + int s = WikiWindow.SCROLL_W; + if (sprite != null && (quarterTurns == 0 || quarterTurns == 2)) + { + g.drawImage(sprite, x, y, s, s, null); + return; + } + g.setColor(BUTTON); + g.fillRect(x, y, s, s); + bevel(g, x, y, s, s, false); + g.setColor(ORANGE); + int cx = x + s / 2; + int cy = y + s / 2; + int[] xs; + int[] ys; + switch (quarterTurns) + { + case 1: + xs = new int[]{cx - 2, cx + 3, cx - 2}; + ys = new int[]{cy - 4, cy, cy + 4}; + break; + case 3: + xs = new int[]{cx + 2, cx - 3, cx + 2}; + ys = new int[]{cy - 4, cy, cy + 4}; + break; + case 2: + xs = new int[]{cx - 4, cx, cx + 4}; + ys = new int[]{cy - 2, cy + 3, cy - 2}; + break; + default: + xs = new int[]{cx - 4, cx, cx + 4}; + ys = new int[]{cy + 2, cy - 3, cy + 2}; + break; + } + g.fillPolygon(xs, ys, 3); + } + + private void paintThumb(Graphics2D g, int x, int y, int w, int h, boolean vertical, boolean hover) + { + if (vertical && thumbTop != null && thumbMiddle != null && thumbBottom != null && h > thumbTop.getHeight() + thumbBottom.getHeight()) + { + Shape clip = g.getClip(); + int midY = y + thumbTop.getHeight(); + int midH = h - thumbTop.getHeight() - thumbBottom.getHeight(); + g.clipRect(x, midY, w, midH); + tile(g, thumbMiddle, x, midY, w, midH); + g.setClip(clip); + g.drawImage(thumbTop, x, y, w, thumbTop.getHeight(), null); + g.drawImage(thumbBottom, x, y + h - thumbBottom.getHeight(), w, thumbBottom.getHeight(), null); + if (hover) + { + g.setColor(new Color(255, 255, 255, 30)); + g.fillRect(x, y, w, h); + } + return; + } + g.setColor(hover ? BUTTON_HOVER : BUTTON); + g.fillRect(x, y, w, h); + bevel(g, x, y, w, h, false); + } + + private static void drawGrip(Graphics2D g, Rectangle r, boolean hover) + { + g.setColor(hover ? ORANGE : BORDER_LIGHT); + for (int i = 0; i < 3; i++) + { + int off = 4 + i * 3; + g.drawLine(r.x + r.width - 3, r.y + r.height - off, r.x + r.width - off, r.y + r.height - 3); + } + } + + private static void button(Graphics2D g, Rectangle r, String label, boolean hover, boolean down) + { + g.setColor(down ? BUTTON_DOWN : hover ? BUTTON_HOVER : BUTTON); + g.fillRect(r.x, r.y, r.width, r.height); + bevel(g, r.x, r.y, r.width, r.height, down); + FontMetrics fm = g.getFontMetrics(); + int tx = r.x + (r.width - fm.stringWidth(label)) / 2 + (down ? 1 : 0); + int ty = r.y + (r.height + fm.getAscent()) / 2 - 1 + (down ? 1 : 0); + text(g, label, tx, ty, hover ? Color.WHITE : ORANGE); + } + + private static void bevel(Graphics2D g, int x, int y, int w, int h, boolean inset) + { + g.setColor(BORDER_DARK); + g.drawRect(x, y, w - 1, h - 1); + g.setColor(inset ? new Color(0x2A241C) : BORDER_LIGHT); + g.drawLine(x + 1, y + 1, x + w - 2, y + 1); + g.drawLine(x + 1, y + 1, x + 1, y + h - 2); + g.setColor(inset ? BORDER_LIGHT : new Color(0x2A241C)); + g.drawLine(x + 1, y + h - 2, x + w - 2, y + h - 2); + g.drawLine(x + w - 2, y + 1, x + w - 2, y + h - 2); + } + + private void drawTooltip(Graphics2D g, String text, Point mouse, Rectangle window) + { + g.setFont(FontManager.getRunescapeSmallFont()); + FontMetrics fm = g.getFontMetrics(); + int w = fm.stringWidth(text) + 8; + int h = fm.getHeight() + 2; + int x = Math.min(mouse.x + 12, window.x + window.width - w - 4); + int y = mouse.y + 20; + if (y + h > window.y + window.height - 4) + { + y = mouse.y - h - 6; + } + x = Math.max(window.x + 4, x); + g.setColor(TOOLTIP_BG); + g.fillRect(x, y, w, h); + g.setColor(Color.BLACK); + g.drawRect(x, y, w - 1, h - 1); + g.drawString(text, x + 4, y + fm.getAscent() + 1); + } + + /** Readable link target for the hover tooltip. */ + private static String describe(String href) + { + String internal = WikiApi.BASE + "/w/"; + if (href.startsWith(internal)) + { + String t = href.substring(internal.length()); + try + { + t = java.net.URLDecoder.decode(t.replace("+", "%2B"), java.nio.charset.StandardCharsets.UTF_8); + } + catch (IllegalArgumentException ignored) + { + // keep encoded + } + return t.replace('_', ' ').replace("#", " > "); + } + if (href.startsWith("#")) + { + return "Section: " + href.substring(1).replace('_', ' '); + } + return href + " (browser)"; + } + + private static void tile(Graphics2D g, BufferedImage img, int x, int y, int w, int h) + { + int iw = img.getWidth(); + int ih = img.getHeight(); + if (iw <= 0 || ih <= 0) + { + return; + } + for (int ty = y; ty < y + h; ty += ih) + { + for (int tx = x; tx < x + w; tx += iw) + { + g.drawImage(img, tx, ty, null); + } + } + } + + private static void text(Graphics2D g, String s, int x, int y, Color color) + { + g.setColor(SHADOW); + g.drawString(s, x + 1, y + 1); + g.setColor(color); + g.drawString(s, x, y); + } + + private static String ellipsize(FontMetrics fm, String s, int max) + { + if (s == null) + { + return ""; + } + 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 ""; + } +} diff --git a/src/main/java/com/example/essentialsplus/wiki/WikiWindow.java b/src/main/java/com/example/essentialsplus/wiki/WikiWindow.java new file mode 100644 index 0000000..4ca79e0 --- /dev/null +++ b/src/main/java/com/example/essentialsplus/wiki/WikiWindow.java @@ -0,0 +1,1120 @@ +package com.example.essentialsplus.wiki; + +import java.awt.FontMetrics; +import java.awt.Point; +import java.awt.Rectangle; +import java.awt.event.MouseEvent; +import java.awt.event.MouseWheelEvent; +import java.awt.image.BufferedImage; +import java.net.URI; +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Deque; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ExecutorService; +import java.util.function.IntSupplier; +import javax.swing.SwingUtilities; +import javax.swing.text.html.HTMLDocument; +import lombok.extern.slf4j.Slf4j; +import net.runelite.client.ui.FontManager; +import net.runelite.client.util.LinkBrowser; + +/** + * One in-game wiki window: page loading, history, contents bar, scrolling and input handling. Mouse events + * (dispatched by {@link WikiWindows}) and page layout run on the Swing thread, the overlay reads the volatile + * fields from the client thread. + */ +@Slf4j +public class WikiWindow +{ + static final int TITLE_H = 30; + static final int SCROLL_W = 16; + static final int PAD = 6; + static final int MIN_W = 300; + static final int MIN_H = 220; + static final int GRIP = 14; + static final int CHIP_H = 16; + static final int CHIP_GAP = 4; + private static final int WHEEL_STEP = 48; + private static final int ARROW_STEP = 24; + private static final int HISTORY = 50; + + static final int CHIP_LABEL = -1; + static final int CHIP_MORE = -2; + static final int CHIP_LESS = -3; + + /** Callbacks into the window manager. */ + interface Host + { + void openInNewWindow(String title, String anchor, WikiWindow from); + + void closed(WikiWindow window); + + void saveBounds(Rectangle bounds); + + void pageOpened(String title); + } + + enum Part + { + NONE, TITLE, CLOSE, EXTERNAL, BACK, TOC, UP, DOWN, TRACK, THUMB, LEFT, RIGHT, HTRACK, HTHUMB, GRIP, CONTENT, FRAME + } + + /** Positions of all window parts for one set of bounds. */ + static final class Layout + { + final Rectangle bounds; + final Rectangle title; + final Rectangle close; + final Rectangle external; + final Rectangle back; + final Rectangle toc; + final Rectangle content; + final Rectangle vbar; + final Rectangle hbar; + final Rectangle grip; + + Layout(Rectangle b, int border, int corner, int externalWidth, boolean horizontal, int tocHeight) + { + bounds = b; + int side = Math.max(border + 4, corner + 2); + title = new Rectangle(b.x + border, b.y + border, b.width - border * 2, TITLE_H); + close = new Rectangle(b.x + b.width - side - 26, b.y + border + 3, 26, 23); + external = new Rectangle(close.x - 6 - externalWidth, b.y + border + 6, externalWidth, 18); + back = new Rectangle(b.x + side, b.y + border + 6, 40, 18); + int left = b.x + border + PAD; + int right = b.x + b.width - border - PAD; + int top = title.y + title.height + 2; + toc = new Rectangle(left, top + 2, right - left, tocHeight); + if (tocHeight > 0) + { + top = toc.y + toc.height + 4; + } + int bottom = b.y + b.height - border - PAD; + int hbarH = horizontal ? SCROLL_W : 0; + vbar = new Rectangle(right - SCROLL_W, top, SCROLL_W, bottom - top - hbarH); + content = new Rectangle(left, top, vbar.x - 4 - left, bottom - top - hbarH); + hbar = horizontal ? new Rectangle(left, bottom - SCROLL_W, content.width, SCROLL_W) : null; + grip = new Rectangle(b.x + b.width - GRIP, b.y + b.height - GRIP, GRIP, GRIP); + } + } + + /** Scrollbar thumb along one axis. */ + static final class Thumb + { + final int pos; + final int size; + + Thumb(int pos, int size) + { + this.pos = pos; + this.size = size; + } + } + + /** A heading of the page (h2 or h3) with its position in page coordinates. */ + static final class Section + { + final String title; + final String anchor; + final int level; + final int y; + + Section(String title, String anchor, int level, int y) + { + this.title = title; + this.anchor = anchor; + this.level = level; + this.y = y; + } + } + + /** One entry of the contents bar; {@code section} is an index into the sections or a CHIP_* constant. */ + static final class Chip + { + final Rectangle rect; + final int section; + final String label; + + Chip(Rectangle rect, int section, String label) + { + this.rect = rect; + this.section = section; + this.label = label; + } + } + + static final class Toc + { + static final Toc EMPTY = new Toc(Collections.emptyList(), 0); + + final List chips; + final int height; + + Toc(List chips, int height) + { + this.chips = chips; + this.height = height; + } + } + + private static final class Entry + { + final String title; + final int scrollY; + + Entry(String title, int scrollY) + { + this.title = title; + this.scrollY = scrollY; + } + } + + private static FontMetrics chipMetrics; + + private final WikiApi api; + private final ExecutorService executor; + private final IntSupplier canvasWidth; + private final IntSupplier canvasHeight; + private final Host host; + private final Deque history = new ArrayDeque<>(); + + private WikiDocumentView view; + private int loadSeq; + private boolean tocExpanded; + + // read by the overlay (client thread) + volatile boolean open; + volatile Rectangle bounds; + volatile int border = 6; + volatile int corner = 6; + volatile int externalWidth = 96; + volatile String title = ""; + volatile String status; + volatile BufferedImage frame; + volatile int scrollX; + volatile int scrollY; + volatile int contentWidth; + volatile int contentHeight; + volatile List
sections = Collections.emptyList(); + volatile Toc toc = Toc.EMPTY; + volatile boolean tocHidden; + volatile int hoverChip = -1; + volatile Part hover = Part.NONE; + volatile Part pressed = Part.NONE; + volatile String hoverLink; + volatile Point mouse = new Point(); + volatile boolean canGoBack; + /** Last time the overlay drew the window; input is only captured while it is actually visible. */ + volatile long lastDrawn; + + private String currentTitle; + private Part drag = Part.NONE; + private int dragDx; + private int dragDy; + private int dragStartScroll; + private int dragStartMouse; + + WikiWindow(WikiApi api, ExecutorService executor, IntSupplier canvasWidth, IntSupplier canvasHeight, Rectangle initialBounds, Host host) + { + this.api = api; + this.executor = executor; + this.canvasWidth = canvasWidth; + this.canvasHeight = canvasHeight; + this.host = host; + this.bounds = initialBounds != null ? initialBounds : new Rectangle(60, 40, 520, 420); + } + + boolean isOpen() + { + return open; + } + + /** Opens a page in this window (Swing thread). */ + void open(String pageTitle, String anchor) + { + navigate(pageTitle, anchor, true, 0); + } + + /** Closes the window (Swing thread). */ + void close() + { + open = false; + loadSeq++; + frame = null; + view = null; + drag = Part.NONE; + hover = Part.NONE; + host.closed(this); + } + + Layout layout() + { + return layout(bounds); + } + + Layout layout(Rectangle b) + { + return new Layout(b, border, corner, externalWidth, contentWidth > 0 && viewportWidth(b) < contentWidth, toc.height); + } + + private int viewportWidth(Rectangle b) + { + return new Layout(b, border, corner, externalWidth, false, 0).content.width; + } + + // ---------------------------------------------------------------- pages + + private void navigate(String pageTitle, String anchor, boolean push, int restoreScroll) + { + if (pageTitle == null || pageTitle.trim().isEmpty()) + { + return; + } + if (!open) + { + open = true; + clampBounds(); + } + if (push && currentTitle != null && !currentTitle.equalsIgnoreCase(pageTitle)) + { + history.push(new Entry(currentTitle, scrollY)); + while (history.size() > HISTORY) + { + history.removeLast(); + } + } + canGoBack = !history.isEmpty(); + int seq = ++loadSeq; + currentTitle = pageTitle; + title = pageTitle; + status = "Loading..."; + frame = null; + hoverLink = null; + sections = Collections.emptyList(); + toc = Toc.EMPTY; + int width = viewportWidth(); + + executor.execute(() -> + { + try + { + WikiApi.Page page = api.fetchPage(pageTitle); + String raw = page.html; + if (page.title.startsWith("File:")) + { + // the parser output of a file page does not contain the file itself + String src = WikiApi.BASE + "/images/" + WikiApi.encodeTitle(page.title.substring(5)); + raw = "

" + raw; + } + int maxImage = Math.max(120, width - 24); + WikiHtml.Result first = WikiHtml.sanitize(raw, maxImage, null); + List urls = new ArrayList<>(first.images); + Map images = api.fetchImages(urls.size() > 250 ? urls.subList(0, 250) : urls, 12_000); + WikiHtml.Result html = WikiHtml.sanitize(raw, maxImage, images); + HTMLDocument doc = WikiDocumentView.buildDocument(html.html, WikiApi.pageUrl(page.title), images); + SwingUtilities.invokeLater(() -> + { + if (seq != loadSeq) + { + return; + } + WikiDocumentView v = new WikiDocumentView(); + v.setDocument(doc, viewportWidth()); + view = v; + currentTitle = page.title; + title = page.title; + status = null; + contentWidth = v.contentWidth(); + contentHeight = v.height(); + tocExpanded = false; + sections = v.sections(); + updateToc(); + scrollX = 0; + int y = restoreScroll; + if (anchor != null) + { + y = Math.max(0, v.anchorY(anchor) - 4); + } + scrollY = clampScrollY(y); + repaint(); + host.pageOpened(page.title); + }); + } + catch (Exception e) + { + log.debug("wiki page {} failed", pageTitle, e); + String msg = e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage(); + SwingUtilities.invokeLater(() -> + { + if (seq == loadSeq) + { + status = "Could not load page: " + msg; + } + }); + } + }); + } + + private void back() + { + Entry e = history.poll(); + canGoBack = !history.isEmpty(); + if (e != null) + { + currentTitle = null; + navigate(e.title, null, false, e.scrollY); + } + } + + /** Internal wiki link split into page title and section anchor. */ + public static final class Target + { + public final String title; + public final String anchor; + + Target(String title, String anchor) + { + this.title = title; + this.anchor = anchor; + } + } + + /** Parses a wiki page url ({@code https://oldschool.runescape.wiki/w/Title#Anchor}); null for anything else. */ + public static Target parseWikiUrl(String href) + { + String internal = WikiApi.BASE + "/w/"; + if (href == null || !href.startsWith(internal) || href.contains("?")) + { + return null; + } + String rest = href.substring(internal.length()); + String anchor = null; + int hash = rest.indexOf('#'); + if (hash >= 0) + { + anchor = decode(rest.substring(hash + 1)); + rest = rest.substring(0, hash); + } + return new Target(decode(rest).replace('_', ' '), anchor); + } + + private void openLink(String href, boolean newWindow) + { + if (href.startsWith("#")) + { + String anchor = decode(href.substring(1)); + if (newWindow && currentTitle != null) + { + host.openInNewWindow(currentTitle, anchor, this); + } + else + { + scrollToAnchor(anchor); + } + return; + } + Target target = parseWikiUrl(href); + if (target != null) + { + if (newWindow) + { + host.openInNewWindow(target.title, target.anchor, this); + } + else if (target.title.equalsIgnoreCase(currentTitle) && target.anchor != null) + { + scrollToAnchor(target.anchor); + } + else + { + navigate(target.title, target.anchor, true, 0); + } + return; + } + try + { + String scheme = URI.create(href).getScheme(); + if ("http".equalsIgnoreCase(scheme) || "https".equalsIgnoreCase(scheme)) + { + LinkBrowser.browse(href); + } + } + catch (IllegalArgumentException ignored) + { + // malformed link + } + } + + private void scrollToAnchor(String anchor) + { + if (view != null) + { + int y = view.anchorY(anchor); + if (y >= 0) + { + scrollY = clampScrollY(y - 4); + repaint(); + } + } + } + + private static String decode(String s) + { + try + { + return URLDecoder.decode(s.replace("+", "%2B"), StandardCharsets.UTF_8); + } + catch (IllegalArgumentException e) + { + return s; + } + } + + String externalUrl() + { + String t = currentTitle != null ? currentTitle : title; + return t == null || t.isEmpty() ? WikiApi.BASE : WikiApi.pageUrl(t); + } + + // ---------------------------------------------------------------- contents bar + + /** Index of the section the reader is currently in (the last heading above the top of the viewport), or -1. */ + static int currentSection(List
sections, int scrollY, boolean includeSubsections) + { + int current = -1; + for (int i = 0; i < sections.size(); i++) + { + Section s = sections.get(i); + if (s.y > scrollY + 8) + { + break; + } + if (includeSubsections || s.level == 2) + { + current = i; + } + } + return current; + } + + /** Lays out the contents bar for the current width; h3 sections only show when expanded. */ + private void updateToc() + { + List
secs = sections; + if (secs.isEmpty()) + { + toc = Toc.EMPTY; + return; + } + Layout base = new Layout(bounds, border, corner, externalWidth, false, 0); + int width = base.vbar.x + base.vbar.width - base.content.x; + FontMetrics fm = metrics(); + int rowStep = CHIP_H + CHIP_GAP; + List chips = new ArrayList<>(); + + String labelText = tocHidden ? "Contents [show]" : "Contents [hide]"; + chips.add(new Chip(new Rectangle(0, 0, fm.stringWidth(labelText) + 4, CHIP_H), CHIP_LABEL, labelText)); + if (tocHidden) + { + toc = new Toc(chips, CHIP_H + 2); + return; + } + + List visible = new ArrayList<>(); + int subsections = 0; + for (int i = 0; i < secs.size(); i++) + { + if (secs.get(i).level == 2 || tocExpanded) + { + visible.add(i); + } + else + { + subsections++; + } + } + int maxRows = tocExpanded ? Math.max(2, (bounds.height / 2) / rowStep) : 2; + + int x = chips.get(0).rect.width + CHIP_GAP * 2; + int row = 0; + int placed = 0; + for (int idx : visible) + { + String text = secs.get(idx).title; + int w = fm.stringWidth(text) + 10; + if (x > 0 && x + w > width) + { + row++; + x = 0; + } + if (row >= maxRows) + { + break; + } + chips.add(new Chip(new Rectangle(x, row * rowStep, Math.min(w, width), CHIP_H), idx, text)); + x += w + CHIP_GAP; + placed++; + } + + int hidden = visible.size() - placed + subsections; + if (hidden > 0 || tocExpanded) + { + int kind = hidden > 0 ? CHIP_MORE : CHIP_LESS; + while (true) + { + String text = kind == CHIP_MORE ? "+" + hidden + " more" : "less"; + int w = fm.stringWidth(text) + 10; + Chip last = chips.get(chips.size() - 1); + int lastRow = last.rect.y / rowStep; + int end = last.rect.x + last.rect.width + CHIP_GAP; + if (end + w <= width) + { + chips.add(new Chip(new Rectangle(end, lastRow * rowStep, w, CHIP_H), kind, text)); + break; + } + if (lastRow + 1 < maxRows || kind == CHIP_LESS) + { + chips.add(new Chip(new Rectangle(0, (lastRow + 1) * rowStep, w, CHIP_H), kind, text)); + break; + } + if (last.section < 0) + { + break; + } + chips.remove(chips.size() - 1); + hidden++; + kind = CHIP_MORE; + } + } + + int rows = 0; + for (Chip c : chips) + { + rows = Math.max(rows, c.rect.y / rowStep + 1); + } + toc = new Toc(chips, rows * rowStep - CHIP_GAP + 2); + } + + private static FontMetrics metrics() + { + if (chipMetrics == null) + { + BufferedImage img = new BufferedImage(1, 1, BufferedImage.TYPE_INT_ARGB); + chipMetrics = img.createGraphics().getFontMetrics(FontManager.getRunescapeSmallFont()); + } + return chipMetrics; + } + + private int chipAt(Point p) + { + Layout l = layout(); + List chips = toc.chips; + for (int i = 0; i < chips.size(); i++) + { + Rectangle r = chips.get(i).rect; + if (p.x >= l.toc.x + r.x && p.x < l.toc.x + r.x + r.width && p.y >= l.toc.y + r.y && p.y < l.toc.y + r.y + r.height) + { + return i; + } + } + return -1; + } + + private void clickChip(int index) + { + List chips = toc.chips; + if (index < 0 || index >= chips.size()) + { + return; + } + Chip chip = chips.get(index); + int oldHeight = toc.height; + switch (chip.section) + { + case CHIP_LABEL: + tocHidden = !tocHidden; + break; + case CHIP_MORE: + tocExpanded = true; + break; + case CHIP_LESS: + tocExpanded = false; + break; + default: + List
secs = sections; + if (chip.section < secs.size()) + { + scrollY = clampScrollY(secs.get(chip.section).y - 4); + } + break; + } + if (chip.section < 0) + { + updateToc(); + hoverChip = -1; + if (toc.height != oldHeight) + { + scrollY = clampScrollY(scrollY); + } + } + repaint(); + } + + // ---------------------------------------------------------------- scrolling & painting + + private int viewportWidth() + { + return Math.max(50, viewportWidth(bounds)); + } + + private int clampScrollY(int y) + { + Layout l = layout(); + return Math.max(0, Math.min(y, contentHeight - l.content.height)); + } + + private int clampScrollX(int x) + { + Layout l = layout(); + return Math.max(0, Math.min(x, contentWidth - l.content.width)); + } + + private void scrollBy(int dx, int dy) + { + int nx = clampScrollX(scrollX + dx); + int ny = clampScrollY(scrollY + dy); + if (nx != scrollX || ny != scrollY) + { + scrollX = nx; + scrollY = ny; + repaint(); + } + } + + private void repaint() + { + WikiDocumentView v = view; + if (v == null) + { + return; + } + Layout l = layout(); + frame = v.paint(l.content.width, l.content.height, scrollX, scrollY); + } + + /** Re-wraps the page after a resize, keeping the reading position roughly in place. */ + private void relayout() + { + updateToc(); + WikiDocumentView v = view; + if (v == null) + { + return; + } + int oldHeight = Math.max(1, contentHeight); + double ratio = (double) scrollY / oldHeight; + v.layout(viewportWidth()); + contentWidth = v.contentWidth(); + contentHeight = v.height(); + sections = v.sections(); + updateToc(); + scrollX = clampScrollX(scrollX); + scrollY = clampScrollY((int) (ratio * contentHeight)); + repaint(); + } + + static Thumb thumb(int trackStart, int trackLength, int viewport, int content, int scroll) + { + if (content <= viewport || trackLength <= 0) + { + return new Thumb(trackStart, trackLength); + } + int size = Math.max(18, (int) ((long) trackLength * viewport / content)); + size = Math.min(size, trackLength); + int range = content - viewport; + int pos = trackStart + (int) ((long) (trackLength - size) * scroll / range); + return new Thumb(pos, size); + } + + private void clampBounds() + { + Rectangle b = new Rectangle(bounds); + int cw = canvasWidth.getAsInt(); + int ch = canvasHeight.getAsInt(); + if (cw <= 0 || ch <= 0) + { + return; + } + b.width = Math.max(MIN_W, Math.min(b.width, cw)); + b.height = Math.max(MIN_H, Math.min(b.height, ch)); + b.x = Math.max(0, Math.min(b.x, cw - b.width)); + b.y = Math.max(0, Math.min(b.y, ch - b.height)); + if (!b.equals(bounds)) + { + bounds = b; + } + } + + /** Called by the overlay when the canvas shrank below the window. */ + void ensureOnScreen() + { + Rectangle b = bounds; + int cw = canvasWidth.getAsInt(); + int ch = canvasHeight.getAsInt(); + if (cw > 0 && ch > 0 && (b.x + b.width > cw || b.y + b.height > ch || b.x < 0 || b.y < 0)) + { + SwingUtilities.invokeLater(() -> + { + Rectangle before = bounds; + clampBounds(); + if (before.width != bounds.width || before.height != bounds.height) + { + relayout(); + } + }); + } + } + + // ---------------------------------------------------------------- input + + Part partAt(Point p) + { + Rectangle b = bounds; + if (!open || !b.contains(p) || System.currentTimeMillis() - lastDrawn > 500) + { + return Part.NONE; + } + Layout l = layout(b); + if (l.grip.contains(p)) + { + return Part.GRIP; + } + if (l.close.contains(p)) + { + return Part.CLOSE; + } + if (l.external.contains(p)) + { + return Part.EXTERNAL; + } + if (canGoBack && l.back.contains(p)) + { + return Part.BACK; + } + if (l.title.contains(p)) + { + return Part.TITLE; + } + if (l.toc.height > 0 && l.toc.contains(p)) + { + return Part.TOC; + } + if (l.vbar.contains(p)) + { + if (p.y < l.vbar.y + SCROLL_W) + { + return Part.UP; + } + if (p.y >= l.vbar.y + l.vbar.height - SCROLL_W) + { + return Part.DOWN; + } + Thumb t = thumb(l.vbar.y + SCROLL_W, l.vbar.height - SCROLL_W * 2, l.content.height, contentHeight, scrollY); + return p.y >= t.pos && p.y < t.pos + t.size ? Part.THUMB : Part.TRACK; + } + if (l.hbar != null && l.hbar.contains(p)) + { + if (p.x < l.hbar.x + SCROLL_W) + { + return Part.LEFT; + } + if (p.x >= l.hbar.x + l.hbar.width - SCROLL_W) + { + return Part.RIGHT; + } + Thumb t = thumb(l.hbar.x + SCROLL_W, l.hbar.width - SCROLL_W * 2, l.content.width, contentWidth, scrollX); + return p.x >= t.pos && p.x < t.pos + t.size ? Part.HTHUMB : Part.HTRACK; + } + if (l.content.contains(p)) + { + return Part.CONTENT; + } + return Part.FRAME; + } + + private String linkAt(Point p) + { + WikiDocumentView v = view; + if (v == null || status != null) + { + return null; + } + Layout l = layout(); + return v.linkAt(p.x - l.content.x + scrollX, p.y - l.content.y + scrollY); + } + + void mousePressed(MouseEvent e) + { + Point p = e.getPoint(); + Part part = partAt(p); + if (part == Part.NONE) + { + return; + } + e.consume(); + if (SwingUtilities.isMiddleMouseButton(e)) + { + if (part == Part.CONTENT) + { + String href = linkAt(p); + if (href != null) + { + openLink(href, true); + } + } + else if (part == Part.TOC) + { + int chip = chipAt(p); + List chips = toc.chips; + List
secs = sections; + if (chip >= 0 && chips.get(chip).section >= 0 && chips.get(chip).section < secs.size() && currentTitle != null) + { + host.openInNewWindow(currentTitle, secs.get(chips.get(chip).section).anchor, this); + } + } + return; + } + if (!SwingUtilities.isLeftMouseButton(e)) + { + return; + } + pressed = part; + Rectangle b = bounds; + Layout l = layout(b); + switch (part) + { + case TITLE: + case FRAME: + drag = Part.TITLE; + dragDx = p.x - b.x; + dragDy = p.y - b.y; + break; + case GRIP: + drag = Part.GRIP; + dragDx = b.x + b.width - p.x; + dragDy = b.y + b.height - p.y; + break; + case TOC: + clickChip(chipAt(p)); + break; + case UP: + scrollBy(0, -ARROW_STEP); + break; + case DOWN: + scrollBy(0, ARROW_STEP); + break; + case LEFT: + scrollBy(-ARROW_STEP, 0); + break; + case RIGHT: + scrollBy(ARROW_STEP, 0); + break; + case TRACK: + { + Thumb t = thumb(l.vbar.y + SCROLL_W, l.vbar.height - SCROLL_W * 2, l.content.height, contentHeight, scrollY); + scrollBy(0, p.y < t.pos ? -l.content.height + 20 : l.content.height - 20); + break; + } + case HTRACK: + { + Thumb t = thumb(l.hbar.x + SCROLL_W, l.hbar.width - SCROLL_W * 2, l.content.width, contentWidth, scrollX); + scrollBy(p.x < t.pos ? -l.content.width + 20 : l.content.width - 20, 0); + break; + } + case THUMB: + drag = Part.THUMB; + dragStartMouse = p.y; + dragStartScroll = scrollY; + break; + case HTHUMB: + drag = Part.HTHUMB; + dragStartMouse = p.x; + dragStartScroll = scrollX; + break; + case CONTENT: + String href = linkAt(p); + if (href != null) + { + openLink(href, false); + } + break; + default: + break; + } + } + + void mouseReleased(MouseEvent e) + { + Part wasPressed = pressed; + pressed = Part.NONE; + if (drag != Part.NONE) + { + Part d = drag; + drag = Part.NONE; + if (d == Part.GRIP) + { + relayout(); + } + if (d == Part.GRIP || d == Part.TITLE) + { + host.saveBounds(bounds); + } + e.consume(); + return; + } + Part part = partAt(e.getPoint()); + if (part != Part.NONE) + { + if (part == wasPressed && SwingUtilities.isLeftMouseButton(e)) + { + switch (part) + { + case CLOSE: + close(); + break; + case EXTERNAL: + LinkBrowser.browse(externalUrl()); + break; + case BACK: + back(); + break; + default: + break; + } + } + e.consume(); + } + } + + void mouseClicked(MouseEvent e) + { + if (partAt(e.getPoint()) != Part.NONE) + { + e.consume(); + } + } + + boolean isDragging() + { + return drag != Part.NONE; + } + + void clearHover() + { + hover = Part.NONE; + hoverLink = null; + hoverChip = -1; + } + + void mouseDragged(MouseEvent e) + { + Point p = e.getPoint(); + mouse = p; + if (drag == Part.NONE) + { + if (partAt(p) != Part.NONE) + { + e.consume(); + } + return; + } + Rectangle b = new Rectangle(bounds); + Layout l = layout(b); + int cw = canvasWidth.getAsInt(); + int ch = canvasHeight.getAsInt(); + switch (drag) + { + case TITLE: + b.x = Math.max(0, Math.min(p.x - dragDx, cw - b.width)); + b.y = Math.max(0, Math.min(p.y - dragDy, ch - b.height)); + bounds = b; + break; + case GRIP: + b.width = Math.max(MIN_W, Math.min(p.x + dragDx - b.x, cw - b.x)); + b.height = Math.max(MIN_H, Math.min(p.y + dragDy - b.y, ch - b.y)); + bounds = b; + updateToc(); + break; + case THUMB: + { + int track = l.vbar.height - SCROLL_W * 2; + Thumb t = thumb(l.vbar.y + SCROLL_W, track, l.content.height, contentHeight, scrollY); + int range = Math.max(1, track - t.size); + int ny = clampScrollY(dragStartScroll + (int) ((long) (p.y - dragStartMouse) * (contentHeight - l.content.height) / range)); + if (ny != scrollY) + { + scrollY = ny; + repaint(); + } + break; + } + case HTHUMB: + { + int track = l.hbar == null ? 1 : l.hbar.width - SCROLL_W * 2; + Thumb t = thumb(0, track, l.content.width, contentWidth, scrollX); + int range = Math.max(1, track - t.size); + int nx = clampScrollX(dragStartScroll + (int) ((long) (p.x - dragStartMouse) * (contentWidth - l.content.width) / range)); + if (nx != scrollX) + { + scrollX = nx; + repaint(); + } + break; + } + default: + break; + } + e.consume(); + } + + void mouseMoved(MouseEvent e) + { + Point p = e.getPoint(); + mouse = p; + Part part = partAt(p); + hover = part; + hoverLink = part == Part.CONTENT ? linkAt(p) : null; + hoverChip = part == Part.TOC ? chipAt(p) : -1; + if (part != Part.NONE) + { + e.consume(); + } + } + + void mouseWheelMoved(MouseWheelEvent e) + { + if (partAt(e.getPoint()) == Part.NONE) + { + return; + } + int amount = (int) Math.round(e.getPreciseWheelRotation() * WHEEL_STEP); + if (amount == 0) + { + amount = e.getWheelRotation() * WHEEL_STEP; + } + if (e.isShiftDown()) + { + scrollBy(amount, 0); + } + else + { + scrollBy(0, amount); + } + hoverLink = linkAt(e.getPoint()); + e.consume(); + } +} diff --git a/src/main/java/com/example/essentialsplus/wiki/WikiWindows.java b/src/main/java/com/example/essentialsplus/wiki/WikiWindows.java new file mode 100644 index 0000000..eefb347 --- /dev/null +++ b/src/main/java/com/example/essentialsplus/wiki/WikiWindows.java @@ -0,0 +1,256 @@ +package com.example.essentialsplus.wiki; + +import java.awt.Point; +import java.awt.Rectangle; +import java.awt.event.MouseEvent; +import java.awt.event.MouseWheelEvent; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.ExecutorService; +import java.util.function.Consumer; +import java.util.function.IntSupplier; +import javax.swing.SwingUtilities; +import net.runelite.client.input.MouseListener; +import net.runelite.client.input.MouseWheelListener; + +/** + * All open in-game wiki windows, in stacking order (last = front). Routes mouse input to the front-most + * window under the cursor and brings a window to the front when it is pressed. + */ +public class WikiWindows implements MouseListener, MouseWheelListener, WikiWindow.Host +{ + private static final int CASCADE = 30; + + private final WikiApi api; + private final ExecutorService executor; + private final IntSupplier canvasWidth; + private final IntSupplier canvasHeight; + private final Consumer saveBounds; + private final Consumer onPageOpened; + private final List windows = new CopyOnWriteArrayList<>(); + + private Rectangle lastBounds; + /** Window that received the press; gets the drag and release events even outside its bounds. */ + private WikiWindow captured; + + public WikiWindows(WikiApi api, ExecutorService executor, IntSupplier canvasWidth, IntSupplier canvasHeight, + Rectangle savedBounds, Consumer saveBounds, Consumer onPageOpened) + { + this.api = api; + this.executor = executor; + this.canvasWidth = canvasWidth; + this.canvasHeight = canvasHeight; + this.lastBounds = savedBounds; + this.saveBounds = saveBounds; + this.onPageOpened = onPageOpened; + } + + /** Windows from back to front (read by the overlay). */ + List windows() + { + return windows; + } + + /** Opens a page in the front window, or in a new window when none is open (any thread). */ + public void open(String title, String anchor) + { + SwingUtilities.invokeLater(() -> + { + WikiWindow front = windows.isEmpty() ? null : windows.get(windows.size() - 1); + if (front == null) + { + front = create(null); + } + front.open(title, anchor); + }); + } + + /** Opens a page in an additional window (any thread). */ + public void openNew(String title, String anchor) + { + SwingUtilities.invokeLater(() -> + { + WikiWindow front = windows.isEmpty() ? null : windows.get(windows.size() - 1); + create(front).open(title, anchor); + }); + } + + public void closeAll() + { + SwingUtilities.invokeLater(() -> + { + for (WikiWindow w : windows) + { + w.close(); + } + windows.clear(); + captured = null; + }); + } + + private WikiWindow create(WikiWindow cascadeFrom) + { + Rectangle b; + if (cascadeFrom != null) + { + b = new Rectangle(cascadeFrom.bounds); + b.translate(CASCADE, CASCADE); + int cw = canvasWidth.getAsInt(); + int ch = canvasHeight.getAsInt(); + if (cw > 0 && ch > 0 && (b.x + b.width > cw || b.y + b.height > ch)) + { + b.setLocation(CASCADE, CASCADE); + } + } + else + { + b = lastBounds == null ? null : new Rectangle(lastBounds); + } + WikiWindow w = new WikiWindow(api, executor, canvasWidth, canvasHeight, b, this); + windows.add(w); + return w; + } + + @Override + public void openInNewWindow(String title, String anchor, WikiWindow from) + { + create(from).open(title, anchor); + } + + @Override + public void closed(WikiWindow window) + { + windows.remove(window); + if (captured == window) + { + captured = null; + } + } + + @Override + public void saveBounds(Rectangle bounds) + { + lastBounds = new Rectangle(bounds); + saveBounds.accept(bounds); + } + + @Override + public void pageOpened(String title) + { + onPageOpened.accept(title); + } + + private WikiWindow windowAt(Point p) + { + for (int i = windows.size() - 1; i >= 0; i--) + { + WikiWindow w = windows.get(i); + if (w.partAt(p) != WikiWindow.Part.NONE) + { + return w; + } + } + return null; + } + + private void toFront(WikiWindow w) + { + if (windows.size() > 1 && windows.get(windows.size() - 1) != w) + { + windows.remove(w); + windows.add(w); + } + } + + @Override + public MouseEvent mousePressed(MouseEvent e) + { + WikiWindow w = windowAt(e.getPoint()); + captured = w; + if (w != null) + { + toFront(w); + w.mousePressed(e); + } + return e; + } + + @Override + public MouseEvent mouseReleased(MouseEvent e) + { + WikiWindow w = captured != null ? captured : windowAt(e.getPoint()); + captured = null; + if (w != null) + { + w.mouseReleased(e); + } + return e; + } + + @Override + public MouseEvent mouseClicked(MouseEvent e) + { + WikiWindow w = windowAt(e.getPoint()); + if (w != null) + { + w.mouseClicked(e); + } + return e; + } + + @Override + public MouseEvent mouseEntered(MouseEvent e) + { + return e; + } + + @Override + public MouseEvent mouseExited(MouseEvent e) + { + for (WikiWindow w : windows) + { + w.clearHover(); + } + return e; + } + + @Override + public MouseEvent mouseDragged(MouseEvent e) + { + WikiWindow w = captured != null && captured.isDragging() ? captured : windowAt(e.getPoint()); + if (w != null) + { + w.mouseDragged(e); + } + return e; + } + + @Override + public MouseEvent mouseMoved(MouseEvent e) + { + WikiWindow top = windowAt(e.getPoint()); + for (WikiWindow w : windows) + { + if (w != top) + { + w.clearHover(); + } + } + if (top != null) + { + top.mouseMoved(e); + } + return e; + } + + @Override + public MouseWheelEvent mouseWheelMoved(MouseWheelEvent e) + { + WikiWindow w = windowAt(e.getPoint()); + if (w != null) + { + w.mouseWheelMoved(e); + } + return e; + } +} diff --git a/src/main/java/com/example/essentialsplus/wiki/ui/WikiCard.java b/src/main/java/com/example/essentialsplus/wiki/ui/WikiCard.java new file mode 100644 index 0000000..c971b1e --- /dev/null +++ b/src/main/java/com/example/essentialsplus/wiki/ui/WikiCard.java @@ -0,0 +1,132 @@ +package com.example.essentialsplus.wiki.ui; + +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 javax.swing.JComponent; +import javax.swing.SwingUtilities; +import net.runelite.client.ui.FontManager; +import net.runelite.client.ui.PluginPanel; + +/** Overview entry for the wiki tab. */ +public class WikiCard extends JComponent +{ + private String lastPage; + private boolean hover; + private boolean showLast = true; + + public WikiCard(Runnable 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.run(); + } + } + }); + } + + public void setLastPage(String lastPage) + { + this.lastPage = lastPage; + repaint(); + } + + public void setShowLast(boolean showLast) + { + this.showLast = showLast; + repaint(); + } + + @Override + public Dimension getPreferredSize() + { + return new Dimension(PluginPanel.PANEL_WIDTH - 16, 72); + } + + @Override + public Dimension getMinimumSize() + { + return new Dimension(10, 64); + } + + @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); + g.drawString("Wiki", pad, head); + g.setFont(bold.deriveFont(14f)); + g.setColor(hover ? Ui.ACCENT : Ui.FAINT); + Ui.drawRight(g, "›", w - pad, head); + + g.setFont(FontManager.getRunescapeFont()); + FontMetrics nfm = g.getFontMetrics(); + int line = head + nfm.getAscent() + 6; + g.setColor(Ui.TEXT); + g.drawString("Search items, monsters, places...", pad, line); + + g.setFont(small); + FontMetrics sfm = g.getFontMetrics(); + int line2 = line + sfm.getAscent() + 5; + g.setColor(Ui.FAINT); + String sub = lastPage == null || !showLast ? "Pages open in a window in-game" : "Last viewed: " + lastPage; + g.drawString(clip(sfm, sub, w - pad * 2), pad, line2); + Ui.paintDragOutline(g, this); + 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 ""; + } +} diff --git a/src/main/java/com/example/essentialsplus/wiki/ui/WikiPanel.java b/src/main/java/com/example/essentialsplus/wiki/ui/WikiPanel.java new file mode 100644 index 0000000..fe87d02 --- /dev/null +++ b/src/main/java/com/example/essentialsplus/wiki/ui/WikiPanel.java @@ -0,0 +1,511 @@ +package com.example.essentialsplus.wiki.ui; + +import com.example.essentialsplus.EssentialsPlusPlugin; +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.awt.Image; +import java.awt.event.KeyAdapter; +import java.awt.event.KeyEvent; +import java.awt.event.MouseAdapter; +import java.awt.event.MouseEvent; +import java.awt.image.BufferedImage; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ExecutorService; +import java.util.function.Consumer; +import java.util.function.Supplier; +import javax.swing.BorderFactory; +import javax.swing.ImageIcon; +import javax.swing.JButton; +import javax.swing.JCheckBox; +import javax.swing.JLabel; +import javax.swing.JMenuItem; +import javax.swing.JPanel; +import javax.swing.JPopupMenu; +import javax.swing.JScrollPane; +import javax.swing.ScrollPaneConstants; +import javax.swing.SwingUtilities; +import javax.swing.Timer; +import javax.swing.event.DocumentEvent; +import javax.swing.event.DocumentListener; +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.LinkBrowser; + +/** Wiki tab: search the OSRS wiki like its own search box; results open in the in-game wiki window. */ +public class WikiPanel extends JPanel +{ + private static final int SUGGEST_LIMIT = 15; + private static final int SEARCH_LIMIT = 30; + private static final int ICON = 32; + + private final EssentialsPlusPlugin plugin; + private final WikiApi api; + private final ExecutorService executor; + private final Consumer openInGame; + private final Supplier> recent; + private final JCheckBox menuBox = new JCheckBox("Show \"Wiki\" in right-click menus"); + private final IconTextField searchField = new IconTextField(); + private final JPanel list = new JPanel(new DynamicGridLayout(0, 1, 0, 2)); + private final Timer debounce; + private final Map thumbs = new ConcurrentHashMap<>(); + private int seq; + + public WikiPanel(EssentialsPlusPlugin plugin, Runnable onBack) + { + this.plugin = plugin; + this.api = plugin.getWikiApi(); + this.executor = plugin.getWikiExecutor(); + this.openInGame = plugin::openWikiPage; + this.recent = plugin::getWikiRecent; + + setLayout(new BorderLayout(0, 6)); + setBackground(Ui.BG); + setBorder(BorderFactory.createEmptyBorder(10, 8, 8, 8)); + + JPanel north = new JPanel(new DynamicGridLayout(0, 1, 0, 6)); + north.setOpaque(false); + + 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("Wiki"); + title.setFont(FontManager.getRunescapeBoldFont()); + title.setForeground(Ui.ACCENT); + title.setHorizontalAlignment(JLabel.RIGHT); + backRow.add(title, BorderLayout.CENTER); + north.add(backRow); + + searchField.setIcon(IconTextField.Icon.SEARCH); + searchField.setPreferredSize(new Dimension(PluginPanel.PANEL_WIDTH - 16, 30)); + searchField.setBackground(Ui.CARD); + searchField.setHoverBackgroundColor(Ui.CARD_HOVER); + searchField.setToolTipText("Search items, monsters, places, quests..."); + searchField.getDocument().addDocumentListener(new DocumentListener() + { + @Override + public void insertUpdate(DocumentEvent e) + { + debounce.restart(); + } + + @Override + public void removeUpdate(DocumentEvent e) + { + debounce.restart(); + } + + @Override + public void changedUpdate(DocumentEvent e) + { + debounce.restart(); + } + }); + searchField.addKeyListener(new KeyAdapter() + { + @Override + public void keyPressed(KeyEvent e) + { + if (e.getKeyCode() == KeyEvent.VK_ENTER) + { + debounce.stop(); + submit(); + } + } + }); + searchField.addClearListener(this::showRecent); + north.add(searchField); + + menuBox.setFont(FontManager.getRunescapeSmallFont()); + menuBox.setForeground(Ui.TEXT); + menuBox.setOpaque(false); + menuBox.setFocusPainted(false); + menuBox.setToolTipText("Right-click an NPC, object or item in-game and pick \"Wiki\" to open its page"); + menuBox.setSelected(plugin.isWikiMenuEnabled()); + menuBox.addActionListener(e -> plugin.setWikiMenuEnabled(menuBox.isSelected())); + north.add(menuBox); + + JLabel tip = new JLabel("Middle-click a result or link: new window"); + tip.setFont(FontManager.getRunescapeSmallFont()); + tip.setForeground(Ui.FAINT); + north.add(tip); + add(north, 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); + + debounce = new Timer(250, e -> suggest()); + debounce.setRepeats(false); + showRecent(); + } + + /** Called when the tab becomes visible. */ + public void onShow() + { + menuBox.setSelected(plugin.isWikiMenuEnabled()); + if (searchField.getText().trim().isEmpty()) + { + showRecent(); + } + SwingUtilities.invokeLater(searchField::requestFocusInWindow); + } + + private void showRecent() + { + seq++; + list.removeAll(); + List titles = recent.get(); + if (titles.isEmpty()) + { + list.add(hint("Search the Old School RuneScape Wiki. Pages open in a window inside the game.")); + } + else + { + list.add(header("RECENTLY VIEWED")); + for (String t : titles) + { + list.add(new ResultRow(new WikiApi.Result(t, "", null), null)); + } + } + refreshList(); + } + + private void suggest() + { + String q = searchField.getText().trim(); + if (q.isEmpty()) + { + showRecent(); + return; + } + int mySeq = ++seq; + executor.execute(() -> + { + try + { + List results = api.suggest(q, SUGGEST_LIMIT); + SwingUtilities.invokeLater(() -> showResults(mySeq, q, results, false)); + } + catch (Exception e) + { + SwingUtilities.invokeLater(() -> showError(mySeq, e)); + } + }); + } + + /** Enter: go straight to an exact title match, otherwise list pages containing the text. */ + private void submit() + { + String q = searchField.getText().trim(); + if (q.isEmpty()) + { + return; + } + int mySeq = ++seq; + executor.execute(() -> + { + try + { + List suggestions = api.suggest(q, SUGGEST_LIMIT); + for (WikiApi.Result r : suggestions) + { + if (r.title.equalsIgnoreCase(q)) + { + SwingUtilities.invokeLater(() -> + { + if (mySeq == seq) + { + showResults(mySeq, q, suggestions, false); + openInGame.accept(r.title); + } + }); + return; + } + } + fullText(mySeq, q); + } + catch (Exception e) + { + SwingUtilities.invokeLater(() -> showError(mySeq, e)); + } + }); + } + + private void searchContaining(String q) + { + int mySeq = ++seq; + list.removeAll(); + list.add(hint("Searching...")); + refreshList(); + executor.execute(() -> + { + try + { + fullText(mySeq, q); + } + catch (Exception e) + { + SwingUtilities.invokeLater(() -> showError(mySeq, e)); + } + }); + } + + private void fullText(int mySeq, String q) throws java.io.IOException + { + List results = api.search(q, SEARCH_LIMIT); + SwingUtilities.invokeLater(() -> showResults(mySeq, q, results, true)); + } + + private void showResults(int mySeq, String q, List results, boolean fullText) + { + if (mySeq != seq) + { + return; + } + list.removeAll(); + list.add(header(fullText ? "PAGES CONTAINING \"" + q.toUpperCase() + "\"" : "RESULTS")); + if (results.isEmpty()) + { + list.add(hint(fullText ? "No pages found." : "No matching titles.")); + } + for (WikiApi.Result r : results) + { + list.add(new ResultRow(r, r.thumbnail)); + } + if (!fullText) + { + list.add(new ActionRow("Search for pages containing \"" + q + "\"", () -> searchContaining(q))); + } + refreshList(); + } + + private void showError(int mySeq, Exception e) + { + if (mySeq != seq) + { + return; + } + list.removeAll(); + list.add(hint("Wiki unreachable: " + (e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage()))); + refreshList(); + } + + private void refreshList() + { + list.revalidate(); + list.repaint(); + } + + private static JLabel header(String text) + { + JLabel l = new JLabel(text); + l.setFont(FontManager.getRunescapeSmallFont()); + l.setForeground(Ui.MUTED); + l.setBorder(BorderFactory.createEmptyBorder(4, 2, 2, 2)); + return l; + } + + private static JLabel hint(String text) + { + JLabel l = new JLabel("" + text.replace("<", "<") + ""); + l.setFont(FontManager.getRunescapeSmallFont()); + l.setForeground(Ui.FAINT); + l.setBorder(BorderFactory.createEmptyBorder(6, 4, 6, 4)); + return l; + } + + private void loadThumb(String url, JLabel target) + { + ImageIcon cached = thumbs.get(url); + if (cached != null) + { + target.setIcon(cached); + return; + } + executor.execute(() -> + { + try + { + BufferedImage img = api.fetchImage(url); + if (img == null) + { + return; + } + double scale = Math.min((double) ICON / img.getWidth(), (double) ICON / img.getHeight()); + int w = Math.max(1, (int) Math.round(img.getWidth() * scale)); + int h = Math.max(1, (int) Math.round(img.getHeight() * scale)); + ImageIcon icon = new ImageIcon(img.getScaledInstance(w, h, Image.SCALE_SMOOTH)); + thumbs.put(url, icon); + SwingUtilities.invokeLater(() -> target.setIcon(icon)); + } + catch (Exception ignored) + { + // no thumbnail + } + }); + } + + private void showMenu(MouseEvent e, String pageTitle) + { + JPopupMenu menu = new JPopupMenu(); + JMenuItem inGame = new JMenuItem("Open in-game"); + inGame.addActionListener(a -> openInGame.accept(pageTitle)); + menu.add(inGame); + JMenuItem newWindow = new JMenuItem("Open in new in-game window"); + newWindow.addActionListener(a -> plugin.openWikiPageNewWindow(pageTitle)); + menu.add(newWindow); + JMenuItem browser = new JMenuItem("Open in browser"); + browser.addActionListener(a -> LinkBrowser.browse(WikiApi.pageUrl(pageTitle))); + menu.add(browser); + menu.show(e.getComponent(), e.getX(), e.getY()); + } + + /** Clickable row with a hover background. */ + private abstract static class Row extends JPanel + { + Row() + { + super(new BorderLayout(8, 0)); + setBackground(Ui.CARD); + setBorder(BorderFactory.createEmptyBorder(5, 6, 5, 6)); + setCursor(Cursor.getPredefinedCursor(Cursor.HAND_CURSOR)); + addMouseListener(new MouseAdapter() + { + @Override + public void mouseEntered(MouseEvent e) + { + setBackground(Ui.CARD_HOVER); + } + + @Override + public void mouseExited(MouseEvent e) + { + setBackground(Ui.CARD); + } + + @Override + public void mouseReleased(MouseEvent e) + { + if (e.isPopupTrigger() || SwingUtilities.isRightMouseButton(e)) + { + onRightClick(e); + } + else if (SwingUtilities.isMiddleMouseButton(e) && contains(e.getPoint())) + { + onMiddleClick(); + } + else if (SwingUtilities.isLeftMouseButton(e) && contains(e.getPoint())) + { + onClick(); + } + } + }); + } + + abstract void onClick(); + + void onMiddleClick() + { + onClick(); + } + + void onRightClick(MouseEvent e) + { + } + } + + private final class ResultRow extends Row + { + private final String pageTitle; + + ResultRow(WikiApi.Result r, String thumbnail) + { + this.pageTitle = r.title; + JLabel icon = new JLabel(); + icon.setPreferredSize(new Dimension(ICON, ICON)); + icon.setHorizontalAlignment(JLabel.CENTER); + if (thumbnail != null) + { + loadThumb(thumbnail, icon); + } + add(icon, BorderLayout.WEST); + + JPanel text = new JPanel(new DynamicGridLayout(0, 1, 0, 0)); + text.setOpaque(false); + JLabel name = new JLabel(r.title); + name.setFont(FontManager.getRunescapeFont()); + name.setForeground(Ui.TEXT); + text.add(name); + if (!r.snippet.isEmpty()) + { + JLabel sub = new JLabel(r.snippet); + sub.setFont(FontManager.getRunescapeSmallFont()); + sub.setForeground(Ui.MUTED); + text.add(sub); + } + add(text, BorderLayout.CENTER); + } + + @Override + void onClick() + { + openInGame.accept(pageTitle); + } + + @Override + void onMiddleClick() + { + plugin.openWikiPageNewWindow(pageTitle); + } + + @Override + void onRightClick(MouseEvent e) + { + showMenu(e, pageTitle); + } + } + + private static final class ActionRow extends Row + { + private final Runnable action; + + ActionRow(String label, Runnable action) + { + this.action = action; + JLabel l = new JLabel(label); + l.setFont(FontManager.getRunescapeSmallFont()); + l.setForeground(Ui.ACCENT); + add(l, BorderLayout.CENTER); + } + + @Override + void onClick() + { + action.run(); + } + } +} diff --git a/src/main/java/com/example/livedashboard/LiveDashboardConfig.java b/src/main/java/com/example/livedashboard/LiveDashboardConfig.java new file mode 100644 index 0000000..bc2914c --- /dev/null +++ b/src/main/java/com/example/livedashboard/LiveDashboardConfig.java @@ -0,0 +1,33 @@ +package com.example.livedashboard; + +import net.runelite.client.config.Config; +import net.runelite.client.config.ConfigGroup; +import net.runelite.client.config.ConfigItem; + +@ConfigGroup(LiveDashboardConfig.GROUP) +public interface LiveDashboardConfig extends Config +{ + String GROUP = "livedashboard"; + + @ConfigItem( + keyName = "port", + name = "Server Port (0 = auto)", + description = "Port fuer den integrierten Webserver. Bei 0 wird automatisch ein freier Port gewaehlt.", + position = 1 + ) + default int port() + { + return 0; + } + + @ConfigItem( + keyName = "autoOpenBrowser", + name = "Browser beim Start oeffnen", + description = "Oeffnet automatisch das Dashboard im Standardbrowser.", + position = 2 + ) + default boolean autoOpenBrowser() + { + return false; + } +} diff --git a/src/main/java/com/example/livedashboard/LiveDashboardPlugin.java b/src/main/java/com/example/livedashboard/LiveDashboardPlugin.java new file mode 100644 index 0000000..c910556 --- /dev/null +++ b/src/main/java/com/example/livedashboard/LiveDashboardPlugin.java @@ -0,0 +1,709 @@ +package com.example.livedashboard; + +import com.example.livedashboard.overlay.GpsPathOverlay; +import com.example.livedashboard.pathfinding.Pathfinder; +import com.example.livedashboard.pathfinding.ShortcutData; +import com.example.livedashboard.ui.LiveDashboardPanel; +import com.google.inject.Provides; +import java.awt.image.BufferedImage; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import javax.inject.Inject; +import lombok.Getter; +import lombok.Setter; +import lombok.extern.slf4j.Slf4j; +import net.runelite.api.Client; +import net.runelite.api.EquipmentInventorySlot; +import net.runelite.api.GameState; +import net.runelite.api.InventoryID; +import net.runelite.api.Item; +import net.runelite.api.ItemComposition; +import net.runelite.api.ItemContainer; +import net.runelite.api.Player; +import net.runelite.api.Skill; +import net.runelite.api.coords.LocalPoint; +import net.runelite.api.coords.WorldPoint; +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.events.ConfigChanged; +import net.runelite.client.game.ItemManager; +import net.runelite.client.plugins.Plugin; +import net.runelite.client.plugins.PluginDescriptor; +import net.runelite.client.ui.ClientToolbar; +import net.runelite.client.ui.NavigationButton; +import net.runelite.client.ui.overlay.OverlayManager; +import net.runelite.client.util.ImageUtil; + +@Slf4j +@PluginDescriptor( + name = "Live Web Dashboard", + description = "Zeigt Echtzeit-Koordinaten, Vitals, Map, Hindernis-GPS-Navigation, Ausruestung, Inventar und Bank an", + tags = {"telemetry", "web", "dashboard", "live", "coordinates", "coords", "map", "bank", "inventory", "equipment", "ge", "gps"} +) +public class LiveDashboardPlugin extends Plugin +{ + @Inject + private Client client; + + @Inject + private ClientThread clientThread; + + @Inject + private ClientToolbar clientToolbar; + + @Inject + private OverlayManager overlayManager; + + @Inject + private GpsPathOverlay gpsPathOverlay; + + @Inject + private ItemManager itemManager; + + @Inject + private LiveDashboardConfig config; + + private LiveDashboardServer server; + private LiveDashboardPanel panel; + private NavigationButton navButton; + private int tickCounter = 0; + + @Getter + private volatile WorldPoint gpsTarget = null; + + @Getter + private volatile List gpsPath = Collections.emptyList(); + + @Getter + private volatile List nearbyShortcuts = Collections.emptyList(); + + private WorldPoint lastCalculatedPlayerPos = null; + private WorldPoint lastCalculatedTargetPos = null; + + // Cached containers (Bank persists once opened during session) + private List> cachedInventory = new ArrayList<>(); + private List> cachedEquipment = new ArrayList<>(); + private List> cachedBank = new ArrayList<>(); + + // Total item count index (Item ID -> total qty across inv, equip, bank) + private Map> cachedItemSummary = new HashMap<>(); + + // Natural regeneration trackers + private int hpRegenTicksLeft = 100; + private int lastHp = -1; + + private int energyRegenTicksLeft = 10; + private int lastEnergy = -1; + + private WorldPoint lastWorldPoint = null; + + @Provides + LiveDashboardConfig provideConfig(ConfigManager configManager) + { + return configManager.getConfig(LiveDashboardConfig.class); + } + + @Override + protected void startUp() throws Exception + { + log.info("Live Dashboard Plugin startet..."); + server = new LiveDashboardServer(this); + panel = new LiveDashboardPanel(this); + + BufferedImage icon = null; + try + { + icon = ImageUtil.loadImageResource(getClass(), "/com/example/livedashboard/icon.png"); + } + catch (Exception e) + { + try + { + icon = ImageUtil.loadImageResource(getClass(), "/com/example/icon.png"); + } + catch (Exception ignored) + { + } + } + + navButton = NavigationButton.builder() + .tooltip("Live Web Dashboard") + .icon(icon) + .priority(6) + .panel(panel) + .build(); + + clientToolbar.addNavigation(navButton); + overlayManager.add(gpsPathOverlay); + + startServer(config.port()); + + clientThread.invokeLater(this::collectAndSendTelemetry); + } + + @Override + protected void shutDown() throws Exception + { + log.info("Live Dashboard Plugin stoppt..."); + overlayManager.remove(gpsPathOverlay); + + if (server != null) + { + server.stop(); + server = null; + } + + if (navButton != null) + { + clientToolbar.removeNavigation(navButton); + navButton = null; + } + panel = null; + setGpsTarget(null); + cachedInventory.clear(); + cachedEquipment.clear(); + cachedBank.clear(); + cachedItemSummary.clear(); + } + + public void setGpsTarget(WorldPoint target) + { + this.gpsTarget = target; + if (target == null) + { + this.gpsPath = Collections.emptyList(); + this.nearbyShortcuts = Collections.emptyList(); + this.lastCalculatedPlayerPos = null; + this.lastCalculatedTargetPos = null; + } + else + { + clientThread.invokeLater(this::recalculatePath); + } + } + + public void toggleServer() + { + if (server != null && server.isRunning()) + { + server.stop(); + if (panel != null) + { + panel.updateServerState(false, -1); + } + } + else + { + startServer(config.port()); + } + } + + private void startServer(int port) + { + try + { + if (server == null) + { + server = new LiveDashboardServer(this); + } + server.start(port); + if (panel != null) + { + panel.updateServerState(true, server.getPort()); + } + } + catch (IOException e) + { + log.error("Fehler beim Starten des Dashboard Webservers auf Port {}", port, e); + if (panel != null) + { + panel.updateServerState(false, -1); + } + } + } + + public String getServerUrl() + { + return server != null ? server.getUrl() : ""; + } + + @Subscribe + public void onGameTick(GameTick event) + { + tickCounter++; + + updateRegenTickers(); + recalculatePath(); + collectAndSendTelemetry(); + } + + private void recalculatePath() + { + if (gpsTarget == null || client.getGameState() != GameState.LOGGED_IN || client.getLocalPlayer() == null) + { + return; + } + + WorldPoint playerPos = client.getLocalPlayer().getWorldLocation(); + if (playerPos == null) + { + return; + } + + if (playerPos.equals(gpsTarget)) + { + setGpsTarget(null); + return; + } + + // Dynamically recompute whenever the player moves or target changes + if (!playerPos.equals(lastCalculatedPlayerPos) || !gpsTarget.equals(lastCalculatedTargetPos)) + { + lastCalculatedPlayerPos = playerPos; + lastCalculatedTargetPos = gpsTarget; + gpsPath = Pathfinder.findPath(client, playerPos, gpsTarget); + nearbyShortcuts = Pathfinder.findNearbyShortcuts(client, gpsPath); + } + } + + private void updateRegenTickers() + { + if (client.getGameState() != GameState.LOGGED_IN) + { + return; + } + + int currentHp = client.getBoostedSkillLevel(Skill.HITPOINTS); + boolean rapidHeal = client.isPrayerActive(net.runelite.api.Prayer.RAPID_HEAL); + int hpMaxPeriod = rapidHeal ? 50 : 100; + + if (lastHp != -1 && currentHp != lastHp) + { + hpRegenTicksLeft = hpMaxPeriod; + } + else + { + hpRegenTicksLeft--; + if (hpRegenTicksLeft <= 0) + { + hpRegenTicksLeft = hpMaxPeriod; + } + } + lastHp = currentHp; + + int currentEnergy = client.getEnergy() / 100; + if (currentEnergy >= 100) + { + energyRegenTicksLeft = 0; + } + else + { + if (lastEnergy != -1 && currentEnergy > lastEnergy) + { + int agility = client.getRealSkillLevel(Skill.AGILITY); + energyRegenTicksLeft = Math.max(2, (int) Math.round((100.0 - (agility * 0.6)) / 10.0)); + } + else + { + if (energyRegenTicksLeft > 0) + { + energyRegenTicksLeft--; + } + else + { + int agility = client.getRealSkillLevel(Skill.AGILITY); + energyRegenTicksLeft = Math.max(2, (int) Math.round((100.0 - (agility * 0.6)) / 10.0)); + } + } + } + lastEnergy = currentEnergy; + } + + @Subscribe + public void onItemContainerChanged(ItemContainerChanged event) + { + int containerId = event.getContainerId(); + if (containerId == InventoryID.INVENTORY.getId()) + { + cachedInventory = serializeContainer(event.getItemContainer(), false); + rebuildItemSummary(); + } + else if (containerId == InventoryID.EQUIPMENT.getId()) + { + cachedEquipment = serializeEquipmentContainer(event.getItemContainer()); + rebuildItemSummary(); + } + else if (containerId == InventoryID.BANK.getId()) + { + cachedBank = serializeContainer(event.getItemContainer(), true); + rebuildItemSummary(); + } + } + + private void rebuildItemSummary() + { + Map> map = new HashMap<>(); + + for (Map item : cachedInventory) + { + if (item == null) continue; + Object idObj = item.get("id"); + Object qtyObj = item.get("qty"); + if (idObj == null) continue; + int id = (int) idObj; + int qty = qtyObj instanceof Number ? ((Number) qtyObj).intValue() : 1; + Map counts = map.computeIfAbsent(id, k -> new HashMap<>()); + counts.put("inv", counts.getOrDefault("inv", 0) + qty); + } + + for (Map item : cachedEquipment) + { + if (item == null) continue; + Object idObj = item.get("id"); + Object qtyObj = item.get("qty"); + if (idObj == null) continue; + int id = (int) idObj; + int qty = qtyObj instanceof Number ? ((Number) qtyObj).intValue() : 1; + Map counts = map.computeIfAbsent(id, k -> new HashMap<>()); + counts.put("equip", counts.getOrDefault("equip", 0) + qty); + } + + for (Map item : cachedBank) + { + if (item == null) continue; + Object idObj = item.get("id"); + Object qtyObj = item.get("qty"); + if (idObj == null) continue; + int id = (int) idObj; + int qty = qtyObj instanceof Number ? ((Number) qtyObj).intValue() : 1; + Map counts = map.computeIfAbsent(id, k -> new HashMap<>()); + counts.put("bank", counts.getOrDefault("bank", 0) + qty); + } + + cachedItemSummary = map; + } + + private List> serializeContainer(ItemContainer container, boolean isBank) + { + List> list = new ArrayList<>(); + if (container == null) + { + return list; + } + + Item[] items = container.getItems(); + for (int i = 0; i < items.length; i++) + { + Item item = items[i]; + if (item == null || item.getId() <= 0) + { + if (!isBank) + { + Map emptyMap = new LinkedHashMap<>(); + emptyMap.put("slot", i); + emptyMap.put("id", 0); + emptyMap.put("qty", 0); + list.add(emptyMap); + } + continue; + } + + Map itemMap = new LinkedHashMap<>(); + itemMap.put("slot", i); + itemMap.put("id", item.getId()); + itemMap.put("qty", item.getQuantity()); + + ItemComposition comp = itemManager.getItemComposition(item.getId()); + if (comp != null) + { + itemMap.put("name", comp.getName()); + itemMap.put("members", comp.isMembers()); + itemMap.put("haPrice", comp.getHaPrice()); + } + else + { + itemMap.put("name", "Item #" + item.getId()); + } + + int gePrice = itemManager.getItemPrice(item.getId()); + itemMap.put("price", gePrice); + + list.add(itemMap); + } + return list; + } + + private List> serializeEquipmentContainer(ItemContainer container) + { + List> list = new ArrayList<>(); + if (container == null) + { + return list; + } + + EquipmentInventorySlot[] slots = { + EquipmentInventorySlot.HEAD, + EquipmentInventorySlot.CAPE, + EquipmentInventorySlot.AMULET, + EquipmentInventorySlot.AMMO, + EquipmentInventorySlot.WEAPON, + EquipmentInventorySlot.BODY, + EquipmentInventorySlot.SHIELD, + EquipmentInventorySlot.LEGS, + EquipmentInventorySlot.GLOVES, + EquipmentInventorySlot.BOOTS, + EquipmentInventorySlot.RING + }; + + for (EquipmentInventorySlot slot : slots) + { + int slotIdx = slot.getSlotIdx(); + Item item = container.getItem(slotIdx); + Map slotMap = new LinkedHashMap<>(); + slotMap.put("slotName", slot.name()); + slotMap.put("slotIdx", slotIdx); + + if (item != null && item.getId() > 0) + { + slotMap.put("id", item.getId()); + slotMap.put("qty", item.getQuantity()); + ItemComposition comp = itemManager.getItemComposition(item.getId()); + if (comp != null) + { + slotMap.put("name", comp.getName()); + slotMap.put("members", comp.isMembers()); + slotMap.put("haPrice", comp.getHaPrice()); + } + else + { + slotMap.put("name", "Item #" + item.getId()); + } + slotMap.put("price", itemManager.getItemPrice(item.getId())); + } + else + { + slotMap.put("id", 0); + } + + list.add(slotMap); + } + return list; + } + + @Subscribe + public void onGameStateChanged(GameStateChanged event) + { + if (event.getGameState() == GameState.LOGIN_SCREEN) + { + cachedInventory.clear(); + cachedEquipment.clear(); + cachedBank.clear(); + cachedItemSummary.clear(); + setGpsTarget(null); + } + collectAndSendTelemetry(); + } + + @Subscribe + public void onConfigChanged(ConfigChanged event) + { + if (!LiveDashboardConfig.GROUP.equals(event.getGroup())) + { + return; + } + + if ("port".equals(event.getKey())) + { + if (server != null && server.isRunning()) + { + startServer(config.port()); + } + } + } + + private void collectAndSendTelemetry() + { + Map data = new LinkedHashMap<>(); + GameState gs = client.getGameState(); + data.put("gameState", gs != null ? gs.name() : "UNKNOWN"); + data.put("gameTick", tickCounter); + + if (gs == GameState.LOGGED_IN) + { + Player player = client.getLocalPlayer(); + boolean isMoving = false; + if (player != null) + { + data.put("player", player.getName()); + WorldPoint wp = player.getWorldLocation(); + if (wp != null) + { + data.put("worldX", wp.getX()); + data.put("worldY", wp.getY()); + data.put("plane", wp.getPlane()); + data.put("regionId", wp.getRegionID()); + data.put("regionX", wp.getRegionX()); + data.put("regionY", wp.getRegionY()); + + if (lastWorldPoint != null && !lastWorldPoint.equals(wp)) + { + isMoving = true; + } + lastWorldPoint = wp; + } + + LocalPoint lp = player.getLocalLocation(); + if (lp != null) + { + data.put("sceneX", lp.getSceneX()); + data.put("sceneY", lp.getSceneY()); + } + + data.put("orientation", player.getOrientation()); + data.put("animation", player.getAnimation()); + data.put("poseAnimation", player.getPoseAnimation()); + if (player.getPoseAnimation() != player.getIdlePoseAnimation()) + { + isMoving = true; + } + } + data.put("isMoving", isMoving); + + data.put("world", client.getWorld()); + data.put("cameraYaw", client.getCameraYaw()); + data.put("cameraPitch", client.getCameraPitch()); + data.put("weight", client.getWeight()); + + // Vitals + int hp = client.getBoostedSkillLevel(Skill.HITPOINTS); + int maxHp = client.getRealSkillLevel(Skill.HITPOINTS); + data.put("hp", hp); + data.put("maxHp", maxHp); + data.put("hpRegenSeconds", (int) Math.ceil(hpRegenTicksLeft * 0.6)); + data.put("hpRegenTicks", hpRegenTicksLeft); + + data.put("prayer", client.getBoostedSkillLevel(Skill.PRAYER)); + data.put("maxPrayer", client.getRealSkillLevel(Skill.PRAYER)); + + int energy = client.getEnergy() / 100; + data.put("energy", energy); + data.put("energyRegenSeconds", energy >= 100 ? 0 : (int) Math.ceil(energyRegenTicksLeft * 0.6)); + data.put("energyRegenTicks", energy >= 100 ? 0 : energyRegenTicksLeft); + + int agility = client.getRealSkillLevel(Skill.AGILITY); + data.put("agilityLevel", agility); + + // Containers + if (cachedInventory.isEmpty()) + { + ItemContainer inv = client.getItemContainer(InventoryID.INVENTORY); + if (inv != null) + { + cachedInventory = serializeContainer(inv, false); + } + } + if (cachedEquipment.isEmpty()) + { + ItemContainer equip = client.getItemContainer(InventoryID.EQUIPMENT); + if (equip != null) + { + cachedEquipment = serializeEquipmentContainer(equip); + } + } + if (cachedBank.isEmpty()) + { + ItemContainer bank = client.getItemContainer(InventoryID.BANK); + if (bank != null) + { + cachedBank = serializeContainer(bank, true); + } + } + if (cachedItemSummary.isEmpty() && (!cachedInventory.isEmpty() || !cachedEquipment.isEmpty() || !cachedBank.isEmpty())) + { + rebuildItemSummary(); + } + + long invCount = cachedInventory.stream().filter(m -> ((Number) m.getOrDefault("id", 0)).intValue() > 0).count(); + data.put("inventory", cachedInventory); + data.put("invCount", invCount); + + long equipCount = cachedEquipment.stream().filter(m -> ((Number) m.getOrDefault("id", 0)).intValue() > 0).count(); + data.put("equipment", cachedEquipment); + data.put("equipCount", equipCount); + + data.put("bank", cachedBank); + data.put("bankCount", cachedBank.size()); + data.put("itemSummary", cachedItemSummary); + + // Active GPS target & obstacle-free path & ETA estimation + if (gpsTarget != null) + { + Map gpsMap = new LinkedHashMap<>(); + gpsMap.put("x", gpsTarget.getX()); + gpsMap.put("y", gpsTarget.getY()); + gpsMap.put("plane", gpsTarget.getPlane()); + + List> pathList = new ArrayList<>(); + if (gpsPath != null) + { + for (WorldPoint p : gpsPath) + { + Map pMap = new LinkedHashMap<>(); + pMap.put("x", p.getX()); + pMap.put("y", p.getY()); + pathList.add(pMap); + } + } + gpsMap.put("path", pathList); + + int totalTiles = (gpsPath != null && !gpsPath.isEmpty()) + ? gpsPath.size() + : (player != null && player.getWorldLocation() != null ? player.getWorldLocation().distanceTo(gpsTarget) : 0); + gpsMap.put("totalTiles", totalTiles); + + // Running = 2 tiles/tick (1.2s per 2 tiles = 0.6s per tile average) + // Walking = 1 tile/tick (0.6s per tile) + int runSeconds = (int) Math.ceil((totalTiles / 2.0) * 0.6); + int walkSeconds = (int) Math.ceil(totalTiles * 0.6); + gpsMap.put("runEtaSeconds", runSeconds); + gpsMap.put("walkEtaSeconds", walkSeconds); + + // Nearby Agility Shortcuts along route + List> scList = new ArrayList<>(); + if (nearbyShortcuts != null) + { + for (ShortcutData sc : nearbyShortcuts) + { + Map m = new LinkedHashMap<>(); + m.put("name", sc.getName()); + m.put("level", sc.getRequiredLevel()); + m.put("desc", sc.getDescription()); + m.put("canUse", agility >= sc.getRequiredLevel()); + scList.add(m); + } + } + gpsMap.put("shortcuts", scList); + + data.put("gpsTarget", gpsMap); + } + } + + if (server != null) + { + server.updateTelemetry(data); + } + if (panel != null) + { + panel.updateTelemetry(data); + } + } +} diff --git a/src/main/java/com/example/livedashboard/LiveDashboardServer.java b/src/main/java/com/example/livedashboard/LiveDashboardServer.java new file mode 100644 index 0000000..a3d53c9 --- /dev/null +++ b/src/main/java/com/example/livedashboard/LiveDashboardServer.java @@ -0,0 +1,1505 @@ +package com.example.livedashboard; + +import com.google.gson.Gson; +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpHandler; +import com.sun.net.httpserver.HttpServer; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.Executors; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; +import net.runelite.api.coords.WorldPoint; + +@Slf4j +public class LiveDashboardServer +{ + private final LiveDashboardPlugin plugin; + private final Gson gson = new Gson(); + private HttpServer server; + + @Getter + private int port = -1; + + @Getter + private volatile boolean running = false; + + private final Map telemetryData = new ConcurrentHashMap<>(); + + public LiveDashboardServer(LiveDashboardPlugin plugin) + { + this.plugin = plugin; + } + + public synchronized void start(int preferredPort) throws IOException + { + stop(); + + server = HttpServer.create(new InetSocketAddress(preferredPort), 0); + server.setExecutor(Executors.newCachedThreadPool()); + + server.createContext("/", new IndexHandler()); + server.createContext("/api/telemetry", new TelemetryHandler()); + server.createContext("/api/gps", new GpsHandler()); + + server.start(); + this.port = server.getAddress().getPort(); + this.running = true; + log.info("Live Dashboard Server started on http://localhost:{}", this.port); + } + + public synchronized void stop() + { + if (server != null) + { + try + { + server.stop(0); + log.info("Live Dashboard Server stopped (Port {})", this.port); + } + catch (Exception e) + { + log.warn("Error stopping server", e); + } + server = null; + } + this.running = false; + this.port = -1; + } + + public void updateTelemetry(Map data) + { + if (data == null) + { + return; + } + telemetryData.clear(); + for (Map.Entry entry : data.entrySet()) + { + if (entry.getKey() != null && entry.getValue() != null) + { + telemetryData.put(entry.getKey(), entry.getValue()); + } + } + } + + public String getUrl() + { + return running ? "http://localhost:" + port : ""; + } + + private class GpsHandler implements HttpHandler + { + @Override + public void handle(HttpExchange exchange) throws IOException + { + exchange.getResponseHeaders().set("Content-Type", "application/json; charset=UTF-8"); + exchange.getResponseHeaders().set("Access-Control-Allow-Origin", "*"); + exchange.getResponseHeaders().set("Access-Control-Allow-Methods", "POST, DELETE, OPTIONS"); + exchange.getResponseHeaders().set("Access-Control-Allow-Headers", "Content-Type"); + + if ("OPTIONS".equalsIgnoreCase(exchange.getRequestMethod())) + { + exchange.sendResponseHeaders(204, -1); + return; + } + + if ("DELETE".equalsIgnoreCase(exchange.getRequestMethod())) + { + plugin.setGpsTarget(null); + byte[] res = "{\"success\":true,\"target\":null}".getBytes(StandardCharsets.UTF_8); + exchange.sendResponseHeaders(200, res.length); + try (OutputStream os = exchange.getResponseBody()) { os.write(res); } + return; + } + + if ("POST".equalsIgnoreCase(exchange.getRequestMethod())) + { + try (InputStream is = exchange.getRequestBody()) + { + String body = new String(is.readAllBytes(), StandardCharsets.UTF_8); + @SuppressWarnings("unchecked") + Map map = gson.fromJson(body, Map.class); + if (map != null && map.containsKey("x") && map.containsKey("y")) + { + int x = ((Number) map.get("x")).intValue(); + int y = ((Number) map.get("y")).intValue(); + int plane = map.containsKey("plane") ? ((Number) map.get("plane")).intValue() : 0; + plugin.setGpsTarget(new WorldPoint(x, y, plane)); + log.info("GPS Target set via web dashboard to: {}, {}, plane {}", x, y, plane); + + byte[] res = ("{\"success\":true,\"x\":" + x + ",\"y\":" + y + ",\"plane\":" + plane + "}").getBytes(StandardCharsets.UTF_8); + exchange.sendResponseHeaders(200, res.length); + try (OutputStream os = exchange.getResponseBody()) { os.write(res); } + return; + } + } + catch (Exception ex) + { + log.warn("Error parsing GPS request", ex); + } + } + + exchange.sendResponseHeaders(400, -1); + } + } + + private class TelemetryHandler implements HttpHandler + { + @Override + public void handle(HttpExchange exchange) throws IOException + { + exchange.getResponseHeaders().set("Content-Type", "application/json; charset=UTF-8"); + exchange.getResponseHeaders().set("Access-Control-Allow-Origin", "*"); + + if ("OPTIONS".equalsIgnoreCase(exchange.getRequestMethod())) + { + exchange.getResponseHeaders().set("Access-Control-Allow-Methods", "GET, OPTIONS"); + exchange.sendResponseHeaders(204, -1); + return; + } + + String json = gson.toJson(telemetryData); + byte[] bytes = json.getBytes(StandardCharsets.UTF_8); + + exchange.sendResponseHeaders(200, bytes.length); + try (OutputStream os = exchange.getResponseBody()) + { + os.write(bytes); + } + } + } + + private class IndexHandler implements HttpHandler + { + @Override + public void handle(HttpExchange exchange) throws IOException + { + if (!"/".equals(exchange.getRequestURI().getPath())) + { + exchange.sendResponseHeaders(404, -1); + return; + } + + String html = "\n" + + "\n" + + "\n" + + " \n" + + " \n" + + " RuneLite Live Map & Telemetrie\n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + "\n" + + "\n" + + "
\n" + + "
\n" + + "
⚔
\n" + + "
\n" + + "

OSRS Live Map & Telemetry

\n" + + "
\n" + + "
\n" + + "
\n" + + " \n" + + " \n" + + " \n" + + "
\n" + + " \n" + + " Verbinde...\n" + + "
\n" + + "
\n" + + "
\n" + + "\n" + + "
\n" + + "
\n" + + "
\n" + + " \n" + + "
\n" + + " 🚩 Ziel: -\n" + + " \n" + + "
\n" + + "
\n" + + "
\n" + + "
\n" + + "\n" + + " \n" + + "
\n" + + "\n" + + " \n" + + "
\n" + + "
\n" + + "
\n" + + "

🎒 Inventar (0/28)

\n" + + " \n" + + "
\n" + + "
\n" + + "
\n" + + "
\n" + + "
\n" + + "
\n" + + "\n" + + " \n" + + "
\n" + + "
\n" + + "
\n" + + "

🛡️ Angelegte Ausrüstung

\n" + + " \n" + + "
\n" + + "
\n" + + "
\n" + + "
\n" + + "
\n" + + "
\n" + + "\n" + + " \n" + + "
\n" + + "
\n" + + "
\n" + + "

🏦 Spieler Bank (0 Items)

\n" + + " \n" + + "
\n" + + "
\n" + + "
\n" + + " \n" + + " \n" + + "
\n" + + "
\n" + + "
\n" + + "
\n" + + "
\n" + + "
\n" + + "
\n" + + "\n" + + " \n" + + "
\n" + + "\n" + + " \n" + + " \n" + + " \n" + + "\n" + + ""; + + byte[] bytes = html.getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().set("Content-Type", "text/html; charset=UTF-8"); + exchange.sendResponseHeaders(200, bytes.length); + try (OutputStream os = exchange.getResponseBody()) + { + os.write(bytes); + } + } + } +} diff --git a/src/main/java/com/example/livedashboard/overlay/GpsPathOverlay.java b/src/main/java/com/example/livedashboard/overlay/GpsPathOverlay.java new file mode 100644 index 0000000..38c94a8 --- /dev/null +++ b/src/main/java/com/example/livedashboard/overlay/GpsPathOverlay.java @@ -0,0 +1,183 @@ +package com.example.livedashboard.overlay; + +import com.example.livedashboard.LiveDashboardPlugin; +import java.awt.BasicStroke; +import java.awt.Color; +import java.awt.Dimension; +import java.awt.Graphics2D; +import java.awt.Polygon; +import java.awt.Stroke; +import java.util.ArrayList; +import java.util.List; +import javax.inject.Inject; +import javax.inject.Singleton; +import net.runelite.api.Client; +import net.runelite.api.GameState; +import net.runelite.api.Perspective; +import net.runelite.api.Point; +import net.runelite.api.coords.LocalPoint; +import net.runelite.api.coords.WorldPoint; +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.OverlayUtil; + +@Singleton +public class GpsPathOverlay extends Overlay +{ + private final Client client; + private final LiveDashboardPlugin plugin; + + // Target tile styles + private static final Color GPS_TARGET_FILL = new Color(245, 158, 11, 75); + private static final Color GPS_TARGET_BORDER = new Color(245, 158, 11, 230); + + // Waypoint steps along the path + private static final Color PATH_TILE_FILL = new Color(245, 158, 11, 25); + private static final Color PATH_TILE_BORDER = new Color(245, 158, 11, 90); + + // Connecting breadcrumb path line + private static final Color PATH_LINE_COLOR = new Color(245, 158, 11, 210); + private static final Stroke PATH_LINE_STROKE = new BasicStroke( + 3.0f, + BasicStroke.CAP_ROUND, + BasicStroke.JOIN_ROUND, + 10.0f, + new float[]{6.0f, 6.0f}, + 0.0f + ); + private static final Stroke TILE_STROKE = new BasicStroke(1.5f); + private static final Stroke TARGET_STROKE = new BasicStroke(2.0f); + + @Inject + public GpsPathOverlay(Client client, LiveDashboardPlugin plugin) + { + this.client = client; + this.plugin = plugin; + setPosition(OverlayPosition.DYNAMIC); + setLayer(OverlayLayer.ABOVE_SCENE); + } + + @Override + public Dimension render(Graphics2D graphics) + { + if (client.getGameState() != GameState.LOGGED_IN || client.getLocalPlayer() == null) + { + return null; + } + + WorldPoint target = plugin.getGpsTarget(); + if (target == null) + { + return null; + } + + if (target.getPlane() != client.getPlane()) + { + return null; + } + + WorldPoint playerWp = client.getLocalPlayer().getWorldLocation(); + if (playerWp == null) + { + return null; + } + + // Clear if player arrived on the tile + if (playerWp.equals(target)) + { + plugin.setGpsTarget(null); + return null; + } + + List path = plugin.getGpsPath(); + List linePoints = new ArrayList<>(); + + // Start path line at player center + LocalPoint playerLp = client.getLocalPlayer().getLocalLocation(); + if (playerLp != null) + { + Point pStart = Perspective.localToCanvas(client, playerLp, client.getPlane()); + if (pStart != null) + { + linePoints.add(pStart); + } + } + + // If a computed path around obstacles exists, render waypoint tiles and connecting path + if (path != null && !path.isEmpty()) + { + int pathSize = path.size(); + for (int i = 0; i < pathSize; i++) + { + WorldPoint wp = path.get(i); + if (wp.getPlane() != client.getPlane()) + { + continue; + } + + LocalPoint lp = LocalPoint.fromWorld(client, wp); + if (lp == null) + { + continue; + } + + // Draw path line segment + Point canvasPt = Perspective.localToCanvas(client, lp, client.getPlane()); + if (canvasPt != null) + { + linePoints.add(canvasPt); + } + + // Subtly highlight waypoint steps (e.g. every 2-3 tiles so screen doesn't get cluttered) + boolean isTarget = (i == pathSize - 1) && wp.equals(target); + if (!isTarget) + { + Polygon tilePoly = Perspective.getCanvasTilePoly(client, lp); + if (tilePoly != null) + { + graphics.setColor(PATH_TILE_FILL); + graphics.fill(tilePoly); + OverlayUtil.renderPolygon(graphics, tilePoly, PATH_TILE_BORDER, TILE_STROKE); + } + } + } + } + + // Draw smooth dashed path line through all obstacle-free waypoints + if (linePoints.size() >= 2) + { + graphics.setColor(PATH_LINE_COLOR); + graphics.setStroke(PATH_LINE_STROKE); + for (int i = 0; i < linePoints.size() - 1; i++) + { + Point p1 = linePoints.get(i); + Point p2 = linePoints.get(i + 1); + graphics.drawLine(p1.getX(), p1.getY(), p2.getX(), p2.getY()); + } + } + + // Render the final destination tile + LocalPoint targetLp = LocalPoint.fromWorld(client, target); + if (targetLp != null) + { + Polygon poly = Perspective.getCanvasTilePoly(client, targetLp); + if (poly != null) + { + graphics.setColor(GPS_TARGET_FILL); + graphics.fill(poly); + OverlayUtil.renderPolygon(graphics, poly, GPS_TARGET_BORDER, TARGET_STROKE); + } + + int dist = playerWp.distanceTo(target); + String text = "📍 GPS Ziel (" + dist + " Tiles)"; + Point textLoc = Perspective.getCanvasTextLocation(client, graphics, targetLp, text, 20); + if (textLoc != null) + { + OverlayUtil.renderTextLocation(graphics, textLoc, text, GPS_TARGET_BORDER); + } + } + + return null; + } +} diff --git a/src/main/java/com/example/livedashboard/pathfinding/Pathfinder.java b/src/main/java/com/example/livedashboard/pathfinding/Pathfinder.java new file mode 100644 index 0000000..9ea59b2 --- /dev/null +++ b/src/main/java/com/example/livedashboard/pathfinding/Pathfinder.java @@ -0,0 +1,304 @@ +package com.example.livedashboard.pathfinding; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; +import java.util.PriorityQueue; +import net.runelite.api.Client; +import net.runelite.api.CollisionData; +import net.runelite.api.CollisionDataFlag; +import net.runelite.api.Constants; +import net.runelite.api.Skill; +import net.runelite.api.coords.LocalPoint; +import net.runelite.api.coords.WorldPoint; + +public class Pathfinder +{ + private static class Node + { + final int x; + final int y; + final Node parent; + final int g; + final int f; + + Node(int x, int y, Node parent, int g, int h) + { + this.x = x; + this.y = y; + this.parent = parent; + this.g = g; + this.f = g + h; + } + } + + public static List findPath(Client client, WorldPoint startWp, WorldPoint targetWp) + { + if (client == null || startWp == null || targetWp == null) + { + return Collections.emptyList(); + } + + if (startWp.getPlane() != targetWp.getPlane()) + { + return Collections.emptyList(); + } + + CollisionData[] colData = client.getCollisionMaps(); + if (colData == null || client.getPlane() >= colData.length || colData[client.getPlane()] == null) + { + return Collections.singletonList(targetWp); + } + + int[][] flags = colData[client.getPlane()].getFlags(); + if (flags == null) + { + return Collections.singletonList(targetWp); + } + + LocalPoint startLp = LocalPoint.fromWorld(client, startWp); + if (startLp == null) + { + return Collections.singletonList(targetWp); + } + + int startSceneX = startLp.getSceneX(); + int startSceneY = startLp.getSceneY(); + + LocalPoint targetLp = LocalPoint.fromWorld(client, targetWp); + int targetSceneX = targetLp != null ? targetLp.getSceneX() : -1; + int targetSceneY = targetLp != null ? targetLp.getSceneY() : -1; + + boolean outsideScene = false; + if (targetSceneX < 0 || targetSceneX >= Constants.SCENE_SIZE || targetSceneY < 0 || targetSceneY >= Constants.SCENE_SIZE) + { + outsideScene = true; + int dx = targetWp.getX() - startWp.getX(); + int dy = targetWp.getY() - startWp.getY(); + double dist = Math.max(Math.abs(dx), Math.abs(dy)); + if (dist > 0) + { + int maxStep = Math.min((int) dist, 48); + int clampedX = startSceneX + (int) Math.round((dx / dist) * maxStep); + int clampedY = startSceneY + (int) Math.round((dy / dist) * maxStep); + targetSceneX = Math.max(2, Math.min(Constants.SCENE_SIZE - 3, clampedX)); + targetSceneY = Math.max(2, Math.min(Constants.SCENE_SIZE - 3, clampedY)); + } + else + { + targetSceneX = startSceneX; + targetSceneY = startSceneY; + } + } + + // A* search in loaded scene (up to 104x104) + PriorityQueue openSet = new PriorityQueue<>(Comparator.comparingInt(n -> n.f)); + boolean[][] visited = new boolean[Constants.SCENE_SIZE][Constants.SCENE_SIZE]; + + Node startNode = new Node(startSceneX, startSceneY, null, 0, manhattan(startSceneX, startSceneY, targetSceneX, targetSceneY) * 10); + openSet.add(startNode); + + Node bestNode = startNode; + int bestDistance = startNode.f; + + int[] dx = {0, 0, -1, 1, -1, 1, -1, 1}; + int[] dy = {1, -1, 0, 0, 1, 1, -1, -1}; + + int iterations = 0; + int maxIterations = 5000; + + while (!openSet.isEmpty() && iterations++ < maxIterations) + { + Node curr = openSet.poll(); + + if (visited[curr.x][curr.y]) + { + continue; + } + visited[curr.x][curr.y] = true; + + int h = manhattan(curr.x, curr.y, targetSceneX, targetSceneY); + if (h < bestDistance) + { + bestDistance = h; + bestNode = curr; + } + + if (curr.x == targetSceneX && curr.y == targetSceneY) + { + bestNode = curr; + break; + } + + for (int i = 0; i < 8; i++) + { + int nx = curr.x + dx[i]; + int ny = curr.y + dy[i]; + + if (nx < 1 || nx >= Constants.SCENE_SIZE - 1 || ny < 1 || ny >= Constants.SCENE_SIZE - 1) + { + continue; + } + + if (visited[nx][ny]) + { + continue; + } + + if (!canWalk(flags, curr.x, curr.y, dx[i], dy[i])) + { + continue; + } + + int stepCost = (i < 4) ? 10 : 14; + int nextG = curr.g + stepCost; + int nextH = manhattan(nx, ny, targetSceneX, targetSceneY) * 10; + openSet.add(new Node(nx, ny, curr, nextG, nextH)); + } + } + + List path = new ArrayList<>(); + Node node = bestNode; + int baseX = client.getBaseX(); + int baseY = client.getBaseY(); + int plane = client.getPlane(); + + while (node != null) + { + if (node.parent != null) + { + path.add(new WorldPoint(baseX + node.x, baseY + node.y, plane)); + } + node = node.parent; + } + + Collections.reverse(path); + + // If destination is beyond the loaded scene, interpolate coarse waypoints every 10 tiles towards target + if (outsideScene && !path.isEmpty()) + { + WorldPoint lastPoint = path.get(path.size() - 1); + int diffX = targetWp.getX() - lastPoint.getX(); + int diffY = targetWp.getY() - lastPoint.getY(); + double dist = Math.sqrt(diffX * diffX + diffY * diffY); + int numSegments = Math.max(1, (int) Math.round(dist / 8.0)); + + for (int i = 1; i <= numSegments; i++) + { + double fraction = (double) i / numSegments; + int wx = lastPoint.getX() + (int) Math.round(diffX * fraction); + int wy = lastPoint.getY() + (int) Math.round(diffY * fraction); + path.add(new WorldPoint(wx, wy, plane)); + } + } + + return path; + } + + // Finds nearby Agility shortcuts along the route + public static List findNearbyShortcuts(Client client, List path) + { + List result = new ArrayList<>(); + if (client == null || path == null || path.isEmpty()) + { + return result; + } + + int playerAgility = client.getRealSkillLevel(Skill.AGILITY); + + for (ShortcutData sc : ShortcutData.SHORTCUTS) + { + for (WorldPoint p : path) + { + if (p.distanceTo(sc.getPoint1()) <= 15 || p.distanceTo(sc.getPoint2()) <= 15) + { + if (!result.contains(sc)) + { + result.add(sc); + } + break; + } + } + } + + return result; + } + + private static boolean canWalk(int[][] flags, int x, int y, int dx, int dy) + { + int targetFlag = flags[x + dx][y + dy]; + + if ((targetFlag & (CollisionDataFlag.BLOCK_MOVEMENT_FULL + | CollisionDataFlag.BLOCK_MOVEMENT_FLOOR + | CollisionDataFlag.BLOCK_MOVEMENT_OBJECT)) != 0) + { + return false; + } + + if (dx == 0 && dy == 1) // North + { + return (flags[x][y] & CollisionDataFlag.BLOCK_MOVEMENT_NORTH) == 0 + && (targetFlag & CollisionDataFlag.BLOCK_MOVEMENT_SOUTH) == 0; + } + if (dx == 0 && dy == -1) // South + { + return (flags[x][y] & CollisionDataFlag.BLOCK_MOVEMENT_SOUTH) == 0 + && (targetFlag & CollisionDataFlag.BLOCK_MOVEMENT_NORTH) == 0; + } + if (dx == -1 && dy == 0) // West + { + return (flags[x][y] & CollisionDataFlag.BLOCK_MOVEMENT_WEST) == 0 + && (targetFlag & CollisionDataFlag.BLOCK_MOVEMENT_EAST) == 0; + } + if (dx == 1 && dy == 0) // East + { + return (flags[x][y] & CollisionDataFlag.BLOCK_MOVEMENT_EAST) == 0 + && (targetFlag & CollisionDataFlag.BLOCK_MOVEMENT_WEST) == 0; + } + + if (dx == -1 && dy == 1) // North-West + { + return canWalk(flags, x, y, -1, 0) + && canWalk(flags, x, y, 0, 1) + && canWalk(flags, x - 1, y, 0, 1) + && canWalk(flags, x, y + 1, -1, 0) + && (flags[x][y] & CollisionDataFlag.BLOCK_MOVEMENT_NORTH_WEST) == 0 + && (targetFlag & CollisionDataFlag.BLOCK_MOVEMENT_SOUTH_EAST) == 0; + } + if (dx == 1 && dy == 1) // North-East + { + return canWalk(flags, x, y, 1, 0) + && canWalk(flags, x, y, 0, 1) + && canWalk(flags, x + 1, y, 0, 1) + && canWalk(flags, x, y + 1, 1, 0) + && (flags[x][y] & CollisionDataFlag.BLOCK_MOVEMENT_NORTH_EAST) == 0 + && (targetFlag & CollisionDataFlag.BLOCK_MOVEMENT_SOUTH_WEST) == 0; + } + if (dx == -1 && dy == -1) // South-West + { + return canWalk(flags, x, y, -1, 0) + && canWalk(flags, x, y, 0, -1) + && canWalk(flags, x - 1, y, 0, -1) + && canWalk(flags, x, y - 1, -1, 0) + && (flags[x][y] & CollisionDataFlag.BLOCK_MOVEMENT_SOUTH_WEST) == 0 + && (targetFlag & CollisionDataFlag.BLOCK_MOVEMENT_NORTH_EAST) == 0; + } + if (dx == 1 && dy == -1) // South-East + { + return canWalk(flags, x, y, 1, 0) + && canWalk(flags, x, y, 0, -1) + && canWalk(flags, x + 1, y, 0, -1) + && canWalk(flags, x, y - 1, 1, 0) + && (flags[x][y] & CollisionDataFlag.BLOCK_MOVEMENT_SOUTH_EAST) == 0 + && (targetFlag & CollisionDataFlag.BLOCK_MOVEMENT_NORTH_WEST) == 0; + } + + return false; + } + + private static int manhattan(int x1, int y1, int x2, int y2) + { + return Math.abs(x1 - x2) + Math.abs(y1 - y2); + } +} diff --git a/src/main/java/com/example/livedashboard/pathfinding/ShortcutData.java b/src/main/java/com/example/livedashboard/pathfinding/ShortcutData.java new file mode 100644 index 0000000..d395645 --- /dev/null +++ b/src/main/java/com/example/livedashboard/pathfinding/ShortcutData.java @@ -0,0 +1,42 @@ +package com.example.livedashboard.pathfinding; + +import java.util.Arrays; +import java.util.List; +import lombok.Getter; +import net.runelite.api.coords.WorldPoint; + +@Getter +public class ShortcutData +{ + private final String name; + private final int requiredLevel; + private final WorldPoint point1; + private final WorldPoint point2; + private final String description; + + public ShortcutData(String name, int requiredLevel, WorldPoint point1, WorldPoint point2, String description) + { + this.name = name; + this.requiredLevel = requiredLevel; + this.point1 = point1; + this.point2 = point2; + this.description = description; + } + + // Curated list of popular OSRS shortcuts + public static final List SHORTCUTS = Arrays.asList( + new ShortcutData("Varrock South Fence", 13, new WorldPoint(3240, 3335, 0), new WorldPoint(3240, 3331, 0), "Zaun springen"), + new ShortcutData("Falador West Crumbling Wall", 5, new WorldPoint(2936, 3355, 0), new WorldPoint(2934, 3355, 0), "Mauer erklettern"), + new ShortcutData("Grand Exchange Underwall Tunnel", 21, new WorldPoint(3139, 3516, 0), new WorldPoint(3143, 3514, 0), "Tunnel zum GE"), + new ShortcutData("Edgeville Dungeon Monkey Bars", 15, new WorldPoint(3120, 9964, 0), new WorldPoint(3120, 9970, 0), "Monkey bars"), + new ShortcutData("Draynor Manor Stepping Stones", 31, new WorldPoint(3150, 3362, 0), new WorldPoint(3154, 3363, 0), "Stepping stones Fluss"), + new ShortcutData("Al Kharid Palace Window", 70, new WorldPoint(3299, 3158, 0), new WorldPoint(3302, 3158, 0), "Fenster"), + new ShortcutData("Yanille Underwall Tunnel", 16, new WorldPoint(2575, 3112, 0), new WorldPoint(2573, 3107, 0), "Mauer"), + new ShortcutData("Karamja Stepping Stones", 12, new WorldPoint(2925, 3155, 0), new WorldPoint(2924, 3149, 0), "Steine Fluss"), + new ShortcutData("Taverley Dungeon Strange Floor", 80, new WorldPoint(2883, 9799, 0), new WorldPoint(2884, 9801, 0), "Spike floor"), + new ShortcutData("Lumbridge Swamp Stepping Stones", 1, new WorldPoint(3200, 3169, 0), new WorldPoint(3204, 3169, 0), "Fluss Steine"), + new ShortcutData("Catherby Cliffside", 32, new WorldPoint(2841, 3424, 0), new WorldPoint(2843, 3425, 0), "Klippe"), + new ShortcutData("Ardougne Log Balance", 33, new WorldPoint(2601, 3336, 0), new WorldPoint(2598, 3336, 0), "Baumstamm"), + new ShortcutData("Seers Village Fence", 13, new WorldPoint(2735, 3489, 0), new WorldPoint(2735, 3491, 0), "Zaun") + ); +} diff --git a/src/main/java/com/example/livedashboard/ui/LiveDashboardPanel.java b/src/main/java/com/example/livedashboard/ui/LiveDashboardPanel.java new file mode 100644 index 0000000..d06f9ca --- /dev/null +++ b/src/main/java/com/example/livedashboard/ui/LiveDashboardPanel.java @@ -0,0 +1,223 @@ +package com.example.livedashboard.ui; + +import com.example.livedashboard.LiveDashboardPlugin; +import java.awt.BorderLayout; +import java.awt.Color; +import java.awt.Cursor; +import java.awt.Desktop; +import java.awt.Dimension; +import java.net.URI; +import java.util.Map; +import javax.swing.BorderFactory; +import javax.swing.Box; +import javax.swing.BoxLayout; +import javax.swing.JButton; +import javax.swing.JLabel; +import javax.swing.JPanel; +import javax.swing.SwingConstants; +import javax.swing.SwingUtilities; +import javax.swing.border.EmptyBorder; +import net.runelite.client.ui.ColorScheme; +import net.runelite.client.ui.FontManager; +import net.runelite.client.ui.PluginPanel; + +public class LiveDashboardPanel extends PluginPanel +{ + private final LiveDashboardPlugin plugin; + + private final JLabel statusDot = new JLabel("● "); + private final JLabel statusLabel = new JLabel("Server offline"); + private final JLabel portLabel = new JLabel("Port: -"); + private final JButton openBrowserBtn = new JButton("🌐 Im Browser öffnen"); + private final JButton toggleServerBtn = new JButton("Server stoppen"); + + private final JLabel coordsLabel = new JLabel("X: -, Y: -, Plane: -"); + private final JLabel regionLabel = new JLabel("Region: -"); + private final JLabel playerLabel = new JLabel("Spieler: -"); + + public LiveDashboardPanel(LiveDashboardPlugin plugin) + { + super(false); + this.plugin = plugin; + + setLayout(new BorderLayout()); + setBackground(ColorScheme.DARK_GRAY_COLOR); + setBorder(new EmptyBorder(10, 10, 10, 10)); + + JPanel container = new JPanel(); + container.setLayout(new BoxLayout(container, BoxLayout.Y_AXIS)); + container.setBackground(ColorScheme.DARK_GRAY_COLOR); + + // Header Card + JPanel headerPanel = new JPanel(new BorderLayout()); + headerPanel.setBackground(ColorScheme.DARKER_GRAY_COLOR); + headerPanel.setBorder(BorderFactory.createCompoundBorder( + BorderFactory.createMatteBorder(0, 0, 1, 0, ColorScheme.BORDER_COLOR), + new EmptyBorder(10, 10, 10, 10) + )); + + JLabel titleLabel = new JLabel("Live Web Dashboard"); + titleLabel.setFont(FontManager.getRunescapeBoldFont()); + titleLabel.setForeground(ColorScheme.BRAND_ORANGE); + headerPanel.add(titleLabel, BorderLayout.WEST); + + container.add(headerPanel); + container.add(Box.createRigidArea(new Dimension(0, 10))); + + // Status Card + JPanel statusPanel = new JPanel(); + statusPanel.setLayout(new BoxLayout(statusPanel, BoxLayout.Y_AXIS)); + statusPanel.setBackground(ColorScheme.DARKER_GRAY_COLOR); + statusPanel.setBorder(new EmptyBorder(10, 10, 10, 10)); + + JPanel statusLine = new JPanel(new BorderLayout()); + statusLine.setBackground(ColorScheme.DARKER_GRAY_COLOR); + statusDot.setFont(FontManager.getDefaultBoldFont().deriveFont(16f)); + statusDot.setForeground(new Color(220, 60, 50)); + statusLabel.setFont(FontManager.getRunescapeFont()); + statusLabel.setForeground(ColorScheme.TEXT_COLOR); + statusLine.add(statusDot, BorderLayout.WEST); + statusLine.add(statusLabel, BorderLayout.CENTER); + + portLabel.setFont(FontManager.getDefaultFont().deriveFont(10.5f)); + portLabel.setForeground(ColorScheme.LIGHT_GRAY_COLOR); + + statusPanel.add(statusLine); + statusPanel.add(Box.createRigidArea(new Dimension(0, 4))); + statusPanel.add(portLabel); + statusPanel.add(Box.createRigidArea(new Dimension(0, 10))); + + // Buttons + openBrowserBtn.setAlignmentX(CENTER_ALIGNMENT); + openBrowserBtn.setBackground(ColorScheme.BRAND_ORANGE); + openBrowserBtn.setForeground(Color.WHITE); + openBrowserBtn.setFocusPainted(false); + openBrowserBtn.setCursor(Cursor.getPredefinedCursor(Cursor.HAND_CURSOR)); + openBrowserBtn.setMaximumSize(new Dimension(Integer.MAX_VALUE, 30)); + openBrowserBtn.addActionListener(e -> openInBrowser()); + + toggleServerBtn.setAlignmentX(CENTER_ALIGNMENT); + toggleServerBtn.setBackground(ColorScheme.DARK_GRAY_COLOR); + toggleServerBtn.setForeground(ColorScheme.TEXT_COLOR); + toggleServerBtn.setFocusPainted(false); + toggleServerBtn.setCursor(Cursor.getPredefinedCursor(Cursor.HAND_CURSOR)); + toggleServerBtn.setMaximumSize(new Dimension(Integer.MAX_VALUE, 26)); + toggleServerBtn.addActionListener(e -> plugin.toggleServer()); + + statusPanel.add(openBrowserBtn); + statusPanel.add(Box.createRigidArea(new Dimension(0, 6))); + statusPanel.add(toggleServerBtn); + + container.add(statusPanel); + container.add(Box.createRigidArea(new Dimension(0, 10))); + + // Quick In-Game Telemetry Card + JPanel telemetryPanel = new JPanel(); + telemetryPanel.setLayout(new BoxLayout(telemetryPanel, BoxLayout.Y_AXIS)); + telemetryPanel.setBackground(ColorScheme.DARKER_GRAY_COLOR); + telemetryPanel.setBorder(new EmptyBorder(10, 10, 10, 10)); + + JLabel telemetryTitle = new JLabel("Live Ingame Telemetrie"); + telemetryTitle.setFont(FontManager.getRunescapeBoldFont()); + telemetryTitle.setForeground(ColorScheme.LIGHT_GRAY_COLOR); + + coordsLabel.setFont(FontManager.getDefaultFont().deriveFont(11f)); + coordsLabel.setForeground(ColorScheme.TEXT_COLOR); + + regionLabel.setFont(FontManager.getDefaultFont().deriveFont(10.5f)); + regionLabel.setForeground(ColorScheme.LIGHT_GRAY_COLOR); + + playerLabel.setFont(FontManager.getDefaultFont().deriveFont(10.5f)); + playerLabel.setForeground(ColorScheme.LIGHT_GRAY_COLOR); + + telemetryPanel.add(telemetryTitle); + telemetryPanel.add(Box.createRigidArea(new Dimension(0, 6))); + telemetryPanel.add(playerLabel); + telemetryPanel.add(Box.createRigidArea(new Dimension(0, 4))); + telemetryPanel.add(coordsLabel); + telemetryPanel.add(Box.createRigidArea(new Dimension(0, 4))); + telemetryPanel.add(regionLabel); + + container.add(telemetryPanel); + + add(container, BorderLayout.NORTH); + } + + public void updateServerState(boolean running, int port) + { + SwingUtilities.invokeLater(() -> + { + if (running) + { + statusDot.setForeground(new Color(46, 204, 113)); + statusLabel.setText("Verbunden & Aktiv"); + statusLabel.setForeground(new Color(46, 204, 113)); + portLabel.setText("Port: " + port + " (http://localhost:" + port + ")"); + openBrowserBtn.setEnabled(true); + toggleServerBtn.setText("Server stoppen"); + } + else + { + statusDot.setForeground(new Color(220, 60, 50)); + statusLabel.setText("Server gestoppt"); + statusLabel.setForeground(new Color(220, 60, 50)); + portLabel.setText("Port: -"); + openBrowserBtn.setEnabled(false); + toggleServerBtn.setText("Server starten"); + } + }); + } + + public void updateTelemetry(Map data) + { + SwingUtilities.invokeLater(() -> + { + if (data == null || data.isEmpty()) + { + playerLabel.setText("Spieler: -"); + coordsLabel.setText("X: -, Y: -, Plane: -"); + regionLabel.setText("Region: -"); + return; + } + + Object player = data.get("player"); + playerLabel.setText("Spieler: " + (player != null ? player : "Nicht eingeloggt")); + + Object wx = data.get("worldX"); + Object wy = data.get("worldY"); + Object wp = data.get("plane"); + coordsLabel.setText("World: " + wx + ", " + wy + " (P" + wp + ")"); + + Object region = data.get("regionId"); + Object rx = data.get("regionX"); + Object ry = data.get("regionY"); + regionLabel.setText("Region ID: " + region + " (" + rx + ", " + ry + ")"); + }); + } + + private void openInBrowser() + { + String url = plugin.getServerUrl(); + if (url == null || url.isEmpty()) + { + return; + } + + try + { + if (Desktop.isDesktopSupported() && Desktop.getDesktop().isSupported(Desktop.Action.BROWSE)) + { + Desktop.getDesktop().browse(new URI(url)); + } + else + { + // Fallback for Linux xdg-open + new ProcessBuilder("xdg-open", url).start(); + } + } + catch (Exception ex) + { + // Ignore or log + } + } +} diff --git a/src/main/java/com/example/model/Folder.java b/src/main/java/com/example/model/Folder.java new file mode 100644 index 0000000..47f9b4c --- /dev/null +++ b/src/main/java/com/example/model/Folder.java @@ -0,0 +1,34 @@ +package com.example.model; + +import java.util.UUID; +import lombok.Data; + +@Data +public class Folder +{ + private String id; + private String name; + private boolean enabled = true; + private boolean collapsed = false; + private String iconItem = "Prayer potion(4)"; + private int iconItemId = -1; + + public Folder() + { + this.id = UUID.randomUUID().toString(); + this.name = "Neuer Ordner"; + } + + public Folder(String name) + { + this.id = UUID.randomUUID().toString(); + this.name = name; + } + + public Folder(String name, String iconItem) + { + this.id = UUID.randomUUID().toString(); + this.name = name; + this.iconItem = iconItem; + } +} diff --git a/src/main/java/com/example/model/Profile.java b/src/main/java/com/example/model/Profile.java new file mode 100644 index 0000000..891155d --- /dev/null +++ b/src/main/java/com/example/model/Profile.java @@ -0,0 +1,301 @@ +package com.example.model; + +import java.util.UUID; +import lombok.Data; +import net.runelite.api.Client; +import net.runelite.api.GameState; +import net.runelite.api.InventoryID; +import net.runelite.api.Item; +import net.runelite.api.ItemComposition; +import net.runelite.api.ItemContainer; +import net.runelite.api.SoundEffectID; +import net.runelite.api.coords.WorldPoint; +import net.runelite.api.widgets.ComponentID; +import net.runelite.api.widgets.Widget; +import net.runelite.client.game.ItemManager; +import net.runelite.client.plugins.banktags.BankTagsService; + +@Data +public class Profile +{ + private String id; + private String name; + private boolean enabled = true; + private String folderId = ""; // Zugehöriger Ordner (oder leer für Hauptansicht) + + // Item Icon + private String iconItem = ""; + private int iconItemId = -1; + + // WENN: Bedingungen (IF...) + private boolean requireBankOpen = false; + private boolean requireBankClosed = false; + private String bankTag = ""; + private String regionId = ""; + private String coordinates = ""; + private String requiredInventory = ""; + private String requiredEquipment = ""; + + // DANN: Item Highlights (Default: Nur Rahmen / Border) + private boolean enableItemHighlight = true; + private String highlightItems = ""; + private boolean highlightInBank = true; + private boolean highlightInInventory = true; + private String highlightColor = "#FF9800"; + private int borderThickness = 2; // 1 - 4 px + private int fillOpacity = 0; // 0 = Nur Rahmen + private boolean showTextLabel = false; + private String customTextLabel = ""; + + // DANN: 3D Welt- & Objekt-Highlights (GameObjects, Bank Booth, Chest, Banker, Agility Obstacles...) + private boolean enableObjectHighlight = false; + private String highlightObjects = ""; + private String objectHighlightColor = "#00E5FF"; + private boolean showObjectText = true; + private String objectCustomText = "Bank"; + + // DANN: 3D Boden-Kacheln & Pfad-Marker (Ground Tiles & Paths) + private boolean enableTileHighlight = false; + private String highlightTiles = ""; // Format: "X, Y, Plane, Label; X2, Y2, Plane, Label2" + private String tileHighlightColor = "#2ECC71"; + private int tileFillOpacity = 40; // 0 - 255 + private boolean showTileLabel = true; + private String customTileLabel = "Hier stehen"; + private boolean renderPathLine = false; + + // DANN: Benachrichtigungen & Feedback + private boolean notifyOnActivation = true; + private String activationMessage = "Profil '{name}' wurde automatisch aktiviert!"; + private boolean screenFlash = false; + private String flashColor = "#FF9800"; + private boolean playSound = false; + private int soundId = SoundEffectID.GE_ADD_OFFER_DINGALING; + + public Profile() + { + this.id = UUID.randomUUID().toString(); + this.name = "Neues Profil"; + } + + public Profile(String name) + { + this.id = UUID.randomUUID().toString(); + this.name = name; + } + + public Profile(String name, String iconItem) + { + this.id = UUID.randomUUID().toString(); + this.name = name; + this.iconItem = iconItem; + } + + public boolean matches(Client client, ItemManager itemManager, BankTagsService bankTagsService) + { + if (!enabled || client == null || client.getGameState() != GameState.LOGGED_IN) + { + return false; + } + + try + { + // Safe Bank Open / Closed check using isSelfHidden() + Widget bankWidget = client.getWidget(ComponentID.BANK_CONTAINER); + boolean isBankOpen = (bankWidget != null && !bankWidget.isSelfHidden()); + + if (requireBankOpen && !isBankOpen) + { + return false; + } + + if (requireBankClosed && isBankOpen) + { + return false; + } + + // Safe Bank Tag check + if (bankTag != null && !bankTag.trim().isEmpty()) + { + if (!isBankOpen) + { + return false; + } + + String currentTag = ""; + if (bankTagsService != null) + { + try + { + currentTag = bankTagsService.getActiveTag(); + } + catch (Throwable ignored) + { + } + } + + if (currentTag == null || currentTag.isEmpty()) + { + Widget titleWidget = client.getWidget(ComponentID.BANK_TITLE_BAR); + if (titleWidget != null && !titleWidget.isSelfHidden() && titleWidget.getText() != null) + { + currentTag = titleWidget.getText(); + } + } + + if (currentTag == null || currentTag.isEmpty() || !currentTag.toLowerCase().contains(bankTag.trim().toLowerCase())) + { + return false; + } + } + + // Safe Region check + if (regionId != null && !regionId.trim().isEmpty()) + { + if (client.getLocalPlayer() == null || client.getLocalPlayer().getWorldLocation() == null) + { + return false; + } + + int currentRegion = client.getLocalPlayer().getWorldLocation().getRegionID(); + boolean regionMatch = false; + for (String r : regionId.split(",")) + { + try + { + if (Integer.parseInt(r.trim()) == currentRegion) + { + regionMatch = true; + break; + } + } + catch (NumberFormatException ignored) + { + } + } + if (!regionMatch) + { + return false; + } + } + + // Safe Coordinates check + if (coordinates != null && !coordinates.trim().isEmpty()) + { + if (client.getLocalPlayer() == null || client.getLocalPlayer().getWorldLocation() == null) + { + return false; + } + + WorldPoint playerLoc = client.getLocalPlayer().getWorldLocation(); + String[] parts = coordinates.split(","); + if (parts.length >= 2) + { + try + { + int targetX = Integer.parseInt(parts[0].trim()); + int targetY = Integer.parseInt(parts[1].trim()); + int targetPlane = parts.length >= 3 ? Integer.parseInt(parts[2].trim()) : playerLoc.getPlane(); + int radius = parts.length >= 4 ? Integer.parseInt(parts[3].trim()) : 10; + + if (playerLoc.getPlane() != targetPlane + || Math.abs(playerLoc.getX() - targetX) > radius + || Math.abs(playerLoc.getY() - targetY) > radius) + { + return false; + } + } + catch (NumberFormatException ignored) + { + } + } + } + + // Safe Inventory check + if (requiredInventory != null && !requiredInventory.trim().isEmpty()) + { + ItemContainer inv = client.getItemContainer(InventoryID.INVENTORY); + if (inv == null || !containsAllItems(inv, requiredInventory, itemManager)) + { + return false; + } + } + + // Safe Equipment check + if (requiredEquipment != null && !requiredEquipment.trim().isEmpty()) + { + ItemContainer equip = client.getItemContainer(InventoryID.EQUIPMENT); + if (equip == null || !containsAllItems(equip, requiredEquipment, itemManager)) + { + return false; + } + } + + return true; + } + catch (Throwable t) + { + return false; + } + } + + private boolean containsAllItems(ItemContainer container, String filterList, ItemManager itemManager) + { + if (container == null) + { + return false; + } + + Item[] items = container.getItems(); + if (items == null || items.length == 0) + { + return false; + } + + String[] needed = filterList.split(","); + for (String need : needed) + { + String query = need.trim().toLowerCase(); + if (query.isEmpty()) + { + continue; + } + + boolean found = false; + for (Item item : items) + { + if (item == null || item.getId() <= 0) + { + continue; + } + + if (String.valueOf(item.getId()).equals(query)) + { + found = true; + break; + } + + if (itemManager != null) + { + try + { + ItemComposition def = itemManager.getItemComposition(item.getId()); + if (def != null && def.getName() != null && def.getName().toLowerCase().contains(query)) + { + found = true; + break; + } + } + catch (Throwable ignored) + { + } + } + } + + if (!found) + { + return false; + } + } + return true; + } +} diff --git a/src/main/java/com/example/overlay/BankHighlighterOverlay.java b/src/main/java/com/example/overlay/BankHighlighterOverlay.java new file mode 100644 index 0000000..f5c5a99 --- /dev/null +++ b/src/main/java/com/example/overlay/BankHighlighterOverlay.java @@ -0,0 +1,185 @@ +package com.example.overlay; + +import com.example.ProfileManager; +import com.example.model.Profile; +import java.awt.BasicStroke; +import java.awt.Color; +import java.awt.FontMetrics; +import java.awt.Graphics2D; +import java.awt.Rectangle; +import javax.inject.Inject; +import javax.inject.Singleton; +import net.runelite.api.ItemComposition; +import net.runelite.api.widgets.ComponentID; +import net.runelite.api.widgets.Widget; +import net.runelite.api.widgets.WidgetItem; +import net.runelite.client.game.ItemManager; +import net.runelite.client.ui.FontManager; +import net.runelite.client.ui.overlay.WidgetItemOverlay; +import net.runelite.client.util.ColorUtil; + +@Singleton +public class BankHighlighterOverlay extends WidgetItemOverlay +{ + private final ProfileManager profileManager; + private final ItemManager itemManager; + + @Inject + public BankHighlighterOverlay(ProfileManager profileManager, ItemManager itemManager) + { + this.profileManager = profileManager; + this.itemManager = itemManager; + + showOnBank(); + showOnInventory(); + showOnEquipment(); + } + + @Override + public void renderItemOverlay(Graphics2D graphics, int itemId, WidgetItem widgetItem) + { + Profile active = profileManager.getActiveProfile(); + if (active == null || !active.isEnabled() || !active.isEnableItemHighlight()) + { + return; + } + + String highlightItems = active.getHighlightItems(); + if (highlightItems == null || highlightItems.trim().isEmpty()) + { + return; + } + + // Container check + Widget widget = widgetItem.getWidget(); + if (widget != null) + { + int parentId = widget.getParentId(); + int widgetId = widget.getId(); + + boolean isBank = (widgetId == ComponentID.BANK_ITEM_CONTAINER || parentId == ComponentID.BANK_ITEM_CONTAINER); + boolean isInv = (widgetId == ComponentID.INVENTORY_CONTAINER || parentId == ComponentID.INVENTORY_CONTAINER + || widgetId == ComponentID.BANK_INVENTORY_ITEM_CONTAINER || parentId == ComponentID.BANK_INVENTORY_ITEM_CONTAINER); + + if (isBank && !active.isHighlightInBank()) + { + return; + } + if (isInv && !active.isHighlightInInventory()) + { + return; + } + } + + // Item Match Check + ItemComposition itemDef = itemManager.getItemComposition(itemId); + if (itemDef == null) + { + return; + } + + String itemName = itemDef.getName() != null ? itemDef.getName().toLowerCase() : ""; + String itemIdStr = String.valueOf(itemId); + + boolean matches = false; + String matchedFilter = ""; + for (String itemQuery : highlightItems.split(",")) + { + String q = itemQuery.trim().toLowerCase(); + if (q.isEmpty()) + { + continue; + } + + if (q.equals(itemIdStr) || (!itemName.isEmpty() && itemName.contains(q))) + { + matches = true; + matchedFilter = itemQuery.trim(); + break; + } + } + + if (!matches) + { + return; + } + + // Parse Color + Color color; + try + { + color = ColorUtil.fromString(active.getHighlightColor()); + if (color == null) + { + color = new Color(255, 152, 0); + } + } + catch (Exception e) + { + color = new Color(255, 152, 0); + } + + Rectangle bounds = widgetItem.getCanvasBounds(); + if (bounds == null) + { + return; + } + + // 1. Fill background ONLY if fillOpacity > 0 (default is 0 = nur Rahmen) + int opacity = Math.min(255, Math.max(0, active.getFillOpacity())); + if (opacity > 0) + { + Color fillColor = ColorUtil.colorWithAlpha(color, opacity); + graphics.setColor(fillColor); + graphics.fillRect(bounds.x, bounds.y, bounds.width, bounds.height); + } + + // 2. Draw crisp outline border with inset + int strokeWidth = Math.max(1, Math.min(4, active.getBorderThickness())); + graphics.setColor(color); + graphics.setStroke(new BasicStroke(strokeWidth)); + int inset = strokeWidth / 2; + graphics.drawRect( + bounds.x + inset, + bounds.y + inset, + bounds.width - strokeWidth, + bounds.height - strokeWidth + ); + + // 3. Draw text label (mit Schrift) + if (active.isShowTextLabel()) + { + String label = active.getCustomTextLabel(); + if (label == null || label.trim().isEmpty()) + { + label = matchedFilter; + } + if (label.isEmpty() && itemDef.getName() != null) + { + label = itemDef.getName(); + } + + if (!label.isEmpty()) + { + graphics.setFont(FontManager.getRunescapeSmallFont()); + FontMetrics fm = graphics.getFontMetrics(); + + if (label.length() > 12) + { + label = label.substring(0, 10) + ".."; + } + + int textX = bounds.x + (bounds.width - fm.stringWidth(label)) / 2; + int textY = bounds.y + fm.getHeight() - 1; + + // Shadow + graphics.setColor(Color.BLACK); + graphics.drawString(label, textX + 1, textY + 1); + + // Foreground + graphics.setColor(Color.WHITE); + graphics.drawString(label, textX, textY); + } + } + } +} diff --git a/src/main/java/com/example/overlay/ScreenFlashOverlay.java b/src/main/java/com/example/overlay/ScreenFlashOverlay.java new file mode 100644 index 0000000..522a645 --- /dev/null +++ b/src/main/java/com/example/overlay/ScreenFlashOverlay.java @@ -0,0 +1,67 @@ +package com.example.overlay; + +import java.awt.Color; +import java.awt.Dimension; +import java.awt.Graphics2D; +import javax.inject.Inject; +import javax.inject.Singleton; +import net.runelite.api.Client; +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.ColorUtil; + +@Singleton +public class ScreenFlashOverlay extends Overlay +{ + private final Client client; + private Color flashColor = new Color(255, 152, 0); + private int remainingTicks = 0; + + @Inject + public ScreenFlashOverlay(Client client) + { + this.client = client; + setPosition(OverlayPosition.DYNAMIC); + setLayer(OverlayLayer.ALWAYS_ON_TOP); + } + + public void flash(Color color, int ticks) + { + this.flashColor = color != null ? color : new Color(255, 152, 0); + this.remainingTicks = Math.max(1, ticks); + } + + public void decrementTick() + { + if (remainingTicks > 0) + { + remainingTicks--; + } + } + + @Override + public Dimension render(Graphics2D graphics) + { + if (remainingTicks <= 0 || client == null) + { + return null; + } + + int width = client.getCanvasWidth(); + int height = client.getCanvasHeight(); + + int alpha = Math.min(120, remainingTicks * 40); + Color renderColor = ColorUtil.colorWithAlpha(flashColor, alpha); + + // Flash Border + graphics.setColor(renderColor); + int borderSize = 25; + graphics.fillRect(0, 0, width, borderSize); // Top + graphics.fillRect(0, height - borderSize, width, borderSize); // Bottom + graphics.fillRect(0, borderSize, borderSize, height - (2 * borderSize)); // Left + graphics.fillRect(width - borderSize, borderSize, borderSize, height - (2 * borderSize)); // Right + + return null; + } +} diff --git a/src/main/java/com/example/overlay/TileHighlighterOverlay.java b/src/main/java/com/example/overlay/TileHighlighterOverlay.java new file mode 100644 index 0000000..9c5d227 --- /dev/null +++ b/src/main/java/com/example/overlay/TileHighlighterOverlay.java @@ -0,0 +1,186 @@ +package com.example.overlay; + +import com.example.ProfileManager; +import com.example.model.Profile; +import java.awt.BasicStroke; +import java.awt.Color; +import java.awt.Dimension; +import java.awt.Graphics2D; +import java.awt.Polygon; +import java.awt.Stroke; +import java.util.ArrayList; +import java.util.List; +import javax.inject.Inject; +import javax.inject.Singleton; +import net.runelite.api.Client; +import net.runelite.api.GameState; +import net.runelite.api.Perspective; +import net.runelite.api.Point; +import net.runelite.api.coords.LocalPoint; +import net.runelite.api.coords.WorldPoint; +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.OverlayUtil; +import net.runelite.client.util.ColorUtil; + +@Singleton +public class TileHighlighterOverlay extends Overlay +{ + private final Client client; + private final ProfileManager profileManager; + + @Inject + public TileHighlighterOverlay(Client client, ProfileManager profileManager) + { + this.client = client; + this.profileManager = profileManager; + setPosition(OverlayPosition.DYNAMIC); + setLayer(OverlayLayer.ABOVE_SCENE); + } + + @Override + public Dimension render(Graphics2D graphics) + { + Profile active = profileManager.getActiveProfile(); + if (active == null || !active.isEnabled() || !active.isEnableTileHighlight()) + { + return null; + } + + String tileStr = active.getHighlightTiles(); + if (tileStr == null || tileStr.trim().isEmpty()) + { + return null; + } + + if (client.getGameState() != GameState.LOGGED_IN || client.getLocalPlayer() == null) + { + return null; + } + + Color baseColor; + try + { + baseColor = ColorUtil.fromString(active.getTileHighlightColor()); + if (baseColor == null) + { + baseColor = new Color(46, 204, 113); + } + } + catch (Exception e) + { + baseColor = new Color(46, 204, 113); + } + + int fillOp = Math.max(0, Math.min(255, active.getTileFillOpacity())); + Color fillColor = new Color(baseColor.getRed(), baseColor.getGreen(), baseColor.getBlue(), fillOp); + Color outlineColor = new Color(baseColor.getRed(), baseColor.getGreen(), baseColor.getBlue(), 255); + Stroke stroke = new BasicStroke(Math.max(1, Math.min(4, active.getBorderThickness()))); + + WorldPoint playerLoc = client.getLocalPlayer().getWorldLocation(); + LocalPoint playerLocal = client.getLocalPlayer().getLocalLocation(); + + List canvasPointsForPath = new ArrayList<>(); + if (active.isRenderPathLine() && playerLocal != null) + { + Point pPt = Perspective.localToCanvas(client, playerLocal, client.getPlane()); + if (pPt != null) + { + canvasPointsForPath.add(pPt); + } + } + + String[] tileEntries = tileStr.split(";"); + for (String entry : tileEntries) + { + if (entry == null || entry.trim().isEmpty()) + { + continue; + } + + String[] parts = entry.trim().split(","); + if (parts.length < 2) + { + continue; + } + + try + { + int wx = Integer.parseInt(parts[0].trim()); + int wy = Integer.parseInt(parts[1].trim()); + int plane = parts.length >= 3 ? Integer.parseInt(parts[2].trim()) : playerLoc.getPlane(); + String label = parts.length >= 4 ? parts[3].trim() : active.getCustomTileLabel(); + + if (plane != client.getPlane()) + { + continue; + } + + WorldPoint wp = new WorldPoint(wx, wy, plane); + LocalPoint lp = LocalPoint.fromWorld(client, wp); + if (lp == null) + { + continue; + } + + Polygon poly = Perspective.getCanvasTilePoly(client, lp); + if (poly != null) + { + if (fillOp > 0) + { + graphics.setColor(fillColor); + graphics.fill(poly); + } + OverlayUtil.renderPolygon(graphics, poly, outlineColor, stroke); + } + + if (active.isShowTileLabel()) + { + String textToDraw = (label != null && !label.isEmpty()) ? label : active.getCustomTileLabel(); + if (textToDraw == null || textToDraw.isEmpty()) + { + textToDraw = "⬇ Ziel"; + } + else if (!textToDraw.startsWith("⬇") && !textToDraw.startsWith("📍")) + { + textToDraw = "⬇ " + textToDraw; + } + + Point textLoc = Perspective.getCanvasTextLocation(client, graphics, lp, textToDraw, 20); + if (textLoc != null) + { + OverlayUtil.renderTextLocation(graphics, textLoc, textToDraw, outlineColor); + } + } + + if (active.isRenderPathLine()) + { + Point tPt = Perspective.localToCanvas(client, lp, client.getPlane()); + if (tPt != null) + { + canvasPointsForPath.add(tPt); + } + } + } + catch (Exception ignored) + { + } + } + + // Draw Path line connecting waypoints + if (active.isRenderPathLine() && canvasPointsForPath.size() >= 2) + { + graphics.setColor(new Color(outlineColor.getRed(), outlineColor.getGreen(), outlineColor.getBlue(), 180)); + graphics.setStroke(new BasicStroke(2.0f, BasicStroke.CAP_ROUND, BasicStroke.JOIN_ROUND, 10.0f, new float[]{6.0f, 6.0f}, 0.0f)); + for (int i = 0; i < canvasPointsForPath.size() - 1; i++) + { + Point p1 = canvasPointsForPath.get(i); + Point p2 = canvasPointsForPath.get(i + 1); + graphics.drawLine(p1.getX(), p1.getY(), p2.getX(), p2.getY()); + } + } + + return null; + } +} diff --git a/src/main/java/com/example/overlay/WorldObjectHighlighterOverlay.java b/src/main/java/com/example/overlay/WorldObjectHighlighterOverlay.java new file mode 100644 index 0000000..3cc48a2 --- /dev/null +++ b/src/main/java/com/example/overlay/WorldObjectHighlighterOverlay.java @@ -0,0 +1,260 @@ +package com.example.overlay; + +import com.example.ProfileManager; +import com.example.model.Profile; +import java.awt.BasicStroke; +import java.awt.Color; +import java.awt.Dimension; +import java.awt.Graphics2D; +import java.awt.Shape; +import java.awt.Stroke; +import java.util.List; +import javax.inject.Inject; +import javax.inject.Singleton; +import net.runelite.api.Client; +import net.runelite.api.DecorativeObject; +import net.runelite.api.GameObject; +import net.runelite.api.GameState; +import net.runelite.api.GroundObject; +import net.runelite.api.NPC; +import net.runelite.api.ObjectComposition; +import net.runelite.api.Perspective; +import net.runelite.api.Point; +import net.runelite.api.Scene; +import net.runelite.api.Tile; +import net.runelite.api.TileObject; +import net.runelite.api.WallObject; +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.OverlayUtil; +import net.runelite.client.util.ColorUtil; + +@Singleton +public class WorldObjectHighlighterOverlay extends Overlay +{ + private final Client client; + private final ProfileManager profileManager; + + @Inject + public WorldObjectHighlighterOverlay(Client client, ProfileManager profileManager) + { + this.client = client; + this.profileManager = profileManager; + setPosition(OverlayPosition.DYNAMIC); + setLayer(OverlayLayer.ABOVE_SCENE); + } + + @Override + public Dimension render(Graphics2D graphics) + { + Profile active = profileManager.getActiveProfile(); + if (active == null || !active.isEnabled() || !active.isEnableObjectHighlight()) + { + return null; + } + + String targetObjects = active.getHighlightObjects(); + if (targetObjects == null || targetObjects.trim().isEmpty()) + { + return null; + } + + if (client.getGameState() != GameState.LOGGED_IN) + { + return null; + } + + Scene scene = client.getScene(); + if (scene == null) + { + return null; + } + + Color color; + try + { + color = ColorUtil.fromString(active.getObjectHighlightColor()); + if (color == null) + { + color = new Color(0, 229, 255); + } + } + catch (Exception e) + { + color = new Color(0, 229, 255); + } + + Stroke stroke = new BasicStroke(Math.max(1, Math.min(4, active.getBorderThickness()))); + String[] queries = targetObjects.split(","); + + // 1. Scan Scene Tiles for matching GameObjects, WallObjects, etc. + Tile[][][] tiles = scene.getTiles(); + int plane = client.getPlane(); + if (tiles != null && plane >= 0 && plane < tiles.length) + { + Tile[][] planeTiles = tiles[plane]; + for (int x = 0; x < planeTiles.length; x++) + { + for (int y = 0; y < planeTiles[x].length; y++) + { + Tile tile = planeTiles[x][y]; + if (tile == null) + { + continue; + } + + // GameObjects + GameObject[] gameObjects = tile.getGameObjects(); + if (gameObjects != null) + { + for (GameObject go : gameObjects) + { + if (go != null) + { + checkAndRenderObject(graphics, go, queries, color, stroke, active); + } + } + } + + // WallObject + WallObject wo = tile.getWallObject(); + if (wo != null) + { + checkAndRenderObject(graphics, wo, queries, color, stroke, active); + } + + // DecorativeObject + DecorativeObject deco = tile.getDecorativeObject(); + if (deco != null) + { + checkAndRenderObject(graphics, deco, queries, color, stroke, active); + } + + // GroundObject + GroundObject gro = tile.getGroundObject(); + if (gro != null) + { + checkAndRenderObject(graphics, gro, queries, color, stroke, active); + } + } + } + } + + // 2. Scan NPCs (Bankers, Clerks, etc.) + List npcs = client.getNpcs(); + if (npcs != null) + { + for (NPC npc : npcs) + { + if (npc == null || npc.getName() == null) + { + continue; + } + + String npcName = npc.getName().toLowerCase(); + String matchedQuery = null; + for (String q : queries) + { + String queryTrimmed = q.trim().toLowerCase(); + if (!queryTrimmed.isEmpty() && npcName.contains(queryTrimmed)) + { + matchedQuery = q.trim(); + break; + } + } + + if (matchedQuery != null) + { + Shape hull = npc.getConvexHull(); + if (hull != null) + { + OverlayUtil.renderPolygon(graphics, hull, color, stroke); + } + + if (active.isShowObjectText()) + { + String label = active.getObjectCustomText(); + if (label == null || label.trim().isEmpty()) + { + label = matchedQuery; + } + + Point textLoc = npc.getCanvasTextLocation(graphics, label, npc.getLogicalHeight() + 40); + if (textLoc != null) + { + OverlayUtil.renderTextLocation(graphics, textLoc, label, color); + } + } + } + } + } + + return null; + } + + private void checkAndRenderObject(Graphics2D graphics, TileObject object, String[] queries, Color color, Stroke stroke, Profile active) + { + if (object == null) + { + return; + } + + ObjectComposition comp = client.getObjectDefinition(object.getId()); + if (comp == null) + { + return; + } + + if (comp.getImpostorIds() != null) + { + comp = comp.getImpostor(); + if (comp == null) + { + return; + } + } + + String name = comp.getName(); + if (name == null || name.equalsIgnoreCase("null")) + { + return; + } + + String nameLower = name.toLowerCase(); + String matchedQuery = null; + for (String q : queries) + { + String queryTrimmed = q.trim().toLowerCase(); + if (!queryTrimmed.isEmpty() && nameLower.contains(queryTrimmed)) + { + matchedQuery = q.trim(); + break; + } + } + + if (matchedQuery != null) + { + Shape clickbox = object.getClickbox(); + if (clickbox != null) + { + OverlayUtil.renderPolygon(graphics, clickbox, color, stroke); + } + + if (active.isShowObjectText()) + { + String label = active.getObjectCustomText(); + if (label == null || label.trim().isEmpty()) + { + label = matchedQuery; + } + + Point textLoc = Perspective.getCanvasTextLocation(client, graphics, object.getLocalLocation(), label, 100); + if (textLoc != null) + { + OverlayUtil.renderTextLocation(graphics, textLoc, label, color); + } + } + } + } +} diff --git a/src/main/java/com/example/ui/ExamplePluginPanel.java b/src/main/java/com/example/ui/ExamplePluginPanel.java new file mode 100644 index 0000000..6a4efdc --- /dev/null +++ b/src/main/java/com/example/ui/ExamplePluginPanel.java @@ -0,0 +1,73 @@ +package com.example.ui; + +import com.example.ExamplePlugin; +import com.example.ProfileManager; +import com.example.model.Profile; +import java.awt.BorderLayout; +import java.awt.CardLayout; +import javax.swing.JPanel; +import net.runelite.api.Client; +import net.runelite.client.ui.ColorScheme; +import net.runelite.client.ui.PluginPanel; + +public class ExamplePluginPanel extends PluginPanel implements ProfileManager.ProfileChangeListener +{ + private static final String VIEW_LIST = "LIST"; + private static final String VIEW_EDIT = "EDIT"; + + private final CardLayout cardLayout = new CardLayout(); + private final JPanel container = new JPanel(cardLayout); + + private final ProfileListPanel listPanel; + private final ProfileEditPanel editPanel; + private final ProfileManager profileManager; + + public ExamplePluginPanel(ExamplePlugin plugin, ProfileManager profileManager) + { + super(false); + this.profileManager = profileManager; + + setLayout(new BorderLayout()); + setBackground(ColorScheme.DARK_GRAY_COLOR); + + listPanel = new ProfileListPanel(plugin, profileManager, this); + editPanel = new ProfileEditPanel(plugin, profileManager, this); + + container.add(listPanel, VIEW_LIST); + container.add(editPanel, VIEW_EDIT); + + add(container, BorderLayout.CENTER); + + profileManager.addListener(this); + } + + public void showList() + { + cardLayout.show(container, VIEW_LIST); + listPanel.refreshList(); + } + + public void showEditor(Profile profile) + { + editPanel.editProfile(profile); + cardLayout.show(container, VIEW_EDIT); + } + + public void updateLiveInspector(Client client) + { + listPanel.updateLiveInspector(client); + } + + @Override + public void onActiveProfileChanged(Profile oldProfile, Profile newProfile) + { + listPanel.updateActiveBanner(newProfile); + listPanel.refreshList(); + } + + @Override + public void onProfileListUpdated() + { + listPanel.refreshList(); + } +} diff --git a/src/main/java/com/example/ui/ProfileEditPanel.java b/src/main/java/com/example/ui/ProfileEditPanel.java new file mode 100644 index 0000000..683a505 --- /dev/null +++ b/src/main/java/com/example/ui/ProfileEditPanel.java @@ -0,0 +1,677 @@ +package com.example.ui; + +import com.example.ExamplePlugin; +import com.example.ProfileManager; +import com.example.model.Folder; +import com.example.model.Profile; +import java.awt.BorderLayout; +import java.awt.Color; +import java.awt.Component; +import java.awt.Dimension; +import java.awt.FlowLayout; +import java.awt.GridLayout; +import java.util.ArrayList; +import java.util.List; +import javax.swing.Box; +import javax.swing.BoxLayout; +import javax.swing.JButton; +import javax.swing.JCheckBox; +import javax.swing.JComboBox; +import javax.swing.JLabel; +import javax.swing.JPanel; +import javax.swing.JScrollPane; +import javax.swing.JTextField; +import javax.swing.ScrollPaneConstants; +import javax.swing.SwingUtilities; +import javax.swing.border.CompoundBorder; +import javax.swing.border.EmptyBorder; +import javax.swing.border.LineBorder; +import net.runelite.api.Client; +import net.runelite.api.InventoryID; +import net.runelite.api.Item; +import net.runelite.api.ItemComposition; +import net.runelite.api.ItemContainer; +import net.runelite.api.coords.WorldPoint; +import net.runelite.api.widgets.ComponentID; +import net.runelite.api.widgets.Widget; +import net.runelite.client.game.ItemManager; +import net.runelite.client.ui.ColorScheme; + +public class ProfileEditPanel extends JPanel +{ + private final ExamplePlugin plugin; + private final ProfileManager profileManager; + private final ExamplePluginPanel parentPanel; + private Profile currentProfile; + + // 1. Allgemein & Ordner & Icon + private final JTextField nameInput = UIUtils.createTextField(""); + private final JCheckBox enabledCb = UIUtils.createCheckBox("Profil aktiv (automatisch prüfen)", true); + private final JComboBox folderCombo = new JComboBox<>(); + private final JTextField iconItemInput = UIUtils.createTextField(""); + private final JLabel iconPreviewLabel = new JLabel(); + + // 2. WENN: Bedingungen + private final JCheckBox requireBankOpenCb = UIUtils.createCheckBox("Bank muss GEÖFFNET sein", false); + private final JCheckBox requireBankClosedCb = UIUtils.createCheckBox("Bank muss GESCHLOSSEN sein", false); + private final JTextField bankTagInput = UIUtils.createTextField(""); + private final JTextField regionIdInput = UIUtils.createTextField(""); + private final JTextField coordinatesInput = UIUtils.createTextField(""); + private final JTextField requiredInventoryInput = UIUtils.createTextField(""); + private final JTextField requiredEquipmentInput = UIUtils.createTextField(""); + + // 3. DANN: Item Highlights + private final JCheckBox enableItemHighlightCb = UIUtils.createCheckBox("Items hervorheben (Highlight)", true); + private final JCheckBox highlightInBankCb = UIUtils.createCheckBox("In Bank", true); + private final JCheckBox highlightInInvCb = UIUtils.createCheckBox("Im Inventar", true); + private final JTextField highlightItemsInput = UIUtils.createTextField(""); + private final JTextField highlightColorInput = UIUtils.createTextField("#FF9800"); + private final JComboBox borderThicknessCombo = new JComboBox<>(new String[]{"1 px", "2 px", "3 px", "4 px"}); + private final JComboBox opacityCombo = new JComboBox<>(new String[]{"0 % (Nur Rahmen)", "20 %", "40 %", "60 %", "80 %", "100 %"}); + private final JCheckBox showTextLabelCb = UIUtils.createCheckBox("Mit Schrift / Text auf Item", false); + private final JTextField customTextLabelInput = UIUtils.createTextField(""); + + // 4. DANN: 3D Welt- & Objekt-Highlights + private final JCheckBox enableObjectHighlightCb = UIUtils.createCheckBox("Objekte in der 3D-Welt hervorheben", false); + private final JTextField highlightObjectsInput = UIUtils.createTextField("Bank booth, Bank chest, Banker"); + private final JTextField objectHighlightColorInput = UIUtils.createTextField("#00E5FF"); + private final JCheckBox showObjectTextCb = UIUtils.createCheckBox("Text über Objekt anzeigen", true); + private final JTextField objectCustomTextInput = UIUtils.createTextField("Bank öffnen"); + + // 5. DANN: 3D Boden-Tiles & Pfade + private final JCheckBox enableTileHighlightCb = UIUtils.createCheckBox("Boden-Kacheln (Ground Tiles) markieren", false); + private final JTextField highlightTilesInput = UIUtils.createTextField(""); + private final JTextField tileHighlightColorInput = UIUtils.createTextField("#2ECC71"); + private final JCheckBox showTileLabelCb = UIUtils.createCheckBox("3D Pfeil & Text über Kachel anzeigen", true); + private final JTextField customTileLabelInput = UIUtils.createTextField("Hier stehen"); + private final JCheckBox renderPathLineCb = UIUtils.createCheckBox("Pfad-Linie zur Kachel zeichnen", true); + + // 6. DANN: Benachrichtigungen & Feedback + private final JCheckBox notifyOnActivationCb = UIUtils.createCheckBox("Chat-Nachricht senden", true); + private final JTextField activationMessageInput = UIUtils.createTextField(""); + private final JCheckBox screenFlashCb = UIUtils.createCheckBox("Bildschirm-Flash Alert", false); + private final JTextField flashColorInput = UIUtils.createTextField("#FF9800"); + private final JCheckBox playSoundCb = UIUtils.createCheckBox("Sound-Effekt abspielen (Ding)", false); + + public ProfileEditPanel(ExamplePlugin plugin, ProfileManager profileManager, ExamplePluginPanel parentPanel) + { + this.plugin = plugin; + this.profileManager = profileManager; + this.parentPanel = parentPanel; + + setLayout(new BorderLayout()); + setBackground(ColorScheme.DARK_GRAY_COLOR); + + styleComboBox(folderCombo); + styleComboBox(borderThicknessCombo); + styleComboBox(opacityCombo); + } + + public void editProfile(Profile profile) + { + this.currentProfile = profile; + removeAll(); + + // Populate Folder Combo + folderCombo.removeAllItems(); + folderCombo.addItem("(Kein Ordner)"); + List folders = profileManager.getFolders(); + int selectedFolderIdx = 0; + for (int i = 0; i < folders.size(); i++) + { + Folder f = folders.get(i); + folderCombo.addItem(f.getName()); + if (f.getId().equals(profile.getFolderId())) + { + selectedFolderIdx = i + 1; + } + } + folderCombo.setSelectedIndex(selectedFolderIdx); + + // 1. Allgemein & Icon + nameInput.setText(profile.getName()); + enabledCb.setSelected(profile.isEnabled()); + iconItemInput.setText(profile.getIconItem()); + updateIconPreview(profile.getIconItem()); + + // 2. WENN + requireBankOpenCb.setSelected(profile.isRequireBankOpen()); + requireBankClosedCb.setSelected(profile.isRequireBankClosed()); + bankTagInput.setText(UIUtils.cleanBankTagName(profile.getBankTag())); + regionIdInput.setText(profile.getRegionId()); + coordinatesInput.setText(profile.getCoordinates()); + requiredInventoryInput.setText(profile.getRequiredInventory()); + requiredEquipmentInput.setText(profile.getRequiredEquipment()); + + // 3. DANN: Item Highlights + enableItemHighlightCb.setSelected(profile.isEnableItemHighlight()); + highlightInBankCb.setSelected(profile.isHighlightInBank()); + highlightInInvCb.setSelected(profile.isHighlightInInventory()); + highlightItemsInput.setText(profile.getHighlightItems()); + highlightColorInput.setText(profile.getHighlightColor()); + + int thickIdx = Math.max(0, Math.min(3, profile.getBorderThickness() - 1)); + borderThicknessCombo.setSelectedIndex(thickIdx); + + int op = profile.getFillOpacity(); + if (op == 0) opacityCombo.setSelectedIndex(0); + else if (op <= 60) opacityCombo.setSelectedIndex(1); + else if (op <= 110) opacityCombo.setSelectedIndex(2); + else if (op <= 160) opacityCombo.setSelectedIndex(3); + else if (op <= 210) opacityCombo.setSelectedIndex(4); + else opacityCombo.setSelectedIndex(5); + + showTextLabelCb.setSelected(profile.isShowTextLabel()); + customTextLabelInput.setText(profile.getCustomTextLabel()); + + // 4. DANN: Object Highlights + enableObjectHighlightCb.setSelected(profile.isEnableObjectHighlight()); + highlightObjectsInput.setText(profile.getHighlightObjects()); + objectHighlightColorInput.setText(profile.getObjectHighlightColor()); + showObjectTextCb.setSelected(profile.isShowObjectText()); + objectCustomTextInput.setText(profile.getObjectCustomText()); + + // 5. DANN: Tile & Path Highlights + enableTileHighlightCb.setSelected(profile.isEnableTileHighlight()); + highlightTilesInput.setText(profile.getHighlightTiles()); + tileHighlightColorInput.setText(profile.getTileHighlightColor()); + showTileLabelCb.setSelected(profile.isShowTileLabel()); + customTileLabelInput.setText(profile.getCustomTileLabel()); + renderPathLineCb.setSelected(profile.isRenderPathLine()); + + // 6. DANN: Feedback + notifyOnActivationCb.setSelected(profile.isNotifyOnActivation()); + activationMessageInput.setText(profile.getActivationMessage()); + screenFlashCb.setSelected(profile.isScreenFlash()); + flashColorInput.setText(profile.getFlashColor()); + playSoundCb.setSelected(profile.isPlaySound()); + + // Top-aligned content container + JPanel topWrapper = new JPanel(new BorderLayout()); + topWrapper.setBackground(ColorScheme.DARK_GRAY_COLOR); + + JPanel contentPanel = new JPanel(); + contentPanel.setLayout(new BoxLayout(contentPanel, BoxLayout.Y_AXIS)); + contentPanel.setBackground(ColorScheme.DARK_GRAY_COLOR); + contentPanel.setBorder(new EmptyBorder(3, 3, 3, 3)); + + // --- Nav Header --- + JPanel navHeader = new JPanel(new BorderLayout()); + navHeader.setBackground(UIUtils.CARD_BG); + navHeader.setBorder(new CompoundBorder( + new LineBorder(UIUtils.BORDER_SUBTLE, 1), + new EmptyBorder(3, 5, 3, 5) + )); + navHeader.setAlignmentX(Component.LEFT_ALIGNMENT); + + JButton backBtn = UIUtils.createButton("← Zurück", UIUtils.INPUT_BG, Color.WHITE); + backBtn.setFont(UIUtils.getSmallFont()); + backBtn.addActionListener(e -> parentPanel.showList()); + navHeader.add(backBtn, BorderLayout.WEST); + + JLabel titleLabel = new JLabel("Profil bearbeiten"); + titleLabel.setFont(UIUtils.getTitleFont()); + titleLabel.setForeground(UIUtils.ACCENT_COLOR); + navHeader.add(titleLabel, BorderLayout.EAST); + + contentPanel.add(navHeader); + contentPanel.add(Box.createRigidArea(new Dimension(0, 4))); + + // --- 1. Allgemein & Ordner & Icon --- + JPanel generalCard = UIUtils.createSectionCard("1. Allgemein & Icon"); + generalCard.add(UIUtils.createFieldLabel("Profilname:")); + generalCard.add(Box.createRigidArea(new Dimension(0, 2))); + generalCard.add(nameInput); + generalCard.add(Box.createRigidArea(new Dimension(0, 4))); + + generalCard.add(UIUtils.createFieldLabel("Ordner zuweisen:")); + generalCard.add(Box.createRigidArea(new Dimension(0, 2))); + generalCard.add(folderCombo); + generalCard.add(Box.createRigidArea(new Dimension(0, 4))); + + generalCard.add(UIUtils.createFieldLabel("Item-Icon (Name oder ID):")); + generalCard.add(Box.createRigidArea(new Dimension(0, 2))); + + JPanel iconRow = new JPanel(new BorderLayout(4, 0)); + iconRow.setBackground(UIUtils.CARD_BG); + iconRow.setAlignmentX(Component.LEFT_ALIGNMENT); + iconRow.add(iconItemInput, BorderLayout.CENTER); + iconRow.add(iconPreviewLabel, BorderLayout.EAST); + generalCard.add(iconRow); + generalCard.add(Box.createRigidArea(new Dimension(0, 4))); + + generalCard.add(enabledCb); + contentPanel.add(generalCard); + contentPanel.add(Box.createRigidArea(new Dimension(0, 4))); + + // --- 2. WENN: Bedingungen --- + JPanel condCard = UIUtils.createSectionCard("2. WENN: Bedingungen"); + + JPanel bankStatusPanel = new JPanel(new GridLayout(1, 2, 4, 0)); + bankStatusPanel.setBackground(UIUtils.CARD_BG); + bankStatusPanel.setAlignmentX(Component.LEFT_ALIGNMENT); + bankStatusPanel.add(requireBankOpenCb); + bankStatusPanel.add(requireBankClosedCb); + condCard.add(bankStatusPanel); + condCard.add(Box.createRigidArea(new Dimension(0, 4))); + + // Bank Tag + condCard.add(UIUtils.createFieldLabel("Bank-Tag / Tab-Titel:")); + condCard.add(Box.createRigidArea(new Dimension(0, 2))); + condCard.add(bankTagInput); + condCard.add(Box.createRigidArea(new Dimension(0, 2))); + condCard.add(UIUtils.createGrabButton("⤓ Aktuellen Bank-Titel übernehmen", () -> grabBankTitle())); + condCard.add(Box.createRigidArea(new Dimension(0, 4))); + + // Region ID + condCard.add(UIUtils.createFieldLabel("Region ID(s):")); + condCard.add(Box.createRigidArea(new Dimension(0, 2))); + condCard.add(regionIdInput); + condCard.add(Box.createRigidArea(new Dimension(0, 2))); + condCard.add(UIUtils.createGrabButton("⤓ Aktuelle Region übernehmen", () -> grabRegion())); + condCard.add(Box.createRigidArea(new Dimension(0, 4))); + + // Coordinates + condCard.add(UIUtils.createFieldLabel("Koordinaten (X, Y, Plane, Radius):")); + condCard.add(Box.createRigidArea(new Dimension(0, 2))); + condCard.add(coordinatesInput); + condCard.add(Box.createRigidArea(new Dimension(0, 2))); + condCard.add(UIUtils.createGrabButton("⤓ Aktuelle Position übernehmen", () -> grabCoords())); + condCard.add(Box.createRigidArea(new Dimension(0, 4))); + + // Required Inventory + condCard.add(UIUtils.createFieldLabel("Benötigte Inventar-Items:")); + condCard.add(Box.createRigidArea(new Dimension(0, 2))); + condCard.add(requiredInventoryInput); + condCard.add(Box.createRigidArea(new Dimension(0, 2))); + condCard.add(UIUtils.createGrabButton("⤓ Aktuelles Inventar übernehmen", () -> grabInventory())); + condCard.add(Box.createRigidArea(new Dimension(0, 4))); + + // Required Equipment + condCard.add(UIUtils.createFieldLabel("Benötigte Ausrüstung:")); + condCard.add(Box.createRigidArea(new Dimension(0, 2))); + condCard.add(requiredEquipmentInput); + condCard.add(Box.createRigidArea(new Dimension(0, 2))); + condCard.add(UIUtils.createGrabButton("⤓ Aktuelle Ausrüstung übernehmen", () -> grabEquipment())); + + contentPanel.add(condCard); + contentPanel.add(Box.createRigidArea(new Dimension(0, 4))); + + // --- 3. DANN: Item Highlights --- + JPanel highlightCard = UIUtils.createSectionCard("3. DANN: Item-Highlights"); + + highlightCard.add(enableItemHighlightCb); + highlightCard.add(Box.createRigidArea(new Dimension(0, 3))); + + JPanel wherePanel = new JPanel(new GridLayout(1, 2, 4, 0)); + wherePanel.setBackground(UIUtils.CARD_BG); + wherePanel.setAlignmentX(Component.LEFT_ALIGNMENT); + wherePanel.add(highlightInBankCb); + wherePanel.add(highlightInInvCb); + highlightCard.add(wherePanel); + highlightCard.add(Box.createRigidArea(new Dimension(0, 4))); + + highlightCard.add(UIUtils.createFieldLabel("Items (Namen/IDs, kommagetrennt):")); + highlightCard.add(Box.createRigidArea(new Dimension(0, 2))); + highlightCard.add(highlightItemsInput); + highlightCard.add(Box.createRigidArea(new Dimension(0, 4))); + + highlightCard.add(UIUtils.createFieldLabel("Highlight-Farbe (Hex z. B. #FF9800):")); + highlightCard.add(Box.createRigidArea(new Dimension(0, 2))); + highlightCard.add(highlightColorInput); + highlightCard.add(Box.createRigidArea(new Dimension(0, 4))); + + JPanel styleGrid = new JPanel(new GridLayout(2, 2, 3, 2)); + styleGrid.setBackground(UIUtils.CARD_BG); + styleGrid.setAlignmentX(Component.LEFT_ALIGNMENT); + styleGrid.add(UIUtils.createFieldLabel("Rahmendicke:")); + styleGrid.add(UIUtils.createFieldLabel("Füllung:")); + styleGrid.add(borderThicknessCombo); + styleGrid.add(opacityCombo); + highlightCard.add(styleGrid); + highlightCard.add(Box.createRigidArea(new Dimension(0, 4))); + + highlightCard.add(showTextLabelCb); + highlightCard.add(Box.createRigidArea(new Dimension(0, 2))); + highlightCard.add(UIUtils.createFieldLabel("Eigener Text (optional):")); + highlightCard.add(Box.createRigidArea(new Dimension(0, 2))); + highlightCard.add(customTextLabelInput); + + contentPanel.add(highlightCard); + contentPanel.add(Box.createRigidArea(new Dimension(0, 4))); + + // --- 4. DANN: 3D Welt- & Objekt-Highlights --- + JPanel objCard = UIUtils.createSectionCard("4. DANN: Welt- & Objekt-Highlights"); + + objCard.add(enableObjectHighlightCb); + objCard.add(Box.createRigidArea(new Dimension(0, 3))); + + objCard.add(UIUtils.createFieldLabel("Objekte/NPCs (z. B. Bank booth, Banker):")); + objCard.add(Box.createRigidArea(new Dimension(0, 2))); + objCard.add(highlightObjectsInput); + objCard.add(Box.createRigidArea(new Dimension(0, 4))); + + objCard.add(UIUtils.createFieldLabel("Objekt-Farbe (Hex z. B. #00E5FF):")); + objCard.add(Box.createRigidArea(new Dimension(0, 2))); + objCard.add(objectHighlightColorInput); + objCard.add(Box.createRigidArea(new Dimension(0, 4))); + + objCard.add(showObjectTextCb); + objCard.add(Box.createRigidArea(new Dimension(0, 2))); + objCard.add(UIUtils.createFieldLabel("Text über Objekt (z. B. 'Bank öffnen'):")); + objCard.add(Box.createRigidArea(new Dimension(0, 2))); + objCard.add(objectCustomTextInput); + + contentPanel.add(objCard); + contentPanel.add(Box.createRigidArea(new Dimension(0, 4))); + + // --- 5. DANN: 3D Boden-Kacheln & Pfade --- + JPanel tileCard = UIUtils.createSectionCard("5. DANN: Boden-Kacheln & Pfade"); + + tileCard.add(enableTileHighlightCb); + tileCard.add(Box.createRigidArea(new Dimension(0, 3))); + + tileCard.add(UIUtils.createFieldLabel("Kacheln (X, Y, Plane, Label; ...):")); + tileCard.add(Box.createRigidArea(new Dimension(0, 2))); + tileCard.add(highlightTilesInput); + tileCard.add(Box.createRigidArea(new Dimension(0, 2))); + tileCard.add(UIUtils.createGrabButton("⤓ Aktuelle Spieler-Kachel hinzufügen", () -> grabCurrentTile())); + tileCard.add(Box.createRigidArea(new Dimension(0, 4))); + + tileCard.add(UIUtils.createFieldLabel("Kachel-Farbe (Hex z. B. #2ECC71):")); + tileCard.add(Box.createRigidArea(new Dimension(0, 2))); + tileCard.add(tileHighlightColorInput); + tileCard.add(Box.createRigidArea(new Dimension(0, 4))); + + tileCard.add(showTileLabelCb); + tileCard.add(Box.createRigidArea(new Dimension(0, 2))); + tileCard.add(UIUtils.createFieldLabel("Standard-Text über Kachel:")); + tileCard.add(Box.createRigidArea(new Dimension(0, 2))); + tileCard.add(customTileLabelInput); + tileCard.add(Box.createRigidArea(new Dimension(0, 4))); + + tileCard.add(renderPathLineCb); + + contentPanel.add(tileCard); + contentPanel.add(Box.createRigidArea(new Dimension(0, 4))); + + // --- 6. DANN: Effekte & Benachrichtigungen --- + JPanel alertCard = UIUtils.createSectionCard("6. DANN: Alerts & Feedback"); + + alertCard.add(notifyOnActivationCb); + alertCard.add(Box.createRigidArea(new Dimension(0, 2))); + alertCard.add(activationMessageInput); + alertCard.add(Box.createRigidArea(new Dimension(0, 4))); + + alertCard.add(screenFlashCb); + alertCard.add(Box.createRigidArea(new Dimension(0, 2))); + alertCard.add(UIUtils.createFieldLabel("Flash-Farbe (Hex z. B. #FF9800):")); + alertCard.add(Box.createRigidArea(new Dimension(0, 2))); + alertCard.add(flashColorInput); + alertCard.add(Box.createRigidArea(new Dimension(0, 4))); + + alertCard.add(playSoundCb); + + contentPanel.add(alertCard); + contentPanel.add(Box.createRigidArea(new Dimension(0, 6))); + + // --- Bottom Sticky Buttons (Save & Cancel) --- + JPanel bottomPanel = new JPanel(new GridLayout(1, 2, 4, 0)); + bottomPanel.setBackground(ColorScheme.DARK_GRAY_COLOR); + bottomPanel.setAlignmentX(Component.LEFT_ALIGNMENT); + bottomPanel.setMaximumSize(new Dimension(Integer.MAX_VALUE, 26)); + + JButton cancelBtn = UIUtils.createButton("Abbrechen", UIUtils.CARD_BG, Color.WHITE); + cancelBtn.addActionListener(e -> parentPanel.showList()); + bottomPanel.add(cancelBtn); + + JButton saveBtn = UIUtils.createButton("💾 Speichern", UIUtils.ACCENT_COLOR, Color.WHITE); + saveBtn.addActionListener(e -> saveCurrentProfile()); + bottomPanel.add(saveBtn); + + contentPanel.add(bottomPanel); + contentPanel.add(Box.createRigidArea(new Dimension(0, 4))); + + topWrapper.add(contentPanel, BorderLayout.NORTH); + + JScrollPane scrollPane = new JScrollPane(topWrapper); + scrollPane.setHorizontalScrollBarPolicy(ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER); + scrollPane.setVerticalScrollBarPolicy(ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED); + scrollPane.setBorder(null); + scrollPane.setBackground(ColorScheme.DARK_GRAY_COLOR); + scrollPane.getVerticalScrollBar().setUnitIncrement(16); + + add(scrollPane, BorderLayout.CENTER); + revalidate(); + repaint(); + } + + private void updateIconPreview(String iconQuery) + { + iconPreviewLabel.setIcon(null); + iconPreviewLabel.setText(""); + if (iconQuery == null || iconQuery.trim().isEmpty() || plugin.getItemManager() == null) + { + return; + } + + int id = UIUtils.resolveItemId(iconQuery, plugin.getItemManager()); + if (id > 0) + { + plugin.getItemManager().getImage(id).addTo(iconPreviewLabel); + } + } + + private void styleComboBox(JComboBox combo) + { + combo.setFont(UIUtils.getSmallFont()); + combo.setBackground(UIUtils.INPUT_BG); + combo.setForeground(Color.WHITE); + combo.setPreferredSize(new Dimension(80, 24)); + combo.setMaximumSize(new Dimension(Integer.MAX_VALUE, 24)); + combo.setAlignmentX(Component.LEFT_ALIGNMENT); + } + + private void grabCurrentTile() + { + plugin.getClientThread().invokeLater(() -> + { + Client client = plugin.getClient(); + if (client != null && client.getLocalPlayer() != null) + { + WorldPoint wp = client.getLocalPlayer().getWorldLocation(); + String current = highlightTilesInput.getText().trim(); + String newEntry = String.format("%d, %d, %d, Ziel", wp.getX(), wp.getY(), wp.getPlane()); + final String combined = current.isEmpty() ? newEntry : current + "; " + newEntry; + SwingUtilities.invokeLater(() -> highlightTilesInput.setText(combined)); + } + }); + } + + private void grabBankTitle() + { + plugin.getClientThread().invokeLater(() -> + { + Client client = plugin.getClient(); + if (client == null) return; + + String rawTag = null; + if (plugin.getBankTagsService() != null) + { + try { rawTag = plugin.getBankTagsService().getActiveTag(); } + catch (Throwable ignored) {} + } + + if (rawTag == null || rawTag.trim().isEmpty()) + { + Widget titleWidget = client.getWidget(ComponentID.BANK_TITLE_BAR); + if (titleWidget != null && titleWidget.getText() != null) + { + rawTag = titleWidget.getText(); + } + } + + final String cleaned = UIUtils.cleanBankTagName(rawTag); + SwingUtilities.invokeLater(() -> + { + if (!cleaned.isEmpty()) bankTagInput.setText(cleaned); + }); + }); + } + + private void grabRegion() + { + plugin.getClientThread().invokeLater(() -> + { + Client client = plugin.getClient(); + if (client != null && client.getLocalPlayer() != null) + { + int reg = client.getLocalPlayer().getWorldLocation().getRegionID(); + SwingUtilities.invokeLater(() -> regionIdInput.setText(String.valueOf(reg))); + } + }); + } + + private void grabCoords() + { + plugin.getClientThread().invokeLater(() -> + { + Client client = plugin.getClient(); + if (client != null && client.getLocalPlayer() != null) + { + WorldPoint wp = client.getLocalPlayer().getWorldLocation(); + String coords = String.format("%d, %d, %d, 10", wp.getX(), wp.getY(), wp.getPlane()); + SwingUtilities.invokeLater(() -> coordinatesInput.setText(coords)); + } + }); + } + + private void grabInventory() + { + plugin.getClientThread().invokeLater(() -> + { + String itemsStr = getContainerItemsString(InventoryID.INVENTORY); + SwingUtilities.invokeLater(() -> + { + if (!itemsStr.isEmpty()) requiredInventoryInput.setText(itemsStr); + }); + }); + } + + private void grabEquipment() + { + plugin.getClientThread().invokeLater(() -> + { + String equipStr = getContainerItemsString(InventoryID.EQUIPMENT); + SwingUtilities.invokeLater(() -> + { + if (!equipStr.isEmpty()) requiredEquipmentInput.setText(equipStr); + }); + }); + } + + private String getContainerItemsString(InventoryID containerId) + { + Client client = plugin.getClient(); + ItemManager itemManager = plugin.getItemManager(); + if (client == null || itemManager == null) return ""; + + ItemContainer container = client.getItemContainer(containerId); + if (container == null || container.getItems() == null) return ""; + + List names = new ArrayList<>(); + for (Item item : container.getItems()) + { + if (item != null && item.getId() > 0) + { + try + { + ItemComposition def = itemManager.getItemComposition(item.getId()); + if (def != null && def.getName() != null && !def.getName().trim().isEmpty() && !def.getName().equalsIgnoreCase("null")) + { + if (!names.contains(def.getName())) names.add(def.getName()); + } + } + catch (Throwable ignored) {} + } + } + return String.join(", ", names); + } + + private void saveCurrentProfile() + { + if (currentProfile == null) return; + + String name = nameInput.getText().trim(); + if (!name.isEmpty()) currentProfile.setName(name); + + currentProfile.setEnabled(enabledCb.isSelected()); + + // Folder Assignment + int folderIdx = folderCombo.getSelectedIndex(); + if (folderIdx <= 0) + { + currentProfile.setFolderId(""); + } + else + { + List folders = profileManager.getFolders(); + if (folderIdx - 1 < folders.size()) + { + currentProfile.setFolderId(folders.get(folderIdx - 1).getId()); + } + } + + // Custom Item Icon + currentProfile.setIconItem(iconItemInput.getText().trim()); + + // WENN + currentProfile.setRequireBankOpen(requireBankOpenCb.isSelected()); + currentProfile.setRequireBankClosed(requireBankClosedCb.isSelected()); + currentProfile.setBankTag(UIUtils.cleanBankTagName(bankTagInput.getText().trim())); + currentProfile.setRegionId(regionIdInput.getText().trim()); + currentProfile.setCoordinates(coordinatesInput.getText().trim()); + currentProfile.setRequiredInventory(requiredInventoryInput.getText().trim()); + currentProfile.setRequiredEquipment(requiredEquipmentInput.getText().trim()); + + // DANN: Item Highlights + currentProfile.setEnableItemHighlight(enableItemHighlightCb.isSelected()); + currentProfile.setHighlightInBank(highlightInBankCb.isSelected()); + currentProfile.setHighlightInInventory(highlightInInvCb.isSelected()); + currentProfile.setHighlightItems(highlightItemsInput.getText().trim()); + currentProfile.setHighlightColor(highlightColorInput.getText().trim()); + currentProfile.setBorderThickness(borderThicknessCombo.getSelectedIndex() + 1); + + int[] opValues = new int[]{0, 50, 100, 150, 200, 255}; + int opIdx = opacityCombo.getSelectedIndex(); + currentProfile.setFillOpacity(opValues[Math.max(0, Math.min(5, opIdx))]); + + currentProfile.setShowTextLabel(showTextLabelCb.isSelected()); + currentProfile.setCustomTextLabel(customTextLabelInput.getText().trim()); + + // DANN: 3D Object Highlights + currentProfile.setEnableObjectHighlight(enableObjectHighlightCb.isSelected()); + currentProfile.setHighlightObjects(highlightObjectsInput.getText().trim()); + currentProfile.setObjectHighlightColor(objectHighlightColorInput.getText().trim()); + currentProfile.setShowObjectText(showObjectTextCb.isSelected()); + currentProfile.setObjectCustomText(objectCustomTextInput.getText().trim()); + + // DANN: 3D Tile & Path Highlights + currentProfile.setEnableTileHighlight(enableTileHighlightCb.isSelected()); + currentProfile.setHighlightTiles(highlightTilesInput.getText().trim()); + currentProfile.setTileHighlightColor(tileHighlightColorInput.getText().trim()); + currentProfile.setShowTileLabel(showTileLabelCb.isSelected()); + currentProfile.setCustomTileLabel(customTileLabelInput.getText().trim()); + currentProfile.setRenderPathLine(renderPathLineCb.isSelected()); + + // DANN: Alerts + currentProfile.setNotifyOnActivation(notifyOnActivationCb.isSelected()); + currentProfile.setActivationMessage(activationMessageInput.getText().trim()); + currentProfile.setScreenFlash(screenFlashCb.isSelected()); + currentProfile.setFlashColor(flashColorInput.getText().trim()); + currentProfile.setPlaySound(playSoundCb.isSelected()); + + profileManager.updateProfile(currentProfile); + parentPanel.showList(); + } +} diff --git a/src/main/java/com/example/ui/ProfileListPanel.java b/src/main/java/com/example/ui/ProfileListPanel.java new file mode 100644 index 0000000..a51a3a3 --- /dev/null +++ b/src/main/java/com/example/ui/ProfileListPanel.java @@ -0,0 +1,729 @@ +package com.example.ui; + +import com.example.ExamplePlugin; +import com.example.ProfileManager; +import com.example.model.Folder; +import com.example.model.Profile; +import java.awt.BorderLayout; +import java.awt.Color; +import java.awt.Component; +import java.awt.Cursor; +import java.awt.Dimension; +import java.awt.FlowLayout; +import java.awt.GridLayout; +import java.awt.Insets; +import java.awt.event.MouseAdapter; +import java.awt.event.MouseEvent; +import java.util.ArrayList; +import java.util.List; +import javax.swing.BorderFactory; +import javax.swing.Box; +import javax.swing.BoxLayout; +import javax.swing.JButton; +import javax.swing.JCheckBox; +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 javax.swing.border.CompoundBorder; +import javax.swing.border.EmptyBorder; +import javax.swing.border.LineBorder; +import net.runelite.api.Client; +import net.runelite.api.GameState; +import net.runelite.api.InventoryID; +import net.runelite.api.ItemContainer; +import net.runelite.api.coords.WorldPoint; +import net.runelite.api.widgets.ComponentID; +import net.runelite.api.widgets.Widget; +import net.runelite.client.ui.ColorScheme; +import net.runelite.client.util.Text; + +public class ProfileListPanel extends JPanel +{ + private final ExamplePlugin plugin; + private final ProfileManager profileManager; + private final ExamplePluginPanel parentPanel; + + private final JPanel activeBannerPanel = new JPanel(new BorderLayout()); + private final JLabel activeStatusLabel = new JLabel(); + private final JLabel activeDetailsLabel = new JLabel(); + + private final JPanel profileCardsContainer = new JPanel(); + private final JPanel liveInfoPanel = new JPanel(); + private final JLabel liveBankLabel = new JLabel(); + private final JLabel liveRegionLabel = new JLabel(); + private final JLabel liveCoordsLabel = new JLabel(); + private final JLabel liveItemsLabel = new JLabel(); + + public ProfileListPanel(ExamplePlugin plugin, ProfileManager profileManager, ExamplePluginPanel parentPanel) + { + this.plugin = plugin; + this.profileManager = profileManager; + this.parentPanel = parentPanel; + + setLayout(new BorderLayout()); + setBackground(ColorScheme.DARK_GRAY_COLOR); + + // Top-aligned content wrapper (pure vertical stack) + JPanel topWrapper = new JPanel(new BorderLayout()); + topWrapper.setBackground(ColorScheme.DARK_GRAY_COLOR); + + JPanel contentPanel = new JPanel(); + contentPanel.setLayout(new BoxLayout(contentPanel, BoxLayout.Y_AXIS)); + contentPanel.setBackground(ColorScheme.DARK_GRAY_COLOR); + contentPanel.setBorder(new EmptyBorder(3, 3, 3, 3)); + + // 1. Compact Header + JPanel headerPanel = new JPanel(new BorderLayout()); + headerPanel.setBackground(UIUtils.CARD_BG); + headerPanel.setBorder(new CompoundBorder( + new LineBorder(UIUtils.BORDER_SUBTLE, 1), + new EmptyBorder(3, 4, 3, 4) + )); + headerPanel.setAlignmentX(Component.LEFT_ALIGNMENT); + + JLabel title = new JLabel("Auto-Profile & Templates"); + title.setFont(UIUtils.getTitleFont()); + title.setForeground(UIUtils.ACCENT_COLOR); + headerPanel.add(title, BorderLayout.WEST); + + contentPanel.add(headerPanel); + contentPanel.add(Box.createRigidArea(new Dimension(0, 4))); + + // 2. Active Profile Banner Card + setupActiveBanner(); + contentPanel.add(activeBannerPanel); + contentPanel.add(Box.createRigidArea(new Dimension(0, 4))); + + // 3. Vertical Buttons Stack (Full Width, Zero Horizontal Overflow) + JPanel topButtonsPanel = new JPanel(); + topButtonsPanel.setLayout(new BoxLayout(topButtonsPanel, BoxLayout.Y_AXIS)); + topButtonsPanel.setBackground(ColorScheme.DARK_GRAY_COLOR); + topButtonsPanel.setAlignmentX(Component.LEFT_ALIGNMENT); + + JButton createBtn = UIUtils.createButton("+ Neues Profil erstellen", UIUtils.ACCENT_COLOR, Color.WHITE); + createBtn.setMaximumSize(new Dimension(Integer.MAX_VALUE, 26)); + createBtn.addActionListener(e -> + { + String name = JOptionPane.showInputDialog(this, "Name für das neue Profil:", "Neues Profil", JOptionPane.PLAIN_MESSAGE); + if (name != null && !name.trim().isEmpty()) + { + Profile p = profileManager.createProfile(name.trim()); + parentPanel.showEditor(p); + } + }); + topButtonsPanel.add(createBtn); + topButtonsPanel.add(Box.createRigidArea(new Dimension(0, 3))); + + JButton createFolderBtn = UIUtils.createButton("+ Neuer Ordner", new Color(35, 45, 55), Color.WHITE); + createFolderBtn.setMaximumSize(new Dimension(Integer.MAX_VALUE, 24)); + createFolderBtn.addActionListener(e -> + { + String name = JOptionPane.showInputDialog(this, "Name für den neuen Ordner:", "Neuer Ordner", JOptionPane.PLAIN_MESSAGE); + if (name != null && !name.trim().isEmpty()) + { + String iconItem = JOptionPane.showInputDialog(this, "Item-Name für Ordner-Icon (z. B. 'Prayer potion(4)'):", "Ordner-Icon", JOptionPane.PLAIN_MESSAGE); + profileManager.createFolder(name.trim(), iconItem != null ? iconItem.trim() : ""); + } + }); + topButtonsPanel.add(createFolderBtn); + topButtonsPanel.add(Box.createRigidArea(new Dimension(0, 3))); + + JButton templateLibraryBtn = UIUtils.createButton("⚡ Vorlagen & Workflows laden...", new Color(25, 40, 55), new Color(130, 200, 255)); + templateLibraryBtn.setMaximumSize(new Dimension(Integer.MAX_VALUE, 24)); + templateLibraryBtn.addActionListener(e -> showTemplateSelector()); + topButtonsPanel.add(templateLibraryBtn); + + contentPanel.add(topButtonsPanel); + contentPanel.add(Box.createRigidArea(new Dimension(0, 4))); + + // 4. Profiles & Folders Stream Container + profileCardsContainer.setLayout(new BoxLayout(profileCardsContainer, BoxLayout.Y_AXIS)); + profileCardsContainer.setBackground(ColorScheme.DARK_GRAY_COLOR); + profileCardsContainer.setAlignmentX(Component.LEFT_ALIGNMENT); + contentPanel.add(profileCardsContainer); + + // 5. Live Game Inspector Box + setupLiveInspector(); + contentPanel.add(Box.createRigidArea(new Dimension(0, 4))); + contentPanel.add(liveInfoPanel); + + topWrapper.add(contentPanel, BorderLayout.NORTH); + + JScrollPane scrollPane = new JScrollPane(topWrapper); + scrollPane.setHorizontalScrollBarPolicy(ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER); + scrollPane.setVerticalScrollBarPolicy(ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED); + scrollPane.setBorder(null); + scrollPane.setBackground(ColorScheme.DARK_GRAY_COLOR); + scrollPane.getVerticalScrollBar().setUnitIncrement(16); + + add(scrollPane, BorderLayout.CENTER); + + refreshList(); + } + + private void showTemplateSelector() + { + String[] options = { + "🌿 Herblore: Prayer Potions (8-Schritte Loop)", + "🍳 Cooking: Karambwan am Herd/Feuer", + "🏃 Agility: Seers' Rooftop Course (3D Hindernisse & Kacheln)" + }; + + String selection = (String) JOptionPane.showInputDialog( + this, + "Wähle ein fertiges Workflow-Template zum Laden:", + "Workflow-Vorlagen", + JOptionPane.PLAIN_MESSAGE, + null, + options, + options[0] + ); + + if (selection != null) + { + if (selection.startsWith("🌿 Herblore")) + { + profileManager.loadHerblorePrayerPotionWorkflow(); + } + else if (selection.startsWith("🍳 Cooking")) + { + profileManager.loadCookingKarambwanWorkflow(); + } + else if (selection.startsWith("🏃 Agility")) + { + profileManager.loadAgilitySeersWorkflow(); + } + } + } + + private void setupActiveBanner() + { + activeBannerPanel.setBackground(UIUtils.CARD_BG); + activeBannerPanel.setAlignmentX(Component.LEFT_ALIGNMENT); + + activeStatusLabel.setFont(UIUtils.getBoldFont()); + activeDetailsLabel.setFont(UIUtils.getSmallFont()); + activeDetailsLabel.setForeground(ColorScheme.LIGHT_GRAY_COLOR); + + JPanel bannerContent = new JPanel(); + bannerContent.setLayout(new BoxLayout(bannerContent, BoxLayout.Y_AXIS)); + bannerContent.setBackground(UIUtils.CARD_BG); + bannerContent.setBorder(new EmptyBorder(3, 4, 3, 4)); + bannerContent.add(activeStatusLabel); + bannerContent.add(Box.createRigidArea(new Dimension(0, 1))); + bannerContent.add(activeDetailsLabel); + + activeBannerPanel.add(bannerContent, BorderLayout.CENTER); + updateActiveBanner(profileManager.getActiveProfile()); + } + + private void setupLiveInspector() + { + liveInfoPanel.setLayout(new BoxLayout(liveInfoPanel, BoxLayout.Y_AXIS)); + liveInfoPanel.setBackground(UIUtils.CARD_BG); + liveInfoPanel.setBorder(new CompoundBorder( + new LineBorder(UIUtils.BORDER_SUBTLE, 1), + new EmptyBorder(3, 4, 3, 4) + )); + liveInfoPanel.setAlignmentX(Component.LEFT_ALIGNMENT); + + JLabel inspectorTitle = new JLabel("Live-Spielerdaten"); + inspectorTitle.setFont(UIUtils.getBoldFont()); + inspectorTitle.setForeground(UIUtils.ACCENT_COLOR); + liveInfoPanel.add(inspectorTitle); + liveInfoPanel.add(Box.createRigidArea(new Dimension(0, 1))); + + JPanel grid = new JPanel(new GridLayout(4, 1, 0, 1)); + grid.setBackground(UIUtils.CARD_BG); + grid.setAlignmentX(Component.LEFT_ALIGNMENT); + + liveBankLabel.setFont(UIUtils.getSmallFont()); + liveRegionLabel.setFont(UIUtils.getSmallFont()); + liveCoordsLabel.setFont(UIUtils.getSmallFont()); + liveItemsLabel.setFont(UIUtils.getSmallFont()); + + grid.add(liveBankLabel); + grid.add(liveRegionLabel); + grid.add(liveCoordsLabel); + grid.add(liveItemsLabel); + + liveInfoPanel.add(grid); + } + + public void updateActiveBanner(Profile active) + { + SwingUtilities.invokeLater(() -> + { + if (active != null) + { + activeBannerPanel.setBorder(new CompoundBorder( + new LineBorder(UIUtils.SUCCESS_GREEN, 1), + new EmptyBorder(0, 0, 0, 0) + )); + activeStatusLabel.setText("
● " + active.getName() + "
"); + + StringBuilder details = new StringBuilder(); + if (active.isRequireBankOpen()) + { + details.append("Bank "); + } + String cleanTag = UIUtils.cleanBankTagName(active.getBankTag()); + if (!cleanTag.isEmpty()) + { + details.append("• Tag: '").append(cleanTag).append("' "); + } + if (!active.getRegionId().isEmpty()) + { + details.append("• Reg: ").append(active.getRegionId()).append(" "); + } + if (active.isEnableObjectHighlight() && !active.getHighlightObjects().isEmpty()) + { + details.append("• 3D OBJ "); + } + if (active.isEnableTileHighlight() && !active.getHighlightTiles().isEmpty()) + { + details.append("• TILE "); + } + activeDetailsLabel.setText("
" + (details.length() > 0 ? details.toString() : "Bedingungen aktiv") + "
"); + } + else + { + activeBannerPanel.setBorder(new CompoundBorder( + new LineBorder(UIUtils.BORDER_SUBTLE, 1), + new EmptyBorder(0, 0, 0, 0) + )); + activeStatusLabel.setText("
○ Kein Profil aktiv
"); + activeDetailsLabel.setText("
Bedingungen nicht erfüllt
"); + } + activeBannerPanel.revalidate(); + activeBannerPanel.repaint(); + }); + } + + public void updateLiveInspector(Client client) + { + if (client == null || client.getGameState() != GameState.LOGGED_IN || client.getLocalPlayer() == null) + { + SwingUtilities.invokeLater(() -> + { + liveBankLabel.setText("Bank: Nicht eingeloggt"); + liveRegionLabel.setText(""); + liveCoordsLabel.setText(""); + liveItemsLabel.setText(""); + }); + return; + } + + try + { + WorldPoint wp = client.getLocalPlayer().getWorldLocation(); + int region = wp != null ? wp.getRegionID() : 0; + int x = wp != null ? wp.getX() : 0; + int y = wp != null ? wp.getY() : 0; + int plane = wp != null ? wp.getPlane() : 0; + + Widget bankWidget = client.getWidget(ComponentID.BANK_CONTAINER); + boolean bankOpen = (bankWidget != null && !bankWidget.isSelfHidden()); + + String bankTitle = "Keiner"; + if (bankOpen) + { + String rawTag = ""; + if (plugin.getBankTagsService() != null) + { + try + { + rawTag = plugin.getBankTagsService().getActiveTag(); + } + catch (Throwable ignored) + { + } + } + + if (rawTag == null || rawTag.isEmpty()) + { + Widget titleWidget = client.getWidget(ComponentID.BANK_TITLE_BAR); + if (titleWidget != null && !titleWidget.isSelfHidden() && titleWidget.getText() != null) + { + rawTag = titleWidget.getText(); + } + } + + String clean = UIUtils.cleanBankTagName(rawTag); + if (!clean.isEmpty()) + { + bankTitle = clean; + } + else if (rawTag != null && !rawTag.isEmpty()) + { + bankTitle = Text.removeTags(rawTag).trim(); + } + } + + ItemContainer inv = client.getItemContainer(InventoryID.INVENTORY); + int invCount = (inv != null && inv.getItems() != null) ? inv.count() : 0; + + ItemContainer equip = client.getItemContainer(InventoryID.EQUIPMENT); + int equipCount = (equip != null && equip.getItems() != null) ? equip.count() : 0; + + final boolean finalBankOpen = bankOpen; + final String finalBankTitle = bankTitle; + + SwingUtilities.invokeLater(() -> + { + liveBankLabel.setText(String.format( + "Bank: %s (%s)", + finalBankOpen ? "Offen" : "Zu", + finalBankTitle + )); + liveRegionLabel.setText(String.format("Region: %d", region)); + liveCoordsLabel.setText(String.format("Koord: %d, %d, %d", x, y, plane)); + liveItemsLabel.setText(String.format("Items: Inv: %d | Equip: %d", invCount, equipCount)); + liveInfoPanel.revalidate(); + }); + } + catch (Throwable ignored) + { + } + } + + public void refreshList() + { + SwingUtilities.invokeLater(() -> + { + profileCardsContainer.removeAll(); + List allProfiles = profileManager.getProfiles(); + List folders = profileManager.getFolders(); + + Profile active = profileManager.getActiveProfile(); + updateActiveBanner(active); + + if (allProfiles.isEmpty() && folders.isEmpty()) + { + JPanel emptyPanel = new JPanel(); + emptyPanel.setBackground(UIUtils.CARD_BG); + emptyPanel.setBorder(new EmptyBorder(6, 6, 6, 6)); + JLabel empty = new JLabel("Keine Profile oder Ordner vorhanden."); + empty.setFont(UIUtils.getLabelFont()); + empty.setForeground(ColorScheme.LIGHT_GRAY_COLOR); + emptyPanel.add(empty); + profileCardsContainer.add(emptyPanel); + } + else + { + // 1. Render Folders as Stream Headers (NO heavy nesting to preserve 100% width) + for (Folder folder : folders) + { + List folderProfiles = new ArrayList<>(); + for (Profile p : allProfiles) + { + if (folder.getId().equals(p.getFolderId())) + { + folderProfiles.add(p); + } + } + + // Folder Header + profileCardsContainer.add(createFolderHeader(folder, folderProfiles.size())); + profileCardsContainer.add(Box.createRigidArea(new Dimension(0, 2))); + + // Unnested child profiles (full width available!) + if (!folder.isCollapsed()) + { + for (Profile profile : folderProfiles) + { + profileCardsContainer.add(createProfileCard(profile, true)); + profileCardsContainer.add(Box.createRigidArea(new Dimension(0, 2))); + } + } + profileCardsContainer.add(Box.createRigidArea(new Dimension(0, 3))); + } + + // 2. Render Uncategorized Profiles + List uncategorized = new ArrayList<>(); + for (Profile p : allProfiles) + { + if (p.getFolderId() == null || p.getFolderId().isEmpty() || profileManager.getFolderById(p.getFolderId()) == null) + { + uncategorized.add(p); + } + } + + if (!uncategorized.isEmpty()) + { + if (!folders.isEmpty()) + { + JLabel uncatLabel = new JLabel("Einzelne Profile:"); + uncatLabel.setFont(UIUtils.getBoldFont()); + uncatLabel.setForeground(ColorScheme.LIGHT_GRAY_COLOR); + uncatLabel.setBorder(new EmptyBorder(3, 2, 2, 2)); + profileCardsContainer.add(uncatLabel); + profileCardsContainer.add(Box.createRigidArea(new Dimension(0, 2))); + } + + for (Profile profile : uncategorized) + { + profileCardsContainer.add(createProfileCard(profile, false)); + profileCardsContainer.add(Box.createRigidArea(new Dimension(0, 2))); + } + } + } + + profileCardsContainer.revalidate(); + profileCardsContainer.repaint(); + }); + } + + private JPanel createFolderHeader(Folder folder, int count) + { + JPanel header = new JPanel(new BorderLayout(3, 0)); + header.setBackground(UIUtils.FOLDER_BG); + header.setBorder(new CompoundBorder( + new LineBorder(folder.isEnabled() ? new Color(60, 75, 90) : UIUtils.BORDER_SUBTLE, 1), + new EmptyBorder(3, 4, 3, 4) + )); + header.setAlignmentX(Component.LEFT_ALIGNMENT); + header.setMaximumSize(new Dimension(Integer.MAX_VALUE, 28)); + + // Left: Arrow + Icon + JPanel left = new JPanel(new FlowLayout(FlowLayout.LEFT, 2, 0)); + left.setBackground(UIUtils.FOLDER_BG); + left.setCursor(Cursor.getPredefinedCursor(Cursor.HAND_CURSOR)); + + JLabel arrow = new JLabel(folder.isCollapsed() ? "▶" : "▼"); + arrow.setFont(UIUtils.getSmallFont()); + arrow.setForeground(ColorScheme.LIGHT_GRAY_COLOR); + left.add(arrow); + + if (folder.getIconItem() != null && !folder.getIconItem().isEmpty()) + { + JLabel icon = UIUtils.createItemIconLabel(folder.getIconItem(), plugin.getItemManager()); + left.add(icon); + } + header.add(left, BorderLayout.WEST); + + // Center: Name (click to collapse/expand) + JLabel name = new JLabel(String.format("
%s (%d)
", folder.getName(), count)); + name.setFont(UIUtils.getBoldFont()); + name.setForeground(folder.isEnabled() ? Color.WHITE : Color.GRAY); + name.setCursor(Cursor.getPredefinedCursor(Cursor.HAND_CURSOR)); + name.addMouseListener(new MouseAdapter() + { + @Override + public void mouseClicked(MouseEvent e) + { + folder.setCollapsed(!folder.isCollapsed()); + profileManager.saveProfiles(); + } + }); + header.add(name, BorderLayout.CENTER); + + // Right: Checkbox + Delete + JPanel right = new JPanel(new FlowLayout(FlowLayout.RIGHT, 2, 0)); + right.setBackground(UIUtils.FOLDER_BG); + + JCheckBox enableCb = new JCheckBox("", folder.isEnabled()); + enableCb.setToolTipText(folder.isEnabled() ? "Ordner deaktivieren" : "Ordner aktivieren"); + enableCb.setBackground(UIUtils.FOLDER_BG); + enableCb.setMargin(new Insets(0, 0, 0, 0)); + enableCb.addActionListener(e -> + { + folder.setEnabled(enableCb.isSelected()); + profileManager.saveProfiles(); + }); + right.add(enableCb); + + JButton delBtn = UIUtils.createButton("✕", UIUtils.INPUT_BG, Color.GRAY); + delBtn.setPreferredSize(new Dimension(18, 18)); + delBtn.setToolTipText("Ordner löschen"); + delBtn.addActionListener(e -> + { + int confirm = JOptionPane.showConfirmDialog(this, "Ordner '" + folder.getName() + "' löschen? (Profile bleiben erhalten)", "Ordner löschen", JOptionPane.YES_NO_OPTION); + if (confirm == JOptionPane.YES_OPTION) + { + profileManager.deleteFolder(folder); + } + }); + right.add(delBtn); + + header.add(right, BorderLayout.EAST); + return header; + } + + private JPanel createProfileCard(Profile profile, boolean inFolder) + { + JPanel card = new JPanel(); + card.setLayout(new BoxLayout(card, BoxLayout.Y_AXIS)); + card.setBackground(UIUtils.CARD_BG); + card.setAlignmentX(Component.LEFT_ALIGNMENT); + + boolean isActive = profileManager.getActiveProfile() != null && + profileManager.getActiveProfile().getId().equals(profile.getId()); + + if (isActive) + { + card.setBorder(new CompoundBorder( + new LineBorder(UIUtils.SUCCESS_GREEN, 1), + new EmptyBorder(2, 3, 2, 3) + )); + } + else if (inFolder) + { + // Left accent line for folder items (NO nested indentation, 100% full width preserved!) + card.setBorder(new CompoundBorder( + BorderFactory.createMatteBorder(0, 3, 0, 0, new Color(45, 65, 85)), + new CompoundBorder( + new LineBorder(UIUtils.BORDER_SUBTLE, 1), + new EmptyBorder(2, 3, 2, 3) + ) + )); + } + else + { + card.setBorder(new CompoundBorder( + new LineBorder(UIUtils.BORDER_SUBTLE, 1), + new EmptyBorder(2, 3, 2, 3) + )); + } + + // Row 1: Profile Icon (WEST) + Name (CENTER) + Enabled Checkbox (EAST) + JPanel topRow = new JPanel(new BorderLayout(3, 0)); + topRow.setBackground(UIUtils.CARD_BG); + + String iconQuery = profile.getIconItem(); + if (iconQuery == null || iconQuery.isEmpty()) + { + if (profile.getHighlightItems() != null && !profile.getHighlightItems().isEmpty()) + { + iconQuery = profile.getHighlightItems().split(",")[0].trim(); + } + } + + if (iconQuery != null && !iconQuery.isEmpty()) + { + JLabel iconLabel = UIUtils.createItemIconLabel(iconQuery, plugin.getItemManager()); + topRow.add(iconLabel, BorderLayout.WEST); + } + + JLabel nameLabel = new JLabel(String.format("
%s
", profile.getName())); + nameLabel.setFont(UIUtils.getBoldFont()); + nameLabel.setForeground(isActive ? UIUtils.SUCCESS_GREEN : Color.WHITE); + topRow.add(nameLabel, BorderLayout.CENTER); + + JCheckBox enableCb = new JCheckBox("", profile.isEnabled()); + enableCb.setToolTipText(profile.isEnabled() ? "Profil aktiv" : "Profil inaktiv"); + enableCb.setBackground(UIUtils.CARD_BG); + enableCb.setMargin(new Insets(0, 0, 0, 0)); + enableCb.setBorder(null); + enableCb.addActionListener(e -> + { + profile.setEnabled(enableCb.isSelected()); + profileManager.saveProfiles(); + }); + topRow.add(enableCb, BorderLayout.EAST); + + card.add(topRow); + card.add(Box.createRigidArea(new Dimension(0, 2))); + + // Row 2: Badges in wrapping FlowLayout + JPanel badgesPanel = new JPanel(new FlowLayout(FlowLayout.LEFT, 2, 1)); + badgesPanel.setBackground(UIUtils.CARD_BG); + badgesPanel.setAlignmentX(Component.LEFT_ALIGNMENT); + + if (profile.isRequireBankOpen()) + { + badgesPanel.add(UIUtils.createBadge("BANK", new Color(40, 50, 60), new Color(130, 180, 240))); + } + if (profile.isRequireBankClosed()) + { + badgesPanel.add(UIUtils.createBadge("BANK ZU", new Color(55, 35, 35), new Color(240, 120, 120))); + } + + String cleanTag = UIUtils.cleanBankTagName(profile.getBankTag()); + if (!cleanTag.isEmpty()) + { + badgesPanel.add(UIUtils.createBadge(cleanTag, new Color(50, 45, 25), UIUtils.ACCENT_COLOR)); + } + if (!profile.getRegionId().isEmpty()) + { + badgesPanel.add(UIUtils.createBadge("REG: " + profile.getRegionId(), new Color(40, 40, 40), Color.LIGHT_GRAY)); + } + if (!profile.getRequiredInventory().isEmpty()) + { + badgesPanel.add(UIUtils.createBadge("INV", new Color(45, 35, 50), new Color(190, 140, 220))); + } + if (profile.isEnableObjectHighlight() && !profile.getHighlightObjects().isEmpty()) + { + badgesPanel.add(UIUtils.createBadge("3D OBJ", new Color(20, 45, 55), new Color(0, 229, 255))); + } + if (profile.isEnableTileHighlight() && !profile.getHighlightTiles().isEmpty()) + { + badgesPanel.add(UIUtils.createBadge("TILE", new Color(25, 50, 35), new Color(46, 204, 113))); + } + + if (badgesPanel.getComponentCount() > 0) + { + card.add(badgesPanel); + card.add(Box.createRigidArea(new Dimension(0, 2))); + } + + // Row 3: Action Toolbar ( ▲ ▼ [Bearbeiten] [Löschen] ) + JPanel actionRow = new JPanel(new BorderLayout(2, 0)); + actionRow.setBackground(UIUtils.CARD_BG); + actionRow.setAlignmentX(Component.LEFT_ALIGNMENT); + actionRow.setPreferredSize(new Dimension(100, 20)); + actionRow.setMaximumSize(new Dimension(Integer.MAX_VALUE, 20)); + + JPanel moveButtons = new JPanel(new GridLayout(1, 2, 1, 0)); + moveButtons.setBackground(UIUtils.CARD_BG); + + JButton upBtn = UIUtils.createButton("▲", UIUtils.INPUT_BG, Color.LIGHT_GRAY); + upBtn.setPreferredSize(new Dimension(18, 18)); + upBtn.setToolTipText("Priorität nach oben"); + upBtn.addActionListener(e -> profileManager.moveProfileUp(profile)); + moveButtons.add(upBtn); + + JButton downBtn = UIUtils.createButton("▼", UIUtils.INPUT_BG, Color.LIGHT_GRAY); + downBtn.setPreferredSize(new Dimension(18, 18)); + downBtn.setToolTipText("Priorität nach unten"); + downBtn.addActionListener(e -> profileManager.moveProfileDown(profile)); + moveButtons.add(downBtn); + + actionRow.add(moveButtons, BorderLayout.WEST); + + JPanel rightActions = new JPanel(new BorderLayout(2, 0)); + rightActions.setBackground(UIUtils.CARD_BG); + + JButton editBtn = UIUtils.createButton("Bearbeiten", UIUtils.INPUT_BG, Color.WHITE); + editBtn.setFont(UIUtils.getSmallFont()); + editBtn.addActionListener(e -> parentPanel.showEditor(profile)); + rightActions.add(editBtn, BorderLayout.CENTER); + + JButton deleteBtn = UIUtils.createButton("Löschen", UIUtils.DANGER_RED, Color.WHITE); + deleteBtn.setFont(UIUtils.getSmallFont()); + deleteBtn.setPreferredSize(new Dimension(48, 18)); + deleteBtn.setToolTipText("Profil löschen"); + deleteBtn.addActionListener(e -> + { + int confirm = JOptionPane.showConfirmDialog( + this, + "Möchtest du das Profil '" + profile.getName() + "' wirklich löschen?", + "Profil löschen", + JOptionPane.YES_NO_OPTION + ); + if (confirm == JOptionPane.YES_OPTION) + { + profileManager.deleteProfile(profile); + } + }); + rightActions.add(deleteBtn, BorderLayout.EAST); + + actionRow.add(rightActions, BorderLayout.CENTER); + + card.add(actionRow); + return card; + } +} diff --git a/src/main/java/com/example/ui/UIUtils.java b/src/main/java/com/example/ui/UIUtils.java new file mode 100644 index 0000000..00994e8 --- /dev/null +++ b/src/main/java/com/example/ui/UIUtils.java @@ -0,0 +1,335 @@ +package com.example.ui; + +import java.awt.BorderLayout; +import java.awt.Color; +import java.awt.Component; +import java.awt.Cursor; +import java.awt.Dimension; +import java.awt.Font; +import java.awt.Image; +import java.awt.Insets; +import java.awt.event.MouseAdapter; +import java.awt.event.MouseEvent; +import java.util.List; +import javax.swing.BorderFactory; +import javax.swing.Box; +import javax.swing.BoxLayout; +import javax.swing.ImageIcon; +import javax.swing.JButton; +import javax.swing.JCheckBox; +import javax.swing.JLabel; +import javax.swing.JPanel; +import javax.swing.JTextField; +import javax.swing.border.CompoundBorder; +import javax.swing.border.EmptyBorder; +import javax.swing.border.LineBorder; +import net.runelite.client.game.ItemManager; +import net.runelite.client.ui.ColorScheme; +import net.runelite.client.ui.FontManager; +import net.runelite.client.util.AsyncBufferedImage; +import net.runelite.client.util.Text; +import net.runelite.http.api.item.ItemPrice; + +public class UIUtils +{ + public static final Color ACCENT_COLOR = new Color(225, 145, 20); // Warm RuneLite Orange + public static final Color ACCENT_HOVER = new Color(245, 165, 40); + public static final Color SUCCESS_GREEN = new Color(46, 204, 113); + public static final Color DANGER_RED = new Color(220, 60, 50); + public static final Color DANGER_RED_HOVER = new Color(195, 45, 35); + public static final Color CARD_BG = new Color(30, 30, 30); + public static final Color FOLDER_BG = new Color(24, 28, 34); + public static final Color INPUT_BG = new Color(20, 20, 20); + public static final Color BORDER_SUBTLE = new Color(45, 45, 45); + + public static Font getTitleFont() + { + return FontManager.getDefaultBoldFont().deriveFont(12f); + } + + public static Font getLabelFont() + { + return FontManager.getDefaultFont().deriveFont(10.5f); + } + + public static Font getBoldFont() + { + return FontManager.getDefaultBoldFont().deriveFont(10.5f); + } + + public static Font getSmallFont() + { + return FontManager.getDefaultFont().deriveFont(9.5f); + } + + public static Font getBadgeFont() + { + return FontManager.getDefaultBoldFont().deriveFont(8.5f); + } + + public static int resolveItemId(String itemQuery, ItemManager itemManager) + { + if (itemQuery == null || itemQuery.trim().isEmpty() || itemManager == null) + { + return -1; + } + + String q = itemQuery.trim(); + try + { + return Integer.parseInt(q); + } + catch (NumberFormatException ignored) + { + } + + try + { + List results = itemManager.search(q); + if (results != null && !results.isEmpty()) + { + for (ItemPrice price : results) + { + if (price.getName().equalsIgnoreCase(q)) + { + return price.getId(); + } + } + return results.get(0).getId(); + } + } + catch (Throwable ignored) + { + } + + return -1; + } + + public static JLabel createItemIconLabel(String iconQuery, ItemManager itemManager) + { + JLabel label = new JLabel(); + label.setPreferredSize(new Dimension(20, 20)); + label.setMinimumSize(new Dimension(20, 20)); + label.setMaximumSize(new Dimension(20, 20)); + + if (itemManager == null || iconQuery == null || iconQuery.trim().isEmpty()) + { + return label; + } + + int itemId = resolveItemId(iconQuery, itemManager); + if (itemId > 0) + { + AsyncBufferedImage img = itemManager.getImage(itemId); + img.addTo(label); + } + + return label; + } + + public static String cleanBankTagName(String raw) + { + if (raw == null) + { + return ""; + } + + String cleaned = Text.removeTags(raw).trim(); + String lower = cleaned.toLowerCase(); + if (lower.startsWith("tag tab ")) + { + cleaned = cleaned.substring(8).trim(); + } + else if (lower.startsWith("bank tag: ")) + { + cleaned = cleaned.substring(10).trim(); + } + else if (lower.startsWith("tag: ")) + { + cleaned = cleaned.substring(5).trim(); + } + else if (lower.startsWith("tab: ")) + { + cleaned = cleaned.substring(5).trim(); + } + else if (cleaned.equalsIgnoreCase("The Bank of Gielinor")) + { + return ""; + } + + cleaned = cleaned.replaceAll("\\s*\\([^)]*\\)$", "").trim(); + return cleaned; + } + + public static JPanel createCard() + { + JPanel panel = new JPanel(); + panel.setLayout(new BoxLayout(panel, BoxLayout.Y_AXIS)); + panel.setBackground(CARD_BG); + panel.setBorder(new CompoundBorder( + new LineBorder(BORDER_SUBTLE, 1), + new EmptyBorder(4, 4, 4, 4) + )); + panel.setAlignmentX(Component.LEFT_ALIGNMENT); + return panel; + } + + public static JPanel createSectionCard(String title) + { + JPanel panel = createCard(); + + JLabel label = new JLabel(title); + label.setFont(getTitleFont()); + label.setForeground(ACCENT_COLOR); + label.setAlignmentX(Component.LEFT_ALIGNMENT); + + panel.add(label); + panel.add(Box.createRigidArea(new Dimension(0, 4))); + return panel; + } + + public static JLabel createFieldLabel(String text) + { + JLabel label = new JLabel(text); + label.setFont(getLabelFont()); + label.setForeground(ColorScheme.LIGHT_GRAY_COLOR); + label.setAlignmentX(Component.LEFT_ALIGNMENT); + return label; + } + + public static JTextField createTextField(String text) + { + JTextField tf = new JTextField(text != null ? text : ""); + tf.setFont(getLabelFont()); + tf.setBackground(INPUT_BG); + tf.setForeground(Color.WHITE); + tf.setCaretColor(Color.WHITE); + tf.setBorder(new CompoundBorder( + new LineBorder(BORDER_SUBTLE, 1), + new EmptyBorder(2, 4, 2, 4) + )); + tf.setPreferredSize(new Dimension(100, 22)); + tf.setMaximumSize(new Dimension(Integer.MAX_VALUE, 22)); + tf.setAlignmentX(Component.LEFT_ALIGNMENT); + return tf; + } + + public static JCheckBox createCheckBox(String text, boolean selected) + { + JCheckBox cb = new JCheckBox(text, selected); + cb.setFont(getLabelFont()); + cb.setBackground(CARD_BG); + cb.setForeground(Color.WHITE); + cb.setFocusPainted(false); + cb.setMargin(new Insets(0, 0, 0, 0)); + cb.setBorder(null); + cb.setAlignmentX(Component.LEFT_ALIGNMENT); + cb.setCursor(Cursor.getPredefinedCursor(Cursor.HAND_CURSOR)); + return cb; + } + + public static JButton createButton(String text, Color bg, Color fg) + { + JButton btn = new JButton(text); + btn.setFont(getSmallFont()); + btn.setBackground(bg); + btn.setForeground(fg); + btn.setFocusPainted(false); + btn.setMargin(new Insets(1, 2, 1, 2)); + btn.setBorder(new CompoundBorder( + new LineBorder(BORDER_SUBTLE, 1), + new EmptyBorder(2, 3, 2, 3) + )); + btn.setCursor(Cursor.getPredefinedCursor(Cursor.HAND_CURSOR)); + btn.setAlignmentX(Component.LEFT_ALIGNMENT); + + btn.addMouseListener(new MouseAdapter() + { + @Override + public void mouseEntered(MouseEvent e) + { + if (bg.equals(ACCENT_COLOR)) + { + btn.setBackground(ACCENT_HOVER); + } + else if (bg.equals(DANGER_RED)) + { + btn.setBackground(DANGER_RED_HOVER); + } + else + { + btn.setBackground(ColorScheme.DARKER_GRAY_HOVER_COLOR); + } + } + + @Override + public void mouseExited(MouseEvent e) + { + btn.setBackground(bg); + } + }); + + return btn; + } + + public static JButton createGrabButton(String text, Runnable action) + { + JButton btn = new JButton(text); + btn.setFont(getSmallFont()); + btn.setBackground(new Color(25, 30, 35)); + btn.setForeground(ACCENT_COLOR); + btn.setFocusPainted(false); + btn.setMargin(new Insets(2, 3, 2, 3)); + btn.setBorder(new CompoundBorder( + new LineBorder(new Color(50, 60, 70), 1), + new EmptyBorder(2, 3, 2, 3) + )); + btn.setCursor(Cursor.getPredefinedCursor(Cursor.HAND_CURSOR)); + btn.setAlignmentX(Component.LEFT_ALIGNMENT); + btn.setMaximumSize(new Dimension(Integer.MAX_VALUE, 22)); + + btn.addMouseListener(new MouseAdapter() + { + @Override + public void mouseEntered(MouseEvent e) + { + btn.setBackground(new Color(35, 45, 55)); + } + + @Override + public void mouseExited(MouseEvent e) + { + btn.setBackground(new Color(25, 30, 35)); + } + }); + + btn.addActionListener(e -> action.run()); + return btn; + } + + public static JPanel createBadge(String text, Color bg, Color fg) + { + JPanel badge = new JPanel(new BorderLayout()); + badge.setBackground(bg); + badge.setBorder(new EmptyBorder(1, 2, 1, 2)); + + String displayText = cleanBankTagName(text); + if (displayText.isEmpty()) + { + displayText = text; + } + + if (displayText.length() > 8) + { + badge.setToolTipText(displayText); + displayText = displayText.substring(0, 7) + ".."; + } + + JLabel label = new JLabel(displayText, JLabel.CENTER); + label.setFont(getBadgeFont()); + label.setForeground(fg); + badge.add(label, BorderLayout.CENTER); + + return badge; + } +} diff --git a/src/main/resources/com/example/essentialsplus/icon.png b/src/main/resources/com/example/essentialsplus/icon.png new file mode 100644 index 0000000..1731554 Binary files /dev/null and b/src/main/resources/com/example/essentialsplus/icon.png differ diff --git a/src/main/resources/com/example/essentialsplus/progress/diaries.json b/src/main/resources/com/example/essentialsplus/progress/diaries.json new file mode 100644 index 0000000..4e35b89 --- /dev/null +++ b/src/main/resources/com/example/essentialsplus/progress/diaries.json @@ -0,0 +1 @@ +[{"name":"Ardougne","icons":[13121,13122,13123,13124],"tiers":[[{"t":"Have Wizard Cromperty teleport you to the Rune Essence mine.","r":["Completion of Rune Mysteries"],"n":"Note: You can right-click him to teleport.","v":["p",1196,0]},{"t":"Steal a cake from the Ardougne market stalls.","r":["5 Thieving"],"v":["p",1196,1]},{"t":"Sell Silk to Silk trader in Ardougne for 60 coins each.","r":["Silk"],"n":"Note: You must first offer to sell your silk for 120 coins, then counter his bid with 60 coins. He'll refuse to speak to you if you've just stolen from his silk stall, and his behaviour only resets after you've spent at least ten consecutive minutes outside of Ardougne without logging off.","v":["p",1196,2]},{"t":"Use the altar in East Ardougne's church.","r":["None"],"n":"Note: Requires you to have less than full Prayer points.","v":["p",1196,4]},{"t":"Go out fishing on the Fishing Trawler","r":["None"],"n":"Note: You only have to start this minigame, completing it is optional.","v":["p",1196,5]},{"t":"Enter the Combat Training Camp north of W. Ardougne.","r":["Completion of Biohazard"],"v":["p",1196,6]},{"t":"Have Tindel Marchant identify a Rusted Sword for you.","r":["Rusty sword and 100 coins"],"n":"Note: There is a 1/100 chance the sword turns out to be nothing, in which case you'll have to bring a new one.","v":["p",1196,7]},{"t":"Use the Ardougne Lever to teleport to the Wilderness.","r":["None"],"n":"Note: You can pull the lever there to return.","v":["p",1196,9]},{"t":"View Aleck's Hunter Emporium in Yanille.","r":["None"],"v":["p",1196,11]},{"t":"Check what pets you have Insured with Probita in Ardougne.","r":["None"],"n":"Note: You can right-click her to check.","v":["p",1196,12]}],[{"t":"Enter the Unicorn pen in Ardougne zoo using Fairy rings.","r":["Started Fairytale II - Cure a Queen","Dramen or Lunar staff to travel to Fairy ring code BIS."],"v":["p",1196,13]},{"t":"Grapple over Yanille's south wall.","r":["39 Agility, 38 Strength and 21 Ranged","Mith grapple and any crossbow"],"v":["p",1196,14]},{"t":"Harvest some strawberries from the Ardougne farming patch.","r":["31 Farming","3 strawberry seeds, spade, seed dibber (or Barbarian Farming), and a rake","Basket of apples recommended to pay the nearby farmer to protect the crop"],"v":["p",1196,15]},{"t":"Cast the Ardougne Teleport spell.","r":["Completion of Plague City","51 Magic","Means to cast Ardougne Teleport ( )"],"v":["p",1196,16]},{"t":"Travel to Castlewars by Hot Air Balloon.","r":["Completion of Enlightened Journey","50 Firemaking","Yew log","If the route has not been unlocked yet, 11 total yew logs are required to unlock it before flying again to Castle Wars to complete this task. Upon unlocking the route, you may use 1 normal log to depart to Taverley before returning to Castle Wars for the task completion."],"v":["p",1196,17]},{"t":"Claim buckets of sand from Bert in Yanille.","r":["Completion of The Hand in the Sand (unless playing as Ultimate Ironman)","Bucket"],"n":"Note: Ultimate Ironmen must instead fill a bucket with sand from Bert's sand pit.","v":["p",1196,18]},{"t":"Catch any fish on the Fishing Platform.","r":["Started Sea Slug","A small fishing net"],"v":["p",1196,19]},{"t":"Pickpocket the master farmer north of Ardougne.","r":["38 Thieving"],"v":["p",1196,20]},{"t":"Collect some Nightshade from the Skavid Caves.","r":["Partial completion of Watchtower","Any light source and the Skavid map"],"n":"Note: It can be found in the northernmost cave.","v":["p",1196,21]},{"t":"Kill a swordchick in the Tower of Life.","r":["Completion of Tower of Life","Raw chicken and raw swordfish","Weaponry"],"v":["p",1196,23]},{"t":"Equip Iban's upgraded staff or upgrade an Iban staff.","r":["Completion of Underground Pass","Iban's upgraded staff, or 200,000 coins and Iban's staff to have the Dark Mage in West Ardougne upgrade it"],"n":"Note: You can upgrade the staff without equipping it.","v":["p",1196,24]},{"t":"Visit the Island East of the Necromancer's tower.","r":["Started Fairytale II - Cure a Queen","Dramen or Lunar staff to travel to Fairy ring code AIR"],"v":["p",1196,25]}],[{"t":"Recharge some Jewellery at the Totem in the Legends Guild.","r":["Completion of Legends' Quest","Less than fully charged skills necklace or combat bracelet"],"v":["p",1196,26]},{"t":"Enter the Magic Guild.","r":["66 Magic"],"v":["p",1196,27]},{"t":"Steal from a chest in Ardougne Castle.","r":["72 Thieving","Lockpick recommended"],"v":["p",1196,28]},{"t":"Have a zookeeper put you in Ardougne Zoo's monkey cage.","r":["Partial completion of the Monkey Madness I quest","Karamjan monkey greegree"],"v":["p",1196,29]},{"t":"Teleport to the Watchtower.","r":["Completion of Watchtower","58 Magic","Means to cast the Watchtower teleport ( )"],"v":["p",1196,30]},{"t":"Catch a Red Salamander.","r":["59 Hunter","Rope and small fishing net"],"v":["p",1196,31]},{"t":"Check the health of a Palm tree near tree gnome village.","r":["68 Farming","Palm sapling, spade and a rake","15 Papaya fruit recommended to pay the gardener"],"n":"Note: This is the farming patch next to Gileth.","v":["p",1197,0]},{"t":"Pick some Poison Ivy berries from the patch south of Ardougne.","r":["70 Farming","Poison ivy seed, seed dibber (or Barbarian Farming), and a rake","Note: Poison ivy cannot get diseased while growing."],"n":"Note: This patch is located next to the Ardougne Monastery.","v":["p",1197,1]},{"t":"Smith a Mithril platebody near Ardougne.","r":["68 Smithing","5 mithril bars and a hammer"],"n":"Note: You'll have to use the Port Khazard, Yanille, or West Ardougne anvil for this.","v":["p",1197,2]},{"t":"Enter your POH from Yanille.","r":["50 Construction","25,000 coins for an estate agent to move your house to Yanille","Note: In Deadman Mode, the task is instead \"Ask the Estate Agent to move your house to Yanille.\" This does not cost anything as your house will not be moved."],"v":["p",1197,3]},{"t":"Smith a Dragon sq shield in West Ardougne.","r":["Partial completion of Plague City","60 Smithing","Shield left half, shield right half and a hammer"],"v":["p",1197,4]},{"t":"Craft some Death runes from Essence.","r":["Completion of Mourning's End Part II","65 Runecraft","Pure or daeyalt essence","Note: Crafting runes using Guardian essence will NOT complete this achievement","Access to the Death Altar via the Abyss"],"n":"Note: Entering the Death Altar from the Mourner Headquarters is not recommended after the quest. It follows a lengthy route that requires the crystal trinket and a death talisman/tiara or catalytic talisman/tiara, and (if Song of the Elves has not been completed) mourner gear and the new key. It's even possible to lock yourself out of this entrance with the light puzzle, which may require you to enter it via the Abyss or through the Underground Pass anyway to change the rotation of the mirrors again.","v":["p",1197,5]}],[{"t":"Catch a Manta ray in the Fishing Trawler and cook it in Port Khazard.","r":["81 Fishing and 91 Cooking","Raw manta ray, multiple recommended in case of burning one"],"n":"Note: Completing or updating any other task in-between these steps will reset the progress for this task.","v":["p",1197,6]},{"t":"Picklock the door to the basement of Yanille Agility Dungeon.","r":["82 Thieving","Lockpick"],"v":["p",1197,7]},{"t":"Pickpocket a Hero.","r":["80 Thieving"],"v":["p",1197,9]},{"t":"Make a rune crossbow yourself from scratch within Witchaven or Yanille.","r":["10 Crafting, 91 Smithing and 69 Fletching","Yew logs, runite bar, hammer, knife, and sinew or a tree root"],"n":"Note: You must use the spinning wheel in Witchaven to make the crossbow string and the anvil in Yanille to make the runite limbs. Fletching the yew stock and completing the rune crossbow can be done in either settlement. Completing or updating any other task in-between these steps will reset the progress for this task.","v":["p",1197,8]},{"t":"Imbue a Salve amulet at Nightmare Zone, or equip a Salve amulet that was imbued there.","r":["Completion of the Haunted Mine","Access to the Nightmare Zone","Salve amulet(i) or Salve amulet(ei), or 800,000 Nightmare Zone points points to imbue the normal one (reduced to 400,000 points with the hard tier of the Combat Achievements completed)"],"n":"Note: You cannot use a Scroll of imbuing from Emir's Arena or use Zeal Tokens from Soul Wars. In Deadman Mode only: the task becomes \"Attempt to equip an imbued Salve amulet at the Nightmare Zone plinth.\" This must be a salve amulet (ei), the regular imbued version will not work.","v":["p",1197,10]},{"t":"Pick some Torstol from the patch north of Ardougne.","r":["85 Farming","Torstol seed, seed dibber (or Barbarian Farming), and a rake","Ultracompost recommended"],"v":["p",1197,11]},{"t":"Complete a lap of Ardougne's rooftop agility course.","r":["90 Agility"],"v":["p",1197,12]},{"t":"Cast Ice Barrage on another player within Castle Wars.","r":["Completion of Desert Treasure I","94 Magic","Means to cast Ice Barrage ( or a Blighted ancient ice sack for every cast)"],"n":"Note: You have to successfully hit the player, so multiple casts are recommended. In Deadman Mode, the task is \"Visit the Castle Wars lobby with at least 94 Magic after completing Desert Treasure I.\"","v":["p",1197,13]}]]},{"name":"Desert","icons":[13133,13134,13135,13136],"tiers":[[{"t":"Catch a Golden Warbler.","r":["5 Hunter","Bird snare","Necklace of passage recommended for quick access to Eagle's Eyrie, near the Uzer Hunter area"],"v":["p",1198,1]},{"t":"Mine 5 clay in the north-eastern desert.","r":["Any pickaxe","Necklace of passage recommended for quick access to Eagle's Eyrie, near the Uzer mine"],"v":["p",1198,2]},{"t":"Enter the Kalphite Hive.","r":["Rope"],"v":["p",1198,3]},{"t":"Enter the Desert with a set of Desert robes equipped.","r":["Desert shirt, desert robe, and desert boots"],"n":"Note: Not to be confused with the desert outfit, which doesn't count for this task. You also cannot use the black desert shirt or black desert robe.","v":["p",1198,4]},{"t":"Kill a Vulture","r":["Weaponry, preferably Ranged or Magic"],"n":"Note: These are found north of Menaphos or Sophanem.","v":["p",1198,5]},{"t":"Have the Nardah Herbalist clean a Herb for you.","r":["Any grimy herb and 200 coins"],"n":"Note: You have to talk to Zahur to complete this task. Do not use the grimy herb on her to clean it.","v":["p",1198,6]},{"t":"Collect 5 Potato Cactus from the Kalphite Hive.","r":["Rope","Antipoison and food recommended if not bringing your own potato cactus"],"n":"Note: You can bring a single potato cactus to the lair, then drop and pick it up five times.","v":["p",1198,7]},{"t":"Sell some artefacts to Simon Templeton.","r":["Players must have started Icthlarin's Little Helper for access to Sophanem.","Any artefact from Pyramid Plunder."],"n":"Note: The pyramid top from the nearby Agility Pyramid does not count.","v":["p",1198,8]},{"t":"Open the Sarcophagus in the first room of Pyramid Plunder","r":["Started Icthlarin's Little Helper","21 Thieving"],"v":["p",1198,9]},{"t":"Cut a desert cactus open to fill a waterskin.","r":["Knife or a slash weapon, and an empty waterskin"],"v":["p",1198,10]},{"t":"Travel from the Shantay Pass to Pollnivneach by Magic Carpet.","r":["200 coins and a Shantay Pass or another 5 coins."],"v":["p",1198,11]}],[{"t":"Climb to the summit of the Agility Pyramid.","r":["30 Agility","Protection against desert heat recommended"],"v":["p",1198,12]},{"t":"Slay a desert lizard.","r":["22 Slayer","Ice cooler"],"v":["p",1198,13]},{"t":"Catch an Orange Salamander.","r":["47 Hunter","Rope and a small fishing net"],"v":["p",1198,14]},{"t":"Steal a feather from the Desert Phoenix.","r":["25 Thieving"],"v":["p",1198,15]},{"t":"Travel to Uzer via Magic Carpet.","r":["Completion of The Golem","200 coins (or 205 coins if using the Shantay Pass)"],"v":["p",1198,16]},{"t":"Travel to the Desert via Eagle.","r":["Completion of Eagles' Peak","Rope"],"n":"Note: You'll unlock travel from the Eagles' Peak Dungeon after the required quest.","v":["p",1198,17]},{"t":"Pray at the Elidinis statuette in Nardah","r":["Completion of Spirits of the Elid"],"n":"Note: Requires you to have less than full Prayer points.","v":["p",1198,18]},{"t":"Create a combat potion in the desert.","r":["36 Herblore","Harralander potion (unf) and goat horn dust"],"v":["p",1198,19]},{"t":"Teleport to Enakhra's Temple with the Camulet.","r":["Completion of Enakhra's Lament","Camulet"],"v":["p",1198,20]},{"t":"Visit the Genie.","r":["Started Spirits of the Elid","Rope and light source"],"n":"Note: This task is automatically completed during Spirits of the Elid.","v":["p",1198,21]},{"t":"Teleport to Pollnivneach with a redirected teleport to house tablet.","r":["20 Construction","Scroll of redirection and a house tablet"],"n":"Note: Ironmen must instead enter their house via the Pollnivneach house portal.","v":["p",1198,22]},{"t":"Chop some Teak Logs near Uzer.","r":["35 Woodcutting","Any axe"],"n":"Note: Teak trees are found north-east of the oasis.","v":["p",1198,23]}],[{"t":"Knock out and pickpocket a Menaphite Thug.","r":["Partial completion of The Feud","65 Thieving","Any blackjack"],"v":["p",1198,24]},{"t":"Mine some Granite.","r":["45 Mining","Any pickaxe"],"v":["p",1198,25]},{"t":"Refill your waterskins in the Desert using Lunar magic.","r":["Completion of Dream Mentor","68 Magic","Lunar spellbook selected","Waterskin that isn't full and means to cast Humidify ( )"],"n":"Note: The spell must be cast outside of a desert settlement.","v":["p",1198,26]},{"t":"Kill the Kalphite Queen.","r":["See the strategy guide"],"v":["p",1198,27]},{"t":"Complete a lap of the Pollnivneach agility course.","r":["70 Agility"],"v":["p",1198,28]},{"t":"Slay a Dust Devil in the desert cave with a Slayer helmet equipped.","r":["Started Desert Treasure I","65 Slayer and 10 Defence","Slayer helmet (requires 55 Crafting ) (and completion of Cabin Fever for Ironman accounts)"],"v":["p",1198,29]},{"t":"Activate Ancient Magicks at the altar in the Jaldraocht Pyramid.","r":["Completion of Desert Treasure I"],"v":["p",1198,30]},{"t":"Defeat a Locust Rider with Keris.","r":["Partial completion of Contact!","50 Attack","Keris or Keris partisan, and any light source"],"n":"Note: This weapon only has to be equipped as it dies, allowing you to deal damage with other weapons. Keris partisan and its variants also work for completing this task.","v":["p",1198,31]},{"t":"Burn some yew logs on the Nardah Mayor's balcony.","r":["60 Firemaking","Yew log and a tinderbox"],"n":"Note: He lives directly east of the central fountain.","v":["p",1199,0]},{"t":"Create a Mithril Platebody in Nardah.","r":["68 Smithing","5 mithril bars and a hammer"],"n":"Note: The anvil is found in the westernmost building.","v":["p",1199,1]}],[{"t":"Bake a wild pie at the Nardah Clay Oven.","r":["85 Cooking","Raw wild pie"],"v":["p",1199,2]},{"t":"Cast Ice Barrage against a foe in the Desert","r":["Completion of Desert Treasure I","94 Magic","Means to cast Ice Barrage ( )"],"n":"Note: The spell must be cast outside of a desert settlement, and must successfully hit.","v":["p",1199,4]},{"t":"Fletch some Dragon darts at the Bedabin Camp.","r":["95 Fletching","Dragon dart tip and feather"],"v":["p",1199,5]},{"t":"Speak to the KQ head in your POH.","r":["Completion of Priest in Peril","78 Construction","2 mahogany planks, 2 gold leaves, saw, and hammer","Kq head or Kq head (tattered), and 50,000 coins to have the taxidermist stuff it","Additional 15,000 coins to construct a Skill Hall if you haven't already"],"n":"Note: The Kalphite Queen has a 1/128 chance of dropping her head on death, and is guaranteed to drop a tattered head on the 256th kill, both of which can be used to create the KQ head.","v":["p",1199,6]},{"t":"Steal from the Grand Gold Chest in the final room of Pyramid Plunder.","r":["Started Icthlarin's Little Helper","91 Thieving"],"v":["p",1199,7]},{"t":"Restore at least 85 Prayer points when praying at the Altar in Sophanem.","r":["Started Icthlarin's Little Helper","85 Prayer"],"v":["p",1199,8]}]]},{"name":"Falador","icons":[13117,13118,13119,13120],"tiers":[[{"t":"Find out what your family crest is from Sir Renitee","r":["16 Construction"],"n":"Note: You must finish the dialogue.","v":["p",1186,0]},{"t":"Climb over the western Falador wall.","r":["5 Agility"],"v":["p",1186,1]},{"t":"Browse Sarah's farm shop.","r":["None"],"v":["p",1186,2]},{"t":"Get a Haircut or a Shave from the Falador Hairdresser.","r":["None"],"v":["p",1186,3]},{"t":"Fill a bucket from the pump north of Falador west bank.","r":["Bucket"],"v":["p",1186,4]},{"t":"Kill a duck in Falador Park.","r":["None, though using Magic or a Ranged weapon is recommended"],"v":["p",1186,5]},{"t":"Make a mind tiara.","r":["Tiara and a mind talisman"],"v":["p",1186,6]},{"t":"Take the boat to Entrana.","r":["Have no weapons or armour equipped or in your inventory"],"v":["p",1186,7]},{"t":"Repair a broken strut in the Motherlode Mine.","r":["Hammer and any pickaxe"],"v":["p",1186,8]},{"t":"Claim a security book from the Security guard at Port Sarim jail.","r":["None"],"n":"Note: The guard is found upstairs. Ask him about security and finish the dialogue.","v":["p",1186,9]},{"t":"Smith some Blurite Limbs on Doric's anvil.","r":["Completion of The Knight's Sword and Doric's Quest","10 Mining and 13 Smithing","Hammer and a Blurite bar"],"v":["p",1186,10]}],[{"t":"Light a Bullseye lantern at the Chemist's in Rimmington.","r":["49 Firemaking","Tinderbox and a regular bullseye lantern"],"v":["p",1186,11]},{"t":"Telegrab some Wine of Zamorak at the Chaos Temple by the Wilderness.","r":["33 Magic","Means to cast Telegrab ( )"],"n":"Note: This altar is located north-west of the Goblin Village. You have to be inside the building until the jug appears in your inventory for the task to register as completed.","v":["p",1186,12]},{"t":"Unlock the Crystal chest in Taverley.","r":["Crystal key"],"v":["p",1186,13]},{"t":"Place a Scarecrow in the Falador farm flower patch.","r":["23 Farming (Ironman accounts require 47 Farming or the ability to kill gryphons)","Bronze spear, watermelon, and a hay sack (you can fill an empty sack with hay at the hay bale in the chicken coop nearby.)"],"v":["p",1186,14]},{"t":"Kill a Mogre at Mudskipper Point.","r":["Completion of Skippy and the Mogres","32 Slayer","Fishing explosive.","It is not possible to skip having to complete Skippy and the Mogres, or needing a fishing explosive, by killing them through a boat cannon through the Sailing skill."],"v":["p",1186,15]},{"t":"Visit the Port Sarim Rat Pits.","r":["Partial completion of Ratcatchers"],"v":["p",1186,16]},{"t":"Grapple up and then jump off the north Falador wall.","r":["11 Agility, 37 Strength, and 19 Ranged","Mith grapple and any crossbow"],"v":["p",1186,17]},{"t":"Pickpocket a Falador guard.","r":["40 Thieving"],"v":["p",1186,18]},{"t":"Pray at the Altar of Guthix in Taverley whilst wearing full Initiate.","r":["Completion of Recruitment Drive","10 Prayer and 20 Defence","Initiate armour"],"v":["p",1186,20]},{"t":"Mine some Gold ore at the Crafting Guild.","r":["40 Crafting and 40 Mining","Any pickaxe and a brown apron, golden apron, or Crafting cape"],"v":["p",1186,21]},{"t":"Squeeze through the crevice in the Dwarven mines.","r":["42 Agility"],"v":["p",1186,22]},{"t":"Chop and burn some Willow logs in Taverley.","r":["30 Woodcutting and 30 Firemaking","Any axe and a tinderbox"],"n":"Note: Willow trees can be found to the south-east along the lake. Player-grown willow trees do not count towards completing the task.","v":["p",1186,23]},{"t":"Craft a fruit basket on the Falador Farm loom.","r":["36 Crafting (and 30 Farming for Ironman accounts)","6 Willow branches"],"v":["p",1186,24]},{"t":"Teleport to Falador.","r":["37 Magic","Means to cast Falador Teleport ( )"],"v":["p",1186,25]}],[{"t":"Craft 140 Mind runes simultaneously from Essence without the use of Extracts.","r":["56 Runecraft","42 Runecraft with 3+ Raiments of the Eye pieces","28 pure, daeyalt, or rune essence (less may suffice at higher Runecraft levels)","Access to the Mind Altar (mind talisman/tiara, catalytic talisman/tiara, or the Abyss)"],"n":"Note: Mind cores do not count.","v":["p",1186,26]},{"t":"Change your family crest to the Saradomin symbol.","r":["70 Prayer","16 Construction","5,000 coins (an additional 5,000 coins if your randomised family crest already is Saradomin, to change to another and back)"],"n":"Note: This is done by talking to Sir Renitee.","v":["p",1186,27]},{"t":"Kill the Giant Mole beneath Falador Park.","r":["Spade and light source","See the strategy guide"],"v":["p",1186,28]},{"t":"Kill a Skeletal Wyvern in the Asgarnia Ice Dungeon.","r":["72 Slayer","Protection from the wyvern's icy breath and weaponry to slay it"],"v":["p",1186,29]},{"t":"Complete a lap of the Falador rooftop agility course.","r":["50 Agility"],"v":["p",1186,30]},{"t":"Enter the mining guild while wearing a Prospector helmet.","r":["60 Mining","Prospector helmet or golden prospector helmet"],"n":"Note: This helmet can be obtained from the Motherlode Mine or the Volcanic Mine.","v":["p",1186,31]},{"t":"Kill the Blue Dragon under the Heroes' Guild.","r":["Completion of the Heroes' Quest","Protection from dragonfire and weaponry to slay it"],"v":["p",1187,0]},{"t":"Crack a wall safe within Rogues' Den.","r":["50 Thieving"],"n":"Note: Wall safes in the maze do not count towards completing this task.","v":["p",1187,1]},{"t":"Recharge your prayer in the Port Sarim church while wearing full Proselyte.","r":["Completion of The Slug Menace","30 Defence","20 Prayer","Full Proselyte armour"],"v":["p",1187,2]},{"t":"Enter the Warriors' Guild.","r":["130 combined levels in Attack and Strength, or level 99 in either"],"v":["p",1187,3]},{"t":"Equip a dwarven helmet within the dwarven mines.","r":["Completion of Grim Tales","50 Defence","Dwarven helmet"],"v":["p",1187,4]}],[{"t":"Craft 252 Air Runes simultaneously from Essence without the use of Extracts.","r":["88 Runecraft","77 Runecraft with 2 Raiments of the Eye pieces or 1 Raiments of the Eye piece and abyssal lantern (with willow or redwood logs)","66 Runecraft with 3 Raiments of the Eye pieces","55 Runecraft with 4 Raiments of the Eye pieces","28 pure, daeyalt, or rune essence (less may suffice at higher Runecraft levels)","Access to the Air Altar (air talisman/tiara, elemental talisman/tiara, or the Abyss)"],"v":["p",1187,5]},{"t":"Purchase a White 2h Sword from Sir Vyvin.","r":["Completion of Wanted!","1,920 coins","Rank of White Knight Master (requires a Black Knight kill score of 1300 - this rank automatically starts at White Knight Novice, with a kill score of 100)"],"n":"Note: Selling this sword to him and then buying it back without having the proper rank does not count for this achievement.","v":["p",1187,6]},{"t":"Find at least 3 magic roots at once when digging up your magic tree in Falador.","r":["91 Farming and 75 Woodcutting","Magic sapling, spade and any axe"],"n":"Note: You need to cut the tree down yourself.","v":["p",1187,7]},{"t":"Perform a skillcape or quest cape emote at the top of Falador Castle.","r":["Completion of all quests or level 99 in any skill","Any Cape of Accomplishment (except for joke skillcapes or the Max cape)"],"v":["p",1187,8]},{"t":"Jump over the strange floor in Taverley dungeon.","r":["80 Agility"],"n":"Note: Might take several tries. You must take no damage from the jump.","v":["p",1187,9]},{"t":"Mix a Saradomin brew in Falador east bank.","r":["81 Herblore","Toadflax potion (unf), crushed nest"],"v":["p",1187,10]}]]},{"name":"Fremennik","icons":[13129,13130,13131,13132],"tiers":[[{"t":"Catch a Cerulean twitch.","r":["11 Hunter","Bird snare"],"n":"Note: These birds are found in the Rellekka Hunter area. While you're here, with a high enough Hunter level you can also complete a medium and a hard task by catching a Snowy knight and Sabre-toothed kyatt, respectively.","v":["p",1184,1]},{"t":"Change your boots at Yrsa's Shoe Store.","r":["Completion of The Fremennik Trials","500 coins (optional)"],"n":"Note: Her shoe store has been removed in an update. You'll now have to talk to her for a makeover for 500 coins. Alternatively, you can freely browse her regular clothing store for the task to count as completed.","v":["p",1184,2]},{"t":"Kill 5 Rock crabs.","r":["Weaponry"],"n":"Note: The giant rock crabs and rock lobsters on Waterbirth Island do not count.","v":["p",1184,3]},{"t":"Craft a tiara from scratch in Rellekka.","r":["Completion of The Fremennik Trials","23 Crafting, 20 Mining and 20 Smithing","Any pickaxe (can be found north of the Rellekka mine) and a tiara mould"],"n":"Note: You have to mine silver ore from the Rellekka mine, then use it on the furnace in the city's westernmost building. Completing or updating any other task in-between these steps will reset the progress for this task.","v":["p",1184,4]},{"t":"Browse the Stonemasons shop.","r":["Started The Giant Dwarf","Dramen or Lunar staff recommended to travel to Fairy ring code DKS for quick access to the city"],"n":"Note: He is located in western Keldagrim. Right-click him to trade.","v":["p",1184,5]},{"t":"Collect 5 Snape grass on Waterbirth Island.","r":["Waterbirth teleport tablet to directly access the island, or either 1000 coins or quest completion of The Fremennik Trials to travel via Jarvald"],"n":"Note: You can drop and pick up the same snape grass five times.","v":["p",1184,6]},{"t":"Steal from the Keldagrim crafting or baker's stall.","r":["Started The Giant Dwarf","5 Thieving"],"n":"Note: These are located on the eastern side of the Keldagrim Palace.","v":["p",1184,7]},{"t":"Fill a bucket with water at the Rellekka well.","r":["Bucket"],"n":"Note: There's a bucket of milk north of the well that can be emptied and filled with water.","v":["p",1184,8]},{"t":"Enter the Troll Stronghold.","r":["Partial completion of the Troll Stronghold, or completion of the Easy Combat Achievements for Ghommal's hilt 1"],"v":["p",1184,9]},{"t":"Chop and burn some oak logs in the Fremennik Province.","r":["15 Woodcutting and 15 Firemaking","Any axe (can be found north of the Rellekka mine) and a tinderbox"],"n":"Note: Oak trees can be found near the Rellekka house portal.","v":["p",1184,10]}],[{"t":"Slay a Brine rat.","r":["Partial completion of Olaf's Quest","47 Slayer","Weaponry, and a spade to enter the Brine Rat Cavern"],"v":["p",1184,11]},{"t":"Travel to the Snowy Hunter Area via Eagle.","r":["Completion of Eagles' Peak","Rope"],"n":"Note: You'll unlock travel from the Eagles' Peak Dungeon after the required quest.","v":["p",1184,12]},{"t":"Mine some coal in Rellekka.","r":["Completion of The Fremennik Trials","30 Mining","Any pickaxe (can be found north of the Rellekka mine)"],"v":["p",1184,13]},{"t":"Steal from the Rellekka Fish stalls.","r":["Completion of The Fremennik Trials","42 Thieving"],"v":["p",1184,14]},{"t":"Travel to Miscellania by Fairy ring.","r":["Completion of The Fremennik Trials and having started Fairytale II - Cure a Queen","Dramen or Lunar staff to travel to Fairy ring code CIP"],"v":["p",1184,15]},{"t":"Catch a Snowy knight.","r":["45 Hunter","35 Hunter with a butterfly net and jar"],"v":["p",1184,17]},{"t":"Pick up your Pet Rock from your POH Menagerie.","r":["Partial completion of The Fremennik Trials","37 Construction","30,000 coins, 4 oak planks, saw, and hammer to construct a menagerie with a pet house, if you haven't already","Pet rock"],"n":"Note: You need to use your pet rock on a pet house, then retrieve it off the ground. You cannot be in building mode.","v":["p",1184,18]},{"t":"Visit the Lighthouse from Waterbirth island.","r":["Completion of Horror from the Deep","Food and 43 Prayer for protection prayers recommended","Way of getting past the two-man doors at the dungeon entrance (help from another player, pet rock and rune thrownaxes, or 85 Agility for the rock shortcut)"],"n":"Note: You must exit the Waterbirth Island Dungeon on the 5th sublevel.","v":["p",1184,19]},{"t":"Mine some gold at the Arzinian mine.","r":["Partial completion of Between a Rock...","40 Mining","30 Defence","Any pickaxe and the gold helmet"],"n":"Note: This task is automatically completed during Between a Rock....","v":["p",1184,20]}],[{"t":"Teleport to Trollheim.","r":["Completion of Eadgar's Ruse","61 Magic","Means to cast Trollheim Teleport ( )"],"v":["p",1184,21]},{"t":"Catch a Sabre-toothed Kyatt.","r":["55 Hunter","Teasing stick, logs, and a knife"],"v":["p",1184,23]},{"t":"Mix a super defence potion in the Fremennik Province.","r":["66 Herblore","Cadantine potion (unf) and white berries"],"n":"Note: This has to be done in or near Rellekka.","v":["p",1184,24]},{"t":"Steal from the Keldagrim Gem Stall.","r":["Started The Giant Dwarf","75 Thieving"],"v":["p",1184,25]},{"t":"Craft a Neitiznot shield on Neitiznot.","r":["Partial completion of The Fremennik Isles","56 Woodcutting","Hammer, rope, a set of bronze nails, and 2 arctic pine logs."],"v":["p",1184,26]},{"t":"Mine 5 Adamantite ores on Jatizso.","r":["Started The Fremennik Isles","70 Mining","Any pickaxe"],"v":["p",1184,27]},{"t":"Obtain 100% support from your kingdom subjects.","r":["Partial completion of Throne of Miscellania","Rake, pickaxe, axe, harpoon and/or lobster pot, depending on how you plan to gain support"],"v":["p",1184,28]},{"t":"Teleport to Waterbirth Island.","r":["Completion of Lunar Diplomacy","72 Magic","Lunar spellbook selected","Means to cast Waterbirth Teleport ( )"],"v":["p",1184,29]},{"t":"Obtain the Blast Furnace Foremans permission to use the Blast Furnace for free.","r":["Started The Giant Dwarf","60 Smithing"],"n":"Note: With the requirements, talk to him and use the following dialogue options . Selecting the wrong choices may require you to pay him, in which case you can hop to another world to try again.","v":["p",1184,30]}],[{"t":"Kill each of the Dagannoth Kings.","r":["Way of getting past the two-player doors at the dungeon entrance (help from another player, pet rock and rune thrownaxes, or 85 Agility for the rock shortcut)","See the strategy guide"],"n":"Note: Completing or updating any other task in-between these steps will reset the progress for this task.","v":["p",1184,31]},{"t":"Craft 56 astral runes simultaneously from Essence without the use of Extracts.","r":["Completion of Lunar Diplomacy","82 Runecraft","28 pure or daeyalt essence (this task is not bypass-able with Raiments of the Eye)"],"v":["p",1185,0]},{"t":"Create a dragonstone amulet in the Neitiznot furnace.","r":["Started The Fremennik Isles","80 Crafting","Dragonstone, gold bar, and an amulet mould"],"n":"Note: Crafting a dragonstone amulet (u) is sufficient, you don't need a ball of wool for this task.","v":["p",1185,1]},{"t":"Complete a lap of the Rellekka agility course.","r":["80 Agility"],"v":["p",1185,2]},{"t":"Kill the generals of Armadyl, Bandos, Saradomin and Zamorak in the God Wars Dungeon.","r":["Partial completion of the Troll Stronghold, or completion of the Easy Combat Achievements for Ghommal's hilt 1","70 Agility, 70 Strength, 70 Hitpoints, and 70 Ranged (only Hitpoints is boostable)","See the boss overview (or see the individual strategy guides for Kree'arra, General Graardor, Commander Zilyana, and K'ril Tsutsaroth)"],"n":"Note: Having started the The Frozen Door miniquest is recommended to simultaneously unlock the key to Nex. You do not have to kill the generals' bodyguards for this task. Unlike most tasks that involve multiple steps, your progress towards this task will not reset when doing other tasks.","v":["p",1185,3]},{"t":"Slay a Spiritual mage within the Godwars Dungeon.","r":["Partial completion of the Troll Stronghold, or completion of the Easy Combat Achievements for Ghommal's hilt 1","83 Slayer","Weaponry"],"v":["p",1185,4]}]]},{"name":"Kandarin","icons":[13137,13138,13139,13140],"tiers":[[{"t":"Catch a Mackerel at Catherby.","r":["16 Fishing","Big fishing net"],"v":["p",1178,1]},{"t":"Buy a candle from the Chandler in Catherby.","r":["3 coins"],"v":["p",1178,2]},{"t":"Collect five Flax from the Seers' flax fields.","r":["None"],"v":["p",1178,3]},{"t":"Play the Organ in Seers' church.","r":["None"],"v":["p",1178,4]},{"t":"Plant some Jute seeds in the patch north of McGrubor's Wood.","r":["13 Farming","3 jute seeds, rake, and a seed dibber"],"n":"Note: This is the farming patch next to Rhonen.","v":["p",1178,5]},{"t":"Have Galahad make you a cup of tea.","r":["None"],"v":["p",1178,6]},{"t":"Defeat one of each elemental in the workshop.","r":["Started Elemental Workshop I","Battered key and weaponry"],"n":"Note: These are the earth, water, air, and fire elemental found on the 1 floor2 floor of the workshop. Only the roaming earth elementals count.","v":["p",1178,7]},{"t":"Get a pet fish from Harry in Catherby.","r":["Fishbowl with water, seaweed, and 10 coins"],"n":"Note: You have to talk to Harry with all required items in your inventory.","v":["p",1178,8]},{"t":"Buy a Stew from the Seers' pub.","r":["20 coins"],"v":["p",1178,9]},{"t":"Speak to Sherlock.","r":["None"],"v":["p",1178,10]},{"t":"Cross the Coal truck log shortcut.","r":["20 Agility"],"n":"Note: You can take a pickaxe to mine coal here to complete a medium task.","v":["p",1178,11]}],[{"t":"Complete a lap of the Barbarian agility course.","r":["Completion of Alfred Grimhand's Barcrawl","35 Agility"],"v":["p",1178,12]},{"t":"Create a Super Antipoison potion from scratch in the Seers/Catherby Area.","r":["48 Herblore","Vial of water, irit leaf, unicorn horn dust"],"n":"Note: Completing or updating any other task in-between adding ingredients will reset the progress for this task.","v":["p",1178,13]},{"t":"Enter the Ranging guild.","r":["40 Ranged"],"v":["p",1178,14]},{"t":"Use the grapple shortcut to get from the water obelisk to Catherby shore.","r":["36 Agility, 22 Strength, and 39 Ranged","Mith grapple and any crossbow","Dusty key or 70 Agility","Protection from dragonfire recommended"],"n":"Note: With 56 Magic, you can bring an unpowered orb and runes for Charge Water Orb to complete a hard task.","v":["p",1178,15]},{"t":"Catch and cook a Bass in Catherby.","r":["46 Fishing and 43 Cooking","Big fishing net"],"n":"Note: Completing or updating any other task in-between these steps will reset the progress for this task.","v":["p",1178,16]},{"t":"Teleport to Camelot.","r":["45 Magic","Means to cast Camelot Teleport ( )"],"v":["p",1178,17]},{"t":"String a Maple shortbow in Seers' Village bank.","r":["50 Fletching","Maple shortbow (u) and bow string"],"v":["p",1178,18]},{"t":"Pick some Limpwurt root from the farming patch in Catherby.","r":["26 Farming","Limpwurt seed, seed dibber (or Barbarian Farming), and a rake","Ultracompost recommended"],"n":"Note: This is the flower patch next to Dantaera.","v":["p",1178,19]},{"t":"Create a Mind helmet.","r":["Partial completion of Elemental Workshop II"],"n":"Note: This task is automatically completed during Elemental Workshop II.","v":["p",1178,20]},{"t":"Kill a Fire Giant inside Baxtorian Waterfall.","r":["Started Waterfall Quest","Weaponry","Rope","Glarial's amulet only if Waterfall Quest isn't yet completed"],"v":["p",1178,21]},{"t":"Complete a wave of Barbarian Assault.","r":["Completion of the Barbarian Assault tutorial to be able to play the minigame"],"v":["p",1178,22]},{"t":"Steal from the chest in Hemenster.","r":["47 Thieving","Lockpick","Combat bracelet recommended for quick access from the nearby Ranging Guild"],"n":"Note: This chest is located in the building between the buildings with the range and anvil.","v":["p",1178,23]},{"t":"Travel to McGrubor's Wood by Fairy Ring.","r":["Started Fairytale II - Cure a Queen","Dramen or Lunar staff to travel to Fairy ring code ALS"],"v":["p",1178,24]},{"t":"Mine some coal near the coal trucks.","r":["30 Mining","Any pickaxe"],"v":["p",1178,25]}],[{"t":"Catch a Leaping Sturgeon.","r":["Access to Barbarian Fishing","70 Fishing, 45 Agility, and 45 Strength","Barbarian rod and suitable bait (fishing bait, feathers, fish offcuts, roe, or caviar)"],"n":"Note: Must be caught at Otto's Grotto for this task.","v":["p",1178,26]},{"t":"Complete a lap of the Seers' Village agility course.","r":["60 Agility"],"v":["p",1178,27]},{"t":"Create a Yew Longbow from scratch around Seers' Village.","r":["70 Fletching and 60 Woodcutting","Any axe, bow string (can be spun from flax), and a knife"],"n":"Note: This requires chopping a yew tree in the village, cutting the yew logs into a yew longbow (u), then finishing it with a bow string. Completing or updating any other task in-between these steps will reset the progress for this task.","v":["p",1178,28]},{"t":"Enter the Seers' Village courthouse with piety turned on.","r":["Completion of King's Ransom and the Knight Waves Training Grounds","70 Prayer and 70 Defence (neither boostable)"],"v":["p",1178,29]},{"t":"Charge a Water Orb.","r":["56 Magic","Dusty key or 70 Agility","Unpowered orb and means to cast Charge Water Orb ( )","Protection from dragonfire recommended"],"v":["p",1178,30]},{"t":"Burn some Maple logs with a bow in Seers' Village.","r":["Access to Barbarian Firemaking","65 Firemaking","Maple logs, and any bow (except the twisted, cursed, dark, crystal, ogre, rain, starter or signed oak bow)"],"v":["p",1178,31]},{"t":"Kill a Shadow Hound in the Shadow dungeon.","r":["Started Desert Treasure I","53 Thieving","Ring of visibility (or a ring of shadows) and weaponry"],"v":["p",1179,0]},{"t":"Kill a Mithril Dragon.","r":["Access to Barbarian Firemaking","Weaponry and protection from dragonfire"],"n":"Note: The mithril dragon fought during Dragon Slayer II does not count for this task.","v":["p",1179,1]},{"t":"Purchase and equip a granite body from Barbarian Assault.","r":["50 Strength and 50 Defence","See the gameplay overview and the strategy guide","95,000 coins to purchase the granite body"],"n":"Note: This requires having defeated the Penance Queen at the end of Barbarian Assault. Buying and equipping the granite body counts as two separate steps, and completing or updating any other task in-between these steps will reset the progress for this task.","v":["p",1179,2]},{"t":"Have the Seers' estate agent decorate your house with Fancy Stone.","r":["50 Construction","25,000 coins","Additional 5,000 coins if already using Fancy Stone, to change to something else and back"],"n":"Note: You must finish the dialogue or the task will not count as completed.","v":["p",1179,3]},{"t":"Smith an Adamant spear at Otto's Grotto.","r":["Access to Barbarian Smithing","75 Smithing","Yew logs, adamantite bar, and a hammer"],"v":["p",1179,4]}],[{"t":"Read the Blackboard at Barbarian Assault after reaching level 5 in every role.","r":["See the gameplay overview and the strategy guide"],"n":"Note: These levels are bought with honour points. Assuming no points are spent on armour or gambles, you'll need a total of 5,600 points (1,400 per role). In Deadman Mode, the task is instead \"Read the Blackboard at Barbarian Assault after finishing your training.\"","v":["p",1179,5]},{"t":"Pick some Dwarf weed from the herb patch at Catherby.","r":["79 Farming","Dwarf weed seed, spade, seed dibber (or Barbarian Farming), and a rake","Ultracompost recommended"],"n":"Note: This is the herb patch next to Dantaera.","v":["p",1179,6]},{"t":"Fish and Cook 5 Sharks in Catherby using the Cooking gauntlets.","r":["Completion of Family Crest","76 Fishing and 80 Cooking","Harpoon (or Barbarian Fishing) and cooking gauntlets"],"n":"Note: You specifically have to use the range in the building between the Catherby bank and the archery shop, the one in the building immediately north of that doesn't count. Extra sharks provided by Rada's blessing don't add up to the required number. Completing or updating any other task in-between these steps will reset the progress for this task.","v":["p",1179,7]},{"t":"Mix a Stamina Mix on top of the Seers' Village bank.","r":["Access to Barbarian Herblore","86 Herblore and 60 Agility","Stamina potion (2) and caviar"],"n":"Note: You have to start the Seers' Village Agility Course to climb on top of the bank.","v":["p",1179,8]},{"t":"Smith a Rune Hasta at Otto's Grotto.","r":["Access to Barbarian Smithing","90 Smithing","Runite bar, magic logs, and a hammer"],"v":["p",1179,9]},{"t":"Construct a Pyre ship from Magic Logs.","r":["Access to Barbarian Firemaking","85 Firemaking and 85 Crafting","Magic logs, chewed bones, tinderbox, and any woodcutting axe"],"n":"Note: You must use chewed bones for the ship, not mangled bones.","v":["p",1179,10]},{"t":"Teleport to Catherby.","r":["Completion of Lunar Diplomacy","87 Magic","Lunar spellbook selected","Means to cast Catherby Teleport ( )"],"n":"Note: Tele Group Catherby will also work here.","v":["p",1179,11]}]]},{"name":"Karamja","icons":[11136,11138,11140,13103],"tiers":[[{"t":"Pick 5 bananas from the plantation located east of the volcano.","r":["None"],"v":["b",3566,4]},{"t":"Use the rope swing to travel to the Moss Giant Island north-west of Karamja.","r":["10 Agility"],"v":["b",3567,0]},{"t":"Mine some gold from the rocks on the north-west peninsula of Karamja.","r":["40 Mining","Any pickaxe"],"v":["b",3568,0]},{"t":"Travel to Port Sarim via the dock, east of Musa Point.","r":["30 coins or a Ring of Charos(a)","This can also be completed by travelling to Port Sarim from The Pandemonium via Seaman Morris."],"v":["b",3569,0]},{"t":"Travel to Ardougne via the port near Brimhaven.","r":["30 coins or a Ring of Charos(a)"],"v":["b",3570,0]},{"t":"Explore Cairn Island to the west of Karamja.","r":["15 Agility"],"v":["b",3571,0]},{"t":"Use the Fishing spots north of the banana plantation.","r":["Harpoon (or bare-handed fishing), lobster pot, small fishing net, or a fishing rod with bait, depending on the spot you fish at"],"n":"Note: Fishing at any of the fishing spots there will count for this task.","v":["b",3572,0]},{"t":"Collect 5 seaweed from anywhere on Karamja.","r":["Seaweed (can be found near Jiminua's Jungle Store)"],"n":"Note: You can just drop and pick up the same seaweed five times.","v":["b",3573,4]},{"t":"Attempt the TzHaar Fight Pits or Fight Cave.","r":["None"],"n":"Note: You can simply enter and leave after the first wave begins. No actual attempt is necessary, but the Fight Pits will not allow entrance without at least one other player.","v":["b",3574,0]},{"t":"Kill a jogre in the Pothole dungeon.","r":["Weaponry"],"v":["b",3575,0]}],[{"t":"Claim a ticket from the Agility Arena in Brimhaven.","r":["200 coins","40 Agility recommended"],"v":["b",3579,0]},{"t":"Discover hidden wall in the dungeon below the volcano.","r":["Started Dragon Slayer I"],"v":["b",3580,0]},{"t":"Visit the Isle of Crandor via the dungeon below the volcano.","r":["Started Dragon Slayer I"],"n":"Note: You must go through the hidden wall, past the lesser demons, and then climb the rope to the surface of Crandor to complete this task.","v":["b",3581,0]},{"t":"Use Vigroy and Hajedy's cart service.","r":["Completion of Shilo Village","200 coins"],"v":["b",3582,0]},{"t":"Earn 100% favour in the village of Tai Bwo Wannai.","r":["Completion of Jungle Potion","10 Woodcutting","See the recommendations"],"v":["b",3583,0]},{"t":"Cook a spider on a stick.","r":["16 Cooking","Spider carcass, and a skewer stick or an arrow shaft"],"n":"Note: Cooking a spider on a shaft also counts for this task. This does not have to be done on Karamja.","v":["b",3584,0]},{"t":"Charter the Lady of the Waves from Cairn Isle to Port Khazard.","r":["Completion of Shilo Village","Ship ticket or 20-50 coins (the price is random)"],"v":["b",3596,0]},{"t":"Cut a log from a teak tree.","r":["Completion of Jungle Potion","35 Woodcutting","Any axe","100 trading sticks and having helped in Tai Bwo Wannai Cleanup (if using the Hardwood Grove)","Machete or 79 Agility, and having started Legends' Quest (if using the Kharazi Jungle)"],"n":"Note: Teak trees can be found in the Hardwood Grove or within the harder-to-access Kharazi Jungle.","v":["b",3586,0]},{"t":"Cut a log from a mahogany tree.","r":["Completion of Jungle Potion","50 Woodcutting","Any axe","100 trading sticks and having helped in Tai Bwo Wannai Cleanup (if using the Hardwood Grove)","Machete or 79 Agility, and having started Legends' Quest (if using the Kharazi Jungle)"],"n":"Note: Mahogany trees can be found in the Hardwood Grove or within the harder-to-access Kharazi Jungle.","v":["b",3587,0]},{"t":"Catch a karambwan.","r":["Partial completion of Tai Bwo Wannai Trio","65 Fishing","Karambwan vessel, some raw karambwanji, and a small fishing net"],"v":["b",3588,0]},{"t":"Exchange gems for a machete.","r":["Completion of Jungle Potion","Gout tuber, as well one of the following:","300 trading sticks and 3 cut or uncut opals","600 trading sticks and 3 cut or uncut jades","1200 trading sticks and 3 cut or uncut red topaz","You need to speak to Sharimika and go through the dialogue options and you must have picked up some trading sticks from a villager as reward before the dialogue options will show up for this diary step."],"n":"Note: This is done by talking to Safta Doc with the required items.","v":["b",3589,0]},{"t":"Use the gnome glider to travel to Karamja.","r":["Completion of The Grand Tree"],"v":["b",3590,0]},{"t":"Grow a healthy fruit tree in the patch near Brimhaven.","r":["27 Farming","Any fruit tree sapling, a spade, and a rake"],"v":["b",3591,0]},{"t":"Trap a horned graahk.","r":["41 Hunter","Teasing stick or Hunter's spear, knife, and any logs."],"v":["b",3592,0]},{"t":"Chop the vines to gain deeper access to Brimhaven Dungeon.","r":["10 Woodcutting","Any axe and 875 coins"],"n":"Note: These are the vines found immediately within the main entrance.","v":["b",3593,0]},{"t":"Cross the lava using the stepping stones within Brimhaven Dungeon.","r":["10 Woodcutting and 12 Agility","Any axe and 875 coins"],"n":"Note: Not to be confused with the stepping stones towards the red dragons.","v":["b",3594,0]},{"t":"Climb the stairs within Brimhaven Dungeon.","r":["10 Woodcutting","Any axe and 875 coins"],"n":"Note: You have to climb the stairs south of the stepping stones of the previous task, next to the moss giants.","v":["b",3595,0]},{"t":"Charter a ship from the shipyard in the far east of Karamja.","r":["Partial completion of The Grand Tree","Enough coins to pay the fare for a charter ship."],"n":"Note: The destination does not matter for this task.","v":["b",3597,0]},{"t":"Mine a red topaz from a gem rock.","r":["Completion of Jungle Potion, and either completion of Shilo Village or items for Tai Bwo Wannai Cleanup","40 Mining","Any pickaxe"],"n":"Note: Gem rocks are found Shilo Village and rarely appear within the Tai Bwo Wannai Cleanup minigame.","v":["b",3585,0]}],[{"t":"Become the Champion of the Fight Pits.","r":["At least one other player","Weaponry, optionally"],"n":"Note: In Deadman Mode, this task is instead \"Speak to TzHaar-Mej-Kah at the Fight Pits.\"","v":["b",3600,0]},{"t":"Kill a Ket-Zek in the Fight Caves.","r":["See the strategy guide"],"n":"Note: Ket-Zeks start to appear on the 31st wave. You can leave the arena safely after killing it. This wave can be reached with relatively little effort, as long as the Yt-MejKots are kept out of melee range and Protect from Missiles is used against the Tok-Xils. Though, you may as well try to complete the entire Fight Caves when going for this task.","v":["b",3601,0]},{"t":"Eat an oomlie wrap.","r":["Cooked oomlie wrap, or the requirements to make it ( 50 Cooking , a palm leaf, and a raw oomlie)"],"v":["b",3602,0]},{"t":"Craft some nature runes from Essence.","r":["44 Runecraft","Pure or daeyalt essence","Access to the Nature Altar (nature talisman/tiara, catalytic talisman/tiara, or the Abyss)"],"v":["b",3603,0]},{"t":"Cook a karambwan thoroughly.","r":["Completion of Tai Bwo Wannai Trio (speak to Tinsay after the quest)","30 Cooking","Raw karambwan, multiple recommended in case of burning one"],"v":["b",3604,0]},{"t":"Kill a deathwing in the dungeon under the Kharazi Jungle.","r":["Partial completion of Legends' Quest","The quest can be started without all the required skills, but reaching this area requires 50 Agility, 50 Thieving, 52 Mining, and 50 Strength (all boostable).","Any axe, any machete, any pickaxe, and a lockpick"],"v":["b",3605,0]},{"t":"Use the crossbow shortcut south of the volcano.","r":["53 Agility, 42 Ranged and 21 Strength","Mith grapple and any crossbow"],"v":["b",3606,0]},{"t":"Collect 5 palm leaves.","r":["Partial completion of Legends' Quest","Any axe and machete, or 79 Agility for the vine shortcut"],"n":"Note: You can just drop and pick up the same palm leaf five times.","v":["b",3607,4]},{"t":"Be assigned a Slayer task by the Slayer Master in Shilo Village.","r":["Completion of Shilo Village","100 Combat level and 50 Slayer, or a Slayer cape"],"v":["b",3608,0]},{"t":"Kill a metal dragon in Brimhaven Dungeon.","r":["10 Woodcutting","Any axe, protection from dragonfire, and weaponry","875 coins (unless you have unlocked permanently free access via either Saniboch or Banisoch)"],"v":["b",3609,0]}],[{"t":"Craft 56 Nature runes simultaneously from Essence without the use of Extracts.","r":["91 Runecraft","28 pure or daeyalt essence (less may suffice with Raiments of the Eye pieces)","Access to the Nature Altar (nature talisman/tiara, catalytic talisman/tiara, or the Abyss)"],"v":["p",1200,1]},{"t":"Equip a Fire Cape or Infernal Cape in Mor Ul Rek.","r":["See the strategy guide for the TzHaar Fight Cave","Fire cape or infernal cape"],"v":["p",1200,2]},{"t":"Check the health of a palm tree in Brimhaven.","r":["68 Farming","Palm sapling, spade and rake","15 papaya fruit recommended to pay the gardener"],"v":["p",1200,3]},{"t":"Create an antivenom potion whilst standing in the horse shoe mine.","r":["87 Herblore","Antidote++ and 5-20 Zulrah's scales (5 per dose)"],"v":["p",1200,4]},{"t":"Check the health of your Calquat tree patch.","r":["72 Farming","Calquat sapling, spade and rake","8 poison ivy berries recommended to pay the gardener"],"n":"Note: This is the farming patch next to Imiago.","v":["p",1200,5]}]]},{"name":"Kourend & Kebos","icons":[22941,22943,22945,22947],"tiers":[[{"t":"Mine some Iron at the Mount Karuulm mine.","r":["15 Mining","Any pickaxe"],"v":["p",2085,1]},{"t":"Kill a Sandcrab.","r":["None"],"v":["p",2085,2]},{"t":"Hand in a book at the Arceuus Library.","r":["None"],"v":["p",2085,3]},{"t":"Steal from a Hosidius Food Stall.","r":["25 Thieving"],"v":["p",2085,4]},{"t":"Browse the Warrens General Store.","r":["None"],"v":["p",2085,5]},{"t":"Take a boat to Land's End.","r":["None"],"n":"Note: You have to travel with Veos or Cabin Boy Herbert from Port Sarim or Port Piscarilius.","v":["p",2085,6]},{"t":"Pray at the Altar in Kourend Castle","r":["None"],"n":"Note: This altar is found on the top floor of the castle.","v":["p",2085,7]},{"t":"Dig up some Saltpetre.","r":["Spade"],"v":["p",2085,8]},{"t":"Enter your Player Owned House from Hosidius.","r":["25 Construction","8,750 coins for an estate agent to move your house to Hosidius (can only be done after you've visited Great Kourend)"],"v":["p",2085,9]},{"t":"Do a lap of either tier of the Shayzien Agility Course.","r":["None"],"v":["p",2085,10]},{"t":"Create a Strength potion in the Lovakengj Pub.","r":["Completion of Druidic Ritual","12 Herblore","Tarromin potion (unf) and limpwurt root"],"v":["p",2085,11]},{"t":"Fish a Trout from the River Molch.","r":["20 Fishing","Fly fishing rod and some feathers (bring enough feathers to account for fishing raw salmon multiple times before you get a raw trout)"],"n":"Note: The fishing spot is found south-east of the Farming Guild.","v":["p",2085,12]}],[{"t":"Travel to the Fairy Ring south of Mount Karuulm.","r":["Started Fairytale II - Cure a Queen","Dramen or Lunar staff to travel to Fairy ring code CIR"],"v":["p",2085,25]},{"t":"Kill a Lizardman.","r":["Combat gear","Antipoison recommended"],"v":["p",2085,13]},{"t":"Use Kharedst's memoirs to teleport to all five cities in Great Kourend.","r":["Completion of The Depths of Despair, The Queen of Thieves, Tale of the Righteous, The Forsaken Tower and The Ascent of Arceuus","Kharedst's memoirs or the Book of the Dead"],"v":["p",2085,14]},{"t":"Mine some Volcanic Sulphur.","r":["42 Mining","Any pickaxe","Face mask, gas mask, or slayer helmet"],"v":["p",2085,15]},{"t":"Enter the Farming Guild.","r":["45 Farming"],"n":"Note: You have to walk through the guild's main entrance.","v":["p",2085,21]},{"t":"Switch to the Necromancy Spellbook at Tyss.","r":["None"],"v":["p",2085,16]},{"t":"Repair a Piscarilius crane.","r":["30 Crafting and 30 Construction","Hammer, 3 regular planks, and about 50 nails of any variant"],"v":["p",2085,17]},{"t":"Deliver some intelligence to Captain Ginea.","r":["Combat gear"],"v":["p",2085,18]},{"t":"Catch a Bluegill on Molch Island.","r":["43 Fishing and 35 Hunter","King worms (found on the island) or fish offcuts"],"v":["p",2085,19]},{"t":"Use the boulder leap shortcut in the Arceuus essence mine.","r":["49 Agility"],"v":["p",2085,22]},{"t":"Subdue the Wintertodt","r":["50 Firemaking","Any axe, tinderbox, and warm clothing","Knife and a hammer or Imcando hammer recommended to score more points","Rejuvenation potions recommended for learners (these are made in the minigame's lobby)"],"n":"Note: You must earn at least 500 points.","v":["p",2085,20]},{"t":"Catch a Chinchompa in the Kourend Woodland.","r":["Partial completion of Eagles' Peak","53 Hunter","Box trap"],"v":["p",2085,23]},{"t":"Chop some Mahogany logs north of the Farming Guild.","r":["50 Woodcutting","Any axe"],"v":["p",2085,24]}],[{"t":"Enter the Woodcutting Guild.","r":["60 Woodcutting"],"v":["p",2085,26]},{"t":"Smelt an Adamantite bar in The Forsaken Tower.","r":["Completion of The Forsaken Tower","70 Smithing","Adamantite ore and 6 coal"],"n":"Note: Using the Superheat Item spell does not count.","v":["p",2085,27]},{"t":"Kill a Lizardman Shaman in the Lizardman Temple.","r":["See the strategy guide"],"v":["p",2085,28]},{"t":"Mine some Lovakite.","r":["65 Mining","Any pickaxe"],"v":["p",2085,29]},{"t":"Plant some Logavano seeds at the Tithe Farm.","r":["74 Farming","Seed dibber"],"v":["p",2085,31]},{"t":"Kill a Zombie in the Shayzien Crypts.","r":["Combat gear and a light source"],"v":["p",2085,30]},{"t":"Teleport to Xeric's Heart using Xeric's Talisman.","r":["Xeric's talisman charged with a lizardman fang, or a mounted xeric's talisman inside any player-owned house (see the House party worlds)"],"v":["p",2086,0]},{"t":"Deliver an artefact to Captain Khaled.","r":["49 Thieving","Lockpick"],"v":["p",2086,1]},{"t":"Kill a Wyrm in the Karuulm Slayer Dungeon.","r":["62 Slayer","Combat gear","Boots of stone, brimstone, or granite"],"v":["p",2086,2]},{"t":"Cast Monster Examine on a Troll south of Mount Quidamortem.","r":["Completion of Dream Mentor","66 Magic","Lunar spellbook selected","Means to cast Monster Examine ( )"],"v":["p",2086,3]}],[{"t":"Craft one or more Blood runes from Dark essence.","r":["77 Runecraft, 38 Mining, and 38 Crafting","Any pickaxe and chisel"],"n":"Note: This requires mining a dark essence block and breaking it down into fragments, then using those fragments on the Kourend Blood Altar.","v":["p",2086,4]},{"t":"Chop some Redwood logs.","r":["90 Woodcutting","Any axe"],"v":["p",2086,5]},{"t":"Defeat Skotizo in the Catacombs of Kourend.","r":["Dark totem","See the strategy guide"],"v":["p",2086,6]},{"t":"Catch an Anglerfish and cook it whilst in Great Kourend.","r":["82 Fishing and 84 Cooking","Fishing rod and some sandworms"],"v":["p",2086,7]},{"t":"Kill a Hydra in the Karuulm Slayer Dungeon.","r":["95 Slayer","Weaponry","Antipoison recommended"],"n":"Note: Either variant may be killed to fulfill this requirement. Only the Alchemical Hydra requires a slayer task to kill.","v":["p",2086,8]},{"t":"Create an Ape Atoll teleport tablet.","r":["90 Magic","Arceuus spellbook selected","Dark essence block and the required runes ( )"],"v":["p",2086,9]},{"t":"Complete a Raid in the Chambers of Xeric.","r":["See the strategy guide"],"v":["p",2086,10]},{"t":"Create your own Battlestaff from scratch within the Farming Guild.","r":["85 Farming and 40 Fletching","Celastrus sapling, knife, spade, rake, and any axe","8 potato cacti recommended to protect your sapling"],"v":["p",2086,11]}]]},{"name":"Lumbridge & Draynor","icons":[13125,13126,13127,13128],"tiers":[[{"t":"Complete a lap of the Draynor Village agility course.","r":["None"],"v":["p",1194,1]},{"t":"Slay a Cave bug beneath Lumbridge Swamp.","r":["7 Slayer","Light source","Rope if the caves haven't been entered yet"],"v":["p",1194,2]},{"t":"Have Sedridor teleport you to the Rune essence mine.","r":["Completion of Rune Mysteries"],"v":["p",1194,3]},{"t":"Craft some water runes from Essence.","r":["5 Runecraft","Pure, daeyalt, or rune essence","Access to the Water Altar (water talisman/tiara, elemental talisman/tiara, or the Abyss)"],"v":["p",1194,4]},{"t":"Learn your age from Hans in Lumbridge.","r":["None"],"v":["p",1194,5]},{"t":"Pickpocket a man or woman in Lumbridge.","r":["None"],"v":["p",1194,6]},{"t":"Chop and burn some oak logs in Lumbridge.","r":["15 Woodcutting and 15 Firemaking","Any axe and tinderbox"],"v":["p",1194,7]},{"t":"Kill a Zombie in Draynor Sewers.","r":["None"],"v":["p",1194,8]},{"t":"Catch some Anchovies in Al Kharid.","r":["15 Fishing","Small fishing net"],"v":["p",1194,9]},{"t":"Bake some Bread on the Lumbridge kitchen range.","r":["Completion of Cook's Assistant","Bread dough, multiple recommended in case of burning one (always successful at 34 Cooking)"],"v":["p",1194,10]},{"t":"Mine some Iron ore at the Al Kharid mine.","r":["15 Mining","Any pickaxe"],"v":["p",1194,11]},{"t":"Enter the H.A.M. Hideout.","r":["None, though a lockpick is recommended"],"v":["p",1194,12]}],[{"t":"Complete a lap of the Al Kharid agility course.","r":["20 Agility"],"v":["p",1194,13]},{"t":"Grapple across the River Lum.","r":["8 Agility, 19 and 37","Mith grapple and any crossbow"],"v":["p",1194,14]},{"t":"Purchase an upgraded device from Ava.","r":["Completion of Animal Magnetism","50 Ranged","75 steel arrows with Ava's attractor, or 75 steel arrows with 999 coins"],"v":["p",1194,15]},{"t":"Travel to the Wizards' Tower by Fairy ring.","r":["Started Fairytale II - Cure a Queen","Dramen or Lunar staff to travel to Fairy ring code DIS"],"v":["p",1194,16]},{"t":"Cast the Teleport to Lumbridge spell.","r":["31 Magic","Means to cast Lumbridge Teleport ( )"],"v":["p",1194,17]},{"t":"Catch some Salmon in Lumbridge.","r":["30 Fishing","Fly fishing rod and some feathers"],"v":["p",1194,18]},{"t":"Craft a coif in the Lumbridge cow pen.","r":["38 Crafting","Leather, and a needle and thread or the costume needle"],"n":"Note: Not to be confused with a cowl.","v":["p",1194,19]},{"t":"Chop some willow logs in Draynor Village.","r":["30 Woodcutting","Any axe"],"v":["p",1194,20]},{"t":"Pickpocket Martin the Master Gardener.","r":["38 Thieving"],"v":["p",1194,21]},{"t":"Get a slayer task from Chaeldar.","r":["Completion of Lost City","70 Combat level","Dramen or Lunar staff to enter Zanaris"],"v":["p",1194,22]},{"t":"Catch an Essence or Eclectic impling in Puro-Puro.","r":["42 Hunter","Dramen or Lunar staff to enter Zanaris","Butterfly net and impling jar"],"v":["p",1194,23]},{"t":"Craft some Lava runes at the fire altar in Al Kharid.","r":["23 Runecraft","Earth talisman, earth runes, and pure or daeyalt essence","Access to the Fire Altar (fire talisman/tiara, elemental talisman/tiara, or the Abyss)","Binding necklace recommended to guarantee the runes are successfully crafted"],"n":"Note: Use the earth runes you brought on the altar, do not use the essence.","v":["p",1194,24]}],[{"t":"Cast Bones to Peaches in Al Kharid palace.","r":["60 Magic","Access to Bones to Peaches via the Mage Training Arena","Bones and means to cast Bones to Peaches ( )"],"n":"Note: Using the Bones to Peaches tablet does not count.","v":["p",1194,25]},{"t":"Squeeze past the jutting wall on your way to the cosmic altar.","r":["Completion of Lost City","46 Agility","Dramen or Lunar staff to enter Zanaris, or access to the Abyss"],"v":["p",1194,26]},{"t":"Craft 56 Cosmic runes simultaneously from Essence without the use of Extracts.","r":["Completion of Lost City","59 Runecraft","28 pure or daeyalt essence (less may suffice with Raiments of the Eye pieces)","Access to the Cosmic Altar (cosmic talisman/tiara, catalytic talisman/tiara, or the Abyss)"],"v":["p",1194,27]},{"t":"Travel from Lumbridge to Edgeville on a Waka Canoe.","r":["57 Woodcutting","Any axe"],"v":["p",1194,28]},{"t":"Collect at least 100 Tears of Guthix in one visit.","r":["Completion of Tears of Guthix","Games necklace recommended for quick access"],"n":"Note: This minigame can only be tried once a week, so be aware. Each quest point increases the time you can spend collecting tears by one game tick. It's recommended to have at least 150 quest points before trying this task. See the Tears of Guthix (minigame) page for other strategies you can use to maximize points.","v":["p",1194,29]},{"t":"Take the train from Dorgesh-Kaan to Keldagrim.","r":["Completion of Another Slice of H.A.M."],"v":["p",1194,30]},{"t":"Purchase some Barrows gloves from the Lumbridge bank chest.","r":["Full completion of Recipe for Disaster","130,000 coins"],"v":["p",1194,31]},{"t":"Pick some Belladonna from the farming patch at Draynor Manor.","r":["63 Farming","Belladonna seed, seed dibber, spade, rake and any gloves"],"v":["p",1195,0]},{"t":"Light your mining helmet in the Lumbridge castle basement.","r":["65 Firemaking","Tinderbox and mining helmet"],"v":["p",1195,1]},{"t":"Recharge your prayer at Emir's Arena with Smite activated.","r":["52 Prayer"],"v":["p",1195,2]},{"t":"Craft, string and enchant an Amulet of Power in Lumbridge.","r":["70 Crafting and 57 Magic","Gold bar, cut diamond, amulet mould, ball of wool","Means to cast Lvl-4 Enchant ( )"],"n":"Note: Doing this upstairs in Lumbridge Castle does not count.","v":["p",1195,3]}],[{"t":"Steal from a Dorgesh-Kaan rich chest.","r":["Completion of Death to the Dorgeshuun","78 Thieving","Lockpick"],"v":["p",1195,4]},{"t":"Grapple across a pylon on the Dorgesh-Kaan Agility Course.","r":["Completion of Death to the Dorgeshuun","70 Agility, 70 Ranged and 70 Strength","Mith grapple, any crossbow, and a light source"],"v":["p",1195,5]},{"t":"Chop some magic logs at the Mage Training Arena.","r":["75 Woodcutting","Any axe"],"v":["p",1195,6]},{"t":"Smith an Adamant platebody down Draynor sewer.","r":["88 Smithing","5 adamantite bars and a hammer"],"v":["p",1195,7]},{"t":"Craft 140 or more Water runes simultaneously from Essence without the use of Extracts.","r":["76 Runecraft","57 Runecraft with 3 Raiments of the Eye pieces, (2 pieces with Abyssal lantern lit with magic logs)","38 Runecraft with 4 Raiments of the Eye pieces (random chance, not guaranteed)","28 pure, daeyalt, or rune essence (less may suffice at higher Runecraft levels)","Access to the Water Altar (water talisman/tiara, elemental talisman/tiara, or the Abyss)"],"v":["p",1195,8]},{"t":"Perform the Quest cape emote in the Wise Old Man's house.","r":["Completion of all quests","Quest point cape"],"v":["p",1195,9]}]]},{"name":"Morytania","icons":[13112,13113,13114,13115],"tiers":[[{"t":"Craft any Snelm from scratch in Morytania.","r":["15 Crafting","Chisel","Any blamish shell"],"n":"Note: Must be done outside of Mort Myre Swamp.","v":["p",1180,1]},{"t":"Cook a thin Snail on the Port Phasmatys range.","r":["12 Cooking","Thin snail","2 Ecto-tokens or quest completion of Ghosts Ahoy to walk into Port Phasmatys, or enough coins for a charter ship."],"v":["p",1180,2]},{"t":"Get a slayer task from the Slayer Master in Canifis.","r":["20 Combat level"],"v":["p",1180,3]},{"t":"Kill a Banshee in the Slayer Tower.","r":["15 Slayer","Earmuffs or Slayer helmet recommended"],"v":["p",1180,4]},{"t":"Have Sbott in Canifis tan something for you.","r":["Any tannable hide and 2-45 coins depending on the type of hide"],"v":["p",1180,5]},{"t":"Enter Mort Myre Swamp.","r":["None"],"v":["p",1180,6]},{"t":"Kill a Ghoul.","r":["Weaponry"],"v":["p",1180,7]},{"t":"Place a Scarecrow in the Morytania flower patch.","r":["23 Farming (Ironman accounts require 47 Farming or the ability to kill gryphons)","Bronze spear, watermelon, and a hay sack"],"v":["p",1180,8]},{"t":"Offer some bonemeal at the Ectofuntus.","r":["Pot, bucket, and any bones"],"v":["p",1180,9]},{"t":"Kill a werewolf in its human form using the Wolfbane Dagger.","r":["Wolfbane"],"v":["p",1180,10]},{"t":"Restore your prayer points at the nature altar.","r":["Completion of Nature Spirit","Dramen or Lunar staff recommended to travel to Fairy ring code BIP"],"n":"Note: Requires you to have less than full Prayer points. If able, activate Piety while praying here to complete a hard task.","v":["p",1180,11]}],[{"t":"Catch a swamp lizard.","r":["29 Hunter","Rope and small fishing net"],"v":["p",1180,12]},{"t":"Complete a lap of the Canifis agility course.","r":["40 Agility"],"v":["p",1180,13]},{"t":"Obtain some Bark from a Hollow tree.","r":["45 Woodcutting","Any axe"],"v":["p",1180,14]},{"t":"Travel to Dragontooth Isle.","r":["Ghostspeak amulet and 25 ecto-tokens (or 10 with the Ring of Charos(a))"],"n":"Note: This task is automatically completed during Ghost Ahoy.","v":["p",1180,15]},{"t":"Kill a Terror Dog.","r":["Completion of Lair of Tarn Razorlor","40 Slayer","Weaponry","Slayer ring recommended for quick access to Tarn's Lair"],"v":["p",1180,16]},{"t":"Complete a game of trouble brewing.","r":["Completion of Cabin Fever","40 Cooking","Empty inventory and head slot","At least one opposing player to start the game"],"n":"Note: You only have to complete the minigame once, winning it is optional. In Deadman Mode, the task is instead \"Speak to Honest Jimmy about Trouble Brewing.\"","v":["p",1180,17]},{"t":"Board the Swampy boat at the Hollows.","r":["Quest start of Nature Spirit","Dramen or Lunar staff recommended to travel to Fairy ring code BKR"],"v":["p",1180,18]},{"t":"Make a batch of cannonballs at the Port Phasmatys furnace.","r":["Completion of Dwarf Cannon","35 Smithing","Steel bar and ammo mould (or 2 steel bars and double ammo mould)"],"v":["p",1180,19]},{"t":"Kill a Fever Spider on Braindeath Island.","r":["Started Rum Deal","42 Slayer","Weaponry to fight at range, or means to negate disease (such as Slayer gloves), recommended"],"n":"Note: This task is automatically completed during Rum Deal.","v":["p",1180,20]},{"t":"Use an ectophial to return to Port Phasmatys.","r":["Completion of Ghosts Ahoy","Ectophial"],"v":["p",1180,21]},{"t":"Mix a Guthix Balance potion while in Morytania.","r":["Partial completion of In Aid of the Myreque","22 Herblore","Restore potion, garlic, and silver dust (made by using a silver bar on the Ectofuntus bone grinder)"],"v":["p",1180,22]}],[{"t":"Enter the Kharyrll Portal in your POH.","r":["Completion of Desert Treasure I","66 Magic and 50 Construction","100,000 coins, 2 limestone bricks, 3 teak planks, hammer, and saw to construct a portal chamber with an activatable teak portal and teleport focus, if you haven't already","200 law runes and 100 blood runes to direct a portal to Kharyrll, or 2000 law runes and 1000 blood runes if adding the portal to a portal nexus instead"],"n":"Note: Teleporting to Kharyrll from a more expensive portal nexus also counts for this task. Higher-tier materials can optionally be used for the portal and centrepiece.","v":["p",1180,23]},{"t":"Climb up the advanced spike chain within Slayer Tower.","r":["71 Agility","Slayer helmet or nose peg recommended against the aberrant spectres, when climbing the spikey chain on the ground floor1 floor as a shortcut to the advanced spikey chain","Only a successful climb (not taking damage) will count as task completed."],"n":"Note: This chain is located on the 1 floor2 floor near the Infernal Mages, leading to the 2 floor3 floor to near the Nechryael.","v":["p",1180,24]},{"t":"Harvest some Watermelon from the Allotment patch on Harmony Island.","r":["Started The Great Brain Robbery","47 Farming","3 watermelon seeds, spade, seed dibber (or Barbarian Farming), and a rake","Ultracompost recommended"],"v":["p",1180,25]},{"t":"Chop and burn some mahogany logs on Mos Le'Harmless.","r":["Completion of Cabin Fever","50 Woodcutting and 50 Firemaking","Any axe and a tinderbox","Witchwood icon and a light source (if Fire of Eternal Light has not been built) recommended"],"v":["p",1180,26]},{"t":"Complete a temple trek with a hard companion.","r":["Completion of In Aid of the Myreque","See the recommendations"],"n":"Note: Completion of Darkness of Hallowvale unlocks the reverse route. It's not necessary to complete any events for this task. In Deadman Mode, the task is instead \"Speak to a hard companion about completing a Temple Trek.\"","v":["p",1180,27]},{"t":"Kill a Cave Horror.","r":["Completion of Cabin Fever","58 Slayer","Witchwood icon and a light source"],"v":["p",1180,28]},{"t":"Harvest some Bittercap Mushrooms from the patch in Canifis.","r":["53 Farming","Mushroom spore, seed dibber (or Barbarian Farming), and a rake","Ultracompost recommended"],"v":["p",1180,29]},{"t":"Pray at the Altar of Nature with Piety activated.","r":["Completion of Nature Spirit and the Knight Waves Training Grounds (which requires completion of King's Ransom)","70 Prayer and 70 Defence (neither boostable)","Dramen or Lunar staff recommended to travel to Fairy ring code BIP"],"v":["p",1180,30]},{"t":"Use the shortcut to get to the bridge over the Salve.","r":["65 Agility"],"n":"Note: You have to use the shortcut to go downwards for this task, not upwards.","v":["p",1181,1]},{"t":"Mine some Mithril ore in the Abandoned Mine.","r":["Completion of Haunted Mine","55 Mining","Any pickaxe","Crystal-mine key recommended (allows use of veins on Level 2, right next to the secret entrance)"],"v":["p",1181,2]}],[{"t":"Catch a shark in Burgh de Rott with your bare hands.","r":["Completion of In Aid of the Myreque","Access to Barbarian Fishing","96 Fishing and 76 Strength (both are boostable)","Shark lure recommended","If you are boosting with the Dragon harpoon special attack, you need to bank the harpoon before catching the shark. Having it in the inventory will use it by default and it will not count as bare handed."],"v":["p",1181,3]},{"t":"Cremate any Shade remains on a Magic or Redwood pyre.","r":["Completion of Shades of Mort'ton","80 Firemaking","Magic or Redwood pyre logs, any shade remains, and a tinderbox"],"v":["p",1181,4]},{"t":"Fertilize the Morytania herb patch using Lunar Magic.","r":["Completion of Lunar Diplomacy","83 Magic","Lunar spellbook selected","Means to cast Fertile Soil ( )"],"n":"Note: This herb patch is located west of Port Phasmatys.","v":["p",1181,5]},{"t":"Craft a Black dragonhide body in Canifis bank.","r":["84 Crafting","3 black dragon leather, and a needle and thread or the costume needle"],"v":["p",1181,6]},{"t":"Kill an Abyssal demon in the Slayer Tower.","r":["85 Slayer","Weaponry"],"n":"Note: Abyssal demons on the 2 floor3 floor do not require a Slayer task to kill like those in the basement.","v":["p",1181,7]},{"t":"Loot the Barrows chest while wearing any complete barrows set.","r":["70 Defence","Various other skill requirements depending on the set used, none of which can be boosted:","70 Attack and 70 Magic for Ahrim's","70 Attack and 70 Strength for Dharok's or Torag's","70 Attack for Guthan's or Verac's","70 Ranged for Karil's"],"n":"Note: This includes the weapon that is part of the equipped set.","v":["p",1181,8]}]]},{"name":"Varrock","icons":[13104,13105,13106,13107],"tiers":[[{"t":"Browse Thessalia's store.","r":["None"],"v":["p",1176,1]},{"t":"Have Aubury teleport you to the Essence mine.","r":["Completion of Rune Mysteries"],"n":"Note: You can right-click him to teleport.","v":["p",1176,2]},{"t":"Mine some Iron in the south-east mining patch near Varrock.","r":["15 Mining","Any pickaxe"],"v":["p",1176,3]},{"t":"Make a normal plank at the Sawmill.","r":["100 coins","Logs, obtainable from a dying tree here with any axe for another easy task"],"v":["p",1176,4]},{"t":"Enter the second level of the Stronghold of Security.","r":["Food recommended at low combat levels"],"v":["p",1176,5]},{"t":"Jump over the fence south of Varrock.","r":["13 Agility"],"v":["p",1176,6]},{"t":"Chop down a dying tree in the Lumber Yard.","r":["Any axe","100 coins recommended, to have the Sawmill operator turn the logs from the tree into a plank for another easy task"],"v":["p",1176,7]},{"t":"Buy a newspaper.","r":["50 coins"],"n":"Note: Can be bought from Benny in Varrock Square.","v":["p",1176,8]},{"t":"Give a dog a bone!","r":["Bones"],"n":"Note: This does not count when done during the investigation segment of Children of the Sun.","v":["p",1176,9]},{"t":"Spin a bowl on the pottery wheel and fire it in the oven in Barb Village.","r":["8 Crafting","Soft clay"],"v":["p",1176,10]},{"t":"Speak to Haig Halen after obtaining at least 50 Kudos.","r":["See Kudos for all activities that grant them"],"v":["p",1176,11]},{"t":"Craft some Earth runes from Essence.","r":["9 Runecraft","Pure, daeyalt, or rune essence","Access to the Earth Altar (earth talisman/tiara, elemental talisman/tiara, or the Abyss)"],"v":["p",1176,12]},{"t":"Catch some trout in the River Lum at Barbarian Village.","r":["20 Fishing","Fly fishing rod and some feathers"],"v":["p",1176,13]},{"t":"Steal from the Tea stall in Varrock.","r":["5 Thieving"],"v":["p",1176,14]}],[{"t":"Have the Apothecary in Varrock make you a strength potion.","r":["Limpwurt root, red spiders' eggs, and 5 coins"],"v":["p",1176,15]},{"t":"Enter the Champions' Guild.","r":["32 Quest points"],"v":["p",1176,16]},{"t":"Select a colour for your kitten.","r":["Completion of Gertrude's Cat and partial completion of Garden of Tranquillity","100 coins","Wear a Ring of Charos(a)"],"n":"Note: You can only have one kitten (or cat) following you at a time. If you already have one, you'll have to shoo it away, store it in a menagerie, or sell it for death runes to West Ardougne civilians. Storing it in your inventory or bank does not allow you to purchase a new one.","v":["p",1176,18]},{"t":"Use the spirit tree north of Varrock.","r":["Completion of Tree Gnome Village"],"n":"Note: This tree is located in the north-eastern corner of the Grand Exchange.","v":["p",1176,19]},{"t":"Perform the 4 emotes from the Stronghold of Security.","r":["Jagex Account or the older RuneScape Authenticator","Food, protection prayers, and/or enough hitpoints to run through all four floors of the stronghold"],"v":["p",1176,20]},{"t":"Enter the Tolna dungeon after completing A Soul's Bane.","r":["Completion of A Soul's Bane"],"v":["p",1176,21]},{"t":"Teleport to the digsite using a Digsite pendant.","r":["Completion of The Dig Site","Digsite pendant, or a mounted digsite pendant inside any player-owned house (see the House party worlds)","Creating the digsite pendant for the first time requires 49 Magic, the clean necklace, and means to cast Lvl-3 Enchant ( )"],"v":["p",1176,22]},{"t":"Cast the teleport to Varrock spell.","r":["25 Magic","Means to cast Varrock Teleport ( )"],"v":["p",1176,23]},{"t":"Get a Slayer task from Vannaka.","r":["40 Combat level"],"v":["p",1176,24]},{"t":"Make 20 Mahogany Planks in one go.","r":["20 mahogany logs and 30,000 coins"],"n":"Note: You can right-click the Sawmill operator to turn your logs into planks. You'll always need 20 mahogany logs, regardless of whether you're using sawmill vouchers.","v":["p",1176,25]},{"t":"Pick a White tree fruit.","r":["Completion of Garden of Tranquillity","25 Farming"],"v":["p",1176,26]},{"t":"Use the balloon to travel from Varrock.","r":["Completion of Enlightened Journey","30 Farming","40 Firemaking","One set of normal logs, oak logs, yew logs, or magic logs, depending on the chosen destination","If the starting point in Varrock has not been unlocked yet, an additional 10 willow logs are needed to first fly towards it from Entrana"],"n":"Note: The destination does not matter for this task. You can even pick a locked destination and not take the trip.","v":["p",1176,27]},{"t":"Complete a lap of the Varrock Agility course.","r":["30 Agility"],"v":["p",1176,28]}],[{"t":"Trade furs with the Fancy Dress Seller for a spottier cape and equip it.","r":["66 Hunter","800 coins","2 dashing kebbit fur (requires 69 Hunter if playing as Ironman)"],"v":["p",1176,29]},{"t":"Speak to Orlando Smith when you have achieved 153 Kudos.","r":["See Kudos for all activities that grant them"],"v":["p",1176,30]},{"t":"Make a Waka canoe near Edgeville.","r":["57 Woodcutting","Any axe"],"v":["p",1176,31]},{"t":"Teleport to Paddewwa.","r":["Completion of Desert Treasure I","54 Magic","Ancient Magicks selected","Means to cast Paddewwa Teleport ( )"],"v":["p",1177,0]},{"t":"Teleport to Barbarian Village with a skull sceptre.","r":["Skull sceptre, or the required items to create it"],"v":["p",1177,1]},{"t":"Chop some yew logs in Varrock and burn them at the top of the Varrock church.","r":["60 Woodcutting and 60 Firemaking","Any axe and tinderbox"],"n":"Note: There is a yew tree next to the church. Completing or updating any other task in-between these steps will reset the progress for this task. In Deadman Mode, the task is instead \"Burn some Yew logs at the top of the Varrock church with 60 Woodcutting.\"","v":["p",1177,2]},{"t":"Have the Varrock estate agent decorate your house with Fancy Stone.","r":["50 Construction","25,000 coins","Additional 5,000 coins if already using Fancy Stone, to change to something else and back"],"n":"Note: You must finish the dialogue or the task will not count as completed.","v":["p",1177,3]},{"t":"Collect at least 2 yew roots from the Tree patch in Varrock Palace.","r":["68 Farming and 60 Woodcutting","Yew sapling, any axe, and a spade","10 cactus spines recommended to pay the gardener"],"n":"Note: While 60 Farming is required to grow the yew tree, you'll need 68 Farming to dig up more than a single root.","v":["p",1177,4]},{"t":"Pray at the altar in Varrock palace with Smite active.","r":["52 Prayer"],"n":"Note: The altar is found on the 1 floor2 floor of the palace.","v":["p",1177,5]},{"t":"Squeeze through the obstacle pipe in Edgeville dungeon.","r":["51 Agility"],"v":["p",1177,6]}],[{"t":"Create a super combat potion in Varrock west bank.","r":["Completion of Druidic Ritual","90 Herblore","Super attack (4), super strength (4), super defence (4), and a torstol"],"v":["p",1177,7]},{"t":"Use Lunar magic to make 20 mahogany planks at the Lumberyard.","r":["Completion of Dream Mentor","86 Magic","Lunar spellbook selected","21,000 coins and 20 mahogany logs","Means to cast Plank Make twenty times ( )"],"n":"Note: You must stand within the Lumber Yard, past the fence.","v":["p",1177,8]},{"t":"Bake a summer pie in the Cooking Guild.","r":["95 Cooking","Raw summer pie","Chef's hat, golden chef's hat, Varrock armour 3, or Cooking cape"],"n":"Note: Instead of using a range, you can use the Bake Pie spell to prevent burning the pie.","v":["p",1177,9]},{"t":"Smith and fletch ten rune darts within Varrock.","r":["Completion of The Tourist Trap","89 Smithing and 81 Fletching","Runite bar, 10 feathers and a hammer"],"v":["p",1177,10]},{"t":"Craft 100 or more earth runes simultaneously from Essence without the use of Extracts.","r":["78 Runecraft","52 Runecraft with 2+ Raiments of the Eye (1 piece with Abyssal lantern lit with magic logs)","28 pure, daeyalt, or rune essence (fewer may suffice with more Raiments of the Eye pieces)","Access to the Earth Altar (earth talisman/tiara, elemental talisman/tiara, or the Abyss)"],"v":["p",1177,11]}]]},{"name":"Western Provinces","icons":[13141,13142,13143,13144],"tiers":[[{"t":"Catch a Copper Longtail.","r":["9 Hunter","Bird snare"],"v":["p",1182,1]},{"t":"Complete a novice game of Pest Control.","r":["40 Combat level"],"n":"Note: You must've earnt commendation points for this task to count as completed. In Deadman Mode, the task is instead \"Attempt to board the novice Pest Control lander with at least 40 combat.\"","v":["p",1182,2]},{"t":"Mine some Iron Ore near Piscatoris.","r":["15 Mining","Any pickaxe"],"n":"Note: This must be done at the Piscatoris mine.","v":["p",1182,3]},{"t":"Complete a lap of the Gnome agility course.","r":["None"],"v":["p",1182,4]},{"t":"Score a goal in a Gnomeball match.","r":["None"],"v":["p",1182,5]},{"t":"Claim any Chompy bird hat from Rantz.","r":["Completion of Big Chompy Bird Hunting","Ogre bellows, (comp) ogre bow, and ogre or brutal arrows","30 chompy or jubbly bird kills"],"n":"Note: You need to have a (comp) ogre bow in your inventory to claim hats.","v":["p",1182,6]},{"t":"Teleport to Pest Control using the Minigame Teleport.","r":["40 Combat level"],"n":"Note: In Deadman Mode, the task is instead \"Travel to the Void Knight Outpost from Port Sarim.\"","v":["p",1182,7]},{"t":"Collect a swamp toad at the Gnome Stronghold.","r":["None"],"n":"Note: Only a swamp toad collected from the swamp pen north-west of Grand Tree counts for this task.","v":["p",1182,8]},{"t":"Have Brimstail teleport you to the Essence Mine.","r":["Completion of Rune Mysteries"],"n":"Note: You can right-click him to teleport.","v":["p",1182,9]},{"t":"Fletch an Oak Shortbow in the Gnome Stronghold.","r":["20 Fletching","Oak shortbow (u) and bow string"],"v":["p",1182,10]},{"t":"Kill a Terrorbird in the Terrorbird enclosure.","r":["Weaponry"],"n":"Note: This enclosure is found in the south-west of the Tree Gnome Stronghold, across the river.","v":["p",1182,11]}],[{"t":"Take the agility shortcut from the Grand Tree to Otto's Grotto.","r":["Completion of Tree Gnome Village and The Grand Tree","37 Agility"],"v":["p",1182,12]},{"t":"Travel to the Gnome Stronghold by Spirit Tree.","r":["Completion of Tree Gnome Village"],"v":["p",1182,13]},{"t":"Trap a Spined Larupia.","r":["31 Hunter","Teasing stick, logs, and a knife"],"n":"Note: This creature is found in the Feldip Hunter area.","v":["p",1182,14]},{"t":"Fish some Bass on Ape Atoll.","r":["Partial completion of Monkey Madness I","46 Fishing","Big fishing net"],"v":["p",1182,15]},{"t":"Chop and burn some teak logs on Ape Atoll.","r":["Partial completion of Monkey Madness I","35 Woodcutting and 35 Firemaking","Any axe and tinderbox"],"n":"Note: With 50 Woodcutting and 50 Firemaking, you can also chop and burn some mahogany logs here for a hard task. Completing or updating any other task in-between these steps will reset the progress for this task.","v":["p",1182,16]},{"t":"Complete an intermediate game of Pest Control.","r":["70 Combat level"],"n":"Note: You must've earnt commendation points for this task to count as completed. In Deadman Mode, the task is instead \"Attempt to board the intermediate Pest Control lander with at least 70 combat.\"","v":["p",1182,17]},{"t":"Travel to the Feldip Hills by Gnome Glider.","r":["Completion of The Grand Tree and partial completion of One Small Favour"],"v":["p",1182,18]},{"t":"Claim a Chompy bird hat from Rantz after registering at least 125 kills.","r":["Completion of Big Chompy Bird Hunting","Ogre bellows, (comp) ogre bow, and ogre or brutal arrows","125 chompy or jubbly bird kills"],"n":"Note: You need to have a (comp) ogre bow in your inventory to claim hats.","v":["p",1182,19]},{"t":"Travel from Eagles' Peak to the Feldip Hills by Eagle.","r":["Completion of Eagles' Peak","Rope"],"n":"Note: You'll unlock travel from the Eagles' Peak Dungeon after the required quest.","v":["p",1182,20]},{"t":"Make a Chocolate Bomb at the Grand Tree.","r":["42 Cooking","Gnomebowl mould, Gianne dough, 4 chocolate bars, an equa leaf, 2 pots of cream, and chocolate dust (can all be purchased at Grand Tree Groceries)"],"v":["p",1182,21]},{"t":"Complete a delivery for the Gnome Restaurant.","r":["Completion of the Gnome Restaurant tutorial","29-42 Cooking (varies per food)"],"v":["p",1182,22]},{"t":"Turn your small crystal seed into a Crystal saw.","r":["Completion of The Eyes of Glouphrie","Crystal saw seed"],"n":"Note: You have to enchant it with the singing bowl in Brimstail's cave.","v":["p",1182,23]},{"t":"Mine some Gold ore underneath the Grand Tree.","r":["Completion of The Grand Tree","40 Mining","Any pickaxe"],"v":["p",1182,24]}],[{"t":"Kill an Elf with a Crystal bow.","r":["Completion of Roving Elves","70 Ranged","Crystal bow or Bow of Faerdhinen"],"n":"Note: an inactive bow can be used if equipped whilst the elf dies to poison.","v":["p",1182,25]},{"t":"Catch and cook a Monkfish in Piscatoris.","r":["Completion of Swan Song","62 Fishing and 62 Cooking","Small fishing net"],"n":"Note: Monkfish is caught in the Piscatoris Fishing Colony.","v":["p",1182,26]},{"t":"Complete a Veteran game of Pest Control.","r":["100 Combat level"],"n":"Note: You must've earnt commendation points for this task to count as completed. In Deadman Mode, the task is instead \"Attempt to board the veteran Pest Control lander with at least 100 combat.\"","v":["p",1182,27]},{"t":"Catch a Dashing Kebbit.","r":["69 Hunter","500 coins"],"n":"Note: This kebbit is found in the Piscatoris falconry area.","v":["p",1182,28]},{"t":"Complete a lap of the Ape Atoll agility course.","r":["Partial completion of Monkey Madness I","48 Agility","Ninja monkey greegree or Kruk monkey greegree (other greegrees will cause you to fail the course)"],"v":["p",1182,29]},{"t":"Chop and burn some Mahogany logs on Ape Atoll.","r":["Partial completion of Monkey Madness I","50 Woodcutting and 50 Firemaking","Any axe (excluding felling axes) and a tinderbox"],"n":"Note: Logs burnt by the Infernal axe do not count towards this task.","v":["p",1182,30]},{"t":"Mine some Adamantite ore in Tirannwn.","r":["Started Regicide","70 Mining","Any pickaxe"],"n":"Note: This can be done in the Isafdar mine outside of Lletya, or the Trahaearn mine in Prifddinas (with completion of Song of the Elves).","v":["p",1182,31]},{"t":"Check the health of your Palm tree in Lletya.","r":["Started Mourning's End Part I","68 Farming","Palm sapling, rake, and spade","15 papayas recommended to pay the gardener"],"v":["p",1183,0]},{"t":"Claim a Chompy bird hat from Rantz after registering at least 300 kills.","r":["Completion of Big Chompy Bird Hunting","Ogre bellows, (comp) ogre bow, and ogre or brutal arrows","300 chompy or jubbly bird kills"],"n":"Note: You need to have a (comp) ogre bow in your inventory to claim hats.","v":["p",1183,1]},{"t":"Build an Isafdar painting in your POH Quest hall.","r":["Completion of Roving Elves","65 Construction","3 mahogany planks, saw, and hammer","Isafdar painting (bought from Sir Renitee for 2,000 coins)","25,000 coins to construct a Quest Hall if you haven't already"],"v":["p",1183,2]},{"t":"Kill Zulrah.","r":["Started Regicide","See the strategy guide"],"n":"Note: In Deadman Mode, the task is listed as \"Attempt to visit Zulrah.\", but doing so will not complete the task, and you must kill Zulrah like normal to complete it.","v":["p",1183,3]},{"t":"Teleport to Ape Atoll.","r":["Completion of the Awowogei subquest of Recipe for Disaster","64 Magic","Means to cast Ape Atoll Teleport ( )"],"v":["p",1183,4]},{"t":"Pickpocket a Gnome.","r":["75 Thieving"],"v":["p",1183,5]}],[{"t":"Fletch a Magic Longbow in Tirannwn.","r":["Started Regicide","85 Fletching","Magic longbow (u) and a bow string"],"v":["p",1183,6]},{"t":"Kill the Thermonuclear Smoke Devil.","r":["93 Slayer","See the strategy guide"],"n":"Note: While this boss normally requires a Slayer task, this is not needed for the first kill. You do not have to contact a Slayer master for this task. You must be in the room when its death animation ends, or the task will not be completed. Be mindful of this if you are attempting the Combat Achievements. If this occurs, you will still be able to kill it again without a slayer task.","v":["p",1183,7]},{"t":"Have Prissy Scilla protect your Magic tree.","r":["75 Farming","Magic sapling, 25 coconuts, rake, and a spade"],"v":["p",1183,8]},{"t":"Use the Elven overpass advanced cliffside shortcut.","r":["Completion of Underground Pass","85 Agility"],"v":["p",1183,9]},{"t":"Equip any complete void set.","r":["40 Combat level, 22 Prayer, 42 Attack, 42 Strength, 42 Defence, 42 Hitpoints, 42 Ranged, and 42 Magic","Any void helm (melee, ranger, or mage helm)","Void knight top, robe, and gloves"],"n":"Note: In Deadman Mode, the task is instead \"Speak to the Elite Void Knight after claiming the Western hard diary reward.\"","v":["p",1183,12]},{"t":"Claim a Chompy bird hat from Rantz after registering at least 1,000 kills.","r":["Completion of Big Chompy Bird Hunting","Ogre bellows, (comp) ogre bow, and ogre or brutal arrows","1,000 chompy or jubbly bird kills"],"n":"Note: You need to have a (comp) ogre bow in your inventory to claim hats.","v":["p",1183,13]},{"t":"Pickpocket an Elf.","r":["Started Mourning's End Part I","85 Thieving"],"v":["p",1183,14]}]]},{"name":"Wilderness","icons":[13108,13109,13110,13111],"tiers":[[{"t":"Cast Low Alchemy at the Fountain of Rune.","r":["21 Magic","Item that can be alchemised (nearly any item)"],"v":["p",1192,1]},{"t":"Enter the Wilderness from the Ardougne or Edgeville lever.","r":["None"],"v":["p",1192,2]},{"t":"Pray at the Chaos Altar in the Western Wilderness.","r":["None"],"v":["p",1192,3]},{"t":"Enter the Chaos Runecrafting temple.","r":["Access to the Chaos Altar (chaos talisman/tiara, catalytic talisman/tiara, or the Abyss)"],"n":"Note: Entering it through the Tunnel of Chaos or Guardians of the Rift will not count for this task.","v":["p",1192,4]},{"t":"Kill a Mammoth in the Wilderness.","r":["Weaponry"],"n":"Note: Mammoths are found south-east of Ferox Enclave.","v":["p",1192,5]},{"t":"Kill an Earth Warrior in the Wilderness beneath Edgeville.","r":["15 Agility","Weaponry"],"v":["p",1192,6]},{"t":"Restore some prayer points at the demonic ruins.","r":["None"],"v":["p",1192,7]},{"t":"Enter the King Black Dragon's lair.","r":["None"],"n":"Note: This lair is located beneath the Lava Maze.","v":["p",1192,8]},{"t":"Collect 5 Red spiders' eggs from the Wilderness","r":["None"],"n":"Note: These can be found in the northern part of the Edgeville Dungeon. You can also bring eggs into the wilderness, fulfilling the diary by dropping and picking them up five times.","v":["p",1192,9]},{"t":"Mine some Iron ore in the Wilderness.","r":["15 Mining","Any pickaxe"],"v":["p",1192,10]},{"t":"Have the Mage of Zamorak teleport you to the Abyss.","r":["Completion of Enter the Abyss"],"n":"Note: The one-time teleport to the Abyss from Varrock during Temple of the Eye does not count for this task.","v":["p",1192,11]},{"t":"Equip any team cape in the Wilderness.","r":["Any team cape"],"n":"Note: Entering the Wilderness with one already equipped does not count for this task.","v":["p",1192,12]}],[{"t":"Mine some Mithril ore in the wilderness.","r":["55 Mining","Any pickaxe"],"v":["p",1192,13]},{"t":"Chop some yew logs from a fallen Ent.","r":["61 Woodcutting","Any axe and weaponry"],"n":"Note: This must be done within the Wilderness. You can find ents north and east of the chaos temple with the elder chaos druids.","v":["p",1192,14]},{"t":"Enter the Wilderness Godwars Dungeon.","r":["60 Agility or 60 Strength","God equipment recommended if going further inside"],"n":"Note: While you're here, with a high enough Slayer level you can also complete another medium task and a hard task by killing a bloodveld and spiritual warrior, respectively.","v":["p",1192,15]},{"t":"Complete a lap of the Wilderness Agility course.","r":["52 Agility"],"v":["p",1192,16]},{"t":"Kill a Green Dragon.","r":["Protection from dragonfire recommended if not safe spotting and weaponry to slay it"],"n":"Note: This must be done within the Wilderness.","v":["p",1192,18]},{"t":"Kill an Ankou in the Wilderness.","r":["Weaponry, or sufficient combat stats to defeat a level 86 or level 98 Ankou while unarmed"],"v":["p",1192,19]},{"t":"Charge an Earth Orb.","r":["60 Magic","Unpowered orb and means to cast Charge Earth Orb ( )"],"v":["p",1192,20]},{"t":"Kill a Bloodveld in the Wilderness Godwars Dungeon.","r":["60 Agility or 60 Strength, and 50 Slayer","Weaponry and god equipment"],"n":"Note: Can be safespotted in the south-west corner while having a Saradomin and Zamorak item equipped.","v":["p",1192,21]},{"t":"Talk to the Emblem Trader in Edgeville about emblems. (He's just north of the bank)","r":["None"],"v":["p",1192,22]},{"t":"Smith a Golden helmet in the Resource Area.","r":["Partial completion of Between a Rock...","50 Smithing","7500 coins, 3 gold bars, and a hammer"],"n":"Note: You cannot do this while you already have a gold helmet in your inventory or bank, this must be dropped first. With 75 Smithing, you can bring 2 adamantite bars here as well to smith an adamant scimitar for a hard task.","v":["p",1192,23]},{"t":"Open the Muddy Chest in the lava maze.","r":["Muddy key","Knife, Wilderness sword 1, or a slashing weapon (Or with 82 Agility using the Stepping Stone shortcut North of the Lava Maze)"],"n":"Note: With 53 Fishing, you can bring an oily fishing rod and fishing bait to catch a raw lava eel for a hard task.","v":["p",1192,24]}],[{"t":"Cast any of the 3 God spells against another player in the Wilderness.","r":["Completion of Mage Arena I","60 Magic","Any god staff and means to cast its related god spell ( per cast)"],"n":"Note: You have to successfully hit the player, so multiple casts are recommended. God spells must first be cast 100 times inside the Mage Arena before they can be cast elsewhere, but this task is just as completable inside the arena.","v":["p",1192,25]},{"t":"Charge an Air Orb.","r":["66 Magic","Unpowered orb and means to cast Charge Air Orb ( )"],"v":["p",1192,26]},{"t":"Catch a Black Salamander in the Wilderness.","r":["67 Hunter","Small fishing net and a rope"],"v":["p",1192,27]},{"t":"Smith an Adamant scimitar in the Resource Area.","r":["75 Smithing","Hammer and 2 adamantite bars","6,000-7,500 coins"],"n":"Note: With 75 Woodcutting and 75 Firemaking, you can bring any axe and a tinderbox to cut and burn some magic logs for an elite task.","v":["p",1192,28]},{"t":"Kill a Lava Dragon.","r":["Protection from dragonfire recommended if not safe spotting and weaponry to slay it"],"n":"Note: Lava dragons are found on Lava Dragon Isle.","v":["p",1192,29]},{"t":"Kill the Chaos Elemental.","r":["See the strategy guide"],"v":["p",1192,30]},{"t":"Kill the Crazy Arc., Chaos Fanatic & Scorpia.","r":["See the individual strategy guides for the Crazy archaeologist, Chaos Fanatic, and Scorpia"],"n":"Note: While these bosses can be killed in any order, completing or updating any other task in-between will reset the progress for this task.","v":["p",1192,31]},{"t":"Take the agility shortcut from Trollheim into the Wilderness.","r":["64 Agility","Partial completion of Troll Stronghold, or completion of the Easy Combat Achievements for Ghommal's hilt 1"],"n":"Note: Not to be confused with the rocky shortcut further north. The shortcut for this task goes in one direction, and cannot be used to leave the Wilderness again.","v":["p",1193,0]},{"t":"Kill a Spiritual warrior in the Wilderness Godwars Dungeon.","r":["60 Agility or 60 Strength, and 68 Slayer","Weaponry and god equipment"],"n":"Note: With 83 Slayer, you can also kill a spiritual mage here to complete an elite task","v":["p",1193,1]},{"t":"Fish some Raw Lava Eel in the Wilderness.","r":["53 Fishing and 25 Herblore","Oily fishing rod, fishing bait","Knife, Wilderness sword 1 or 2, or a slashing weapon"],"v":["p",1193,2]}],[{"t":"Kill Callisto, Venenatis & Vet'ion.","r":["Completion of the Medium Wilderness diary is required to kill the greater versions, and completion of the Hard Wilderness diary is required to kill the lesser versions","See the individual strategy guides for Callisto, Venenatis, and Vet'ion, or those for the weaker bosses Artio, Spindel, and Calvar'ion"],"n":"Note: The lesser versions of these bosses (Artio, Spindel, and Calvar'ion) also count towards this task. You must have claimed the rewards from the hard diary tasks before you can access these lesser bosses. While these bosses can be killed in any order, completing or updating any other task in-between will reset the progress for this task.","v":["p",1193,3]},{"t":"Teleport to Ghorrock.","r":["Completion of Desert Treasure I","96 Magic","Ancient Magicks selected","Means to cast Ghorrock Teleport ( )"],"v":["p",1193,5]},{"t":"Fish and Cook a Dark Crab in the Resource Area.","r":["85 Fishing and 90 Cooking","Lobster pot and dark fishing bait","3,750-7,500 coins"],"v":["p",1193,7]},{"t":"Smith a rune scimitar from scratch in the Resource Area.","r":["85 Mining and 90 Smithing","Weaponry, any pickaxe, hammer, and 16 coal","3,750-7,500 coins"],"n":"Note: You must kill two separate Runite Golems and mine them each for runite ores, smelt two runite bars in the nearby furnace (Superheat Item does not work), then smith the rune scimitar on the anvil. The steps for mining the ores are still completed if the ores are destroyed by an infernal pickaxe. Completing or updating any other task in-between these steps will reset the progress for this task.","v":["p",1193,8]},{"t":"Steal from the Rogues' chest.","r":["84 Thieving"],"n":"Note: You must right-click the chest to search for traps. You'll then be attacked by all nearby rogues.","v":["p",1193,9]},{"t":"Slay a spiritual mage inside the wilderness Godwars Dungeon.","r":["60 Agility or 60 Strength, and 83 Slayer","Weaponry and god equipment"],"v":["p",1193,10]},{"t":"Cut and burn some magic logs in the Resource Area.","r":["75 Woodcutting and 75 Firemaking","Any axe and tinderbox","3,750-7,500 coins"],"n":"Note: Logs burnt by an infernal axe do not count towards this task. Logs may also be burnt on the Forester's Campfire. Completing or updating any other task in-between these steps will reset the progress for this task.","v":["p",1193,11]}]]}] \ No newline at end of file diff --git a/src/main/resources/com/example/icon.png b/src/main/resources/com/example/icon.png new file mode 100644 index 0000000..82ca8be Binary files /dev/null and b/src/main/resources/com/example/icon.png differ diff --git a/src/main/resources/com/example/livedashboard/icon.png b/src/main/resources/com/example/livedashboard/icon.png new file mode 100644 index 0000000..82ca8be Binary files /dev/null and b/src/main/resources/com/example/livedashboard/icon.png differ diff --git a/src/main/resources/icon.png b/src/main/resources/icon.png new file mode 100644 index 0000000..82ca8be Binary files /dev/null and b/src/main/resources/icon.png differ diff --git a/src/test/java/com/example/ExamplePluginTest.java b/src/test/java/com/example/ExamplePluginTest.java new file mode 100644 index 0000000..3b57949 --- /dev/null +++ b/src/test/java/com/example/ExamplePluginTest.java @@ -0,0 +1,121 @@ +package com.example; + +import com.example.essentialsplus.EssentialsPlusPlugin; +import java.lang.reflect.Field; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Set; +import net.runelite.client.RuneLite; +import net.runelite.client.externalplugins.ExternalPluginManager; +import net.runelite.client.plugins.Plugin; + +public class ExamplePluginTest +{ + private static final Set ALLOWED_FLAGS = Set.of( + "developer-mode", + "debug", + "safe-mode", + "insecure-skip-tls-verification", + "disable-telemetry", + "noupdate", + "insecure-write-credentials", + "help" + ); + + private static final Set ALLOWED_OPTIONS_WITH_ARG = Set.of( + "jav_config", + "profile", + "sessionfile" + ); + + @SuppressWarnings("unchecked") + public static void main(String[] args) throws Exception + { + System.out.println("RuneLite Custom JAR started with args: " + Arrays.toString(args)); + + List filteredArgs = new ArrayList<>(); + for (int i = 0; i < args.length; i++) + { + String arg = args[i]; + + // Parse JVM system properties from -J-Dkey=val or -Dkey=val + if (arg.startsWith("-J-D") || arg.startsWith("-D")) + { + String prop = arg.startsWith("-J-D") ? arg.substring(4) : arg.substring(2); + int eq = prop.indexOf('='); + if (eq != -1) + { + String key = prop.substring(0, eq); + String val = prop.substring(eq + 1); + System.setProperty(key, val); + System.out.println("Set System Property: " + key + " = " + val); + } + continue; + } + + // Ignore other JVM flags passed with -J (e.g. -J-Xmx768m) + if (arg.startsWith("-J")) + { + continue; + } + + // Validate and forward RuneLite client arguments + if (arg.startsWith("--")) + { + String opt = arg.substring(2); + int eqIndex = opt.indexOf('='); + String optName = eqIndex != -1 ? opt.substring(0, eqIndex) : opt; + + if (ALLOWED_FLAGS.contains(optName)) + { + filteredArgs.add(arg); + } + else if (ALLOWED_OPTIONS_WITH_ARG.contains(optName)) + { + filteredArgs.add(arg); + if (eqIndex == -1 && i + 1 < args.length && !args[i + 1].startsWith("-")) + { + filteredArgs.add(args[++i]); + } + } + else + { + System.out.println("Ignoring option: " + arg); + } + } + else if (arg.startsWith("-")) + { + System.out.println("Ignoring short option: " + arg); + } + else + { + filteredArgs.add(arg); + } + } + + Class[] plugins = new Class[]{ + EssentialsPlusPlugin.class + }; + + try + { + Field field = ExternalPluginManager.class.getDeclaredField("builtinExternals"); + field.setAccessible(true); + field.set(null, plugins); + } + catch (Throwable t) + { + try + { + ExternalPluginManager.loadBuiltin(plugins); + } + catch (Throwable ignored) + { + } + } + + System.out.println("Passing filtered args to RuneLite.main: " + filteredArgs); + RuneLite.main(filteredArgs.toArray(new String[0])); + } +} diff --git a/src/test/java/com/example/essentialsplus/BankTransferTest.java b/src/test/java/com/example/essentialsplus/BankTransferTest.java new file mode 100644 index 0000000..13e60a9 --- /dev/null +++ b/src/test/java/com/example/essentialsplus/BankTransferTest.java @@ -0,0 +1,93 @@ +package com.example.essentialsplus; + +import java.util.HashMap; +import java.util.Map; +import java.util.function.IntUnaryOperator; +import org.junit.Assert; +import org.junit.Test; + +public class BankTransferTest +{ + private static final int GRACEFUL_HOOD = 11850; + private static final int GRACEFUL_TOP = 11854; + private static final int SHARK = 385; + private static final int SHARK_NOTED = 386; + private static final int COAL = 453; + private static final int COAL_NOTED = 454; + + private final IntUnaryOperator canonicalize = id -> { + if (id == SHARK_NOTED) return SHARK; + if (id == COAL_NOTED) return COAL; + return id; + }; + + @Test + public void testWithdrawEntireStackRemovesItem() + { + Map bank = new HashMap<>(); + bank.put(GRACEFUL_HOOD, 1L); + bank.put(GRACEFUL_TOP, 1L); + + EssentialsPlusPlugin.deductFromBank(bank, GRACEFUL_HOOD, 1, canonicalize); + + Assert.assertFalse(bank.containsKey(GRACEFUL_HOOD)); + Assert.assertEquals(Long.valueOf(1L), bank.get(GRACEFUL_TOP)); + } + + @Test + public void testWithdrawPartialStackDecrementsQuantity() + { + Map bank = new HashMap<>(); + bank.put(COAL, 1000L); + + EssentialsPlusPlugin.deductFromBank(bank, COAL, 300, canonicalize); + + Assert.assertEquals(Long.valueOf(700L), bank.get(COAL)); + } + + @Test + public void testWithdrawNotedDeductsUnnotedFromBank() + { + Map bank = new HashMap<>(); + bank.put(SHARK, 500L); + + // Withdrawing 100 sharks as notes (item ID 386) + EssentialsPlusPlugin.deductFromBank(bank, SHARK_NOTED, 100, canonicalize); + + Assert.assertEquals(Long.valueOf(400L), bank.get(SHARK)); + } + + @Test + public void testDepositExistingItemIncrementsQuantity() + { + Map bank = new HashMap<>(); + bank.put(COAL, 1000L); + + EssentialsPlusPlugin.addToBank(bank, COAL, 500, canonicalize); + + Assert.assertEquals(Long.valueOf(1500L), bank.get(COAL)); + } + + @Test + public void testDepositNewItemAddsToBank() + { + Map bank = new HashMap<>(); + + EssentialsPlusPlugin.addToBank(bank, GRACEFUL_HOOD, 1, canonicalize); + + Assert.assertEquals(Long.valueOf(1L), bank.get(GRACEFUL_HOOD)); + } + + @Test + public void testDepositNotedMergesIntoUnnotedStack() + { + Map bank = new HashMap<>(); + bank.put(SHARK, 200L); + + // Depositing 50 noted sharks + EssentialsPlusPlugin.addToBank(bank, SHARK_NOTED, 50, canonicalize); + + Assert.assertEquals(Long.valueOf(250L), bank.get(SHARK)); + Assert.assertFalse(bank.containsKey(SHARK_NOTED)); + } +} diff --git a/src/test/java/com/example/essentialsplus/DiaryTableTest.java b/src/test/java/com/example/essentialsplus/DiaryTableTest.java new file mode 100644 index 0000000..176c126 --- /dev/null +++ b/src/test/java/com/example/essentialsplus/DiaryTableTest.java @@ -0,0 +1,24 @@ +package com.example.essentialsplus; + +import com.example.essentialsplus.progress.ProgressService; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import org.junit.Test; + +public class DiaryTableTest +{ + @Test + public void everyTierHasTasks() + { + assertEquals(12, ProgressService.DIARIES.size()); + for (ProgressService.Diary d : ProgressService.DIARIES) + { + assertEquals(d.name, 4, d.tiers.size()); + for (int t = 0; t < 4; t++) + { + assertFalse(d.name + " " + t, d.tiers.get(t).isEmpty()); + } + } + assertEquals(492, ProgressService.diaryTasksTotal(null)); + } +} diff --git a/src/test/java/com/example/essentialsplus/DoomCombatTrackerTest.java b/src/test/java/com/example/essentialsplus/DoomCombatTrackerTest.java new file mode 100644 index 0000000..db5509b --- /dev/null +++ b/src/test/java/com/example/essentialsplus/DoomCombatTrackerTest.java @@ -0,0 +1,134 @@ +package com.example.essentialsplus; + +import com.example.essentialsplus.loot.combat.CombatStats; +import com.example.essentialsplus.loot.combat.CombatTracker; +import com.example.essentialsplus.loot.combat.FightSession; +import com.example.essentialsplus.loot.combat.SingleFightRecord; +import net.runelite.api.coords.WorldPoint; +import org.junit.Assert; +import org.junit.Test; + +public class DoomCombatTrackerTest +{ + @Test + public void testDoomEncounterMapping() + { + Assert.assertTrue(CombatTracker.isDoomEncounter("Doom of Mokhaiotl")); + Assert.assertTrue(CombatTracker.isDoomEncounter("Doom of Mokhaiotl (Burrowed)")); + Assert.assertTrue(CombatTracker.isDoomEncounter("Doom of Mokhaiotl (Shielded)")); + Assert.assertTrue(CombatTracker.isDoomEncounter("Demonic larva")); + Assert.assertTrue(CombatTracker.isDoomEncounter("Volatile earth")); + Assert.assertFalse(CombatTracker.isDoomEncounter("Brutus")); + Assert.assertFalse(CombatTracker.isDoomEncounter("Dharok the Wretched")); + + Assert.assertEquals("Doom of Mokhaiotl", CombatTracker.getCanonicalEncounterName("Doom of Mokhaiotl")); + Assert.assertEquals("Doom of Mokhaiotl", CombatTracker.getCanonicalEncounterName("Demonic larva")); + Assert.assertEquals("Doom of Mokhaiotl", CombatTracker.getCanonicalEncounterName("Volatile earth")); + Assert.assertEquals("Brutus", CombatTracker.getCanonicalEncounterName("Brutus")); + + Assert.assertTrue(CombatTracker.isDoomMinion("Demonic larva")); + Assert.assertTrue(CombatTracker.isDoomMinion("Volatile earth")); + Assert.assertFalse(CombatTracker.isDoomMinion("Doom of Mokhaiotl")); + } + + @Test + public void testUptimeCalculationAndZeroDamageDoesNotCountAsIdle() + { + WorldPoint loc = new WorldPoint(1000, 1000, 0); + FightSession session = new FightSession("Doom of Mokhaiotl", 1, 100, loc, 4, 750); + + // Initial state: tick 0, attacksDealt = 0, idleTicks = 0 + Assert.assertEquals(0, session.getIdleTicks()); + + // Attack on tick 100 + session.recordAttack(4, 100); + + // Simulate ticks 101, 102, 103, 104 (within 4-tick weapon speed) + session.onGameTick(loc); // tick 101: ticksSinceLastAttack = 1 + session.onGameTick(loc); // tick 102: ticksSinceLastAttack = 2 + session.onGameTick(loc); // tick 103: ticksSinceLastAttack = 3 + session.onGameTick(loc); // tick 104: ticksSinceLastAttack = 4 + Assert.assertEquals(0, session.getIdleTicks()); + + // Next tick without attack: tick 105 (ticksSinceLastAttack = 5 > 4) -> 1 idle tick + session.onGameTick(loc); + Assert.assertEquals(1, session.getIdleTicks()); + + // Now player attacks a Demonic larva or lands a 0-damage hitsplat on tick 106: + // Reset ticksSinceLastAttack + session.resetTicksSinceLastAttack(); + session.recordAttack(4, 106); + + // Ticks 107, 108, 109, 110: no idle ticks added + session.onGameTick(loc); + session.onGameTick(loc); + session.onGameTick(loc); + session.onGameTick(loc); + Assert.assertEquals(1, session.getIdleTicks()); // Still only 1 idle tick! + + // Splashing or dealing 0 damage during shield phase on tick 111: + session.resetTicksSinceLastAttack(); + session.onGameTick(loc); // ticksSinceLastAttack was 0 -> 1 <= 4 -> no idle tick! + Assert.assertEquals(1, session.getIdleTicks()); + } + + @Test + public void testDelveTracking() + { + WorldPoint loc = new WorldPoint(1000, 1000, 0); + FightSession session = new FightSession("Doom of Mokhaiotl", 1, 100, loc, 4, 750); + session.setDelve(4); + Assert.assertEquals(4, session.getDelve()); + + session.recordFood("Shark", 2, 40, 2000); + session.recordPotion("Saradomin brew", 3, 1500); + session.recordAmmo("Dragon arrow", 15, 12000); + + CombatStats stats = new CombatStats(); + stats.addFight(session, 50000); + + Assert.assertEquals(1, stats.getFightsCount()); + Assert.assertEquals(4, stats.getMaxDelve()); + Assert.assertEquals(4, stats.getLastDelve()); + Assert.assertEquals(1, stats.getFightHistory().size()); + + SingleFightRecord single = stats.getFightHistory().get(0); + Assert.assertEquals(4, single.getDelve()); + Assert.assertEquals(2, (int) single.getFoodUsed().get("Shark")); + Assert.assertEquals(3, (int) single.getPotionsUsed().get("Saradomin brew")); + Assert.assertEquals(15, (int) single.getAmmoUsed().get("Dragon arrow")); + Assert.assertEquals(2000 + 1500 + 12000, single.getSuppliesCost()); + Assert.assertEquals(50000, single.getLootValue()); + Assert.assertEquals(50000 - (2000 + 1500 + 12000), single.getNetProfit()); + } + + @Test + public void testMultipleDelvesRecordMaxDelve() + { + CombatStats stats = new CombatStats(); + WorldPoint loc = new WorldPoint(1000, 1000, 0); + + FightSession f1 = new FightSession("Doom of Mokhaiotl", 1, 100, loc, 4, 750); + f1.setDelve(1); + stats.addFight(f1, 5000); + + Assert.assertEquals(1, stats.getMaxDelve()); + Assert.assertEquals(1, stats.getLastDelve()); + + FightSession f2 = new FightSession("Doom of Mokhaiotl", 1, 300, loc, 4, 750); + f2.setDelve(5); + stats.addFight(f2, 25000); + + Assert.assertEquals(5, stats.getMaxDelve()); + Assert.assertEquals(5, stats.getLastDelve()); + + FightSession f3 = new FightSession("Doom of Mokhaiotl", 1, 600, loc, 4, 750); + f3.setDelve(3); + stats.addFight(f3, 12000); + + Assert.assertEquals(5, stats.getMaxDelve()); // Max delve remains 5 + Assert.assertEquals(3, stats.getLastDelve()); + Assert.assertEquals(3, stats.getFightHistory().size()); + Assert.assertEquals(3, stats.getFightHistory().get(2).getDelve()); + } +} diff --git a/src/test/java/com/example/essentialsplus/DropTableTest.java b/src/test/java/com/example/essentialsplus/DropTableTest.java new file mode 100644 index 0000000..91854b4 --- /dev/null +++ b/src/test/java/com/example/essentialsplus/DropTableTest.java @@ -0,0 +1,55 @@ +package com.example.essentialsplus; + +import com.example.essentialsplus.loot.droptable.DropLine; +import com.example.essentialsplus.loot.droptable.DropTable; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; +import org.junit.Test; + +public class DropTableTest +{ + @Test + public void combinesRatesPerItem() + { + List lines = Arrays.asList( + new DropLine("Zulrah", "Zulrah's scales", "Always", 1, 100, 300), + new DropLine("Zulrah", "Tanzanite fang", "1/1,024", 2, 1, 1), + new DropLine("Zulrah", "Coins", "~3/128", 1, 10, 10), + new DropLine("Zulrah", "Coins", "1/128", 1, 50, 50), + new DropLine("Zulrah", "Clue scroll (elite)", "Varies", 1, 1, 1)); + DropTable table = new DropTable("Zulrah", lines); + assertEquals(1, table.variants.size()); + + Map rates = DropTable.rates(table.variants.get(0)); + assertEquals("1/1,024", rates.get("tanzanite fang").text); + assertEquals(2 / 1024.0, rates.get("tanzanite fang").perKill, 1e-12); + assertEquals("1/32.0", rates.get("coins").text); + assertEquals("Always", rates.get("zulrah's scales").text); + assertTrue(Double.isNaN(rates.get("clue scroll (elite)").perKill)); + } + + @Test + public void smallSectionsAreAddedToMainTable() + { + List lines = Arrays.asList( + new DropLine("Abyssal demon#Standard", "Abyssal whip", "1/512", 1, 1, 1), + new DropLine("Abyssal demon#Standard", "Bones", "Always", 1, 1, 1), + new DropLine("Abyssal demon#Standard", "Coins", "1/10", 1, 1, 1), + new DropLine("Abyssal demon#Standard", "Law rune", "1/20", 1, 3, 3), + new DropLine("Abyssal demon#Catacombs of Kourend", "Ancient shard", "1/250", 1, 1, 1), + new DropLine("Abyssal demon#Wilderness Slayer Cave", "Abyssal whip", "1/512", 1, 1, 1), + new DropLine("Abyssal demon#Wilderness Slayer Cave", "Bones", "Always", 1, 1, 1), + new DropLine("Abyssal demon#Wilderness Slayer Cave", "Larran's key", "1/100", 1, 1, 1), + new DropLine("Abyssal demon#Wilderness Slayer Cave", "Law rune", "1/20", 1, 3, 3)); + DropTable table = new DropTable("Abyssal demon", lines); + assertEquals(3, table.variants.size()); + assertEquals("Standard", table.variants.get(0).name); + assertEquals("Wilderness Slayer Cave", table.variants.get(1).name); + assertEquals("Standard + Catacombs of Kourend", table.variants.get(2).name); + assertEquals(5, table.variants.get(2).lines.size()); + assertTrue(table.variants.get(2).lines.stream().anyMatch(l -> l.item.equals("Ancient shard"))); + } +} diff --git a/src/test/java/com/example/essentialsplus/GeBuyLimitTest.java b/src/test/java/com/example/essentialsplus/GeBuyLimitTest.java new file mode 100644 index 0000000..0614469 --- /dev/null +++ b/src/test/java/com/example/essentialsplus/GeBuyLimitTest.java @@ -0,0 +1,246 @@ +package com.example.essentialsplus; + +import com.example.essentialsplus.data.AccountData; +import com.example.essentialsplus.data.GeBuyLimit; +import com.example.essentialsplus.data.GeLimitInfo; +import org.junit.Assert; +import org.junit.Test; + +public class GeBuyLimitTest +{ + private static final int RUNITE_ORE = 451; + private static final int RUNITE_LIMIT = 11000; + private static final int NATURE_RUNE = 561; + private static final int NATURE_LIMIT = 25000; + private static final long HOUR_MS = 3600 * 1000L; + + @Test + public void testFreshAccountHasFullLimit() + { + AccountData acc = new AccountData(); + acc.normalize(); + + long now = 10_000_000L; + GeLimitInfo info = acc.getBuyLimitInfo(RUNITE_ORE, RUNITE_LIMIT, now); + + Assert.assertEquals(RUNITE_LIMIT, info.limit); + Assert.assertEquals(0, info.bought); + Assert.assertEquals(RUNITE_LIMIT, info.remaining); + Assert.assertFalse(info.windowActive); + Assert.assertEquals(0, info.resetInMs); + } + + @Test + public void testFirstPurchaseStartsFourHourWindow() + { + AccountData acc = new AccountData(); + acc.normalize(); + + long t0 = 10_000_000L; + acc.recordBuy(RUNITE_ORE, 500, t0); + + GeLimitInfo info = acc.getBuyLimitInfo(RUNITE_ORE, RUNITE_LIMIT, t0); + Assert.assertEquals(RUNITE_LIMIT, info.limit); + Assert.assertEquals(500, info.bought); + Assert.assertEquals(10500, info.remaining); + Assert.assertTrue(info.windowActive); + Assert.assertEquals(4 * HOUR_MS, info.resetInMs); + + // 1 hour later + GeLimitInfo info1h = acc.getBuyLimitInfo(RUNITE_ORE, RUNITE_LIMIT, t0 + HOUR_MS); + Assert.assertEquals(500, info1h.bought); + Assert.assertEquals(10500, info1h.remaining); + Assert.assertTrue(info1h.windowActive); + Assert.assertEquals(3 * HOUR_MS, info1h.resetInMs); + } + + @Test + public void testAdditionalPurchasesWithinWindow() + { + AccountData acc = new AccountData(); + acc.normalize(); + + long t0 = 10_000_000L; + acc.recordBuy(RUNITE_ORE, 1000, t0); + acc.recordBuy(RUNITE_ORE, 2500, t0 + HOUR_MS); + + GeLimitInfo info = acc.getBuyLimitInfo(RUNITE_ORE, RUNITE_LIMIT, t0 + HOUR_MS); + Assert.assertEquals(3500, info.bought); + Assert.assertEquals(7500, info.remaining); + Assert.assertTrue(info.windowActive); + Assert.assertEquals(3 * HOUR_MS, info.resetInMs); + } + + @Test + public void testLimitReached() + { + AccountData acc = new AccountData(); + acc.normalize(); + + long t0 = 10_000_000L; + acc.recordBuy(RUNITE_ORE, RUNITE_LIMIT, t0); + + GeLimitInfo info = acc.getBuyLimitInfo(RUNITE_ORE, RUNITE_LIMIT, t0); + Assert.assertEquals(RUNITE_LIMIT, info.bought); + Assert.assertEquals(0, info.remaining); + Assert.assertTrue(info.windowActive); + + String formatted = EssentialsPlusPlugin.formatBuyLimitLine(info); + Assert.assertTrue(formatted.contains("Buy limit (4h): 11,000 / 11,000 (0 left)")); + Assert.assertTrue(formatted.contains("")); + } + + @Test + public void testWindowExpirationAndRollover() + { + AccountData acc = new AccountData(); + acc.normalize(); + + long t0 = 10_000_000L; + acc.recordBuy(RUNITE_ORE, 5000, t0); + + // Exactly at 4 hours: window expired + long tExpire = t0 + 4 * HOUR_MS; + GeLimitInfo infoExpired = acc.getBuyLimitInfo(RUNITE_ORE, RUNITE_LIMIT, tExpire); + Assert.assertEquals(0, infoExpired.bought); + Assert.assertEquals(RUNITE_LIMIT, infoExpired.remaining); + Assert.assertFalse(infoExpired.windowActive); + + // New purchase 4.5 hours later starts a new window + long tNew = t0 + 4 * HOUR_MS + 30 * 60 * 1000L; + acc.recordBuy(RUNITE_ORE, 1200, tNew); + + GeLimitInfo infoNew = acc.getBuyLimitInfo(RUNITE_ORE, RUNITE_LIMIT, tNew); + Assert.assertEquals(1200, infoNew.bought); + Assert.assertEquals(RUNITE_LIMIT - 1200, infoNew.remaining); + Assert.assertTrue(infoNew.windowActive); + Assert.assertEquals(4 * HOUR_MS, infoNew.resetInMs); + } + + @Test + public void testMultipleItemsTrackedIndependently() + { + AccountData acc = new AccountData(); + acc.normalize(); + + long t0 = 10_000_000L; + acc.recordBuy(RUNITE_ORE, 500, t0); + acc.recordBuy(NATURE_RUNE, 10000, t0 + HOUR_MS); + + GeLimitInfo oreInfo = acc.getBuyLimitInfo(RUNITE_ORE, RUNITE_LIMIT, t0 + HOUR_MS); + GeLimitInfo runeInfo = acc.getBuyLimitInfo(NATURE_RUNE, NATURE_LIMIT, t0 + HOUR_MS); + + Assert.assertEquals(500, oreInfo.bought); + Assert.assertEquals(10500, oreInfo.remaining); + Assert.assertEquals(3 * HOUR_MS, oreInfo.resetInMs); + + Assert.assertEquals(10000, runeInfo.bought); + Assert.assertEquals(15000, runeInfo.remaining); + Assert.assertEquals(4 * HOUR_MS, runeInfo.resetInMs); + } + + @Test + public void testStripBuyLimitLine() + { + String original = "This needs refining."; + Assert.assertEquals(original, EssentialsPlusPlugin.stripBuyLimitLine(original)); + + String withOurLine = "This needs refining.
Buy limit (4h): 0 / 11,000 (11,000 left)"; + Assert.assertEquals(original, EssentialsPlusPlugin.stripBuyLimitLine(withOurLine)); + + String withRuneLiteLine = "Runite ore
This needs refining.
Buy limit: 11,000 (3:42)
Actively traded price: 10,150"; + String expected = "Runite ore
This needs refining.
Actively traded price: 10,150"; + Assert.assertEquals(expected, EssentialsPlusPlugin.stripBuyLimitLine(withRuneLiteLine)); + } + + @Test + public void testFormatDuration() + { + Assert.assertEquals("45m", EssentialsPlusPlugin.formatDuration(45 * 60 * 1000L)); + Assert.assertEquals("2h 15m", EssentialsPlusPlugin.formatDuration((2 * 60 + 15) * 60 * 1000L)); + Assert.assertEquals("1d 2h", EssentialsPlusPlugin.formatDuration((26 * 60) * 60 * 1000L)); + } + + @Test + public void testGeBuyLimitSlotIsSameOffer() + { + AccountData.GeBuyLimitSlot s1 = new AccountData.GeBuyLimitSlot(RUNITE_ORE, 11000, 100, 0, 0); + AccountData.GeBuyLimitSlot s2 = new AccountData.GeBuyLimitSlot(RUNITE_ORE, 11000, 100, 20, 220000); + AccountData.GeBuyLimitSlot diffItem = new AccountData.GeBuyLimitSlot(NATURE_RUNE, 11000, 100, 0, 0); + AccountData.GeBuyLimitSlot diffPrice = new AccountData.GeBuyLimitSlot(RUNITE_ORE, 12000, 100, 0, 0); + AccountData.GeBuyLimitSlot diffTotal = new AccountData.GeBuyLimitSlot(RUNITE_ORE, 11000, 50, 0, 0); + + Assert.assertTrue(s1.isSameOffer(s2)); + Assert.assertFalse(s1.isSameOffer(diffItem)); + Assert.assertFalse(s1.isSameOffer(diffPrice)); + Assert.assertFalse(s1.isSameOffer(diffTotal)); + Assert.assertFalse(s1.isSameOffer(null)); + } + + @Test + public void testIncrementalSlotQuantityTracking() + { + AccountData acc = new AccountData(); + acc.normalize(); + long t0 = 10_000_000L; + + // 1. Offer placed: 500 Runite ore at 11,000 gp, 0 bought + AccountData.GeBuyLimitSlot slot0 = new AccountData.GeBuyLimitSlot(RUNITE_ORE, 11000, 500, 0, 0); + acc.geBuyLimitSlots[0] = slot0; + + // No buy limit recorded yet + GeLimitInfo initial = acc.getBuyLimitInfo(RUNITE_ORE, RUNITE_LIMIT, t0); + Assert.assertEquals(0, initial.bought); + Assert.assertFalse(initial.windowActive); + + // 2. First purchase happens: 50 bought + AccountData.GeBuyLimitSlot update1 = new AccountData.GeBuyLimitSlot(RUNITE_ORE, 11000, 500, 50, 550000); + boolean same1 = update1.isSameOffer(acc.geBuyLimitSlots[0]) && update1.quantity >= acc.geBuyLimitSlots[0].quantity; + int delta1 = same1 ? (update1.quantity - acc.geBuyLimitSlots[0].quantity) : update1.quantity; + Assert.assertEquals(50, delta1); + acc.recordBuy(RUNITE_ORE, delta1, t0); + acc.geBuyLimitSlots[0] = update1; + + GeLimitInfo afterFirstBuy = acc.getBuyLimitInfo(RUNITE_ORE, RUNITE_LIMIT, t0); + Assert.assertEquals(50, afterFirstBuy.bought); + Assert.assertTrue(afterFirstBuy.windowActive); + Assert.assertEquals(4 * HOUR_MS, afterFirstBuy.resetInMs); + + // 3. Second purchase: 150 total bought (delta = 100) + long t1 = t0 + HOUR_MS; + AccountData.GeBuyLimitSlot update2 = new AccountData.GeBuyLimitSlot(RUNITE_ORE, 11000, 500, 150, 1650000); + boolean same2 = update2.isSameOffer(acc.geBuyLimitSlots[0]) && update2.quantity >= acc.geBuyLimitSlots[0].quantity; + int delta2 = same2 ? (update2.quantity - acc.geBuyLimitSlots[0].quantity) : update2.quantity; + Assert.assertEquals(100, delta2); + acc.recordBuy(RUNITE_ORE, delta2, t1); + acc.geBuyLimitSlots[0] = update2; + + GeLimitInfo afterSecondBuy = acc.getBuyLimitInfo(RUNITE_ORE, RUNITE_LIMIT, t1); + Assert.assertEquals(150, afterSecondBuy.bought); + Assert.assertTrue(afterSecondBuy.windowActive); + Assert.assertEquals(3 * HOUR_MS, afterSecondBuy.resetInMs); + + // 4. Offer clears + acc.geBuyLimitSlots[0] = null; + Assert.assertNull(acc.geBuyLimitSlots[0]); + // Limit remains tracked + Assert.assertEquals(150, acc.getBuyLimitInfo(RUNITE_ORE, RUNITE_LIMIT, t1).bought); + } + + @Test + public void testClearBuyLimits() + { + AccountData acc = new AccountData(); + acc.normalize(); + acc.recordBuy(RUNITE_ORE, 500, 10_000_000L); + acc.geBuyLimitSlots[0] = new AccountData.GeBuyLimitSlot(RUNITE_ORE, 11000, 500, 500, 5500000); + + Assert.assertEquals(500, acc.getBuyLimitInfo(RUNITE_ORE, RUNITE_LIMIT, 10_000_000L).bought); + Assert.assertNotNull(acc.geBuyLimitSlots[0]); + + acc.clearBuyLimits(); + + Assert.assertEquals(0, acc.getBuyLimitInfo(RUNITE_ORE, RUNITE_LIMIT, 10_000_000L).bought); + Assert.assertNull(acc.geBuyLimitSlots[0]); + } +} diff --git a/src/test/java/com/example/essentialsplus/ItemChargesFullTest.java b/src/test/java/com/example/essentialsplus/ItemChargesFullTest.java new file mode 100644 index 0000000..818ac4b --- /dev/null +++ b/src/test/java/com/example/essentialsplus/ItemChargesFullTest.java @@ -0,0 +1,264 @@ +package com.example.essentialsplus; + +import com.example.essentialsplus.helpers.ItemCharges; +import java.lang.reflect.Proxy; +import java.util.List; +import net.runelite.api.ChatMessageType; +import net.runelite.api.Client; +import net.runelite.api.events.ChatMessage; +import net.runelite.api.gameval.ItemID; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; + +public class ItemChargesFullTest +{ + private Client client; + private EssentialsPlusConfig config; + private EssentialsPlusPlugin plugin; + private ItemCharges itemCharges; + + @SuppressWarnings("unchecked") + private static T mockInterface(Class iface, java.lang.reflect.InvocationHandler handler) + { + return (T) Proxy.newProxyInstance(iface.getClassLoader(), new Class[]{iface}, handler); + } + + @Before + public void setup() + { + client = mockInterface(Client.class, (proxy, method, args) -> { + if ("getAccountHash".equals(method.getName())) return 123456789L; + if ("getVarbitValue".equals(method.getName())) return 0; + if ("getItemContainer".equals(method.getName())) return null; + if ("getItemDefinition".equals(method.getName())) { + int id = (Integer) args[0]; + return mockInterface(net.runelite.api.ItemComposition.class, (p2, m2, a2) -> { + if ("getName".equals(m2.getName())) { + if (id == net.runelite.api.ItemID.PRAYER_POTION4) return "Prayer potion(4)"; + if (id == net.runelite.api.ItemID.PRAYER_POTION3) return "Prayer potion(3)"; + if (id == net.runelite.api.ItemID.PRAYER_POTION2) return "Prayer potion(2)"; + if (id == net.runelite.api.ItemID.PRAYER_POTION1) return "Prayer potion(1)"; + if (id == net.runelite.api.ItemID.RING_OF_DUELING8) return "Ring of dueling(8)"; + if (id == net.runelite.api.ItemID.GAMES_NECKLACE8) return "Games necklace(8)"; + return "Item " + id; + } + return null; + }); + } + return null; + }); + + config = mockInterface(EssentialsPlusConfig.class, (proxy, method, args) -> { + if ("itemCharges".equals(method.getName())) return true; + return null; + }); + + plugin = new EssentialsPlusPlugin() + { + @Override + public T getHelperValue(String key, Class type, T def) + { + return def; + } + }; + + itemCharges = new ItemCharges(client, config, plugin, null, null); + } + + private void sendChat(String text) + { + ChatMessage event = new ChatMessage(null, ChatMessageType.GAMEMESSAGE, "", text, "", 0); + itemCharges.onChatMessage(event); + } + + @Test + public void testBlowpipeFlow() + { + // 1. Initial loaded state + List initial = itemCharges.chargesFor(ItemID.TOXIC_BLOWPIPE_LOADED); + Assert.assertEquals(2, initial.size()); + Assert.assertEquals("?", initial.get(0).getText()); // darts (top) + Assert.assertEquals("?", initial.get(1).getText()); // scales (bottom) + + // 2. Check message + sendChat("Darts: Dragon dart x 1,500. Scales: 3,200 (19.5%)."); + List afterCheck = itemCharges.chargesFor(ItemID.TOXIC_BLOWPIPE_LOADED); + Assert.assertEquals(2, afterCheck.size()); + Assert.assertEquals("1.5K", afterCheck.get(0).getText()); // darts (top) + Assert.assertEquals("3.2K", afterCheck.get(1).getText()); // scales (bottom) + + // 3. Load darts + sendChat("You load the blowpipe with 500 Dragon darts."); + List afterLoadDarts = itemCharges.chargesFor(ItemID.TOXIC_BLOWPIPE_LOADED); + Assert.assertEquals("2K", afterLoadDarts.get(0).getText()); + + // 4. Add scales + sendChat("You add 1,000 Zulrah's scales to the blowpipe."); + List afterAddScales = itemCharges.chargesFor(ItemID.TOXIC_BLOWPIPE_LOADED); + Assert.assertEquals("4.2K", afterAddScales.get(1).getText()); + + // 5. Uncharge + sendChat("You uncharge the blowpipe."); + List afterUncharge = itemCharges.chargesFor(ItemID.TOXIC_BLOWPIPE_LOADED); + Assert.assertEquals("0", afterUncharge.get(0).getText()); + Assert.assertEquals("0", afterUncharge.get(1).getText()); + } + + @Test + public void testBarrowsFlow() + { + // 1. Unsure/untracked state returns "?" (except broken which is always 0%) + List dharok100 = itemCharges.chargesFor(ItemID.BARROWS_DHAROK_HEAD_100); + Assert.assertEquals(1, dharok100.size()); + Assert.assertEquals("?", dharok100.get(0).getText()); + + List dharok75 = itemCharges.chargesFor(ItemID.BARROWS_DHAROK_HEAD_75); + Assert.assertEquals("?", dharok75.get(0).getText()); + + List dharok0 = itemCharges.chargesFor(ItemID.BARROWS_DHAROK_HEAD_BROKEN); + Assert.assertEquals("0%", dharok0.get(0).getText()); + + // 2. Exact check messages from user screenshot (colon format + unicode right single quote) + sendChat("Dharok’s helmet: 72% remaining until degradation."); + sendChat("Dharok’s body: 72% remaining until degradation."); + sendChat("Dharok’s legs: 72% remaining until degradation."); + sendChat("Dharok’s weapon: 74% remaining until degradation."); + + Assert.assertEquals("72%", itemCharges.chargesFor(ItemID.BARROWS_DHAROK_HEAD_75).get(0).getText()); + Assert.assertEquals("72%", itemCharges.chargesFor(ItemID.BARROWS_DHAROK_BODY_75).get(0).getText()); + Assert.assertEquals("72%", itemCharges.chargesFor(ItemID.BARROWS_DHAROK_LEGS_75).get(0).getText()); + Assert.assertEquals("74%", itemCharges.chargesFor(ItemID.BARROWS_DHAROK_WEAPON_75).get(0).getText()); + + // 3. Check another barrows item with classic format + sendChat("Your Karil's leathertop 75 has 65% of its charges left."); + List karil = itemCharges.chargesFor(ItemID.BARROWS_KARIL_BODY_75); + Assert.assertEquals("65%", karil.get(0).getText()); + + // 4. Fully degraded message + sendChat("Your Dharok's helm has fully degraded."); + List afterDegrade = itemCharges.chargesFor(ItemID.BARROWS_DHAROK_HEAD_100); + Assert.assertEquals("0%", afterDegrade.get(0).getText()); + + // 5. Repair message + sendChat("You repair all your degraded equipment."); + List afterRepair = itemCharges.chargesFor(ItemID.BARROWS_DHAROK_HEAD_100); + Assert.assertEquals("100%", afterRepair.get(0).getText()); + } + + @Test + public void testOtherItemsFlow() + { + // Untracked tentacle returns "?" + Assert.assertEquals("?", itemCharges.chargesFor(ItemID.ABYSSAL_TENTACLE).get(0).getText()); + + // Abyssal tentacle after check + sendChat("Your abyssal tentacle has 8,400 charges remaining."); + Assert.assertEquals("8.4K", itemCharges.chargesFor(ItemID.ABYSSAL_TENTACLE).get(0).getText()); + + // Scythe of Vitur + Assert.assertEquals("?", itemCharges.chargesFor(ItemID.SCYTHE_OF_VITUR).get(0).getText()); + sendChat("Your Scythe of Vitur only has 1,200 charges left."); + Assert.assertEquals("1.2K", itemCharges.chargesFor(ItemID.SCYTHE_OF_VITUR).get(0).getText()); + Assert.assertEquals("0", itemCharges.chargesFor(ItemID.SCYTHE_OF_VITUR_UNCHARGED).get(0).getText()); + + // Dodgy necklace + sendChat("Your dodgy necklace protects you...It has 7 charges left."); + Assert.assertEquals("7", itemCharges.chargesFor(ItemID.DODGY_NECKLACE).get(0).getText()); + + // Gem bag + sendChat("The gem bag contains 10 sapphires, 8 emeralds, 6 rubies, 4 diamonds, 2 dragonstones."); + Assert.assertEquals("30", itemCharges.chargesFor(ItemID.GEM_BAG).get(0).getText()); + + // Herb sack + sendChat("The herb sack contains 45 grimy herbs."); + Assert.assertEquals("45", itemCharges.chargesFor(ItemID.SLAYER_HERB_SACK).get(0).getText()); + + // Coal bag + sendChat("The coal bag contains 27 pieces of coal."); + Assert.assertEquals("27", itemCharges.chargesFor(ItemID.COAL_BAG).get(0).getText()); + + // Test dynamic decrementing + itemCharges.decrementCount("abyssal_tentacle", 100); + Assert.assertEquals("8.3K", itemCharges.chargesFor(ItemID.ABYSSAL_TENTACLE).get(0).getText()); + + itemCharges.decrementCount("dodgy_necklace", 1); + Assert.assertEquals("6", itemCharges.chargesFor(ItemID.DODGY_NECKLACE).get(0).getText()); + } + + @Test + public void testMoonsOfPerilFlow() + { + // 1. Untracked full piece returns 100% + Assert.assertEquals("100%", itemCharges.chargesFor(ItemID.BLOOD_MOON_CHESTPLATE).get(0).getText()); + Assert.assertEquals("100%", itemCharges.chargesFor(ItemID.FROST_MOON_CHESTPLATE).get(0).getText()); + Assert.assertEquals("100%", itemCharges.chargesFor(ItemID.ECLIPSE_MOON_CHESTPLATE).get(0).getText()); + + // 2. Broken piece returns 0% + Assert.assertEquals("0%", itemCharges.chargesFor(ItemID.BLOOD_MOON_CHESTPLATE_BROKEN).get(0).getText()); + Assert.assertEquals("0%", itemCharges.chargesFor(ItemID.FROST_MOON_CHESTPLATE_BROKEN).get(0).getText()); + + // 3. Degraded chat check + sendChat("Your Blood moon chestplate has 1,500 charges remaining."); + Assert.assertEquals("50%", itemCharges.chargesFor(ItemID.BLOOD_MOON_CHESTPLATE_DEGRADED).get(0).getText()); + + sendChat("Your Blue moon tassets have 2,250 charges remaining."); + Assert.assertEquals("75%", itemCharges.chargesFor(ItemID.FROST_MOON_TASSETS_DEGRADED).get(0).getText()); + + // 4. Fully degraded + sendChat("Your Eclipse moon helm has fully degraded."); + Assert.assertEquals("0%", itemCharges.chargesFor(ItemID.ECLIPSE_MOON_HELM).get(0).getText()); + } + + @Test + public void testShieldsAndBucketsAndPouches() + { + // Dragonfire shield + sendChat("Your Dragonfire shield has 45 charges remaining."); + Assert.assertEquals("45", itemCharges.chargesFor(ItemID.DRAGONFIRE_SHIELD).get(0).getText()); + Assert.assertEquals("0", itemCharges.chargesFor(ItemID.DRAGONFIRE_SHIELD_UNCHARGED).get(0).getText()); + + // Bottomless compost bucket + sendChat("Your bottomless compost bucket has 4,500 uses remaining."); + Assert.assertEquals("4.5K", itemCharges.chargesFor(ItemID.BOTTOMLESS_COMPOST_BUCKET_FILLED).get(0).getText()); + Assert.assertEquals("0", itemCharges.chargesFor(ItemID.BOTTOMLESS_COMPOST_BUCKET).get(0).getText()); + + // Gricoller's can + sendChat("Your Gricoller's can contains 850 charges."); + Assert.assertEquals("850", itemCharges.chargesFor(ItemID.ZEAH_WATERINGCAN).get(0).getText()); + + // Colossal pouch + sendChat("There are 35 pure essences in your colossal pouch."); + Assert.assertEquals("35", itemCharges.chargesFor(ItemID.RCU_POUCH_COLOSSAL).get(0).getText()); + } + + @Test + public void testPotionsAndJewelryDoses() + { + // 4 doses = white + List p4 = itemCharges.chargesFor(net.runelite.api.ItemID.PRAYER_POTION4); + Assert.assertEquals(1, p4.size()); + Assert.assertEquals("4", p4.get(0).getText()); + Assert.assertEquals(java.awt.Color.WHITE, p4.get(0).getColor()); + + // 3 doses = cyan + List p3 = itemCharges.chargesFor(net.runelite.api.ItemID.PRAYER_POTION3); + Assert.assertEquals("3", p3.get(0).getText()); + Assert.assertEquals(new java.awt.Color(0x4DD0E1), p3.get(0).getColor()); + + // 2 doses = orange + List p2 = itemCharges.chargesFor(net.runelite.api.ItemID.PRAYER_POTION2); + Assert.assertEquals("2", p2.get(0).getText()); + Assert.assertEquals(new java.awt.Color(0xFFB74D), p2.get(0).getColor()); + + // 1 dose = red + List p1 = itemCharges.chargesFor(net.runelite.api.ItemID.PRAYER_POTION1); + Assert.assertEquals("1", p1.get(0).getText()); + Assert.assertEquals(new java.awt.Color(0xFF3333), p1.get(0).getColor()); + + // Ring of dueling (8) + List rod = itemCharges.chargesFor(net.runelite.api.ItemID.RING_OF_DUELING8); + Assert.assertEquals("8", rod.get(0).getText()); + } +} + diff --git a/src/test/java/com/example/essentialsplus/ItemChargesTest.java b/src/test/java/com/example/essentialsplus/ItemChargesTest.java new file mode 100644 index 0000000..7120999 --- /dev/null +++ b/src/test/java/com/example/essentialsplus/ItemChargesTest.java @@ -0,0 +1,73 @@ +package com.example.essentialsplus; + +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import org.junit.Assert; +import org.junit.Test; + +public class ItemChargesTest +{ + private static final Pattern BARROWS_COLON_PATTERN = Pattern.compile( + "(?[^:]+?):\\s*(?\\d+)%\\s+remaining\\b.*", + Pattern.CASE_INSENSITIVE + ); + + private static final Pattern BARROWS_HAS_PATTERN = Pattern.compile( + "(?:Your\\s+)?(?.+?)(?:\\s+(?:100|75|50|25|0))?\\s+(?:only\\s+)?has\\s+(?\\d+)%\\s+(?:of\\s+its\\s+)?charges?\\s+(?:remaining|left)\\.?", + Pattern.CASE_INSENSITIVE + ); + + @Test + public void testScreenshotMessages() + { + String msg1 = "Dharok’s helmet: 72% remaining until degradation."; + Matcher m1 = BARROWS_COLON_PATTERN.matcher(msg1.replace('’', '\'')); + Assert.assertTrue(m1.find()); + Assert.assertEquals("Dharok's helmet", m1.group("item")); + Assert.assertEquals("72", m1.group("pct")); + + String msg2 = "Dharok’s body: 72% remaining until degradation."; + Matcher m2 = BARROWS_COLON_PATTERN.matcher(msg2.replace('’', '\'')); + Assert.assertTrue(m2.find()); + Assert.assertEquals("Dharok's body", m2.group("item")); + Assert.assertEquals("72", m2.group("pct")); + + String msg3 = "Dharok’s legs: 72% remaining until degradation."; + Matcher m3 = BARROWS_COLON_PATTERN.matcher(msg3.replace('’', '\'')); + Assert.assertTrue(m3.find()); + Assert.assertEquals("Dharok's legs", m3.group("item")); + Assert.assertEquals("72", m3.group("pct")); + + String msg4 = "Dharok’s weapon: 74% remaining until degradation."; + Matcher m4 = BARROWS_COLON_PATTERN.matcher(msg4.replace('’', '\'')); + Assert.assertTrue(m4.find()); + Assert.assertEquals("Dharok's weapon", m4.group("item")); + Assert.assertEquals("74", m4.group("pct")); + } + + @Test + public void testCoalBagMessages() + { + Pattern pAmt = Pattern.compile("The coal bag contains ([0-9]+) pieces? of coal\\.", Pattern.CASE_INSENSITIVE); + Pattern pOne = Pattern.compile("The coal bag contains one piece of coal\\.", Pattern.CASE_INSENSITIVE); + Pattern pEmpty = Pattern.compile("(The coal bag is empty\\.|You empty (all of )?the coal from the coal bag\\.|The coal bag is now empty\\.|You empty the coal bag\\.)", Pattern.CASE_INSENSITIVE); + Pattern pFull = Pattern.compile("(The coal bag is now full\\.|The coal bag cannot hold any more coal\\.)", Pattern.CASE_INSENSITIVE); + Pattern pFill = Pattern.compile("You fill the coal bag with ([0-9]+) pieces? of coal\\.", Pattern.CASE_INSENSITIVE); + + Matcher mAmt = pAmt.matcher("The coal bag contains 15 pieces of coal."); + Assert.assertTrue(mAmt.find()); + Assert.assertEquals("15", mAmt.group(1)); + + Assert.assertTrue(pOne.matcher("The coal bag contains one piece of coal.").find()); + Assert.assertTrue(pEmpty.matcher("The coal bag is empty.").find()); + Assert.assertTrue(pEmpty.matcher("You empty all of the coal from the coal bag.").find()); + Assert.assertTrue(pEmpty.matcher("The coal bag is now empty.").find()); + Assert.assertTrue(pEmpty.matcher("You empty the coal bag.").find()); + Assert.assertTrue(pFull.matcher("The coal bag is now full.").find()); + Assert.assertTrue(pFull.matcher("The coal bag cannot hold any more coal.").find()); + + Matcher mFill = pFill.matcher("You fill the coal bag with 27 pieces of coal."); + Assert.assertTrue(mFill.find()); + Assert.assertEquals("27", mFill.group(1)); + } +} diff --git a/src/test/java/com/example/essentialsplus/LootRecordFilterTest.java b/src/test/java/com/example/essentialsplus/LootRecordFilterTest.java new file mode 100644 index 0000000..f06b939 --- /dev/null +++ b/src/test/java/com/example/essentialsplus/LootRecordFilterTest.java @@ -0,0 +1,40 @@ +package com.example.essentialsplus; + +import com.example.essentialsplus.loot.LootEvent; +import com.example.essentialsplus.loot.LootRecord; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; +import org.junit.Test; + +public class LootRecordFilterTest +{ + private static final long HOUR = 3_600_000L; + + @Test + public void filtersByEventTime() + { + LootRecord r = new LootRecord("Goblin", "NPC"); + r.addKills(1); + r.addDrop(995, "Coins", 10, 1); + long now = System.currentTimeMillis(); + + // fake an old event from 3 days ago + LootEvent old = new LootEvent(now - 72 * HOUR); + old.k = 5; + old.i.put(995, 500L); + r.getEvents().add(0, old); + + assertSame(r, r.since(0)); + LootRecord day = r.since(now - 24 * HOUR); + assertEquals(1, day.getKills()); + assertEquals(10, day.getItems().get(995).getQuantity()); + assertEquals(10, day.getTotalValue()); + + assertTrue(r.since(now + HOUR).isEmpty()); + + r.compactEvents(now); + assertEquals(2, r.getEvents().size()); + assertEquals(6, r.since(now - 100 * HOUR).getKills()); + } +} diff --git a/src/test/java/com/example/essentialsplus/SkillHistoryTest.java b/src/test/java/com/example/essentialsplus/SkillHistoryTest.java new file mode 100644 index 0000000..80a8421 --- /dev/null +++ b/src/test/java/com/example/essentialsplus/SkillHistoryTest.java @@ -0,0 +1,105 @@ +package com.example.essentialsplus; + +import com.example.essentialsplus.skills.SkillHistory; +import com.example.essentialsplus.ui.TimeFilter; +import com.example.essentialsplus.ui.TimeRange; +import java.util.List; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; +import org.junit.Test; + +public class SkillHistoryTest +{ + private static final long MIN = 60_000L; + + private static int[] xp(int attack) + { + int[] x = new int[SkillHistory.SKILLS.length]; + x[0] = attack; + x[3] = 1154; + return x; + } + + @Test + public void gainedLevelsAndActiveTime() throws Exception + { + SkillHistory h = new SkillHistory("k", "name"); + java.lang.reflect.Method record = SkillHistory.class.getDeclaredMethod("record", int[].class, long.class); + record.setAccessible(true); + long t0 = 1_000_000 * MIN; + record.invoke(h, xp(0), t0); + record.invoke(h, xp(50), t0 + MIN); + record.invoke(h, xp(83), t0 + MIN + 10_000); // same minute -> merged + record.invoke(h, xp(200), t0 + 2 * MIN); + record.invoke(h, xp(100), t0 + 3 * MIN); // xp drop ignored + + assertEquals(3, h.samples.size()); + assertEquals(200, h.gained(0)[0]); + assertEquals(117, h.gained(t0 + MIN + 30_000)[0]); + + List ups = h.levelUps(0); + assertEquals(2, ups.size()); + assertEquals(1, ups.get(0).from); + assertEquals(2, ups.get(0).to); + assertEquals(t0 + MIN + 10_000, ups.get(0).t); + assertEquals(3, ups.get(1).to); + + // two gaps of ~1 minute both gain attack xp + assertEquals(2 * MIN, h.activeMillis(0, 0)); + } + + @Test + public void resetHourly() throws Exception + { + SkillHistory h = new SkillHistory("k", "name"); + java.lang.reflect.Method record = SkillHistory.class.getDeclaredMethod("record", int[].class, long.class); + record.setAccessible(true); + long t0 = 1_000_000 * MIN; + record.invoke(h, xp(0), t0); + record.invoke(h, xp(50), t0 + MIN); + record.invoke(h, xp(100), t0 + 2 * MIN); + record.invoke(h, xp(250), t0 + 3 * MIN); + + TimeFilter sessionFilter = new TimeFilter(TimeRange.SESSION, null); + sessionFilter.setSessionStart(t0); + + TimeFilter allFilter = new TimeFilter(TimeRange.ALL, null); + + assertEquals(250, h.gained(sessionFilter)[0]); + assertEquals(250, h.gained(allFilter)[0]); + + // Reset non-session/non-current range -> no-op + h.reset(TimeRange.ALL); + h.reset(TimeRange.DAY); + assertEquals(0, h.resetSessionAt); + assertEquals(0, h.resetNowAt); + assertEquals(250, h.gained(sessionFilter)[0]); + + // Simulate reset at t0 + 2*MIN + h.resetSessionAt = t0 + 2 * MIN; + // Gained XP (the green number) stays the full 250 for the session: + assertEquals(250, h.gained(sessionFilter)[0]); + // ALL remains completely unaffected: + assertEquals(250, h.gained(allFilter)[0]); + // Rate start is clamped to resetSessionAt: + assertEquals(t0 + 2 * MIN, h.rateStarts(sessionFilter)[0]); + assertEquals(t0 + 2 * MIN, h.rateStart(sessionFilter)); + // XP gained since rate reset is 250 - 100 = 150: + assertEquals(150, h.gainedSince(h.rateStarts(sessionFilter)[0], 0)); + assertEquals(150, h.gainedTotalSince(h.rateStart(sessionFilter))); + + // Test reset(TimeRange.SESSION) sets resetSessionAt and dirty flag + h.dirty = false; + long before = System.currentTimeMillis(); + h.reset(TimeRange.SESSION); + long after = System.currentTimeMillis(); + assertTrue(h.dirty); + assertTrue(h.resetSessionAt >= before && h.resetSessionAt <= after); + + // Test reset(TimeRange.CURRENT) sets resetNowAt + h.dirty = false; + h.reset(TimeRange.CURRENT); + assertTrue(h.dirty); + assertTrue(h.resetNowAt >= before && h.resetNowAt <= System.currentTimeMillis()); + } +} diff --git a/src/test/java/com/example/essentialsplus/WealthHourlyTrackerTest.java b/src/test/java/com/example/essentialsplus/WealthHourlyTrackerTest.java new file mode 100644 index 0000000..322dbb9 --- /dev/null +++ b/src/test/java/com/example/essentialsplus/WealthHourlyTrackerTest.java @@ -0,0 +1,144 @@ +package com.example.essentialsplus; + +import com.example.essentialsplus.data.AccountData; +import com.example.essentialsplus.data.Snapshot; +import com.example.essentialsplus.ui.Analysis; +import com.example.essentialsplus.ui.WealthPanel; +import java.util.ArrayList; +import java.util.List; +import org.junit.Assert; +import org.junit.Test; + +public class WealthHourlyTrackerTest +{ + private static Snapshot snap(long t, long total) + { + long[] v = new long[WealthCategory.COUNT]; + v[WealthCategory.INVENTORY.ordinal()] = total; + return new Snapshot(t, v); + } + + @Test + public void testCalculateHourlyRatePositive() + { + // Active for 30 minutes (snapshots every 10 mins), gained 500k -> 1,000,000 gp/h + List series = new ArrayList<>(); + series.add(snap(1_000_000L, 10_000_000L)); + series.add(snap(1_000_000L + 10 * 60_000L, 10_100_000L)); + series.add(snap(1_000_000L + 20 * 60_000L, 10_300_000L)); + series.add(snap(1_000_000L + 30 * 60_000L, 10_500_000L)); + + long rateStart = 1_000_000L; + long now = rateStart + 30 * 60_000L; + + Long rate = WealthPanel.calculateHourlyRate(series, rateStart, now); + Assert.assertNotNull(rate); + Assert.assertEquals(1_000_000L, rate.longValue()); + Assert.assertEquals("+1M gp/h", WealthPanel.formatHourlyRate(rate)); + } + + @Test + public void testCalculateHourlyRateNegative() + { + // Active for 15 minutes (snapshots every 5 mins), lost 250k -> -1,000,000 gp/h + List series = new ArrayList<>(); + series.add(snap(0L, 5_000_000L)); + series.add(snap(5 * 60_000L, 4_900_000L)); + series.add(snap(10 * 60_000L, 4_800_000L)); + series.add(snap(15 * 60_000L, 4_750_000L)); + + Long rate = WealthPanel.calculateHourlyRate(series, 0L, 15 * 60_000L); + Assert.assertNotNull(rate); + Assert.assertEquals(-1_000_000L, rate.longValue()); + Assert.assertEquals("-1M gp/h", WealthPanel.formatHourlyRate(rate)); + } + + @Test + public void testCalculateHourlyRateZeroDelta() + { + List series = new ArrayList<>(); + series.add(snap(0L, 10_000_000L)); + series.add(snap(5 * 60_000L, 10_000_000L)); + series.add(snap(10 * 60_000L, 10_000_000L)); + + Long rate = WealthPanel.calculateHourlyRate(series, 0L, 10 * 60_000L); + Assert.assertNotNull(rate); + Assert.assertEquals(0L, rate.longValue()); + Assert.assertEquals("0 gp/h", WealthPanel.formatHourlyRate(rate)); + } + + @Test + public void testCalculateHourlyRateUnderOneMinuteReturnsNull() + { + // Active time under 1 minute should return null to prevent wild spikes + List series = new ArrayList<>(); + series.add(snap(0L, 10_000_000L)); + series.add(snap(30_000L, 10_100_000L)); + + Long rate = WealthPanel.calculateHourlyRate(series, 0L, 30_000L); + Assert.assertNull(rate); + Assert.assertEquals("", WealthPanel.formatHourlyRate(rate)); + } + + @Test + public void testCalculateActiveMillisWithIdleGaps() + { + // t=0 to t=5min (active: 5m) + // gap to t=25min (20m > 10m idle gap, ignored) + // t=25m to t=30m (active: 5m) + // now = t=32m (tail: 2m) + // Total active: 5 + 5 + 2 = 12 minutes = 720_000 ms + List series = new ArrayList<>(); + series.add(snap(0L, 10_000_000L)); + series.add(snap(5 * 60_000L, 10_100_000L)); + series.add(snap(25 * 60_000L, 10_100_000L)); + series.add(snap(30 * 60_000L, 10_200_000L)); + + long active = WealthPanel.calculateActiveMillis(series, 0L, 32 * 60_000L); + Assert.assertEquals(12 * 60_000L, active); + } + + @Test + public void testAccountDataResetHourly() + { + AccountData data = new AccountData(); + data.key = "test-account"; + data.name = "TestHero"; + Assert.assertEquals(0L, data.resetHourlyNowAt); + Assert.assertEquals(0L, data.resetHourlySessionAt); + + long before = System.currentTimeMillis(); + data.resetHourly(false); + Assert.assertTrue(data.resetHourlyNowAt >= before); + Assert.assertEquals(0L, data.resetHourlySessionAt); + + data.resetHourly(true); + Assert.assertTrue(data.resetHourlySessionAt >= before); + } + + @Test + public void testStatsPerHourVsPerDay() + { + Analysis.Stats stats = new Analysis.Stats(); + stats.has = true; + stats.change = 500_000L; + stats.ratio = 0.05; + stats.perDay = 1_000_000L; + stats.peak = 10_500_000L; + stats.low = 10_000_000L; + + // When hourlyRate is provided (Now / Session), cell 2 must be "Per hour" + List hourlyCells = WealthPanel.DetailView.cells(stats, false, "+1M gp/h"); + Assert.assertEquals("Per hour", hourlyCells.get(1).label); + Assert.assertEquals("+1M gp/h", hourlyCells.get(1).value); + + // When hourlyRate is empty (""), cell 2 must still be "Per hour" with "-" as placeholder + List emptyHourlyCells = WealthPanel.DetailView.cells(stats, false, ""); + Assert.assertEquals("Per hour", emptyHourlyCells.get(1).label); + Assert.assertEquals("-", emptyHourlyCells.get(1).value); + + // When hourlyRate is null (historical ranges like 1D, 2W, All), cell 2 must be "Per day" + List dayCells = WealthPanel.DetailView.cells(stats, false, null); + Assert.assertEquals("Per day", dayCells.get(1).label); + } +} diff --git a/src/test/java/com/example/essentialsplus/WealthSpikeFilterTest.java b/src/test/java/com/example/essentialsplus/WealthSpikeFilterTest.java new file mode 100644 index 0000000..7521690 --- /dev/null +++ b/src/test/java/com/example/essentialsplus/WealthSpikeFilterTest.java @@ -0,0 +1,238 @@ +package com.example.essentialsplus; + +import com.example.essentialsplus.data.AccountData; +import com.example.essentialsplus.data.Snapshot; +import java.util.ArrayList; +import java.util.List; +import org.junit.Assert; +import org.junit.Test; + +public class WealthSpikeFilterTest +{ + private static Snapshot snap(long t, long total) + { + long[] v = new long[WealthCategory.COUNT]; + v[WealthCategory.INVENTORY.ordinal()] = total; + return new Snapshot(t, v); + } + + @Test + public void testPositiveSpikeIsRemoved() + { + // 135M -> 179.8M (1 min) -> 135.1M (1 min) + List raw = new ArrayList<>(); + raw.add(snap(1000, 135_000_000L)); + raw.add(snap(61_000, 179_800_000L)); + raw.add(snap(121_000, 135_100_000L)); + + List clean = AccountData.sanitizeHistory(raw); + + Assert.assertEquals(2, clean.size()); + Assert.assertEquals(135_000_000L, clean.get(0).total()); + Assert.assertEquals(135_100_000L, clean.get(1).total()); + } + + @Test + public void testNegativeSpikeIsRemoved() + { + // 150M -> 20M (45 sec) -> 150.2M (45 sec) + List raw = new ArrayList<>(); + raw.add(snap(1000, 150_000_000L)); + raw.add(snap(46_000, 20_000_000L)); + raw.add(snap(91_000, 150_200_000L)); + + List clean = AccountData.sanitizeHistory(raw); + + Assert.assertEquals(2, clean.size()); + Assert.assertEquals(150_000_000L, clean.get(0).total()); + Assert.assertEquals(150_200_000L, clean.get(1).total()); + } + + @Test + public void testDoubleSpikeIsRemoved() + { + // Glitch lasting for 2 consecutive snapshot points before returning + // 135M -> 180M -> 180M -> 135M within 3 minutes + List raw = new ArrayList<>(); + raw.add(snap(1000, 135_000_000L)); + raw.add(snap(60_000, 180_000_000L)); + raw.add(snap(120_000, 180_000_000L)); + raw.add(snap(180_000, 135_000_000L)); + + List clean = AccountData.sanitizeHistory(raw); + + Assert.assertEquals(2, clean.size()); + Assert.assertEquals(135_000_000L, clean.get(0).total()); + Assert.assertEquals(135_000_000L, clean.get(1).total()); + } + + @Test + public void testRealGainIsPreserved() + { + // Real drop/gain: 135M -> 185M -> 185M -> 186M + List raw = new ArrayList<>(); + raw.add(snap(1000, 135_000_000L)); + raw.add(snap(60_000, 185_000_000L)); + raw.add(snap(120_000, 185_000_000L)); + raw.add(snap(180_000, 186_000_000L)); + + List clean = AccountData.sanitizeHistory(raw); + + Assert.assertEquals(4, clean.size()); + Assert.assertEquals(135_000_000L, clean.get(0).total()); + Assert.assertEquals(185_000_000L, clean.get(1).total()); + Assert.assertEquals(185_000_000L, clean.get(2).total()); + Assert.assertEquals(186_000_000L, clean.get(3).total()); + } + + @Test + public void testRealLossIsPreserved() + { + // Real expense: 200M -> 150M -> 150M + List raw = new ArrayList<>(); + raw.add(snap(1000, 200_000_000L)); + raw.add(snap(60_000, 150_000_000L)); + raw.add(snap(120_000, 150_000_000L)); + + List clean = AccountData.sanitizeHistory(raw); + + Assert.assertEquals(3, clean.size()); + Assert.assertEquals(150_000_000L, clean.get(1).total()); + } + + @Test + public void testSmallFluctuationIsPreserved() + { + // Normal trading/activity fluctuation: 10M -> 10.2M -> 10M (2% jump) + List raw = new ArrayList<>(); + raw.add(snap(1000, 10_000_000L)); + raw.add(snap(60_000, 10_200_000L)); + raw.add(snap(120_000, 10_000_000L)); + + List clean = AccountData.sanitizeHistory(raw); + + Assert.assertEquals(3, clean.size()); + } + + @Test + public void testLongDurationChangeIsPreserved() + { + // Player bought and sold item 30 minutes later (not a transient glitch) + List raw = new ArrayList<>(); + raw.add(snap(0, 100_000_000L)); + raw.add(snap(15 * 60 * 1000L, 150_000_000L)); + raw.add(snap(30 * 60 * 1000L, 100_000_000L)); + + List clean = AccountData.sanitizeHistory(raw); + + Assert.assertEquals(3, clean.size()); + } + + @Test + public void testNormalizeSanitizesAccountDataHistory() + { + AccountData acc = new AccountData(); + acc.history.add(snap(1000, 135_000_000L)); + acc.history.add(snap(60_000, 179_800_000L)); // spike + acc.history.add(snap(120_000, 135_100_000L)); + acc.history.add(snap(180_000, 135_200_000L)); + + acc.normalize(); + + Assert.assertEquals(3, acc.history.size()); + Assert.assertEquals(135_000_000L, acc.history.get(0).total()); + Assert.assertEquals(135_100_000L, acc.history.get(1).total()); + Assert.assertEquals(135_200_000L, acc.history.get(2).total()); + } + + @Test + public void testZeroWealthGlitchIsRemoved() + { + // 80M -> 0 (30 sec container dropout) -> 80.1M + List raw = new ArrayList<>(); + raw.add(snap(1000, 80_000_000L)); + raw.add(snap(31_000, 0L)); + raw.add(snap(61_000, 80_100_000L)); + + List clean = AccountData.sanitizeHistory(raw); + + Assert.assertEquals(2, clean.size()); + Assert.assertEquals(80_000_000L, clean.get(0).total()); + Assert.assertEquals(80_100_000L, clean.get(1).total()); + } + + @Test + public void testMultipleSpikesOverTimeAreAllCleaned() + { + List raw = new ArrayList<>(); + raw.add(snap(0, 100_000_000L)); + raw.add(snap(60_000, 150_000_000L)); // spike 1 + raw.add(snap(120_000, 100_000_000L)); + raw.add(snap(10 * 60_000, 105_000_000L)); + raw.add(snap(11 * 60_000, 20_000_000L)); // spike 2 (drop) + raw.add(snap(12 * 60_000, 105_500_000L)); + raw.add(snap(20 * 60_000, 110_000_000L)); + + List clean = AccountData.sanitizeHistory(raw); + + Assert.assertEquals(5, clean.size()); + Assert.assertEquals(100_000_000L, clean.get(0).total()); + Assert.assertEquals(100_000_000L, clean.get(1).total()); + Assert.assertEquals(105_000_000L, clean.get(2).total()); + Assert.assertEquals(105_500_000L, clean.get(3).total()); + Assert.assertEquals(110_000_000L, clean.get(4).total()); + } + + @Test + public void testLoginDropAfterLongOfflineGapIsRemoved() + { + // 145.5M at midnight -> 14h offline -> 104M on login (1 min glitch) -> 145.3M (normal session continues) + List raw = new ArrayList<>(); + raw.add(snap(0, 145_493_423L)); + raw.add(snap(14 * 3600 * 1000L, 104_131_175L)); // 1-minute login glitch + raw.add(snap(14 * 3600 * 1000L + 62_000L, 145_303_115L)); + raw.add(snap(14 * 3600 * 1000L + 124_000L, 145_341_893L)); + + List clean = AccountData.sanitizeHistory(raw); + + Assert.assertEquals(3, clean.size()); + Assert.assertEquals(145_493_423L, clean.get(0).total()); + Assert.assertEquals(145_303_115L, clean.get(1).total()); + Assert.assertEquals(145_341_893L, clean.get(2).total()); + } + + @Test + public void testTripleSpikeIsRemoved() + { + // Glitch lasting 3 snapshots (e.g. 2 minutes) + // 140M -> 50M -> 50M -> 50M -> 140M + List raw = new ArrayList<>(); + raw.add(snap(0, 140_000_000L)); + raw.add(snap(45_000, 50_000_000L)); + raw.add(snap(90_000, 50_000_000L)); + raw.add(snap(135_000, 50_000_000L)); + raw.add(snap(180_000, 140_100_000L)); + + List clean = AccountData.sanitizeHistory(raw); + + Assert.assertEquals(2, clean.size()); + Assert.assertEquals(140_000_000L, clean.get(0).total()); + Assert.assertEquals(140_100_000L, clean.get(1).total()); + } + + @Test + public void testSlopingBaselineSpikeIsRemoved() + { + // Baseline linearly trending from 100M to 102M, temporary drop to 50M + List raw = new ArrayList<>(); + raw.add(snap(0, 100_000_000L)); + raw.add(snap(60_000, 50_000_000L)); + raw.add(snap(120_000, 102_000_000L)); + + List clean = AccountData.sanitizeHistory(raw); + + Assert.assertEquals(2, clean.size()); + Assert.assertEquals(100_000_000L, clean.get(0).total()); + Assert.assertEquals(102_000_000L, clean.get(1).total()); + } +} diff --git a/src/test/java/com/example/essentialsplus/currency/CurrencyHistoryTest.java b/src/test/java/com/example/essentialsplus/currency/CurrencyHistoryTest.java new file mode 100644 index 0000000..d64b91f --- /dev/null +++ b/src/test/java/com/example/essentialsplus/currency/CurrencyHistoryTest.java @@ -0,0 +1,50 @@ +package com.example.essentialsplus.currency; + +import com.example.essentialsplus.ui.TimeFilter; +import com.example.essentialsplus.ui.TimeRange; +import java.util.List; +import static org.junit.Assert.assertEquals; +import org.junit.Test; + +public class CurrencyHistoryTest +{ + private static final long MIN = 60_000L; + + @Test + public void earnedSpentAndSync() + { + long now = System.currentTimeMillis(); + CurrencyHistory h = new CurrencyHistory("k", "name"); + h.record(Currency.PEST_CONTROL, 100, now - 10 * MIN, true); // baseline + h.record(Currency.PEST_CONTROL, 104, now - 8 * MIN, false); + h.record(Currency.PEST_CONTROL, 104, now - 7 * MIN, false); // unchanged, not stored + h.record(Currency.PEST_CONTROL, 54, now - 6 * MIN, false); // spent + h.record(Currency.PEST_CONTROL, 80, now - 5 * MIN, true); // login sync + h.record(Currency.PEST_CONTROL, 85, now - 2 * MIN, false); + + assertEquals(5, h.samples(Currency.PEST_CONTROL).size()); + assertEquals(85, h.amount(Currency.PEST_CONTROL)); + long[] change = h.change(Currency.PEST_CONTROL, new TimeFilter(TimeRange.HOUR, null), now); + assertEquals(9, change[0]); + assertEquals(-50, change[1]); + assertEquals(Currency.PEST_CONTROL.name(), h.lastActive); + assertEquals(-1, h.amount(Currency.SLAYER)); + } + + @Test + public void compactKeepsTotals() + { + long now = System.currentTimeMillis(); + long old = (now - 30L * 24 * 60 * MIN) / (60 * MIN) * (60 * MIN); + CurrencyHistory h = new CurrencyHistory("k", "name"); + h.record(Currency.MARKS_OF_GRACE, 1, old, true); + for (int i = 1; i <= 10; i++) + { + h.record(Currency.MARKS_OF_GRACE, 1 + i, old + i * MIN, false); + } + List compacted = CurrencyStore.compact(h.samples(Currency.MARKS_OF_GRACE), now); + assertEquals(2, compacted.size()); + assertEquals(11, compacted.get(1).v); + assertEquals(10, compacted.get(1).g); + } +} diff --git a/src/test/java/com/example/essentialsplus/helpers/AttackTimersTest.java b/src/test/java/com/example/essentialsplus/helpers/AttackTimersTest.java new file mode 100644 index 0000000..bf9546a --- /dev/null +++ b/src/test/java/com/example/essentialsplus/helpers/AttackTimersTest.java @@ -0,0 +1,101 @@ +package com.example.essentialsplus.helpers; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +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.AttackType; +import com.example.essentialsplus.helpers.combat.CastingSoundData; +import com.example.essentialsplus.helpers.combat.CombatUtils; +import com.example.essentialsplus.helpers.combat.PoweredStaves; +import com.example.essentialsplus.helpers.combat.Spellbook; +import com.example.essentialsplus.helpers.combat.WeaponType; +import com.example.essentialsplus.helpers.combat.variablespeed.state.TickCount; +import net.runelite.api.gameval.ItemID; +import org.junit.Test; + +public class AttackTimersTest +{ + @Test + public void testPoweredStavesResolution() + { + PoweredStaves trident = PoweredStaves.getPoweredStaves(ItemID.TOXIC_TOTS_CHARGED, AnimationData.MAGIC_STANDARD_WAVE_STAFF); + assertNotNull(trident); + assertEquals(PoweredStaves.WEAPON_SWAMP, trident); + + PoweredStaves warped = PoweredStaves.getPoweredStaves(ItemID.WARPED_SCEPTRE, AnimationData.MAGIC_WARPED_SCEPTRE); + assertNotNull(warped); + assertEquals(PoweredStaves.WEAPON_WARPED_SCEPTRE, warped); + + PoweredStaves harm = PoweredStaves.getPoweredStaves(ItemID.NIGHTMARE_STAFF_HARMONISED, AnimationData.MAGIC_STANDARD_SURGE_STAFF); + assertNotNull(harm); + assertEquals(PoweredStaves.WEAPON_HARM, harm); + + PoweredStaves nonPowered = PoweredStaves.getPoweredStaves(ItemID.ABYSSAL_WHIP, AnimationData.MELEE_ABYSSAL_WHIP); + assertNull(nonPowered); + } + + @Test + public void testCastingSounds() + { + assertTrue(CastingSoundData.isCastingSound(171)); // Ice spell + assertEquals(Spellbook.ANCIENT, CastingSoundData.getSpellBookFromId(171)); + + assertTrue(CastingSoundData.isCastingSound(4032)); // Fire surge + assertEquals(Spellbook.STANDARD, CastingSoundData.getSpellBookFromId(4032)); + + assertTrue(CastingSoundData.isCastingSound(5053)); // Dark demonbane + assertEquals(Spellbook.ARCEUUS, CastingSoundData.getSpellBookFromId(5053)); + } + + @Test + public void testWeaponTypeMapping() + { + WeaponType bow = WeaponType.getWeaponType(3); + assertNotNull(bow); + assertEquals(AttackType.RANGED, bow.getAttackTypes()[0]); + + WeaponType whip = WeaponType.getWeaponType(20); + assertNotNull(whip); + assertEquals(AttackType.SLASH, whip.getAttackTypes()[0]); + } + + @Test + public void testAttackTypeIsMelee() + { + assertTrue(AttackType.CRUSH.IsMelee()); + assertTrue(AttackType.SLASH.IsMelee()); + assertTrue(AttackType.STAB.IsMelee()); + org.junit.Assert.assertFalse(AttackType.RANGED.IsMelee()); + org.junit.Assert.assertFalse(AttackType.MAGIC.IsMelee()); + } + + @Test + public void testTickCount() + { + TickCount tc = new TickCount(); + assertEquals(0, tc.get()); + tc.onGameTick(null, null); + assertEquals(1, tc.get()); + tc.onGameTick(null, null); + assertEquals(2, tc.get()); + assertTrue(tc.isWithinNTicks(0, 2)); + org.junit.Assert.assertFalse(tc.isWithinNTicks(0, 1)); + } + + @Test + public void testSpeedSamples() + { + AttackTimers.SpeedSamples samples = new AttackTimers.SpeedSamples(); + assertEquals(4, samples.speed()); // default + samples.add(5); + samples.add(5); + samples.add(5); + assertEquals(5, samples.speed()); + assertEquals(3, samples.count()); + } +} diff --git a/src/test/java/com/example/essentialsplus/helpers/CombatStyleTest.java b/src/test/java/com/example/essentialsplus/helpers/CombatStyleTest.java new file mode 100644 index 0000000..afb58bc --- /dev/null +++ b/src/test/java/com/example/essentialsplus/helpers/CombatStyleTest.java @@ -0,0 +1,32 @@ +package com.example.essentialsplus.helpers; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import java.util.Map; +import net.runelite.api.gameval.AnimationID; +import net.runelite.api.gameval.SpotanimID; +import org.junit.Test; + +public class CombatStyleTest +{ + @Test + public void namesToStyles() + { + assertEquals(CombatStyle.RANGED, CombatStyle.fromName("CRYSTAL_HUNLLEF_RANGE_TRAVEL")); + assertEquals(CombatStyle.MAGIC, CombatStyle.fromName("ZEBAK_MAGE_PROJANIM_INITIAL")); + assertEquals(CombatStyle.MELEE, CombatStyle.fromName("HUNLLEF_ATTACK_MELEE")); + assertNull(CombatStyle.fromName("KEPHRI_LIGHTNING")); + assertNull(CombatStyle.fromName("ORANGE_MAGENTA_THING")); + } + + @Test + public void gamevalTables() + { + Map spot = AttackStyles.stylesByName(SpotanimID.class); + Map anim = AttackStyles.stylesByName(AnimationID.class); + assertEquals(CombatStyle.MAGIC, spot.get(SpotanimID.CRYSTAL_HUNLLEF_MAGIC_TRAVEL_HM)); + assertEquals(CombatStyle.RANGED, spot.get(3384)); + assertEquals(CombatStyle.RANGED, anim.get(AnimationID.HUNLLEF_ATTACK_RANGED)); + System.out.println("spotanims " + spot.size() + ", animations " + anim.size()); + } +} diff --git a/src/test/java/com/example/essentialsplus/helpers/TreeCooldownsTest.java b/src/test/java/com/example/essentialsplus/helpers/TreeCooldownsTest.java new file mode 100644 index 0000000..d628f9a --- /dev/null +++ b/src/test/java/com/example/essentialsplus/helpers/TreeCooldownsTest.java @@ -0,0 +1,94 @@ +package com.example.essentialsplus.helpers; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; +import net.runelite.api.coords.WorldPoint; +import net.runelite.api.gameval.AnimationID; +import org.junit.Test; + +public class TreeCooldownsTest +{ + @Test + public void testTreeTypeMechanics() + { + // Short trees (no chop countdown) + assertFalse(TreeCooldowns.TreeType.NORMAL.hasChopTimer()); + assertFalse(TreeCooldowns.TreeType.OAK.hasChopTimer()); + assertFalse(TreeCooldowns.TreeType.WILLOW.hasChopTimer()); + assertFalse(TreeCooldowns.TreeType.TEAK.hasChopTimer()); + + // Long-lived trees (despawnSec > 30 -> chop countdown active) + assertTrue(TreeCooldowns.TreeType.MAPLE.hasChopTimer()); + assertTrue(TreeCooldowns.TreeType.HOLLOW.hasChopTimer()); + assertTrue(TreeCooldowns.TreeType.MAHOGANY.hasChopTimer()); + assertTrue(TreeCooldowns.TreeType.ARCTIC_PINE.hasChopTimer()); + assertTrue(TreeCooldowns.TreeType.YEW.hasChopTimer()); + assertTrue(TreeCooldowns.TreeType.CAMPHOR.hasChopTimer()); + assertTrue(TreeCooldowns.TreeType.MAGIC.hasChopTimer()); + assertTrue(TreeCooldowns.TreeType.IRONWOOD.hasChopTimer()); + assertTrue(TreeCooldowns.TreeType.REDWOOD.hasChopTimer()); + assertTrue(TreeCooldowns.TreeType.ROSEWOOD.hasChopTimer()); + + // Ticks verification + assertEquals(100, TreeCooldowns.TreeType.MAPLE.getDespawnTicks()); // 60s / 0.6 = 100 ticks + assertEquals(190, TreeCooldowns.TreeType.YEW.getDespawnTicks()); // 114s / 0.6 = 190 ticks + assertEquals(390, TreeCooldowns.TreeType.MAGIC.getDespawnTicks()); // 234s / 0.6 = 390 ticks + assertEquals(440, TreeCooldowns.TreeType.REDWOOD.getDespawnTicks()); // 264s / 0.6 = 440 ticks + } + + @Test + public void testRespawnTicksLookup() + { + assertEquals(TreeCooldowns.TreeType.OAK, TreeCooldowns.TreeType.fromRespawnTicks(14)); + assertEquals(TreeCooldowns.TreeType.TEAK, TreeCooldowns.TreeType.fromRespawnTicks(15)); + assertEquals(TreeCooldowns.TreeType.MAPLE, TreeCooldowns.TreeType.fromRespawnTicks(59)); + assertEquals(TreeCooldowns.TreeType.HOLLOW, TreeCooldowns.TreeType.fromRespawnTicks(43)); + assertEquals(TreeCooldowns.TreeType.YEW, TreeCooldowns.TreeType.fromRespawnTicks(99)); + assertEquals(TreeCooldowns.TreeType.MAGIC, TreeCooldowns.TreeType.fromRespawnTicks(199)); + assertEquals(TreeCooldowns.TreeType.ROSEWOOD, TreeCooldowns.TreeType.fromRespawnTicks(205)); + } + + @Test + public void testWoodcutAnims() + { + assertTrue(TreeCooldowns.WOODCUT_ANIMS.contains(AnimationID.HUMAN_WOODCUTTING_RUNE_AXE)); + assertTrue(TreeCooldowns.WOODCUT_ANIMS.contains(AnimationID.HUMAN_WOODCUTTING_DRAGON_AXE)); + assertTrue(TreeCooldowns.WOODCUT_ANIMS.contains(AnimationID.HUMAN_WOODCUTTING_CRYSTAL_AXE)); + assertTrue(TreeCooldowns.WOODCUT_ANIMS.contains(AnimationID.FORESTRY_2H_AXE_CHOPPING_DRAGON)); + } + + @Test + public void testRespawnTimerCalculations() + { + long now = System.currentTimeMillis(); + long deadline = now + 10_000L; // 10 seconds remaining + TreeCooldowns.RespawnTimer timer = new TreeCooldowns.RespawnTimer( + TreeCooldowns.TreeType.YEW, + new WorldPoint(3200, 3200, 0), + now, + deadline, + 99, + 1000 + ); + + assertEquals(10, timer.getRemainingSeconds()); + assertTrue(timer.getProgress() >= 0.0 && timer.getProgress() <= 0.1); + } + + @Test + public void testChopTimerCalculations() + { + TreeCooldowns.ChopTimer timer = new TreeCooldowns.ChopTimer( + TreeCooldowns.TreeType.MAPLE, + new WorldPoint(3200, 3200, 0), + null, + 50, + 100 + ); + + assertEquals(30, timer.getRemainingSeconds()); // 50 ticks * 0.6 = 30 seconds + assertEquals(0.5, timer.getProgress(), 0.001); + } +} diff --git a/src/test/java/com/example/essentialsplus/loot/gear/DpsCalcTest.java b/src/test/java/com/example/essentialsplus/loot/gear/DpsCalcTest.java new file mode 100644 index 0000000..121bcf4 --- /dev/null +++ b/src/test/java/com/example/essentialsplus/loot/gear/DpsCalcTest.java @@ -0,0 +1,117 @@ +package com.example.essentialsplus.loot.gear; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; +import com.google.gson.Gson; +import com.google.gson.JsonObject; +import java.util.Arrays; +import java.util.List; +import org.junit.Test; + +public class DpsCalcTest +{ + private static final PlayerSetup MAXED = new PlayerSetup(99, 99, 99, 99, 99, 99, 1, 1, 1, 1, 1, 0, -1, 0, -1, false); + + private static GearItem weapon(String name, int astab, int aslash, int acrush, int amagic, int arange, int str, int rstr, int speed, boolean twoHanded) + { + return new GearItem(name.hashCode() & 0xffff, name, GearItem.WEAPON, twoHanded, astab, aslash, acrush, amagic, arange, + 0, 0, 0, 0, 0, str, rstr, 0, 0, speed, GearItem.Source.BANK); + } + + private static GearItem item(int id, String name, int slot, int aslash, int str) + { + return new GearItem(id, name, slot, false, 0, aslash, 0, 0, 0, 0, 0, 0, 0, 0, str, 0, 0, 0, 0, GearItem.Source.BANK); + } + + @Test + public void meleeMaxHits() + { + DpsCalc.Result unarmed = DpsCalc.calc(Loadout.EMPTY, MAXED, Monster.DUMMY, + new CombatChoice(CombatChoice.Type.CRUSH, CombatChoice.Stance.AGGRESSIVE, null)); + assertEquals(11, unarmed.maxHit); + + Loadout whip = Loadout.EMPTY.with(GearItem.WEAPON, weapon("Abyssal whip", 0, 82, 0, 0, 0, 82, 0, 4, false)); + DpsCalc.Result lash = DpsCalc.calc(whip, MAXED, Monster.DUMMY, + new CombatChoice(CombatChoice.Type.SLASH, CombatChoice.Stance.CONTROLLED, null)); + assertEquals(25, lash.maxHit); + assertEquals(4, lash.speed); + } + + @Test + public void poweredStaff() + { + Loadout trident = Loadout.EMPTY.with(GearItem.WEAPON, weapon("Trident of the seas", 0, 0, 0, 15, 0, 0, 0, 4, false)); + CombatChoice c = CombatChoice.infer(trident.weapon(), 99, -1); + assertEquals(CombatChoice.Type.MAGIC, c.type); + assertNull(c.spell); + assertEquals(28, DpsCalc.calc(trident, MAXED, Monster.DUMMY, c).maxHit); + } + + @Test + public void hitChance() + { + assertEquals(0.0, DpsCalc.hitChance(0, 100), 1e-9); + assertEquals(100.0 / 202, DpsCalc.hitChance(100, 100), 1e-9); + assertEquals(1 - 102.0 / 402, DpsCalc.hitChance(200, 100), 1e-9); + } + + @Test + public void twoHandedRemovesShield() + { + GearItem shield = new GearItem(1, "Dragon defender", GearItem.SHIELD, false, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 6, 0, 0, 0, 0, GearItem.Source.BANK); + Loadout l = Loadout.EMPTY.with(GearItem.SHIELD, shield).with(GearItem.WEAPON, weapon("Dragon 2h sword", 0, 92, 80, 0, 0, 93, 0, 7, true)); + assertNull(l.get(GearItem.SHIELD)); + assertNull(l.with(GearItem.SHIELD, shield).weapon()); + } + + @Test + public void styleFromVarbits() + { + GearItem whip = weapon("Abyssal whip", 0, 82, 0, 0, 0, 82, 0, 4, false); + CombatChoice lash = CombatChoice.fromVarbits(20, 1, 0, 99, whip); + assertEquals(CombatChoice.Type.SLASH, lash.type); + assertEquals(CombatChoice.Stance.CONTROLLED, lash.stance); + assertNull(CombatChoice.fromVarbits(20, 2, 0, 99, whip)); + CombatChoice cast = CombatChoice.fromVarbits(18, 4, 51, 99, weapon("Staff of fire", 0, 0, 0, 10, 0, 0, 0, 5, false)); + assertEquals("Fire Surge", cast.spell.name); + } + + @Test + public void optimizerPicksBetterItems() + { + GearItem whip = weapon("Abyssal whip", 0, 82, 0, 0, 0, 82, 0, 4, false); + GearItem scim = weapon("Rune scimitar", 7, 45, -2, 0, 0, 44, 0, 4, false); + GearItem torture = item(10, "Amulet of torture", GearItem.AMULET, 15, 10); + GearItem glory = item(11, "Amulet of glory", GearItem.AMULET, 10, 6); + List pool = Arrays.asList(whip, scim, torture, glory); + Loadout worn = Loadout.EMPTY.with(GearItem.WEAPON, scim).with(GearItem.AMULET, glory); + + List best = GearOptimizer.bestPerStyle(pool, worn, MAXED, Monster.DUMMY); + assertEquals(1, best.size()); + assertEquals(whip.id, best.get(0).loadout.id(GearItem.WEAPON)); + assertEquals(torture.id, best.get(0).loadout.id(GearItem.AMULET)); + assertEquals(2, best.get(0).loadout.changesFrom(worn).size()); + } + + @Test + public void parsesWikiMonster() + { + String json = "{\"bucket\":[{\"standard_range_defence_bonus\":26,\"light_range_defence_bonus\":26,\"crush_defence_bonus\":108," + + "\"slash_defence_bonus\":108,\"hitpoints\":750,\"elemental_weakness\":\"Fire\",\"combat_level\":732,\"size\":7," + + "\"page_name\":\"Vorkath\",\"defence_level\":214,\"magic_defence_bonus\":240,\"id\":[\"8059\",\"8061\"]," + + "\"heavy_range_defence_bonus\":26,\"elemental_weakness_percent\":40,\"attribute\":[\"dragon\",\"undead\",\"fiery\"]," + + "\"attack_speed\":5,\"name\":\"Vorkath\",\"attack_style\":[\"Slash\",\"Magic\"],\"version_anchor\":\"Post-quest\"," + + "\"max_hit\":[\"30 (Magic)\",\"32 (Ranged)\"],\"stab_defence_bonus\":26,\"magic_level\":150}]}"; + Monster m = MonsterService.parse(new Gson().fromJson(json, JsonObject.class), 8061, 732); + assertNotNull(m); + assertEquals("Vorkath", m.name); + assertEquals(750, m.hitpoints); + assertEquals(214, m.defenceLevel); + assertEquals(26, m.dstandard); + assertEquals("Fire", m.elementalWeakness); + assertTrue(m.hasAttribute("dragon")); + assertEquals(2, m.maxHits.size()); + } +} diff --git a/src/test/resources/logback-test.xml b/src/test/resources/logback-test.xml new file mode 100644 index 0000000..938aefd --- /dev/null +++ b/src/test/resources/logback-test.xml @@ -0,0 +1,56 @@ + + + + + + + + %d{yyyy-MM-dd HH:mm:ss zzz} [%thread] %-5level %logger{36} - %msg%n + + + + + ${user.home}/.runelite/logs/client.log + + + + ${user.home}/.runelite/logs/client_%d{yyyy-MM-dd}.%i.log + 10MB + + 30 + + + + %d{yyyy-MM-dd HH:mm:ss zzz} [%thread] %-5level %logger{36} - %msg%n + + + + + + + + + \ No newline at end of file diff --git a/tree-cooldown.md b/tree-cooldown.md new file mode 100644 index 0000000..609a988 --- /dev/null +++ b/tree-cooldown.md @@ -0,0 +1,74 @@ +# Tree Cooldowns – Taskbeschreibung + +Implementiere ein **„Tree Cooldowns"**-Helper in **Essentials Plus** (`com.example.essentialsplus.helpers`). Es gibt zwei Anzeigen: + +1. **Respawn-Countdown** über Baumstümpfen: Sekunden bis der Baum wieder wächst (nur Stümpfe, deren Fällung man in der Nähe *miterlebt* hat — zugehörige Zeiten sind sonst nicht messbar). +2. **Verbleibende Chop-Zeit** auf langlebigen Bäumen (Maple+, z. B. Yew/Magic/Redwood) über dem Baum, solange man ihn hackt. + +**Format:** reine Sekunden (Ganzzahl, z. B. `8`). Deutsch als Display-Sprache. + +## Mechanik (OSRS-Wiki, Depletion-System) + +Despawn = wie lange aktiv gehäckt werden kann (beginnt beim ersten Hieb, **regeneriert** bei Pause). Respawn = Zeit bis der Baum wieder da ist. + +| Baum | Despawn | Respawn | +|---|---|---| +| Normal tree | — | ~10 s | +| Oak | 27 s | 8,4 s | +| Willow | 30 s | 8,4 s | +| Teak | 30 s | 9 s | +| Maple | 60 s | 35,4 s | +| Hollow | 36 s | 25,8 s | +| Mahogany | 60 s | 8,4 s | +| Arctic pine | 84 s | 8,4 s | +| Yew | 114 s | 59,4 s | +| Camphor | 120 s | 59,4 s | +| Magic | 234 s | 119,4 s | +| Ironwood | 240 s | 119,4 s | +| Redwood | 264 s | 119,4 s | +| Rosewood | 275 s | 123 s | + +Chop-Countdown nur wenn `despawnSec > 30` (`CHOP_MIN_DESPAWN_SEC`). + +## Neue Dateien + +### `helpers/TreeCooldowns.java` (`@Singleton`, Logik) + +- **`TreeType`-Enum** mit `despawnSec`, `respawnSec` und Objekt-ID-Sets aus `net.runelite.api.gameval.ObjectID` (z. B. `OAKTREE`, `OAKTREE_STUMP`, `WILLOWTREE`, `YEWTREE`, `YEWTREE_STUMP`, `MAPLETREE`, `MAGICTREE`, `TEAKTREE`, `MAHOGANYTREE`, `ACHEY_TREE`, `REDWOODTREE_L/_R(+_CUT)`, diverse Varianten wie `TREE/TREE2/LIGHTTREE/DEADTREE/JUNGLETREE/EVERGREEN` plus jeweilige `*_STUMP`-Ids). IDs zusätzlich über `ObjectComposition.getName()` verifizieren; unbekannte Regio-Varianten ergänzen. +- **`WOODCUT_ANIMS`**: alle `AnimationID.HUMAN_WOODCUTTING_*`. +- **State**: `Map`. +- **Events** (im Plugin registriert): + - `GameTick`: Ist `client.getLocalPlayer().getAnimation()` ∈ `WOODCUT_ANIMS` → nächsten Baum in Reichweite suchen (Scan der Scene-Tiles wie `WorldObjectHighlighterOverlay.java:91`), als Ziel merken und `choppedMs += 600`. Kein Hacken (Animation weg / anderes Ziel) → `choppedMs` zurücksetzen (Depletion regeneriert). + - `GameObjectDespawned`: getracktes Ziel (Baum) verschwindet → felled: `respawnDeadlineMs = now + respawnSec(type)` am Tile setzen. + - `GameObjectSpawned`: + - Stump-Id → fremder Baum wurde in der Nähe gefällt → ebenfalls `respawnDeadlineMs = now + respawnSec(stumpType)` setzen (Fallback `TreeType.NORMAL` bei unbekannter Stump-Id). Nur wenn Tile in einem Radius von ~16 Tiles um den Spieler („in der Nähe gefällt"). + - Baum-Id an einem Tile mit aktiver Respawn-Deadline → Eintrag löschen (wiedergewachsen). + - `GameStateChanged` (`!= LOGGED_IN`) und Region-Wechsel → `reset()`. +- **API für das Overlay**: Iteratoren `respawnTimers()`, `chopTimers()` (nur Records mit Rest > 0) samt Referenz auf das Tile (`LocalPoint`, `sceneX/Y`, plane). + +### `helpers/TreeCooldownOverlay.java` (`Overlay`) + +- `OverlayPosition.DYNAMIC`, `OverlayLayer.ABOVE_SCENE`. +- Text-Draw mit Schatten, identisch zu `AttackTimerOverlay.shadowed`; Font über `TimerTextSize`. +- **Respawn**: Sekunden-Zahl über dem Stumpf/Perspektivenpunkt der Tree-Basis (Anchor über `Perspective.getCanvasTextLocation`, s. `AttackTimerOverlay.java:130`), Höhe = `treeCooldownOffset`. +- **Chop**: Rest-Sekunden über dem Baum, Höhe = `treeCooldownOffset + 80`. +- Optional Fortschrittsbalken (`treeCooldownBar`), Muster aus `AttackTimerOverlay.drawTimer` (drainende Pips). +- Nichts rendern wenn `!config.treeCooldowns()`. + +## Zu ändernde Dateien + +- **`EssentialsPlusConfig.java`**: `@ConfigItem treeCooldowns` (bool, default `false`, position 34). Stil/Toggles als `configManager`-Keys via `setHelperOption` (eingesetztes Muster): + - `treeCooldownRespawn` (bool, true), `treeCooldownChop` (bool, true), `treeCooldownBar` (bool, false) + - `treeCooldownSize` (`TimerTextSize`, NORMAL), `treeCooldownOffset` (int, 50), `treeCooldownColor` (Color, weiß oder grün `0x8FBF3F`), `treeCooldownReadyColor` (Color, `0x66FF66`) +- **`EssentialsPlusPlugin.java`**: `@Inject TreeCooldowns` + `TreeCooldownOverlay`; `startUp()`: `eventBus.register(treeCooldowns)`, `overlayManager.add(treeCooldownOverlay)`; `shutDown()`: spiegeln + `treeCooldowns.reset()`. `isHelperOption()`: `case "treeCooldowns"`. +- **`HelpersCard.java`**: neues `JCheckBox "Tree cooldowns"` (Tooltip: „Respawn-Countdown auf gefällten Bäumen + Rest-Zeit auf langlebigen Bäumen. Rechtsklick für Optionen"). Rechtsklick-Menü analog `priceMenu`/`styleMenu`: Toggles `treeCooldownRespawn`/`treeCooldownChop`/`treeCooldownBar`, Größe, Höhe, Farben, Reset. `refresh()` erweitern. + +## Verifikation + +- `./gradlew compileJava` (bzw. `./gradlew deploy` für Bolt). +- Manuell: Baum fällen (Oak/Willow → Respawn-Zahl auf Stump), Yew/Magic/Redwood hacken (Chop-Rest über Baum, zählt runter, fällt → Respawn-Zahl), weggehen und zurück (fremder frisch gefällter Stump in der Nähe → Timer), ausloggen (alles reset). + +## Hinweise / Einschränkungen + +- Nur Bäume, die selbst beobachtet gefällt wurden (eigene oder fremde in der Nähe); vorab vorhandene Stümpfe unbekannten Alters → kein Timer. +- Times anderer Spieler stimmen nicht exakt (Depletion läuft global); selbst gehackte Bäume sind exakt. \ No newline at end of file