chore: init

This commit is contained in:
Hikyu
2026-09-18 17:18:57 +02:00
commit 15463c2e3c
200 changed files with 51032 additions and 0 deletions
+9
View File
@@ -0,0 +1,9 @@
.gradle
build
.idea/
.project
.settings/
.classpath
nbactions.xml
nb-configuration.xml
nbproject/
+40
View File
@@ -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.
+182
View File
@@ -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.
+94
View File
@@ -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
+61
View File
@@ -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"
+102
View File
@@ -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!"
}
}
Binary file not shown.
+8
View File
@@ -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
Vendored Executable
+252
View File
@@ -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" "$@"
Vendored
+94
View File
@@ -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
+136
View File
@@ -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.<accountKey>`, 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<ChargeValue> 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<Integer itemId, ChargeSource>` 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<Integer, ...>`-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<ChargeValue>` 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/<hash>/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/<hash>/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`.
+7
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
rootProject.name = 'bank-highlighter'
+94
View File
@@ -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<XpSample> 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<MenuEntry>` 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<MenuEntry>` 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/<key>.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.
@@ -0,0 +1,41 @@
package com.example;
import net.runelite.client.config.Config;
import net.runelite.client.config.ConfigGroup;
import net.runelite.client.config.ConfigItem;
import net.runelite.client.config.ConfigSection;
@ConfigGroup("example")
public interface ExampleConfig extends Config
{
@ConfigSection(
name = "General Settings",
description = "Allgemeine Einstellungen für das Plugin",
position = 0
)
String generalSection = "generalSection";
@ConfigItem(
keyName = "greeting",
name = "Welcome Greeting",
description = "The message to show in game chat when logging in or enabling the plugin",
section = generalSection,
position = 1
)
default String greeting()
{
return "Hello World!";
}
@ConfigItem(
keyName = "showOnLogin",
name = "Show on Login",
description = "Whether to send the greeting message when logging in",
section = generalSection,
position = 2
)
default boolean showOnLogin()
{
return true;
}
}
@@ -0,0 +1,245 @@
package com.example;
import com.example.model.Profile;
import com.example.overlay.BankHighlighterOverlay;
import com.example.overlay.ScreenFlashOverlay;
import com.example.overlay.TileHighlighterOverlay;
import com.example.overlay.WorldObjectHighlighterOverlay;
import com.example.ui.ExamplePluginPanel;
import com.google.inject.Provides;
import java.awt.Color;
import java.awt.image.BufferedImage;
import javax.annotation.Nullable;
import javax.inject.Inject;
import lombok.Getter;
import lombok.extern.slf4j.Slf4j;
import net.runelite.api.ChatMessageType;
import net.runelite.api.Client;
import net.runelite.api.GameState;
import net.runelite.api.events.GameStateChanged;
import net.runelite.api.events.GameTick;
import net.runelite.api.events.ItemContainerChanged;
import net.runelite.client.callback.ClientThread;
import net.runelite.client.config.ConfigManager;
import net.runelite.client.eventbus.Subscribe;
import net.runelite.client.game.ItemManager;
import net.runelite.client.plugins.Plugin;
import net.runelite.client.plugins.PluginDescriptor;
import net.runelite.client.plugins.banktags.BankTagsService;
import net.runelite.client.ui.ClientToolbar;
import net.runelite.client.ui.NavigationButton;
import net.runelite.client.ui.overlay.OverlayManager;
import net.runelite.client.util.ColorUtil;
import net.runelite.client.util.ImageUtil;
@Slf4j
@PluginDescriptor(
name = "Bank Highlighter / Auto-Profile",
description = "Aktiviert automatisch Profile, Item-Highlights, 3D-Objekte, Boden-Tiles und Pfade",
tags = {"bank", "highlighter", "profile", "auto", "herblore", "tiles", "path", "agility"}
)
public class ExamplePlugin extends Plugin implements ProfileManager.ProfileChangeListener
{
@Getter
@Inject
private Client client;
@Getter
@Inject
private ClientThread clientThread;
@Getter
@Inject
private ItemManager itemManager;
@Getter
@Nullable
@com.google.inject.Inject(optional = true)
private BankTagsService bankTagsService;
@Inject
private ConfigManager configManager;
@Inject
private ClientToolbar clientToolbar;
@Inject
private OverlayManager overlayManager;
@Inject
private ProfileManager profileManager;
@Inject
private BankHighlighterOverlay bankHighlighterOverlay;
@Inject
private WorldObjectHighlighterOverlay worldObjectHighlighterOverlay;
@Inject
private TileHighlighterOverlay tileHighlighterOverlay;
@Inject
private ScreenFlashOverlay screenFlashOverlay;
private NavigationButton navButton;
private ExamplePluginPanel panel;
@Override
protected void startUp() throws Exception
{
log.info("Bank Highlighter / Auto-Profile started!");
profileManager.addListener(this);
panel = new ExamplePluginPanel(this, profileManager);
final BufferedImage icon = ImageUtil.loadImageResource(getClass(), "/com/example/icon.png");
navButton = NavigationButton.builder()
.tooltip("Bank Highlighter & Profile")
.icon(icon)
.priority(5)
.panel(panel)
.build();
clientToolbar.addNavigation(navButton);
overlayManager.add(bankHighlighterOverlay);
overlayManager.add(worldObjectHighlighterOverlay);
overlayManager.add(tileHighlighterOverlay);
overlayManager.add(screenFlashOverlay);
clientThread.invokeLater(() ->
{
if (client.getGameState() == GameState.LOGGED_IN)
{
profileManager.evaluate(client, itemManager, bankTagsService);
panel.updateLiveInspector(client);
}
});
}
@Override
protected void shutDown() throws Exception
{
log.info("Bank Highlighter / Auto-Profile stopped!");
profileManager.removeListener(this);
overlayManager.remove(bankHighlighterOverlay);
overlayManager.remove(worldObjectHighlighterOverlay);
overlayManager.remove(tileHighlighterOverlay);
overlayManager.remove(screenFlashOverlay);
if (navButton != null)
{
clientToolbar.removeNavigation(navButton);
navButton = null;
}
panel = null;
}
@Subscribe
public void onGameTick(GameTick event)
{
if (client.getGameState() == GameState.LOGGED_IN)
{
screenFlashOverlay.decrementTick();
profileManager.evaluate(client, itemManager, bankTagsService);
if (panel != null)
{
panel.updateLiveInspector(client);
}
}
}
@Subscribe
public void onItemContainerChanged(ItemContainerChanged event)
{
if (client.getGameState() == GameState.LOGGED_IN)
{
profileManager.evaluate(client, itemManager, bankTagsService);
if (panel != null)
{
panel.updateLiveInspector(client);
}
}
}
@Subscribe
public void onGameStateChanged(GameStateChanged gameStateChanged)
{
if (gameStateChanged.getGameState() == GameState.LOGGED_IN)
{
clientThread.invokeLater(() ->
{
profileManager.evaluate(client, itemManager, bankTagsService);
if (panel != null)
{
panel.updateLiveInspector(client);
}
});
}
}
@Override
public void onActiveProfileChanged(Profile oldProfile, Profile newProfile)
{
if (newProfile != null)
{
// 1. Chat Message
if (newProfile.isNotifyOnActivation())
{
String msg = newProfile.getActivationMessage().replace("{name}", newProfile.getName());
sendChatMessage(msg);
}
// 2. Screen Flash
if (newProfile.isScreenFlash())
{
Color color = ColorUtil.fromString(newProfile.getFlashColor());
screenFlashOverlay.flash(color != null ? color : new Color(255, 152, 0), 2);
}
// 3. Sound Effect
if (newProfile.isPlaySound())
{
clientThread.invokeLater(() ->
{
if (client.getGameState() == GameState.LOGGED_IN)
{
client.playSoundEffect(newProfile.getSoundId());
}
});
}
}
}
@Override
public void onProfileListUpdated()
{
}
public void sendManualChatMessage(String message)
{
clientThread.invokeLater(() ->
{
if (client.getGameState() == GameState.LOGGED_IN)
{
sendChatMessage(message);
}
else
{
log.warn("Cannot send chat message - player is not logged in");
}
});
}
public void sendChatMessage(String message)
{
client.addChatMessage(ChatMessageType.GAMEMESSAGE, "", "<col=FF9800>[Profile]</col> " + message, null);
}
@Provides
ExampleConfig provideConfig(ConfigManager configManager)
{
return configManager.getConfig(ExampleConfig.class);
}
}
@@ -0,0 +1,483 @@
package com.example;
import com.example.model.Folder;
import com.example.model.Profile;
import com.google.gson.Gson;
import com.google.gson.reflect.TypeToken;
import java.lang.reflect.Type;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import javax.inject.Inject;
import javax.inject.Singleton;
import lombok.Getter;
import lombok.extern.slf4j.Slf4j;
import net.runelite.api.Client;
import net.runelite.client.config.ConfigManager;
import net.runelite.client.game.ItemManager;
@Slf4j
@Singleton
public class ProfileManager
{
private static final String CONFIG_GROUP = "bankhighlighter";
private static final String PROFILES_KEY = "profiles";
private static final String FOLDERS_KEY = "folders";
@Getter
private final List<Folder> folders = new ArrayList<>();
@Getter
private final List<Profile> profiles = new ArrayList<>();
@Getter
private Profile activeProfile = null;
private final ConfigManager configManager;
private final Gson gson;
public interface ProfileChangeListener
{
void onActiveProfileChanged(Profile oldProfile, Profile newProfile);
void onProfileListUpdated();
}
private final List<ProfileChangeListener> listeners = new ArrayList<>();
@Inject
public ProfileManager(ConfigManager configManager, Gson gson)
{
this.configManager = configManager;
this.gson = gson;
loadProfiles();
}
public void addListener(ProfileChangeListener listener)
{
if (!listeners.contains(listener))
{
listeners.add(listener);
}
}
public void removeListener(ProfileChangeListener listener)
{
listeners.remove(listener);
}
public void loadProfiles()
{
profiles.clear();
folders.clear();
// Load Folders
String foldersJson = configManager.getConfiguration(CONFIG_GROUP, FOLDERS_KEY);
if (foldersJson != null && !foldersJson.trim().isEmpty())
{
try
{
Type listType = new TypeToken<List<Folder>>() {}.getType();
List<Folder> loaded = gson.fromJson(foldersJson, listType);
if (loaded != null)
{
folders.addAll(loaded);
}
}
catch (Exception e)
{
log.error("Fehler beim Laden der Ordner aus Config", e);
}
}
// Load Profiles
String json = configManager.getConfiguration(CONFIG_GROUP, PROFILES_KEY);
if (json != null && !json.trim().isEmpty())
{
try
{
Type listType = new TypeToken<List<Profile>>() {}.getType();
List<Profile> loaded = gson.fromJson(json, listType);
if (loaded != null && !loaded.isEmpty())
{
profiles.addAll(loaded);
}
}
catch (Exception e)
{
log.error("Fehler beim Laden der Profile aus Config", e);
}
}
if (profiles.isEmpty())
{
loadHerblorePrayerPotionWorkflow();
}
notifyListUpdated();
}
public void saveProfiles()
{
try
{
String json = gson.toJson(profiles);
configManager.setConfiguration(CONFIG_GROUP, PROFILES_KEY, json);
String foldersJson = gson.toJson(folders);
configManager.setConfiguration(CONFIG_GROUP, FOLDERS_KEY, foldersJson);
notifyListUpdated();
}
catch (Exception e)
{
log.error("Fehler beim Speichern der Profile / Ordner", e);
}
}
// Preset 1: Herblore Prayer Potions
public void loadHerblorePrayerPotionWorkflow()
{
Folder herbFolder = new Folder("Prayer Potions", "Prayer potion(4)");
folders.add(herbFolder);
Profile step2 = new Profile("2. Inv: Grimy säubern", "Grimy ranarr weed");
step2.setFolderId(herbFolder.getId());
step2.setRequiredInventory("Grimy ranarr weed");
step2.setHighlightInBank(false);
step2.setHighlightInInventory(true);
step2.setHighlightItems("Grimy ranarr weed");
step2.setHighlightColor("#4CAF50");
step2.setShowTextLabel(true);
step2.setCustomTextLabel("Clean");
step2.setActivationMessage("Schritt 2: Grimy Ranarr im Inventar säubern!");
Profile step3 = new Profile("3. Inv: Unf Potion mixen", "Ranarr weed");
step3.setFolderId(herbFolder.getId());
step3.setRequiredInventory("Ranarr weed, Vial of water");
step3.setHighlightInBank(false);
step3.setHighlightInInventory(true);
step3.setHighlightItems("Ranarr weed, Vial of water");
step3.setHighlightColor("#FF9800");
step3.setShowTextLabel(true);
step3.setCustomTextLabel("Combine");
step3.setActivationMessage("Schritt 3: Ranarr Weed & Vial of Water kombinieren!");
Profile step5 = new Profile("5. Inv: Potion fertigstellen", "Prayer potion(3)");
step5.setFolderId(herbFolder.getId());
step5.setRequiredInventory("Ranarr potion (unf), Snape grass");
step5.setHighlightInBank(false);
step5.setHighlightInInventory(true);
step5.setHighlightItems("Ranarr potion (unf), Snape grass");
step5.setHighlightColor("#9C27B0");
step5.setShowTextLabel(true);
step5.setCustomTextLabel("Combine");
step5.setActivationMessage("Schritt 5: Snape Grass & Unf Potion zur Prayer Potion mixen!");
Profile step4a = new Profile("4a. Welt: Bank öffnen", "Ranarr potion (unf)");
step4a.setFolderId(herbFolder.getId());
step4a.setRequireBankClosed(true);
step4a.setRequiredInventory("Ranarr potion (unf)");
step4a.setEnableObjectHighlight(true);
step4a.setHighlightObjects("Bank, Bank booth, Bank chest, Banker, Grand Exchange booth");
step4a.setObjectHighlightColor("#00E5FF");
step4a.setShowObjectText(true);
step4a.setObjectCustomText("Bank öffnen");
step4a.setActivationMessage("Schritt 4a: Klicke auf die Bank, um Snape Grass zu holen!");
Profile step6a = new Profile("6a. Welt: Bank öffnen", "Prayer potion(4)");
step6a.setFolderId(herbFolder.getId());
step6a.setRequireBankClosed(true);
step6a.setRequiredInventory("Prayer potion");
step6a.setEnableObjectHighlight(true);
step6a.setHighlightObjects("Bank, Bank booth, Bank chest, Banker, Grand Exchange booth");
step6a.setObjectHighlightColor("#00E5FF");
step6a.setShowObjectText(true);
step6a.setObjectCustomText("Bank öffnen");
step6a.setActivationMessage("Schritt 6a: Klicke auf die Bank, um fertige Potions einzulagern!");
Profile step6b = new Profile("6b. Bank: Potions deponieren", "Prayer potion(4)");
step6b.setFolderId(herbFolder.getId());
step6b.setRequireBankOpen(true);
step6b.setRequiredInventory("Prayer potion");
step6b.setHighlightInBank(false);
step6b.setHighlightInInventory(true);
step6b.setHighlightItems("Prayer potion");
step6b.setHighlightColor("#FF5722");
step6b.setShowTextLabel(true);
step6b.setCustomTextLabel("Depot");
step6b.setActivationMessage("Schritt 6b: Fertige Prayer Potions in die Bank einlagern!");
Profile step4b = new Profile("4b. Bank: Snape Grass nehmen", "Snape grass");
step4b.setFolderId(herbFolder.getId());
step4b.setRequireBankOpen(true);
step4b.setRequiredInventory("Ranarr potion (unf)");
step4b.setHighlightInBank(true);
step4b.setHighlightInInventory(false);
step4b.setHighlightItems("Snape grass");
step4b.setHighlightColor("#00E5FF");
step4b.setShowTextLabel(true);
step4b.setCustomTextLabel("Nehmen");
step4b.setActivationMessage("Schritt 4b: Snape Grass aus der Bank entnehmen!");
Profile step1 = new Profile("1. Bank: Zutaten nehmen", "Vial of water");
step1.setFolderId(herbFolder.getId());
step1.setRequireBankOpen(true);
step1.setBankTag("herblore");
step1.setHighlightInBank(true);
step1.setHighlightInInventory(false);
step1.setHighlightItems("Vial of water, Grimy ranarr weed");
step1.setHighlightColor("#00E5FF");
step1.setShowTextLabel(true);
step1.setCustomTextLabel("Nehmen");
step1.setActivationMessage("Schritt 1: Vial of Water & Grimy Ranarr aus der Bank nehmen!");
profiles.add(step2);
profiles.add(step3);
profiles.add(step5);
profiles.add(step4a);
profiles.add(step6a);
profiles.add(step6b);
profiles.add(step4b);
profiles.add(step1);
saveProfiles();
}
// Preset 2: 🍳 Cooking (z. B. Raw Karambwan am Feuer/Range)
public void loadCookingKarambwanWorkflow()
{
Folder cookFolder = new Folder("Cooking: Karambwan", "Cooked karambwan");
folders.add(cookFolder);
Profile step1 = new Profile("1. Bank: Rohen Fisch nehmen", "Raw karambwan");
step1.setFolderId(cookFolder.getId());
step1.setRequireBankOpen(true);
step1.setHighlightInBank(true);
step1.setHighlightInInventory(false);
step1.setHighlightItems("Raw karambwan, Raw shark, Raw monkfish");
step1.setHighlightColor("#00E5FF");
step1.setShowTextLabel(true);
step1.setCustomTextLabel("Nehmen");
step1.setActivationMessage("Schritt 1: Rohen Fisch aus der Bank entnehmen!");
Profile step2 = new Profile("2. Welt: Am Herd/Feuer braten", "Raw karambwan");
step2.setFolderId(cookFolder.getId());
step2.setRequireBankClosed(true);
step2.setRequiredInventory("Raw karambwan");
step2.setEnableObjectHighlight(true);
step2.setHighlightObjects("Range, Stove, Fire, Cooking range, Clay oven");
step2.setObjectHighlightColor("#FF5722");
step2.setShowObjectText(true);
step2.setObjectCustomText("Braten");
step2.setActivationMessage("Schritt 2: Klicke auf den Herd/das Feuer zum Braten!");
Profile step3 = new Profile("3. Welt: Zurück zur Bank", "Cooked karambwan");
step3.setFolderId(cookFolder.getId());
step3.setRequireBankClosed(true);
step3.setRequiredInventory("Cooked karambwan");
step3.setEnableObjectHighlight(true);
step3.setHighlightObjects("Bank, Bank booth, Bank chest, Banker");
step3.setObjectHighlightColor("#00E5FF");
step3.setShowObjectText(true);
step3.setObjectCustomText("Bank öffnen");
step3.setActivationMessage("Schritt 3: Gehe zur Bank, um den gebratenen Fisch einzulagern!");
Profile step4 = new Profile("4. Bank: Fisch deponieren", "Cooked karambwan");
step4.setFolderId(cookFolder.getId());
step4.setRequireBankOpen(true);
step4.setRequiredInventory("Cooked karambwan");
step4.setHighlightInBank(false);
step4.setHighlightInInventory(true);
step4.setHighlightItems("Cooked karambwan, Burnt karambwan");
step4.setHighlightColor("#4CAF50");
step4.setShowTextLabel(true);
step4.setCustomTextLabel("Depot");
step4.setActivationMessage("Schritt 4: Gebratenen Fisch in die Bank einlagern!");
profiles.add(step2);
profiles.add(step3);
profiles.add(step4);
profiles.add(step1);
saveProfiles();
}
// Preset 3: 🏃 Agility Rooftop Course Guide (mit 3D Hindernissen + Boden-Tiles + Pfadlinien)
public void loadAgilitySeersWorkflow()
{
Folder agilFolder = new Folder("Agility: Seers Rooftop", "Graceful hood");
folders.add(agilFolder);
Profile start = new Profile("1. Start: Bank Wall hochklettern", "Graceful top");
start.setFolderId(agilFolder.getId());
start.setRegionId("10806, 10550");
start.setEnableObjectHighlight(true);
start.setHighlightObjects("Wall, Climbing wall, Rough wall");
start.setObjectHighlightColor("#2ECC71");
start.setShowObjectText(true);
start.setObjectCustomText("Klettern");
start.setEnableTileHighlight(true);
start.setHighlightTiles("2729, 3488, 0, Start-Kachel");
start.setTileHighlightColor("#2ECC71");
start.setRenderPathLine(true);
start.setActivationMessage("Agility: Starte den Seers' Rooftop Kurs an der Wand!");
Profile gap1 = new Profile("2. Rooftop: Lücke überspringen", "Graceful legs");
gap1.setFolderId(agilFolder.getId());
gap1.setEnableObjectHighlight(true);
gap1.setHighlightObjects("Gap, Tightrope, Edge");
gap1.setObjectHighlightColor("#00E5FF");
gap1.setShowObjectText(true);
gap1.setObjectCustomText("Springen");
gap1.setActivationMessage("Agility: Nächstes Dach-Hindernis überqueren!");
profiles.add(gap1);
profiles.add(start);
saveProfiles();
}
public Folder createFolder(String name, String iconItem)
{
Folder f = new Folder(name, iconItem);
folders.add(f);
saveProfiles();
return f;
}
public void deleteFolder(Folder folder)
{
if (folder == null) return;
for (Profile p : profiles)
{
if (folder.getId().equals(p.getFolderId()))
{
p.setFolderId("");
}
}
folders.removeIf(f -> f.getId().equals(folder.getId()));
saveProfiles();
}
public Folder getFolderById(String id)
{
if (id == null || id.isEmpty()) return null;
for (Folder f : folders)
{
if (f.getId().equals(id)) return f;
}
return null;
}
public Profile createProfile(String name)
{
Profile p = new Profile(name);
profiles.add(p);
saveProfiles();
return p;
}
public void deleteProfile(Profile profile)
{
if (profile == null)
{
return;
}
if (activeProfile != null && activeProfile.getId().equals(profile.getId()))
{
setActiveProfile(null);
}
profiles.removeIf(p -> p.getId().equals(profile.getId()));
saveProfiles();
}
public void updateProfile(Profile updated)
{
for (int i = 0; i < profiles.size(); i++)
{
if (profiles.get(i).getId().equals(updated.getId()))
{
profiles.set(i, updated);
break;
}
}
saveProfiles();
}
public void moveProfileUp(Profile profile)
{
int idx = profiles.indexOf(profile);
if (idx > 0)
{
Collections.swap(profiles, idx, idx - 1);
saveProfiles();
}
}
public void moveProfileDown(Profile profile)
{
int idx = profiles.indexOf(profile);
if (idx >= 0 && idx < profiles.size() - 1)
{
Collections.swap(profiles, idx, idx + 1);
saveProfiles();
}
}
public void evaluate(Client client, ItemManager itemManager, net.runelite.client.plugins.banktags.BankTagsService bankTagsService)
{
if (client == null)
{
return;
}
Profile matched = null;
for (Profile p : profiles)
{
if (!p.isEnabled())
{
continue;
}
if (p.getFolderId() != null && !p.getFolderId().isEmpty())
{
Folder f = getFolderById(p.getFolderId());
if (f != null && !f.isEnabled())
{
continue;
}
}
if (p.matches(client, itemManager, bankTagsService))
{
matched = p;
break;
}
}
if (matched != activeProfile)
{
setActiveProfile(matched);
}
}
private void setActiveProfile(Profile newActive)
{
Profile old = this.activeProfile;
this.activeProfile = newActive;
for (ProfileChangeListener listener : listeners)
{
listener.onActiveProfileChanged(old, newActive);
}
}
private void notifyListUpdated()
{
for (ProfileChangeListener listener : listeners)
{
listener.onProfileListUpdated();
}
}
}
@@ -0,0 +1,587 @@
package com.example.essentialsplus;
import com.example.essentialsplus.helpers.TimerTextSize;
import java.awt.Color;
import net.runelite.client.config.Config;
import net.runelite.client.config.ConfigGroup;
import net.runelite.client.config.ConfigItem;
import net.runelite.client.config.Range;
import net.runelite.client.config.Units;
@ConfigGroup(EssentialsPlusConfig.GROUP)
public interface EssentialsPlusConfig extends Config
{
String GROUP = "essentialsplus";
@ConfigItem(
keyName = "snapshotSeconds",
name = "Min. Abstand Datenpunkte",
description = "Mindestabstand zwischen zwei gespeicherten Datenpunkten. Unveraenderte Werte werden nie doppelt gespeichert.",
position = 1
)
@Units(Units.SECONDS)
@Range(min = 10, max = 3600)
default int snapshotSeconds()
{
return 60;
}
@ConfigItem(
keyName = "rawDays",
name = "Jede Aenderung behalten (Tage)",
description = "So lange wird jeder einzelne Datenpunkt gespeichert. Danach: stuendliche Werte (Open/Close/Min/Max/Durchschnitt).",
position = 2
)
@Units(" d")
@Range(min = 1, max = 90)
default int rawDays()
{
return 7;
}
@ConfigItem(
keyName = "hourlyDays",
name = "Stuendliche Werte (Tage)",
description = "So lange bleiben stuendliche Werte erhalten. Aeltere Daten werden zu Tageswerten (Open/Close/Min/Max/Durchschnitt).",
position = 3
)
@Units(" d")
@Range(min = 1, max = 3650)
default int hourlyDays()
{
return 90;
}
@ConfigItem(
keyName = "includeGe",
name = "GE-Angebote zaehlen",
description = "Gold und Items in laufenden Grand Exchange Angeboten mitzaehlen.",
position = 4
)
default boolean includeGe()
{
return true;
}
@ConfigItem(
keyName = "revalueOffline",
name = "Offline-Charaktere neu bewerten",
description = "Bewertet ausgeloggte Charaktere stuendlich mit aktuellen GE-Preisen neu (Marktschwankungen im Verlauf).",
position = 5
)
default boolean revalueOffline()
{
return true;
}
@ConfigItem(
keyName = "showGeBuyLimits",
name = "GE-Kauflimit (4h) im Interface",
description = "Zeigt im Grand Exchange direkt das 4-Stunden-Kauflimit, bereits gekaufte Menge und verbleibendes Limit an.",
position = 7
)
default boolean showGeBuyLimits()
{
return true;
}
@ConfigItem(
keyName = "showSkillsHud",
name = "XP-HUD im Spiel",
description = "Zeigt ingame die gefarmte XP pro Skill im gewaehlten Zeitraum (1H, 1D, ...) an.",
position = 8
)
default boolean showSkillsHud()
{
return false;
}
@ConfigItem(
keyName = "skillsHudMax",
name = "XP-HUD max. Skills",
description = "Maximale Anzahl Skills im XP-HUD (meiste XP zuerst).",
position = 9
)
@Range(min = 1, max = 24)
default int skillsHudMax()
{
return 8;
}
@ConfigItem(
keyName = "skillsHudLevel",
name = "XP-HUD: Level",
description = "Zeigt das aktuelle Level pro Skill im XP-HUD.",
position = 10
)
default boolean skillsHudLevel()
{
return true;
}
@ConfigItem(
keyName = "skillsHudPercent",
name = "XP-HUD: Fortschritt %",
description = "Zeigt den Fortschritt im aktuellen Level in Prozent.",
position = 11
)
default boolean skillsHudPercent()
{
return true;
}
@ConfigItem(
keyName = "skillsHudXpLeft",
name = "XP-HUD: XP bis Level",
description = "Zeigt die verbleibende XP bis zum naechsten Level.",
position = 12
)
default boolean skillsHudXpLeft()
{
return false;
}
@ConfigItem(
keyName = "skillsHudTimeToLevel",
name = "XP-HUD: Zeit bis Level",
description = "Geschaetzte Zeit bis zum naechsten Level (basierend auf XP/h im gewaehlten Zeitraum).",
position = 13
)
default boolean skillsHudTimeToLevel()
{
return false;
}
@ConfigItem(
keyName = "skillsHudXpHour",
name = "XP-HUD: XP/h",
description = "Zeigt XP pro Stunde aktiver Trainingszeit im gewaehlten Zeitraum.",
position = 14
)
default boolean skillsHudXpHour()
{
return false;
}
@ConfigItem(
keyName = "skillsHudProgressBar",
name = "XP-HUD: Fortschrittsbalken",
description = "Zeigt einen Balken mit dem Fortschritt zum naechsten Level.",
position = 15
)
default boolean skillsHudProgressBar()
{
return false;
}
@ConfigItem(
keyName = "wikiMenuOption",
name = "Wiki im Rechtsklick-Menue",
description = "Fuegt beim Rechtsklick auf NPCs, Objekte und Items eine 'Wiki'-Option hinzu, die die Seite im Spiel oeffnet.",
position = 20
)
default boolean wikiMenuOption()
{
return true;
}
@ConfigItem(
keyName = "attackTimer",
name = "Attack Timer",
description = "Zeigt ueber dem eigenen Kopf, wie viele Ticks bis zum naechsten Angriff verbleiben (0 = Angriff).",
position = 30
)
default boolean attackTimer()
{
return false;
}
@ConfigItem(
keyName = "enemyAttackTimer",
name = "Enemy Attack Timer",
description = "Zeigt ueber NPCs, die dich angreifen, die Ticks bis zu ihrem naechsten Angriff. Die Angriffsgeschwindigkeit wird aus ihren Angriffen gelernt (Standard 4).",
position = 31
)
default boolean enemyAttackTimer()
{
return false;
}
@ConfigItem(
keyName = "enemyAttackStyle",
name = "Enemy Attack Style",
description = "Zeigt ueber NPCs, die dich angreifen, womit sie zuletzt angegriffen haben (Nahkampf, Fernkampf, Magie).",
position = 32
)
default boolean enemyAttackStyle()
{
return false;
}
@ConfigItem(
keyName = "itemPrices",
name = "Item Preise",
description = "Zeigt beim Hovern ueber Inventar-Items eine Zeile mit GE- und/oder HA-Preis.",
position = 33
)
default boolean itemPrices()
{
return false;
}
@ConfigItem(
keyName = "itemPricesGe",
name = "Item Preise: GE",
description = "GE-Preis in der Hover-Zeile anzeigen.",
position = 34
)
default boolean itemPricesGe()
{
return true;
}
@ConfigItem(
keyName = "itemPricesHa",
name = "Item Preise: HA",
description = "High-Alch-Wert in der Hover-Zeile anzeigen.",
position = 35
)
default boolean itemPricesHa()
{
return true;
}
@ConfigItem(
keyName = "treeCooldowns",
name = "Tree Cooldowns",
description = "Zeigt Respawn-Countdown auf gefaellten Baeumen und verbleibende Hack-Zeit auf langlebigen Baeumen.",
position = 36
)
default boolean treeCooldowns()
{
return false;
}
@ConfigItem(
keyName = "itemCharges",
name = "Item charges",
description = "Zeigt Ladungen und Füllstände direkt auf Item-Icons an (Blowpipe, Trident, Tome, Quiver, Loot-Bag, Coal-Bag).",
position = 37
)
default boolean itemCharges()
{
return false;
}
@ConfigItem(
keyName = "enemyAttackStyleText",
name = "Enemy Attack Style: Text statt Icon",
description = "Zeigt 'Melee', 'Range' oder 'Mage' statt des Gebets-Icons.",
position = 60
)
default boolean enemyAttackStyleText()
{
return false;
}
@ConfigItem(
keyName = "enemyAttackStyleSize",
name = "Enemy Attack Style: Groesse",
description = "Groesse von Icon bzw. Text.",
position = 61
)
default TimerTextSize enemyAttackStyleSize()
{
return TimerTextSize.NORMAL;
}
@ConfigItem(
keyName = "enemyAttackStyleOffset",
name = "Enemy Attack Style: Hoehe",
description = "Abstand ueber dem Kopf, unabhaengig vom Enemy Attack Timer.",
position = 62
)
@Range(min = -100, max = 300)
default int enemyAttackStyleOffset()
{
return 60;
}
@ConfigItem(
keyName = "attackTimerBar",
name = "Attack Timer: Tick-Balken",
description = "Zeigt unter der Zahl einen Balken mit einem Kaestchen pro Tick.",
position = 40
)
default boolean attackTimerBar()
{
return false;
}
@ConfigItem(
keyName = "attackTimerSize",
name = "Attack Timer: Textgroesse",
description = "Groesse der Zahl.",
position = 41
)
default TimerTextSize attackTimerSize()
{
return TimerTextSize.NORMAL;
}
@ConfigItem(
keyName = "attackTimerColor",
name = "Attack Timer: Farbe",
description = "Farbe der Zahl und des Balkens.",
position = 42
)
default Color attackTimerColor()
{
return Color.WHITE;
}
@ConfigItem(
keyName = "attackTimerReadyColor",
name = "Attack Timer: Farbe bei 0",
description = "Farbe, wenn der Angriff faellig ist (0).",
position = 43
)
default Color attackTimerReadyColor()
{
return new Color(0x66FF66);
}
@ConfigItem(
keyName = "attackTimerOffset",
name = "Attack Timer: Hoehe",
description = "Abstand ueber dem Kopf.",
position = 44
)
@Range(min = -100, max = 200)
default int attackTimerOffset()
{
return 30;
}
@ConfigItem(
keyName = "enemyAttackTimerBar",
name = "Enemy Attack Timer: Tick-Balken",
description = "Zeigt unter der Zahl einen Balken mit einem Kaestchen pro Tick.",
position = 50
)
default boolean enemyAttackTimerBar()
{
return false;
}
@ConfigItem(
keyName = "enemyAttackTimerSize",
name = "Enemy Attack Timer: Textgroesse",
description = "Groesse der Zahl.",
position = 51
)
default TimerTextSize enemyAttackTimerSize()
{
return TimerTextSize.NORMAL;
}
@ConfigItem(
keyName = "enemyAttackTimerColor",
name = "Enemy Attack Timer: Farbe",
description = "Farbe der Zahl und des Balkens.",
position = 52
)
default Color enemyAttackTimerColor()
{
return new Color(0xFF7043);
}
@ConfigItem(
keyName = "enemyAttackTimerReadyColor",
name = "Enemy Attack Timer: Farbe bei 0",
description = "Farbe, wenn der Angriff faellig ist (0).",
position = 53
)
default Color enemyAttackTimerReadyColor()
{
return new Color(0x66FF66);
}
@ConfigItem(
keyName = "enemyAttackTimerOffset",
name = "Enemy Attack Timer: Hoehe",
description = "Abstand ueber dem Kopf.",
position = 54
)
@Range(min = -100, max = 200)
default int enemyAttackTimerOffset()
{
return 30;
}
@ConfigItem(
keyName = "treeCooldownRespawn",
name = "Tree Cooldowns: Respawn",
description = "Respawn-Countdown ueber Baumstuempfen anzeigen.",
position = 70
)
default boolean treeCooldownRespawn()
{
return true;
}
@ConfigItem(
keyName = "treeCooldownChop",
name = "Tree Cooldowns: Hackzeit",
description = "Verbleibende Hack-Zeit ueber Baeumen waehrend des Faellens anzeigen.",
position = 71
)
default boolean treeCooldownChop()
{
return true;
}
@ConfigItem(
keyName = "treeCooldownBar",
name = "Tree Cooldowns: Fortschrittsbalken",
description = "Zeigt einen kleinen Fortschrittsbalken unter der Sekundenzahl.",
position = 72
)
default boolean treeCooldownBar()
{
return false;
}
@ConfigItem(
keyName = "treeCooldownSize",
name = "Tree Cooldowns: Textgroesse",
description = "Groesse der Zahl.",
position = 73
)
default TimerTextSize treeCooldownSize()
{
return TimerTextSize.NORMAL;
}
@ConfigItem(
keyName = "treeCooldownColor",
name = "Tree Cooldowns: Farbe",
description = "Farbe der Zahl.",
position = 74
)
default Color treeCooldownColor()
{
return new Color(0x8FBF3F);
}
@ConfigItem(
keyName = "treeCooldownReadyColor",
name = "Tree Cooldowns: Farbe bei Bereit",
description = "Farbe kurz vor bzw. beim Respawn.",
position = 75
)
default Color treeCooldownReadyColor()
{
return new Color(0x66FF66);
}
@ConfigItem(
keyName = "treeCooldownOffset",
name = "Tree Cooldowns: Hoehe",
description = "Abstand ueber dem Stumpf bzw. der Baum-Basis.",
position = 76
)
@Range(min = -100, max = 200)
default int treeCooldownOffset()
{
return 50;
}
@ConfigItem(
keyName = "itemChargesWeapons",
name = "Item Charges: Waffen & Rüstung",
description = "Ladungen von Waffen und Rüstungsteilen anzeigen.",
position = 80
)
default boolean itemChargesWeapons()
{
return true;
}
@ConfigItem(
keyName = "itemChargesBags",
name = "Item Charges: Beutel & Säcke",
description = "Geldwert im Looting Bag und Füllstand des Coal Bags anzeigen.",
position = 81
)
default boolean itemChargesBags()
{
return true;
}
@ConfigItem(
keyName = "itemChargesBank",
name = "Item Charges: Bank",
description = "Ladungen auch auf Items im Bank-Interface anzeigen (Achtung: Varbits sind charakterweit).",
position = 82
)
default boolean itemChargesBank()
{
return true;
}
@ConfigItem(
keyName = "itemChargesSize",
name = "Item Charges: Textgröße",
description = "Schriftgröße der Ladungsanzeige.",
position = 83
)
default TimerTextSize itemChargesSize()
{
return TimerTextSize.SMALL;
}
@ConfigItem(
keyName = "itemChargesColor",
name = "Item Charges: Farbe",
description = "Farbe der primären Ladungszahl.",
position = 84
)
default Color itemChargesColor()
{
return Color.WHITE;
}
@ConfigItem(
keyName = "itemChargesSecondaryColor",
name = "Item Charges: Sekundärfarbe",
description = "Farbe für Sekundärwerte (z. B. Quiver-Blessing, Darts-Marker).",
position = 85
)
default Color itemChargesSecondaryColor()
{
return new Color(0xC0C0C0);
}
@ConfigItem(
keyName = "itemChargesLowColor",
name = "Item Charges: Warnfarbe",
description = "Farbe bei niedrigem Ladungsstand.",
position = 86
)
default Color itemChargesLowColor()
{
return new Color(0xFF3333);
}
@ConfigItem(
keyName = "itemChargesLowAt",
name = "Item Charges: Niedrig ab",
description = "Schwellenwert für niedrige Ladungen (0 = deaktiviert).",
position = 87
)
@Range(min = 0, max = 10000)
default int itemChargesLowAt()
{
return 0;
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,318 @@
package com.example.essentialsplus;
import com.example.essentialsplus.data.AccountData;
import com.example.essentialsplus.data.GeSlot;
import java.util.HashMap;
import java.util.Map;
import java.util.function.IntUnaryOperator;
import net.runelite.api.Client;
import net.runelite.api.GrandExchangeOffer;
import net.runelite.api.GrandExchangeOfferState;
import net.runelite.api.Item;
import net.runelite.api.ItemContainer;
import net.runelite.api.gameval.InterfaceID;
import net.runelite.api.gameval.InventoryID;
import net.runelite.api.gameval.ItemID;
import net.runelite.api.widgets.Widget;
/**
* Values Grand Exchange offers without double counting.
* <p>
* A slot holds two things: the escrow of the part that has not traded yet and the collection box
* (bought items / received coins). The client does not say how much of a partially completed offer was
* already collected, so collections are detected: whenever the offer item or coins show up in inventory
* or bank while a GE / bank interface is open, that amount is treated as collected. If a slot empties and
* the items did not show up anywhere visible they went to the bank unseen and are booked there.
* The game's collection box containers are used directly when the client has them.
*/
final class GrandExchangeTracker
{
private static final int SLOTS = 8;
private static final int[] COLLECT_CONTAINERS = {
InventoryID.TRADINGPOST_SELL_0, InventoryID.TRADINGPOST_SELL_1, InventoryID.TRADINGPOST_SELL_2,
InventoryID.TRADINGPOST_SELL_3, InventoryID.TRADINGPOST_SELL_4, InventoryID.TRADINGPOST_SELL_5,
InventoryID.GE_COLLECT_6, InventoryID.GE_COLLECT_7
};
/** how long a visible coin loss may be matched to a new buy offer */
private static final long COIN_LOSS_WINDOW_MS = 15_000L;
/** inventory + visible bank holdings (canonical ids) at the previous update */
private Map<Integer, Long> previousHoldings;
/** coins that recently left inventory / visible bank and were not matched to a buy offer yet */
private long recentCoinLoss;
private long recentCoinLossAt;
void reset()
{
previousHoldings = null;
recentCoinLoss = 0;
}
/**
* @param live false while the offers of a fresh login have not arrived yet - persisted values are used then
* @return canonical item id -> quantity held by the Grand Exchange
*/
Map<Integer, Long> update(AccountData acc, Client client, IntUnaryOperator canonicalize, boolean live)
{
boolean migrate = acc.geSlots == null || acc.geSlots.length != SLOTS;
Map<Integer, Long> holdings = new HashMap<>();
addCanonical(holdings, acc.containers.get("inventory"), canonicalize);
addCanonical(holdings, acc.containers.get("bank"), canonicalize);
Map<Integer, Long> gains = new HashMap<>();
long updateTime = System.currentTimeMillis();
if (updateTime - recentCoinLossAt > COIN_LOSS_WINDOW_MS)
{
recentCoinLoss = 0;
}
if (previousHoldings != null)
{
long coinLoss = previousHoldings.getOrDefault(ItemID.COINS, 0L) - holdings.getOrDefault(ItemID.COINS, 0L);
if (coinLoss > 0)
{
recentCoinLoss += coinLoss;
recentCoinLossAt = updateTime;
}
for (Map.Entry<Integer, Long> e : holdings.entrySet())
{
long d = e.getValue() - previousHoldings.getOrDefault(e.getKey(), 0L);
if (d > 0)
{
gains.put(e.getKey(), d);
}
}
}
boolean haveBaseline = previousHoldings != null;
previousHoldings = holdings;
GrandExchangeOffer[] offers = client.getGrandExchangeOffers();
Map<Integer, Long> out = new HashMap<>();
if (!live || offers == null || offers.length < SLOTS || (!haveBaseline && !migrate))
{
// keep the last known state
if (!migrate)
{
for (GeSlot s : acc.geSlots)
{
addSlot(out, s);
}
}
return out;
}
if (migrate)
{
acc.geSlots = new GeSlot[SLOTS];
}
boolean seeded = migrate;
boolean collecting = isOpen(client, InterfaceID.GE_OFFERS) || isOpen(client, InterfaceID.GE_COLLECT) || isOpen(client, InterfaceID.BANKMAIN);
for (int i = 0; i < SLOTS; i++)
{
GrandExchangeOffer o = offers[i];
GeSlot s = acc.geSlots[i];
GrandExchangeOfferState state = o == null ? null : o.getState();
if (state == null || state == GrandExchangeOfferState.EMPTY || o.getItemId() <= 0)
{
if (s != null && !migrate)
{
// slot cleared: whatever was still collectable was collected now
int item = canonicalize.applyAsInt(s.item);
long items = s.collectItems - consume(gains, item, s.collectItems);
long coins = s.collectCoins - consume(gains, ItemID.COINS, s.collectCoins);
bookToBank(acc, item, items);
bookToBank(acc, ItemID.COINS, coins);
}
acc.geSlots[i] = null;
continue;
}
boolean buy = state == GrandExchangeOfferState.BUYING || state == GrandExchangeOfferState.BOUGHT
|| state == GrandExchangeOfferState.CANCELLED_BUY;
boolean cancelled = state == GrandExchangeOfferState.CANCELLED_BUY || state == GrandExchangeOfferState.CANCELLED_SELL;
long total = o.getTotalQuantity();
long price = o.getPrice();
long done = o.getQuantitySold();
long spent = o.getSpent();
// what the slot has produced so far (collectable if nothing was taken out)
long grossItems = buy ? done : (cancelled ? total - done : 0);
long grossCoins = buy ? Math.max(0, done * price - spent) + (cancelled ? (total - done) * price : 0) : spent;
long escrowItems = !buy && !cancelled ? total - done : 0;
long escrowCoins = buy && !cancelled ? (total - done) * price : 0;
if (s == null || s.item != o.getItemId() || s.buy != buy || s.total != total || s.price != price)
{
GeSlot fresh = new GeSlot();
fresh.item = o.getItemId();
fresh.buy = buy;
fresh.total = total;
fresh.price = price;
long now = System.currentTimeMillis();
fresh.trackedSince = now;
fresh.placedAt = migrate ? 0 : now;
fresh.done = migrate ? done : 0;
fresh.trackedDone = fresh.done;
if (migrate && acc.geSeedCollected != null)
{
// first run with this tracker: take over what is known to be collected already
long seed = acc.geSeedCollected.getOrDefault(fresh.item, 0L);
fresh.collectedItems = Math.min(grossItems, seed);
if (seed > 0)
{
acc.geSeedCollected.put(fresh.item, seed - fresh.collectedItems);
}
}
if (!migrate && buy)
{
// A buy offer can be paid with coins from the bank while the bank is not visible. Whatever did not
// leave inventory / visible bank came from there - otherwise the stale bank and the offer both count it.
long cost = total * price;
long seen = Math.min(cost, recentCoinLoss);
recentCoinLoss -= seen;
bookToBank(acc, ItemID.COINS, -(cost - seen));
}
s = fresh;
acc.geSlots[i] = s;
}
int item = canonicalize.applyAsInt(s.item);
// items / coins of this slot showing up in inventory or bank were collected
if (collecting)
{
s.collectedItems += consume(gains, item, Math.max(0, grossItems - s.collectedItems));
s.collectedCoins += consume(gains, ItemID.COINS, Math.max(0, grossCoins - s.collectedCoins));
}
long collectItems = Math.max(0, grossItems - s.collectedItems);
long collectCoins = Math.max(0, grossCoins - s.collectedCoins);
ItemContainer box = client.getItemContainer(COLLECT_CONTAINERS[i]);
if (box != null)
{
long boxItems = 0;
long boxCoins = 0;
for (Item it : box.getItems())
{
if (it == null || it.getId() <= 0 || it.getQuantity() <= 0)
{
continue;
}
if (it.getId() == ItemID.COINS)
{
boxCoins += it.getQuantity();
}
else if (canonicalize.applyAsInt(it.getId()) == item)
{
boxItems += it.getQuantity();
}
}
// The client can keep an outdated box after collecting and may fill it a tick after the offer, so the box
// only counts when it shrank since the last update: that part went to the bank without being seen.
long goneItems = Math.min(collectItems - boxItems, s.collectItems - boxItems);
if (goneItems > 0)
{
bookToBank(acc, item, goneItems);
s.collectedItems += goneItems;
collectItems -= goneItems;
}
long goneCoins = Math.min(collectCoins - boxCoins, s.collectCoins - boxCoins);
if (goneCoins > 0)
{
bookToBank(acc, ItemID.COINS, goneCoins);
s.collectedCoins += goneCoins;
collectCoins -= goneCoins;
}
}
long now = System.currentTimeMillis();
if (s.trackedSince == 0)
{
s.trackedSince = now;
s.done = done;
s.trackedDone = done;
}
if (done != s.done)
{
s.lastTradeAt = now;
}
s.done = done;
s.spent = spent;
s.state = state.name();
s.escrowItems = escrowItems;
s.escrowCoins = escrowCoins;
s.collectItems = collectItems;
s.collectCoins = collectCoins;
addSlot(out, s);
}
if (seeded)
{
acc.geSeedCollected = null;
}
return out;
}
private static void addSlot(Map<Integer, Long> out, GeSlot s)
{
if (s == null)
{
return;
}
long items = s.escrowItems + s.collectItems;
long coins = s.escrowCoins + s.collectCoins;
if (items > 0)
{
out.merge(s.item, items, Long::sum);
}
if (coins > 0)
{
out.merge(ItemID.COINS, coins, Long::sum);
}
}
/** Takes up to {@code max} of a visible gain and returns how much was taken. */
private static long consume(Map<Integer, Long> gains, int item, long max)
{
if (max <= 0)
{
return 0;
}
long g = gains.getOrDefault(item, 0L);
long take = Math.min(g, max);
if (take > 0)
{
gains.put(item, g - take);
}
return take;
}
/** Books items that went to the bank while the bank was not visible. */
private static void bookToBank(AccountData acc, int item, long qty)
{
if (qty == 0)
{
return;
}
Map<Integer, Long> pending = new HashMap<>(acc.bankPending);
pending.merge(item, qty, Long::sum);
acc.bankPending = pending;
}
private static void addCanonical(Map<Integer, Long> into, Map<Integer, Long> items, IntUnaryOperator canonicalize)
{
if (items == null)
{
return;
}
for (Map.Entry<Integer, Long> e : items.entrySet())
{
into.merge(canonicalize.applyAsInt(e.getKey()), e.getValue(), Long::sum);
}
}
private static boolean isOpen(Client client, int group)
{
Widget w = client.getWidget(group, 0);
return w != null && !w.isHidden();
}
}
@@ -0,0 +1,27 @@
package com.example.essentialsplus;
import java.awt.Color;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
/**
* Wealth buckets. Ordinal is used as index into persisted value arrays - only append new entries.
*/
@Getter
@RequiredArgsConstructor
public enum WealthCategory
{
BANK("Bank", new Color(232, 163, 61)),
INVENTORY("Inventory", new Color(90, 176, 232)),
EQUIPMENT("Equipment", new Color(165, 123, 224)),
STORAGE("Storage", new Color(77, 201, 166)),
COFFERS("Coffers", new Color(232, 112, 122)),
GRAND_EXCHANGE("Grand Exchange", new Color(201, 214, 90)),
/** Totals imported from other plugins without a breakdown. */
IMPORTED("Imported (total only)", new Color(120, 120, 128));
public static final int COUNT = values().length;
private final String label;
private final Color color;
}
@@ -0,0 +1,475 @@
package com.example.essentialsplus;
import com.example.essentialsplus.data.AccountData;
import com.example.essentialsplus.data.ItemLedger;
import com.example.essentialsplus.data.ItemSnapshot;
import com.example.essentialsplus.data.Snapshot;
import com.google.gson.Gson;
import java.io.File;
import java.io.IOException;
import java.io.Reader;
import java.io.Writer;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.StandardCopyOption;
import java.time.DayOfWeek;
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.time.temporal.ChronoUnit;
import java.time.temporal.TemporalAdjusters;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CopyOnWriteArrayList;
import javax.inject.Inject;
import javax.inject.Singleton;
import lombok.extern.slf4j.Slf4j;
import net.runelite.client.RuneLite;
/**
* Loads and saves the files in ~/.runelite/essentials-plus:
* <ul>
* <li>{@code <key>.json} - state + wealth timeline</li>
* <li>{@code <key>.items.json} - item holdings over time</li>
* </ul>
* Several RuneLite clients may run at once, so files of characters that are not logged in
* in this client are re-read when they change on disk.
*/
@Slf4j
@Singleton
public class WealthStore
{
public static final File DIR = new File(RuneLite.RUNELITE_DIR, "essentials-plus");
private static final String LEDGER_SUFFIX = ".items.json";
private final Gson gson;
private final Map<String, AccountData> accounts = new ConcurrentHashMap<>();
private final Map<String, ItemLedger> ledgers = new ConcurrentHashMap<>();
@Inject
WealthStore(Gson gson)
{
this.gson = gson.newBuilder().disableHtmlEscaping().create();
}
// ------------------------------------------------------------------ accounts
public List<AccountData> all()
{
List<AccountData> list = new ArrayList<>(accounts.values());
list.sort(Comparator.comparingLong((AccountData a) -> -a.currentTotal())
.thenComparing(a -> a.name == null ? "" : a.name, String.CASE_INSENSITIVE_ORDER));
return list;
}
public AccountData get(String key)
{
return key == null ? null : accounts.get(key);
}
public AccountData getOrCreate(String key, String name)
{
return accounts.computeIfAbsent(key, k ->
{
AccountData a = new AccountData();
a.key = k;
a.name = name;
return a;
});
}
public void put(AccountData account)
{
accounts.put(account.key, account);
}
/**
* Re-reads account files changed on disk, skipping the character logged in here (this client owns it).
*
* @return true if anything changed
*/
public boolean reloadExternal(String activeKey)
{
File[] files = DIR.listFiles((d, n) -> n.endsWith(".json") && !n.endsWith(LEDGER_SUFFIX));
if (files == null)
{
return false;
}
boolean changed = false;
Set<String> seen = new HashSet<>();
for (File f : files)
{
AccountData existing = findByFile(f);
if (existing != null)
{
seen.add(existing.key);
if (existing.key.equals(activeKey) || existing.fileModified >= f.lastModified())
{
continue;
}
}
AccountData loaded = read(f, AccountData.class);
if (loaded == null || loaded.key == null || loaded.key.equals(activeKey))
{
continue;
}
loaded.normalize();
loaded.fileModified = f.lastModified();
accounts.put(loaded.key, loaded);
seen.add(loaded.key);
changed = true;
}
// files deleted by another client
for (String key : new ArrayList<>(accounts.keySet()))
{
AccountData a = accounts.get(key);
if (a != null && !seen.contains(key) && !key.equals(activeKey) && a.fileModified > 0)
{
accounts.remove(key);
ledgers.remove(key);
changed = true;
}
}
return changed;
}
/** Replaces the in-memory copy with the file if the file is newer (used when a character logs in). */
public AccountData refreshFromDisk(String key)
{
File f = fileFor(key);
AccountData mem = accounts.get(key);
if (f.exists() && (mem == null || mem.fileModified < f.lastModified()))
{
AccountData loaded = read(f, AccountData.class);
if (loaded != null)
{
loaded.key = key;
loaded.normalize();
loaded.fileModified = f.lastModified();
accounts.put(key, loaded);
ledgers.remove(key);
return loaded;
}
}
return mem;
}
private AccountData findByFile(File f)
{
for (AccountData a : accounts.values())
{
if (fileFor(a.key).getName().equals(f.getName()))
{
return a;
}
}
return null;
}
public void save(AccountData account)
{
if (account == null || account.key == null)
{
return;
}
File target = fileFor(account.key);
if (write(target, account))
{
account.fileModified = target.lastModified();
}
}
public void delete(String key)
{
accounts.remove(key);
ledgers.remove(key);
for (File f : new File[]{fileFor(key), ledgerFileFor(key)})
{
if (f.exists() && !f.delete())
{
log.warn("Wealth tracker: could not delete {}", f);
}
}
}
// ------------------------------------------------------------------ item ledgers
/** Item history of a character, loaded lazily and re-read when another client changed it. */
public ItemLedger ledger(String key, boolean owned)
{
ItemLedger mem = ledgers.get(key);
File f = ledgerFileFor(key);
if (mem != null && (owned || !f.exists() || mem.fileModified >= f.lastModified()))
{
return mem;
}
ItemLedger loaded = f.exists() ? read(f, ItemLedger.class) : null;
if (loaded == null)
{
loaded = mem != null ? mem : new ItemLedger();
}
else
{
loaded.normalize();
loaded.fileModified = f.lastModified();
}
ledgers.put(key, loaded);
return loaded;
}
public void saveLedger(String key, ItemLedger ledger)
{
File target = ledgerFileFor(key);
if (write(target, ledger))
{
ledger.fileModified = target.lastModified();
ledger.dirty = false;
}
}
// ------------------------------------------------------------------ io
private <T> T read(File f, Class<T> type)
{
try (Reader r = Files.newBufferedReader(f.toPath(), StandardCharsets.UTF_8))
{
return gson.fromJson(r, type);
}
catch (Exception e)
{
log.warn("Wealth tracker: could not read {}", f, e);
return null;
}
}
private synchronized boolean write(File target, Object data)
{
try
{
Files.createDirectories(DIR.toPath());
File tmp = new File(DIR, target.getName() + ".tmp");
try (Writer w = Files.newBufferedWriter(tmp.toPath(), StandardCharsets.UTF_8))
{
gson.toJson(data, w);
}
Files.move(tmp.toPath(), target.toPath(), StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE);
return true;
}
catch (IOException e)
{
log.warn("Wealth tracker: could not save {}", target, e);
return false;
}
}
static String fileBase(String key)
{
return key.replaceAll("[^A-Za-z0-9 _()\\-]", "_");
}
static File fileFor(String key)
{
return new File(DIR, fileBase(key) + ".json");
}
static File ledgerFileFor(String key)
{
return new File(DIR, fileBase(key) + LEDGER_SUFFIX);
}
// ------------------------------------------------------------------ compaction
/**
* Keeps the timeline useful for analysis while bounding its size:
* <ul>
* <li>younger than {@code rawDays}: every change (identical consecutive points dropped)</li>
* <li>younger than {@code hourlyDays}: one point per hour</li>
* <li>older: one point per day</li>
* </ul>
* Buckets keep open, close, low, high and the time-weighted average breakdown.
* Must be called on the thread that appends to the history (client thread).
*/
public static void compact(AccountData account, int rawDays, int hourlyDays, long now)
{
List<Snapshot> src = AccountData.sanitizeHistory(account.history);
if (src.size() < 2)
{
if (src.size() != account.history.size())
{
account.history = new CopyOnWriteArrayList<>(src);
}
return;
}
ZoneId zone = ZoneId.systemDefault();
ZonedDateTime nowZ = Instant.ofEpochMilli(now).atZone(zone);
long rawCutoff = nowZ.minusDays(rawDays).truncatedTo(ChronoUnit.HOURS).toInstant().toEpochMilli();
long hourlyCutoff = nowZ.minusDays(Math.max(rawDays, hourlyDays)).truncatedTo(ChronoUnit.DAYS).toInstant().toEpochMilli();
List<Snapshot> out = new ArrayList<>(src.size());
List<Snapshot> bucket = new ArrayList<>();
long bucketStart = Long.MIN_VALUE;
String bucketG = null;
Snapshot prevRaw = null;
for (Snapshot s : src)
{
if (s.t >= rawCutoff)
{
flush(bucket, bucketStart, bucketG, zone, out);
bucketStart = Long.MIN_VALUE;
if (!s.isAggregate() && prevRaw != null && prevRaw.sameValues(s.v))
{
continue;
}
out.add(s);
prevRaw = s.isAggregate() ? null : s;
continue;
}
String g = s.t >= hourlyCutoff ? Snapshot.HOUR : Snapshot.DAY;
ZonedDateTime z = Instant.ofEpochMilli(s.t).atZone(zone);
long bs = (g.equals(Snapshot.HOUR) ? z.truncatedTo(ChronoUnit.HOURS) : z.truncatedTo(ChronoUnit.DAYS)).toInstant().toEpochMilli();
if (bs != bucketStart || !g.equals(bucketG))
{
flush(bucket, bucketStart, bucketG, zone, out);
bucketStart = bs;
bucketG = g;
}
bucket.add(s);
}
flush(bucket, bucketStart, bucketG, zone, out);
if (out.size() != src.size())
{
account.history = new CopyOnWriteArrayList<>(out);
}
}
private static void flush(List<Snapshot> bucket, long start, String g, ZoneId zone, List<Snapshot> out)
{
if (bucket.isEmpty())
{
return;
}
if (bucket.size() == 1 && g.equals(bucket.get(0).g))
{
out.add(bucket.get(0));
bucket.clear();
return;
}
long end = g.equals(Snapshot.HOUR)
? start + Ui_HOUR
: Instant.ofEpochMilli(start).atZone(zone).plusDays(1).toInstant().toEpochMilli();
double[] sum = new double[WealthCategory.COUNT];
double weight = 0;
long lo = Long.MAX_VALUE;
long hi = Long.MIN_VALUE;
for (int i = 0; i < bucket.size(); i++)
{
Snapshot s = bucket.get(i);
long from = Math.max(s.t, start);
long to = i + 1 < bucket.size() ? bucket.get(i + 1).t : end;
double w = Math.max(1, Math.min(to, end) - from);
for (WealthCategory c : WealthCategory.values())
{
sum[c.ordinal()] += s.get(c) * w;
}
weight += w;
lo = Math.min(lo, s.low());
hi = Math.max(hi, s.high());
}
long[] avg = new long[WealthCategory.COUNT];
for (int i = 0; i < avg.length; i++)
{
avg[i] = Math.round(sum[i] / weight);
}
Snapshot agg = new Snapshot(start + (end - start) / 2, avg);
agg.o = bucket.get(0).open();
agg.c = bucket.get(bucket.size() - 1).close();
agg.lo = lo;
agg.hi = hi;
agg.g = g;
agg.m = bucket.get(bucket.size() - 1).m;
out.add(agg);
bucket.clear();
}
private static final long Ui_HOUR = 3_600_000L;
/**
* Item history resolution: hourly for 2 days, daily for 90 days, weekly afterwards.
* Identical consecutive snapshots are dropped.
*/
public static void compact(ItemLedger ledger, long now)
{
List<ItemSnapshot> src = ledger.snapshots;
if (src.size() < 2)
{
return;
}
ZoneId zone = ZoneId.systemDefault();
List<ItemSnapshot> out = new ArrayList<>(src.size());
long prevBucket = Long.MIN_VALUE;
for (ItemSnapshot s : src)
{
long age = now - s.t;
ZonedDateTime z = Instant.ofEpochMilli(s.t).atZone(zone);
long bucket;
if (age < 2 * 86_400_000L)
{
bucket = z.truncatedTo(ChronoUnit.HOURS).toInstant().toEpochMilli();
}
else if (age < 90 * 86_400_000L)
{
bucket = z.truncatedTo(ChronoUnit.DAYS).toInstant().toEpochMilli() + 1;
}
else
{
LocalDate monday = z.toLocalDate().with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY));
bucket = monday.atStartOfDay(zone).toInstant().toEpochMilli() + 2;
}
if (Boolean.TRUE.equals(s.k) && age < 2 * 86_400_000L)
{
out.add(s);
prevBucket = Long.MIN_VALUE;
continue;
}
if (!out.isEmpty() && bucket == prevBucket && !Boolean.TRUE.equals(out.get(out.size() - 1).k))
{
// keep the latest state of each bucket
out.set(out.size() - 1, s);
}
else if (!out.isEmpty() && s.sameAs(out.get(out.size() - 1)))
{
continue;
}
else
{
out.add(s);
}
prevBucket = bucket;
}
if (out.size() != src.size())
{
ledger.snapshots = new CopyOnWriteArrayList<>(out);
ledger.dirty = true;
}
}
}
@@ -0,0 +1,515 @@
package com.example.essentialsplus.collectionlog;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.stream.Collectors;
import lombok.extern.slf4j.Slf4j;
import net.runelite.api.ChatMessageType;
import net.runelite.api.Client;
import net.runelite.api.EnumComposition;
import net.runelite.api.GameState;
import net.runelite.api.Item;
import net.runelite.api.ItemContainer;
import net.runelite.api.StructComposition;
import net.runelite.api.events.ChatMessage;
import net.runelite.api.gameval.InterfaceID;
import net.runelite.api.widgets.Widget;
import net.runelite.client.config.ConfigManager;
import net.runelite.client.util.Text;
/**
* Collection log pages and items (read from the game cache) plus which items the logged-in account has
* obtained. Obtained items are learned whenever a collection log page is open and from the
* "New item added to your collection log" chat message, and stored per RuneScape profile.
*/
@Slf4j
public class CollectionLogService
{
private static final String GROUP = com.example.essentialsplus.EssentialsPlusConfig.GROUP;
private static final String KEY = "clogObtained";
private static final String KEY_FULL_SYNC = "clogFullSync";
/** runs once per log item (item id, count) when the log's search is opened */
private static final int SCRIPT_SEARCH_ITEM = 4100;
/** tab structs (Bosses, Raids, Clues, Minigames, Other) */
private static final int[] TAB_STRUCTS = {471, 472, 473, 474, 475};
private static final String[] TAB_NAMES = {"Bosses", "Raids", "Clues", "Minigames", "Other"};
private static final int PARAM_TAB_PAGES = 683;
private static final int PARAM_PAGE_NAME = 689;
private static final int PARAM_PAGE_ITEMS = 690;
/** server-sent inventory holding the obtained count of every collection log item */
private static final int CLOG_INVENTORY = 620;
/** obj -> obj enum the log script uses to remap page items before looking them up in the inventory */
private static final int ENUM_ITEM_REMAP = 3721;
/** unobtained items are drawn with this transparency */
private static final int UNOBTAINED_OPACITY = 175;
private static final String NEW_ITEM = "New item added to your collection log:";
/** A collection log page, e.g. "Zulrah", with its item ids in log order. */
public static final class Page
{
public final String name;
public final List<Integer> itemIds;
Page(String name, List<Integer> itemIds)
{
this.name = name;
this.itemIds = itemIds;
}
}
/** A collection log tab, e.g. "Bosses", with its pages in log order. */
public static final class Tab
{
public final String name;
public final List<Page> pages;
Tab(String name, List<Page> pages)
{
this.name = name;
this.pages = pages;
}
}
private final Client client;
private final ConfigManager configManager;
private final List<Runnable> listeners = new CopyOnWriteArrayList<>();
private volatile List<Page> pages = Collections.emptyList();
private volatile List<Tab> tabs = Collections.emptyList();
private volatile Map<Integer, String> itemNames = Collections.emptyMap();
private final Set<Integer> obtained = ConcurrentHashMap.newKeySet();
private volatile String profileKey;
private volatile boolean synced;
/** when all items were last read via the log search, 0 = never */
private volatile long fullSyncAt;
/** items reported by the search script this tick: id -> count */
private final Map<Integer, Integer> searchItems = new java.util.HashMap<>();
private int searchTick = -1;
private int lastWidgetHash;
private int lastInventoryHash;
public CollectionLogService(Client client, ConfigManager configManager)
{
this.client = client;
this.configManager = configManager;
}
public void addListener(Runnable r)
{
listeners.add(r);
}
public void removeListener(Runnable r)
{
listeners.remove(r);
}
/** Client thread. Loads the page list once and the obtained items when the profile changes. */
public void onGameTick()
{
if (client.getGameState() != GameState.LOGGED_IN)
{
return;
}
if (pages.isEmpty())
{
loadPages();
}
String key = configManager.getRSProfileKey();
if (key != null && !key.equals(profileKey))
{
profileKey = key;
loadObtained();
log.info("collection log: profile {} has {} stored obtained items", key, obtained.size());
notifyListeners();
}
finishSearchSync();
scanInventory();
scanOpenPage();
}
/** Client thread. The log search reports every item with its count: a complete, exact sync. */
public void onScriptPreFired(net.runelite.api.events.ScriptPreFired event)
{
if (event.getScriptId() != SCRIPT_SEARCH_ITEM || event.getScriptEvent() == null)
{
return;
}
Object[] args = event.getScriptEvent().getArguments();
if (args == null || args.length < 3 || !(args[1] instanceof Integer) || !(args[2] instanceof Integer))
{
return;
}
searchItems.merge((Integer) args[1], (Integer) args[2], Math::max);
searchTick = client.getTickCount();
}
private void finishSearchSync()
{
if (searchItems.isEmpty() || client.getTickCount() == searchTick || itemNames.isEmpty())
{
return;
}
Map<Integer, Integer> seen = new java.util.HashMap<>(searchItems);
searchItems.clear();
EnumComposition remap = client.getEnum(ENUM_ITEM_REMAP);
Set<Integer> have = new HashSet<>();
int known = 0;
for (int id : itemNames.keySet())
{
int mapped = remap.getIntValue(id);
boolean inSearch = seen.containsKey(id) || mapped > 0 && seen.containsKey(mapped);
known += inSearch ? 1 : 0;
if (seen.getOrDefault(id, 0) > 0 || mapped > 0 && seen.getOrDefault(mapped, 0) > 0)
{
have.add(id);
}
}
boolean complete = known >= itemNames.size() * 9 / 10;
log.info("collection log search: {} items reported, {} log items matched, {} obtained, complete={}", seen.size(), known, have.size(), complete);
if (complete)
{
// the search lists every item, so this replaces what was guessed from pages before
obtained.clear();
fullSyncAt = System.currentTimeMillis();
if (profileKey != null)
{
configManager.setRSProfileConfiguration(GROUP, KEY_FULL_SYNC, fullSyncAt);
}
client.addChatMessage(ChatMessageType.GAMEMESSAGE, "", String.format(
"<col=ff9040>Essentials Plus:</col> collection log synced - %d / %d items.", have.size(), itemNames.size()), null);
}
obtained.addAll(have);
synced = true;
saveObtained();
notifyListeners();
}
/** When the log was last fully synced through its search, 0 = never. */
public long getFullSyncAt()
{
return fullSyncAt;
}
/** Client thread. */
public void onChatMessage(ChatMessage event)
{
if (event.getType() != ChatMessageType.GAMEMESSAGE && event.getType() != ChatMessageType.SPAM)
{
return;
}
String msg = Text.removeTags(event.getMessage());
if (!msg.startsWith(NEW_ITEM))
{
return;
}
String name = msg.substring(NEW_ITEM.length()).trim();
boolean changed = false;
for (Map.Entry<Integer, String> e : itemNames.entrySet())
{
if (e.getValue().equalsIgnoreCase(name))
{
changed |= obtained.add(e.getKey());
}
}
if (changed)
{
saveObtained();
notifyListeners();
}
}
public void reset()
{
profileKey = null;
obtained.clear();
synced = false;
fullSyncAt = 0;
searchItems.clear();
}
/** True once any collection log data is known for the current profile. */
public boolean isSynced()
{
return synced;
}
public boolean isLoaded()
{
return !pages.isEmpty();
}
public boolean isObtained(int itemId)
{
return obtained.contains(itemId);
}
public List<Tab> getTabs()
{
return tabs;
}
/** Number of distinct items in the log. */
public int totalItems()
{
return itemNames.size();
}
/** Number of distinct log items the account has obtained. */
public int obtainedItems()
{
int n = 0;
for (int id : itemNames.keySet())
{
if (obtained.contains(id))
{
n++;
}
}
return n;
}
/** Obtained items of a page or tab. */
public int obtainedCount(List<Integer> itemIds)
{
int n = 0;
for (int id : itemIds)
{
if (obtained.contains(id))
{
n++;
}
}
return n;
}
public String itemName(int itemId)
{
return itemNames.getOrDefault(itemId, "Item " + itemId);
}
/** All item ids in the log with the given name (some items appear under several ids). */
public Set<Integer> idsByName(String name)
{
Set<Integer> out = new HashSet<>();
for (Map.Entry<Integer, String> e : itemNames.entrySet())
{
if (e.getValue().equalsIgnoreCase(name))
{
out.add(e.getKey());
}
}
return out;
}
/** The log page of a loot source: exact name, else a page starting with it ("Barrows" -> "Barrows Chests"). */
public Page findPage(String source)
{
if (source == null)
{
return null;
}
String s = source.trim().toLowerCase(Locale.ROOT);
for (Page p : pages)
{
if (p.name.toLowerCase(Locale.ROOT).equals(s))
{
return p;
}
}
for (Page p : pages)
{
if (!p.name.equalsIgnoreCase("All Pets") && p.name.toLowerCase(Locale.ROOT).startsWith(s))
{
return p;
}
}
return null;
}
private void loadPages()
{
try
{
List<Page> out = new ArrayList<>();
List<Tab> tabList = new ArrayList<>();
Map<Integer, String> names = new LinkedHashMap<>();
for (int t = 0; t < TAB_STRUCTS.length; t++)
{
int tabStart = out.size();
StructComposition tab = client.getStructComposition(TAB_STRUCTS[t]);
EnumComposition pageEnum = client.getEnum(tab.getIntValue(PARAM_TAB_PAGES));
for (int pageStructId : pageEnum.getIntVals())
{
StructComposition page = client.getStructComposition(pageStructId);
EnumComposition items = client.getEnum(page.getIntValue(PARAM_PAGE_ITEMS));
List<Integer> ids = new ArrayList<>();
for (int id : items.getIntVals())
{
ids.add(id);
names.computeIfAbsent(id, i -> client.getItemDefinition(i).getName());
}
out.add(new Page(page.getStringValue(PARAM_PAGE_NAME), Collections.unmodifiableList(ids)));
}
tabList.add(new Tab(TAB_NAMES[t], Collections.unmodifiableList(new ArrayList<>(out.subList(tabStart, out.size())))));
}
itemNames = names;
tabs = Collections.unmodifiableList(tabList);
pages = Collections.unmodifiableList(out);
log.debug("collection log: {} pages, {} items", out.size(), names.size());
notifyListeners();
}
catch (Exception e)
{
log.warn("could not read collection log structure", e);
}
}
/** Reads the collection log inventory, which the server sends while the log is open. */
private void scanInventory()
{
ItemContainer inv = client.getItemContainer(CLOG_INVENTORY);
if (inv == null || itemNames.isEmpty())
{
return;
}
Item[] items = inv.getItems();
int hash = java.util.Arrays.hashCode(items);
if (items.length == 0 || hash == lastInventoryHash)
{
return;
}
lastInventoryHash = hash;
Set<Integer> have = new HashSet<>();
for (Item it : items)
{
if (it.getId() > 0 && it.getQuantity() > 0)
{
have.add(it.getId());
}
}
log.info("collection log inventory: {} slots, {} obtained items", items.length, have.size());
EnumComposition remap = client.getEnum(ENUM_ITEM_REMAP);
boolean changed = !synced;
for (int id : itemNames.keySet())
{
int mapped = remap.getIntValue(id);
// add only: the inventory may hold just the open page, and log items are never lost
if (have.contains(id) || (mapped > 0 && have.contains(mapped)))
{
changed |= obtained.add(id);
}
}
synced = true;
if (changed)
{
saveObtained();
notifyListeners();
}
}
/** Fallback: items drawn fully opaque on the open log page are obtained. */
private void scanOpenPage()
{
List<Widget> children = new ArrayList<>();
for (int component : new int[]{InterfaceID.Collection.ITEMS, InterfaceID.Collection.ITEMS_CONTENTS})
{
Widget w = client.getWidget(component);
if (w == null || w.isHidden() || w.getDynamicChildren() == null)
{
continue;
}
for (Widget c : w.getDynamicChildren())
{
if (c != null && c.getItemId() > 0)
{
children.add(c);
}
}
}
if (children.isEmpty())
{
lastWidgetHash = 0;
return;
}
int hash = 1;
for (Widget c : children)
{
hash = 31 * hash + c.getItemId() * 7 + c.getOpacity();
}
if (hash == lastWidgetHash)
{
return;
}
lastWidgetHash = hash;
log.info("collection log page widgets: {} items", children.size());
boolean changed = !synced;
for (Widget c : children)
{
int id = c.getItemId();
if (c.getOpacity() != UNOBTAINED_OPACITY)
{
changed |= obtained.add(id);
}
}
synced = true;
if (changed)
{
saveObtained();
notifyListeners();
}
}
private void loadObtained()
{
obtained.clear();
Long full = configManager.getRSProfileConfiguration(GROUP, KEY_FULL_SYNC, Long.class);
fullSyncAt = full == null ? 0 : full;
String raw = configManager.getRSProfileConfiguration(GROUP, KEY);
synced = raw != null;
if (raw == null || raw.isEmpty())
{
return;
}
for (String s : raw.split(","))
{
try
{
obtained.add(Integer.parseInt(s.trim()));
}
catch (NumberFormatException ignored)
{
}
}
}
private void saveObtained()
{
if (profileKey == null)
{
return;
}
synced = true;
String raw = obtained.stream().sorted().map(String::valueOf).collect(Collectors.joining(","));
configManager.setRSProfileConfiguration(GROUP, KEY, raw);
}
private void notifyListeners()
{
for (Runnable r : listeners)
{
r.run();
}
}
}
@@ -0,0 +1,77 @@
package com.example.essentialsplus.currency;
import net.runelite.api.gameval.ItemID;
import net.runelite.api.gameval.VarPlayerID;
import net.runelite.api.gameval.VarbitID;
/** Points, marks and tokens earned from minigames and activities. */
public enum Currency
{
PEST_CONTROL("Pest Control points", Source.WIDGET, -1, ItemID.PEST_VOID_KNIGHT_TOP),
SLAYER("Slayer points", Source.VARBIT, VarbitID.SLAYER_POINTS, ItemID.SLAYER_HELM),
NMZ("Nightmare Zone points", Source.VARP, VarPlayerID.NZONE_REWARDPOINTS, ItemID.NZONE4DOSEOVERLOADPOTION),
TITHE_FARM("Tithe Farm points", Source.VARBIT, VarbitID.HOSIDIUS_TITHE_REWARDPOINTS, ItemID.HOSIDIUS_TITHE_FRUIT_A),
TEMPOROSS("Reward permits", Source.VARBIT, VarbitID.TEMPOROSS_REWARDPERMITS, ItemID.SPIRIT_FLAKES),
SOUL_WARS("Zeal", Source.VARP, VarPlayerID.SOUL_WARS_ZEAL, ItemID.SOUL_FRAGMENT),
GIANTS_FOUNDRY("Foundry reputation", Source.VARP, VarPlayerID.GIANTS_FOUNDRY_REWARD_SHOP_POINTS, ItemID.DOUBLE_AMMO_MOULD),
COLOSSEUM("Colosseum glory", Source.VARP, VarPlayerID.COLOSSEUM_GLORY, -1),
MIXOLOGY_MOX("Mox resin", Source.VARP, VarPlayerID.MIXOLOGY_MOX_POINTS, ItemID.MM_MOX_PASTE),
MIXOLOGY_AGA("Aga resin", Source.VARP, VarPlayerID.MIXOLOGY_AGA_POINTS, ItemID.MM_AGA_PASTE),
MIXOLOGY_LYE("Lye resin", Source.VARP, VarPlayerID.MIXOLOGY_LYE_POINTS, ItemID.MM_LYE_PASTE),
COMBAT_ACHIEVEMENTS("Combat achievement points", Source.VARBIT, VarbitID.CA_POINTS, -1),
BOUNTY_HUNTER("Bounty Hunter points", Source.VARP, VarPlayerID.BH_2023_POINTS, ItemID.BH_EMBLEM),
PVP_ARENA("PvP Arena points", Source.VARBIT, VarbitID.PVPA_POINTS_CURRENCY, -1),
MARKS_OF_GRACE("Marks of grace", Source.ITEM, ItemID.GRACE, ItemID.GRACE),
TOKKUL("Tokkul", Source.ITEM, ItemID.TZHAAR_TOKEN, ItemID.TZHAAR_TOKEN),
GOLDEN_NUGGETS("Golden nuggets", Source.ITEM, ItemID.MOTHERLODE_NUGGET, ItemID.MOTHERLODE_NUGGET),
HALLOWED_MARKS("Hallowed marks", Source.ITEM, ItemID.HALLOWED_MARK, ItemID.HALLOWED_MARK),
ABYSSAL_PEARLS("Abyssal pearls", Source.ITEM, ItemID.ABYSSAL_PEARL, ItemID.ABYSSAL_PEARL),
STARDUST("Stardust", Source.ITEM, ItemID.STAR_DUST, ItemID.STAR_DUST),
MOLCH_PEARLS("Molch pearls", Source.ITEM, ItemID.AERIAL_FISHING_PEARL, ItemID.AERIAL_FISHING_PEARL),
NUMULITE("Numulite", Source.ITEM, ItemID.FOSSIL_NUMULITE, ItemID.FOSSIL_NUMULITE),
SPIRIT_FLAKES("Spirit flakes", Source.ITEM, ItemID.SPIRIT_FLAKES, ItemID.SPIRIT_FLAKES),
ANIMA_BARK("Anima-infused bark", Source.ITEM, ItemID.FORESTRY_CURRENCY, ItemID.FORESTRY_CURRENCY),
CRYSTAL_SHARDS("Crystal shards", Source.ITEM, ItemID.PRIF_CRYSTAL_SHARD, ItemID.PRIF_CRYSTAL_SHARD),
CASTLE_WARS("Castle wars tickets", Source.ITEM, ItemID.CASTLEWARS_TICKET, ItemID.CASTLEWARS_TICKET),
AGILITY_ARENA("Agility arena tickets", Source.ITEM, ItemID.AGILITYARENA_TICKET, ItemID.AGILITYARENA_TICKET),
ECTO_TOKENS("Ecto-tokens", Source.ITEM, ItemID.ECTOTOKEN, ItemID.ECTOTOKEN),
WARRIOR_GUILD("Warrior guild tokens", Source.ITEM, ItemID.WARGUILD_TOKENS, ItemID.WARGUILD_TOKENS),
PIECES_OF_EIGHT("Pieces of eight", Source.ITEM, ItemID.BREW_PIECE_OF_EIGHT, ItemID.BREW_PIECE_OF_EIGHT),
MINNOWS("Minnows", Source.ITEM, ItemID.MINNOW, ItemID.MINNOW);
public enum Source
{
VARBIT, VARP, ITEM, WIDGET
}
public final String label;
public final Source source;
/** varbit / varp / item id */
public final int id;
/** item shown as icon, -1 = letter badge */
public final int icon;
Currency(String label, Source source, int id, int icon)
{
this.label = label;
this.source = source;
this.id = id;
this.icon = icon;
}
public static Currency byName(String name)
{
if (name != null)
{
for (Currency c : values())
{
if (c.name().equals(name))
{
return c;
}
}
}
return null;
}
}
@@ -0,0 +1,170 @@
package com.example.essentialsplus.currency;
import com.example.essentialsplus.ui.TimeFilter;
import com.example.essentialsplus.ui.TimeRange;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/** Point / token amounts of one character over time. */
public class CurrencyHistory
{
/** One observed amount. */
public static class Sample
{
public long t;
/** amount held */
public long v;
/** change counted as earned (> 0) or spent (< 0); 0 for a sync (value changed while not watched) */
public long g;
public Sample()
{
}
Sample(long t, long v, long g)
{
this.t = t;
this.v = v;
this.g = g;
}
}
public String key;
public String name;
/** currency enum name -> samples, oldest first */
public Map<String, List<Sample>> series = new HashMap<>();
/** currency with the latest earned change and when that happened */
public String lastActive;
public long lastActiveAt;
public transient boolean dirty;
public CurrencyHistory()
{
}
public CurrencyHistory(String key, String name)
{
this.key = key;
this.name = name;
}
void normalize()
{
if (series == null)
{
series = new HashMap<>();
}
series.values().removeIf(l -> l == null || l.isEmpty());
}
public synchronized List<Sample> samples(Currency c)
{
List<Sample> s = series.get(c.name());
return s == null ? new ArrayList<>() : new ArrayList<>(s);
}
public synchronized boolean has(Currency c)
{
List<Sample> s = series.get(c.name());
return s != null && !s.isEmpty();
}
/** @return the latest known amount, -1 if never seen */
public synchronized long amount(Currency c)
{
List<Sample> s = series.get(c.name());
return s == null || s.isEmpty() ? -1 : s.get(s.size() - 1).v;
}
/** Time of the latest earned change, 0 if none. */
public synchronized long lastGain(Currency c)
{
List<Sample> s = series.get(c.name());
if (s != null)
{
for (int i = s.size() - 1; i >= 0; i--)
{
if (s.get(i).g > 0)
{
return s.get(i).t;
}
}
}
return 0;
}
/**
* Stores an observed amount.
*
* @param sync the change happened while it could not be watched (login, bank open): not counted as earned
* @return the change counted as earned or spent, 0 when nothing was counted
*/
synchronized long record(Currency c, long value, long now, boolean sync)
{
List<Sample> s = series.computeIfAbsent(c.name(), k -> new ArrayList<>());
Sample last = s.isEmpty() ? null : s.get(s.size() - 1);
if (last != null && last.v == value)
{
return 0;
}
long delta = last == null || sync ? 0 : value - last.v;
s.add(new Sample(now, value, delta));
dirty = true;
if (delta > 0)
{
lastActive = c.name();
lastActiveAt = now;
}
return delta;
}
/** Earned (positive) and spent (negative) in the range of the filter. */
public synchronized long[] change(Currency c, TimeFilter filter, long now)
{
List<Sample> s = series.get(c.name());
long[] out = new long[2];
if (s == null || s.isEmpty())
{
return out;
}
long start = filter.getRange() == TimeRange.CURRENT
? TimeFilter.streakStart(s.size(), i -> s.get(i).t, i -> s.get(i).g > 0, now)
: filter.start();
for (int i = s.size() - 1; i >= 0 && s.get(i).t >= start; i--)
{
long g = s.get(i).g;
if (g > 0)
{
out[0] += g;
}
else
{
out[1] += g;
}
}
return out;
}
/** Earned per bucket over the range, oldest first (for a small bar chart). */
public synchronized long[] earnedBuckets(Currency c, long start, long end, int buckets)
{
long[] out = new long[buckets];
List<Sample> s = series.get(c.name());
if (s == null || end <= start)
{
return out;
}
for (Sample x : s)
{
if (x.g > 0 && x.t >= start && x.t <= end)
{
int b = (int) Math.min(buckets - 1, (x.t - start) * buckets / (end - start));
out[b] += x.g;
}
}
return out;
}
}
@@ -0,0 +1,147 @@
package com.example.essentialsplus.currency;
import com.example.essentialsplus.WealthStore;
import com.google.gson.Gson;
import java.io.File;
import java.io.Reader;
import java.io.Writer;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.StandardCopyOption;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import javax.inject.Inject;
import javax.inject.Singleton;
import lombok.extern.slf4j.Slf4j;
/** Loads and saves {@link CurrencyHistory} files (one per character) in {@code essentials-plus/currencies}. */
@Slf4j
@Singleton
public class CurrencyStore
{
public static final File DIR = new File(WealthStore.DIR, "currencies");
private static final long HOUR = 3_600_000L;
private static final long DAY = 24 * HOUR;
private final Gson gson;
private final Map<String, CurrencyHistory> histories = new ConcurrentHashMap<>();
@Inject
public CurrencyStore(Gson gson)
{
this.gson = gson.newBuilder().disableHtmlEscaping().create();
}
public void load()
{
File[] files = DIR.listFiles((d, n) -> n.endsWith(".json"));
if (files == null)
{
return;
}
for (File f : files)
{
try (Reader reader = Files.newBufferedReader(f.toPath(), StandardCharsets.UTF_8))
{
CurrencyHistory h = gson.fromJson(reader, CurrencyHistory.class);
if (h != null && h.key != null)
{
h.normalize();
histories.putIfAbsent(h.key, h);
}
}
catch (Exception e)
{
log.warn("Currencies: could not read {}", f, e);
}
}
}
public CurrencyHistory get(String key)
{
return key == null ? null : histories.get(key);
}
CurrencyHistory getOrCreate(String key, String name)
{
CurrencyHistory h = histories.computeIfAbsent(key, k -> new CurrencyHistory(k, name));
h.name = name;
return h;
}
public void saveDirty()
{
for (CurrencyHistory h : histories.values())
{
if (h.dirty)
{
save(h);
}
}
}
private synchronized void save(CurrencyHistory h)
{
String json;
synchronized (h)
{
long now = System.currentTimeMillis();
for (Map.Entry<String, List<CurrencyHistory.Sample>> e : h.series.entrySet())
{
e.setValue(compact(e.getValue(), now));
}
json = gson.toJson(h);
h.dirty = false;
}
try
{
Files.createDirectories(DIR.toPath());
File target = new File(DIR, h.key.replaceAll("[^A-Za-z0-9 _()\\-]", "_") + ".json");
File tmp = new File(DIR, target.getName() + ".tmp");
try (Writer w = Files.newBufferedWriter(tmp.toPath(), StandardCharsets.UTF_8))
{
w.write(json);
}
Files.move(tmp.toPath(), target.toPath(), StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
}
catch (Exception e)
{
h.dirty = true;
log.warn("Currencies: could not save {}", h.key, e);
}
}
/**
* Samples older than 7 days are merged per hour, older than 90 days per day: the last amount is kept
* and earned / spent are summed separately. The first sample stays as baseline.
*/
static List<CurrencyHistory.Sample> compact(List<CurrencyHistory.Sample> in, long now)
{
if (in.size() < 3)
{
return in;
}
List<CurrencyHistory.Sample> out = new ArrayList<>(in.size());
out.add(in.get(0));
for (int i = 1; i < in.size(); i++)
{
CurrencyHistory.Sample s = in.get(i);
long age = now - s.t;
long size = age > 90 * DAY ? DAY : age > 7 * DAY ? HOUR : 0;
CurrencyHistory.Sample prev = out.get(out.size() - 1);
// only merge changes of the same sign, so earned and spent stay apart
if (size > 0 && out.size() > 1 && prev.t / size == s.t / size && Long.signum(prev.g) * Long.signum(s.g) >= 0)
{
out.set(out.size() - 1, new CurrencyHistory.Sample(s.t, s.v, prev.g + s.g));
}
else
{
out.add(s);
}
}
return out;
}
}
@@ -0,0 +1,269 @@
package com.example.essentialsplus.currency;
import java.util.EnumSet;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.function.IntUnaryOperator;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import net.runelite.api.Client;
import net.runelite.api.gameval.InterfaceID;
import net.runelite.api.widgets.Widget;
/**
* Reads point varbits / varps every tick and token items after each wealth valuation, records the changes and
* works out what the player is doing right now. Client thread only, except the getters.
*/
public class CurrencyTracker
{
/** a currency stays "what you are doing" this long after it was last earned */
public static final long ACTIVE_WINDOW = 20 * 60_000L;
/** varps read 0 or stale values right after login: changes until then are synced, not earned */
private static final int SETTLE_TICKS = 10;
private static final String[] ITEM_CONTAINERS = {"bank", "inventory", "equipment", "lootingBag", "seedVault"};
private static final Pattern NUMBER = Pattern.compile("(\\d[\\d,]*)");
private static final Map<Integer, Currency> ITEMS = new HashMap<>();
static
{
for (Currency c : Currency.values())
{
if (c.source == Currency.Source.ITEM)
{
ITEMS.put(c.id, c);
}
}
}
private final Client client;
private final CurrencyStore store;
private final List<Runnable> listeners = new CopyOnWriteArrayList<>();
/** currencies read at least once since login; the first reading is a sync */
private final Set<Currency> seen = EnumSet.noneOf(Currency.class);
private volatile String key;
/** Pest Control lander / game overlay is on screen */
private volatile boolean inPestControl;
public CurrencyTracker(Client client, CurrencyStore store)
{
this.client = client;
this.store = store;
}
public void addListener(Runnable r)
{
listeners.add(r);
}
public void removeListener(Runnable r)
{
listeners.remove(r);
}
public String getKey()
{
return key;
}
public CurrencyHistory history()
{
return store.get(key);
}
/** Logged out: the next login starts with a sync again. */
public void reset()
{
seen.clear();
key = null;
inPestControl = false;
notifyListeners();
}
public void onGameTick(String accountKey, String name, int ticksSinceLogin)
{
if (accountKey == null || ticksSinceLogin < 3)
{
return;
}
if (!accountKey.equals(key))
{
seen.clear();
key = accountKey;
notifyListeners();
}
CurrencyHistory h = store.getOrCreate(accountKey, name);
long now = System.currentTimeMillis();
boolean settled = ticksSinceLogin >= SETTLE_TICKS;
boolean changed = false;
for (Currency c : Currency.values())
{
long value;
switch (c.source)
{
case VARBIT:
value = client.getVarbitValue(c.id);
break;
case VARP:
value = client.getVarpValue(c.id);
break;
default:
continue;
}
changed |= record(h, c, value, now, !settled);
}
Widget lander = visible(InterfaceID.PestLanderOverlay.PEST_LANDER_OVER_POINTS);
boolean pest = lander != null || visible(InterfaceID.PestStatusOverlay.PEST_STATUS_OVER_TIME) != null;
if (pest != inPestControl)
{
inPestControl = pest;
changed = true;
}
long points = parse(lander);
if (points < 0)
{
points = parse(visible(InterfaceID.PestRewardshop.TOTAL));
}
if (points >= 0)
{
changed |= record(h, Currency.PEST_CONTROL, points, now, false);
}
if (changed)
{
notifyListeners();
}
}
/**
* Counts token items over all known containers of the character.
*
* @param sync items moved in a way that is not earning or spending (bank open, login)
*/
public void onItems(String accountKey, String name, Map<String, Map<Integer, Long>> containers, Map<Integer, Long> bankPending,
boolean sync, IntUnaryOperator canonicalize)
{
if (accountKey == null || containers == null || !accountKey.equals(key))
{
return;
}
Map<Currency, Long> totals = new HashMap<>();
for (String k : ITEM_CONTAINERS)
{
count(containers.get(k), canonicalize, totals);
}
count(bankPending, canonicalize, totals);
CurrencyHistory h = store.getOrCreate(accountKey, name);
long now = System.currentTimeMillis();
boolean changed = false;
for (Currency c : ITEMS.values())
{
long value = totals.getOrDefault(c, 0L);
// never seen and none held: nothing to show
if (value == 0 && !h.has(c))
{
continue;
}
changed |= record(h, c, value, now, sync);
}
if (changed)
{
notifyListeners();
}
}
private static void count(Map<Integer, Long> items, IntUnaryOperator canonicalize, Map<Currency, Long> totals)
{
if (items == null)
{
return;
}
for (Map.Entry<Integer, Long> e : items.entrySet())
{
Currency c = ITEMS.get(e.getKey());
if (c == null)
{
c = ITEMS.get(canonicalize.applyAsInt(e.getKey()));
}
if (c != null)
{
totals.merge(c, e.getValue(), Long::sum);
}
}
}
private boolean record(CurrencyHistory h, Currency c, long value, long now, boolean sync)
{
if (value < 0)
{
return false;
}
// a currency never held is not worth a row
if (value == 0 && !h.has(c))
{
seen.add(c);
return false;
}
boolean first = seen.add(c);
long before = h.amount(c);
h.record(c, value, now, sync || first);
return before != value;
}
/**
* What the player is doing right now: Pest Control while its overlay is shown, otherwise the currency
* earned last within {@link #ACTIVE_WINDOW}; null when idle.
*/
public Currency active()
{
if (inPestControl)
{
return Currency.PEST_CONTROL;
}
CurrencyHistory h = history();
if (h == null || h.lastActive == null || System.currentTimeMillis() - h.lastActiveAt > ACTIVE_WINDOW)
{
return null;
}
return Currency.byName(h.lastActive);
}
private Widget visible(int component)
{
Widget w = client.getWidget(component);
return w == null || w.isHidden() ? null : w;
}
private static long parse(Widget w)
{
if (w == null || w.getText() == null)
{
return -1;
}
Matcher m = NUMBER.matcher(w.getText().replaceAll("<[^>]*>", ""));
long last = -1;
while (m.find())
{
try
{
last = Long.parseLong(m.group(1).replace(",", ""));
}
catch (NumberFormatException ignored)
{
// keep the previous number
}
}
return last;
}
private void notifyListeners()
{
for (Runnable r : listeners)
{
r.run();
}
}
}
@@ -0,0 +1,217 @@
package com.example.essentialsplus.currency.ui;
import com.example.essentialsplus.currency.Currency;
import com.example.essentialsplus.ui.Ui;
import java.awt.Cursor;
import java.awt.Dimension;
import java.awt.Font;
import java.awt.FontMetrics;
import java.awt.Graphics;
import java.awt.Graphics2D;
import java.awt.event.MouseAdapter;
import java.awt.event.MouseEvent;
import java.text.NumberFormat;
import java.util.Locale;
import javax.swing.JComponent;
import javax.swing.SwingUtilities;
import net.runelite.client.ui.FontManager;
import net.runelite.client.ui.PluginPanel;
/** Overview entry: the points / marks of what is being played right now, big. */
public class CurrencyCard extends JComponent
{
private static final int ICON = 36;
private final CurrencyIcons icons;
private Currency currency;
private long amount;
private long earned;
private String rangeLabel = "";
private boolean now;
private long lastAt;
private int tracked;
private boolean hover;
private boolean showTracked = true;
private boolean pinned;
CurrencyCard(CurrencyIcons icons, Runnable onClick)
{
this.icons = icons;
setCursor(Cursor.getPredefinedCursor(Cursor.HAND_CURSOR));
addMouseListener(new MouseAdapter()
{
@Override
public void mouseEntered(MouseEvent e)
{
hover = true;
repaint();
}
@Override
public void mouseExited(MouseEvent e)
{
hover = false;
repaint();
}
@Override
public void mousePressed(MouseEvent e)
{
if (SwingUtilities.isLeftMouseButton(e))
{
onClick.run();
}
}
});
}
/**
* @param currency shown currency, null when nothing is tracked yet
* @param now it is being played right now (otherwise it is the one played last)
*/
void update(Currency currency, long amount, long earned, String rangeLabel, boolean now, long lastAt, int tracked)
{
this.currency = currency;
this.amount = amount;
this.earned = earned;
this.rangeLabel = rangeLabel;
this.now = now;
this.lastAt = lastAt;
this.tracked = tracked;
repaint();
}
void setOptions(boolean showTracked, boolean pinned)
{
this.showTracked = showTracked;
this.pinned = pinned;
repaint();
}
static String number(long v)
{
return v >= 10_000_000 ? Ui.gp(v) : NumberFormat.getIntegerInstance(Locale.US).format(v);
}
static String ago(long t)
{
long d = System.currentTimeMillis() - t;
if (t <= 0)
{
return "never";
}
if (d < Ui.MINUTE)
{
return "just now";
}
if (d < Ui.HOUR)
{
return d / Ui.MINUTE + "m ago";
}
if (d < Ui.DAY)
{
return d / Ui.HOUR + "h ago";
}
return d / Ui.DAY + "d ago";
}
@Override
public Dimension getPreferredSize()
{
return new Dimension(PluginPanel.PANEL_WIDTH - 16, 104);
}
@Override
public Dimension getMinimumSize()
{
return new Dimension(10, 96);
}
@Override
protected void paintComponent(Graphics g0)
{
Graphics2D g = Ui.prepare(g0);
int w = getWidth();
int h = getHeight();
g.setColor(hover ? Ui.CARD_HOVER : Ui.CARD);
g.fillRoundRect(0, 0, w, h, 8, 8);
int pad = 10;
Font bold = FontManager.getRunescapeBoldFont();
Font small = FontManager.getRunescapeSmallFont();
g.setFont(bold);
FontMetrics fm = g.getFontMetrics();
int head = 6 + fm.getAscent();
g.setColor(Ui.ACCENT);
String title = "Points & Marks";
g.drawString(title, pad, head);
g.setFont(bold.deriveFont(14f));
g.setColor(hover ? Ui.ACCENT : Ui.FAINT);
Ui.drawRight(g, "›", w - pad, head);
g.setFont(small);
FontMetrics sfm = g.getFontMetrics();
int subRight = w - pad - 14;
if (currency != null)
{
String sub = now ? "● Playing now" : pinned ? "Pinned" : "Last: " + ago(lastAt);
g.setColor(now ? Ui.UP : Ui.MUTED);
if (subRight - sfm.stringWidth(sub) > pad + fm.stringWidth(title) + 8)
{
Ui.drawRight(g, sub, subRight, head);
}
}
if (currency == null)
{
g.setFont(FontManager.getRunescapeFont());
g.setColor(Ui.TEXT);
g.drawString("Nothing tracked yet", pad, head + 22);
g.setFont(small);
g.setColor(Ui.FAINT);
g.drawString("Play a minigame or collect marks,", pad, head + 40);
g.drawString("tokens or points to start", pad, head + 54);
g.dispose();
return;
}
int top = head + 8;
icons.paint(g, currency, pad - 2, top + 4, ICON, this);
int tx = pad + ICON + 8;
g.setFont(bold.deriveFont(26f));
FontMetrics bfm = g.getFontMetrics();
int bigBase = top + bfm.getAscent() - 2;
g.setColor(Ui.GOLD);
g.drawString(number(amount), tx, bigBase);
g.setFont(small);
int labelBase = bigBase + sfm.getAscent() + 3;
g.setColor(Ui.TEXT);
g.drawString(currency.label, tx, labelBase);
int earnBase = labelBase + sfm.getAscent() + 6;
String gain = "+" + number(earned);
g.setFont(bold);
FontMetrics gfm = g.getFontMetrics();
g.setColor(earned > 0 ? Ui.UP : Ui.FAINT);
g.drawString(gain, tx, earnBase);
g.setFont(small);
g.setColor(Ui.FAINT);
int afterGain = tx + gfm.stringWidth(gain);
String earnedText = Ui.ellipsize(sfm, " earned " + rangeLabel, w - pad - afterGain);
g.drawString(earnedText, afterGain, earnBase);
if (showTracked && tracked > 1)
{
// in the label line, right of the name - never over the earned text
String t = tracked + " tracked";
if (w - pad - sfm.stringWidth(t) > tx + sfm.stringWidth(currency.label) + 8)
{
Ui.drawRight(g, t, w - pad, labelBase);
}
}
Ui.paintDragOutline(g, this);
g.dispose();
}
}
@@ -0,0 +1,57 @@
package com.example.essentialsplus.currency.ui;
import com.example.essentialsplus.currency.Currency;
import com.example.essentialsplus.ui.Ui;
import java.awt.Component;
import java.awt.Font;
import java.awt.FontMetrics;
import java.awt.Graphics2D;
import java.util.EnumMap;
import java.util.Map;
import net.runelite.client.game.ItemManager;
import net.runelite.client.ui.FontManager;
import net.runelite.client.util.AsyncBufferedImage;
/** Item icons of the currencies, with a letter badge for those without one (Swing thread only). */
final class CurrencyIcons
{
private final ItemManager itemManager;
private final Map<Currency, AsyncBufferedImage> icons = new EnumMap<>(Currency.class);
CurrencyIcons(ItemManager itemManager)
{
this.itemManager = itemManager;
}
void paint(Graphics2D g, Currency c, int x, int y, int size, Component repaintTarget)
{
if (c.icon > 0)
{
AsyncBufferedImage img = icons.get(c);
if (img == null)
{
img = itemManager.getImage(c.icon);
icons.put(c, img);
img.onLoaded(repaintTarget::repaint);
}
int w = img.getWidth();
int h = img.getHeight();
if (w > 0 && h > 0)
{
double scale = Math.min(1.0, Math.min((double) size / w, (double) size / h));
int dw = (int) Math.round(w * scale);
int dh = (int) Math.round(h * scale);
g.drawImage(img, x + (size - dw) / 2, y + (size - dh) / 2, dw, dh, null);
}
return;
}
g.setColor(Ui.alpha(Ui.ACCENT, 50));
g.fillOval(x + 2, y + 2, size - 4, size - 4);
Font f = FontManager.getRunescapeBoldFont();
g.setFont(f);
FontMetrics fm = g.getFontMetrics();
String s = c.label.substring(0, 1);
g.setColor(Ui.ACCENT);
g.drawString(s, x + (size - fm.stringWidth(s)) / 2, y + (size + fm.getAscent()) / 2 - 2);
}
}
@@ -0,0 +1,353 @@
package com.example.essentialsplus.currency.ui;
import com.example.essentialsplus.currency.Currency;
import com.example.essentialsplus.currency.CurrencyHistory;
import com.example.essentialsplus.currency.CurrencyTracker;
import com.example.essentialsplus.ui.TimeFilter;
import com.example.essentialsplus.ui.Ui;
import java.awt.BorderLayout;
import java.awt.Cursor;
import java.awt.Dimension;
import java.awt.Font;
import java.awt.FontMetrics;
import java.awt.Graphics;
import java.awt.Graphics2D;
import java.util.ArrayList;
import java.util.List;
import javax.swing.BorderFactory;
import javax.swing.JButton;
import javax.swing.JComponent;
import javax.swing.JLabel;
import javax.swing.JPanel;
import javax.swing.JScrollPane;
import javax.swing.ScrollPaneConstants;
import javax.swing.SwingUtilities;
import net.runelite.client.game.ItemManager;
import net.runelite.client.ui.DynamicGridLayout;
import net.runelite.client.ui.FontManager;
import net.runelite.client.ui.PluginPanel;
/** Points & Marks tab: every tracked minigame point, mark and token with the amount earned in the range. */
public class CurrencyPanel extends JPanel
{
private final CurrencyTracker tracker;
private final TimeFilter filter;
private final CurrencyIcons icons;
private final JPanel list = new JPanel(new DynamicGridLayout(0, 1, 0, 4));
private final CurrencyCard hero;
private CurrencyCard overviewCard;
/** currency always shown on the overview card, null = what is being played */
private Currency overviewPin;
private boolean overviewShowTracked = true;
public CurrencyPanel(CurrencyTracker tracker, ItemManager itemManager, TimeFilter filter, Runnable onBack)
{
this.tracker = tracker;
this.filter = filter;
this.icons = new CurrencyIcons(itemManager);
this.hero = new CurrencyCard(icons, () -> { });
hero.setCursor(Cursor.getDefaultCursor());
setLayout(new BorderLayout(0, 6));
setBackground(Ui.BG);
setBorder(BorderFactory.createEmptyBorder(10, 8, 8, 8));
JPanel backRow = new JPanel(new BorderLayout(6, 0));
backRow.setOpaque(false);
JButton backBtn = new JButton("← Overview");
backBtn.setFont(FontManager.getRunescapeSmallFont());
backBtn.setForeground(Ui.ACCENT);
backBtn.setFocusPainted(false);
backBtn.setMargin(new java.awt.Insets(2, 2, 2, 2));
backBtn.setCursor(Cursor.getPredefinedCursor(Cursor.HAND_CURSOR));
backBtn.addActionListener(e -> onBack.run());
backRow.add(backBtn, BorderLayout.WEST);
JLabel title = new JLabel("Points & Marks");
title.setFont(FontManager.getRunescapeBoldFont());
title.setForeground(Ui.ACCENT);
title.setHorizontalAlignment(JLabel.RIGHT);
backRow.add(title, BorderLayout.CENTER);
add(backRow, BorderLayout.NORTH);
list.setOpaque(false);
JPanel listWrap = new JPanel(new BorderLayout());
listWrap.setOpaque(false);
listWrap.add(list, BorderLayout.NORTH);
JScrollPane scroll = new JScrollPane(listWrap);
scroll.setBorder(null);
scroll.setOpaque(false);
scroll.getViewport().setOpaque(false);
scroll.setHorizontalScrollBarPolicy(ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER);
scroll.getVerticalScrollBar().setUnitIncrement(16);
scroll.getVerticalScrollBar().setPreferredSize(new Dimension(8, 0));
add(scroll, BorderLayout.CENTER);
refresh();
}
public void setOverviewOptions(Currency pin, boolean showTracked)
{
overviewPin = pin;
overviewShowTracked = showTracked;
refresh();
}
/** Currencies with data for the logged-in character, in enum order. */
public List<Currency> tracked()
{
List<Currency> out = new ArrayList<>();
CurrencyHistory h = tracker.history();
if (h != null)
{
for (Currency c : Currency.values())
{
if (h.has(c))
{
out.add(c);
}
}
}
return out;
}
/** Card for the overview, kept up to date by {@link #refresh}. */
public CurrencyCard createOverviewCard(Runnable onClick)
{
overviewCard = new CurrencyCard(icons, onClick);
refresh();
return overviewCard;
}
/** Swing thread. */
public void refresh()
{
CurrencyHistory h = tracker.history();
long now = System.currentTimeMillis();
String range = filter.inLabel();
List<Currency> shown = new ArrayList<>();
if (h != null)
{
for (Currency c : Currency.values())
{
if (h.has(c))
{
shown.add(c);
}
}
}
Currency active = tracker.active();
Currency featured = active;
if (featured == null && h != null)
{
featured = Currency.byName(h.lastActive);
if (featured == null)
{
long best = -1;
for (Currency c : shown)
{
List<CurrencyHistory.Sample> s = h.samples(c);
long t = s.get(s.size() - 1).t;
if (t > best)
{
best = t;
featured = c;
}
}
}
}
for (CurrencyCard card : new CurrencyCard[]{hero, overviewCard})
{
if (card == null)
{
continue;
}
Currency c = featured;
boolean pinned = card == overviewCard && overviewPin != null && h != null && h.has(overviewPin);
if (pinned)
{
c = overviewPin;
}
card.setOptions(card != overviewCard || overviewShowTracked, pinned && c != active);
if (c == null || h == null)
{
card.update(null, 0, 0, range, false, 0, 0);
}
else
{
card.update(c, Math.max(0, h.amount(c)), h.change(c, filter, now)[0], range,
c == active, h.lastGain(c), shown.size());
}
}
list.removeAll();
list.add(hero);
if (h == null || shown.isEmpty())
{
list.add(hint(h == null
? "Log in to start tracking."
: "Nothing tracked yet. Points show up once they change or are read in-game."));
}
else
{
// earned in the range first, then most recently earned
shown.sort((a, b) ->
{
int byActive = Boolean.compare(b == active, a == active);
if (byActive != 0)
{
return byActive;
}
return Long.compare(h.lastGain(b), h.lastGain(a));
});
addSection("POINTS", shown, h, active, now, false);
addSection("MARKS & TOKENS", shown, h, active, now, true);
}
list.add(hint("Pest Control points are read while the lander or reward shop is open. "
+ "Items count bank, inventory, gear, looting bag and seed vault."));
list.revalidate();
list.repaint();
}
private void addSection(String name, List<Currency> shown, CurrencyHistory h, Currency active, long now, boolean items)
{
boolean header = false;
for (Currency c : shown)
{
if ((c.source == Currency.Source.ITEM) != items)
{
continue;
}
if (!header)
{
list.add(header(name));
header = true;
}
long[] change = h.change(c, filter, now);
list.add(new Row(c, h.amount(c), change[0], change[1], h.lastGain(c), c == active));
}
}
private static JLabel header(String text)
{
JLabel l = new JLabel(text);
l.setFont(FontManager.getRunescapeSmallFont());
l.setForeground(Ui.MUTED);
l.setBorder(BorderFactory.createEmptyBorder(6, 2, 0, 2));
return l;
}
private static JLabel hint(String text)
{
JLabel l = new JLabel("<html>" + text + "</html>");
l.setFont(FontManager.getRunescapeSmallFont());
l.setForeground(Ui.FAINT);
l.setBorder(BorderFactory.createEmptyBorder(6, 4, 6, 4));
return l;
}
/** One currency: icon, name, amount, earned / spent in the range. */
private final class Row extends JComponent
{
private static final int ICON = 28;
private final Currency currency;
private final long amount;
private final long earned;
private final long spent;
private final long lastGain;
private final boolean active;
Row(Currency currency, long amount, long earned, long spent, long lastGain, boolean active)
{
this.currency = currency;
this.amount = amount;
this.earned = earned;
this.spent = spent;
this.lastGain = lastGain;
this.active = active;
setToolTipText(currency.label + ": " + CurrencyCard.number(amount)
+ (lastGain > 0 ? " - last earned " + CurrencyCard.ago(lastGain) : ""));
}
@Override
public Dimension getPreferredSize()
{
return new Dimension(PluginPanel.PANEL_WIDTH - 24, 44);
}
@Override
protected void paintComponent(Graphics g0)
{
Graphics2D g = Ui.prepare(g0);
int w = getWidth();
int h = getHeight();
g.setColor(active ? Ui.CARD_SELECTED : Ui.CARD);
g.fillRoundRect(0, 0, w, h, 6, 6);
if (active)
{
g.setColor(Ui.UP);
g.fillRoundRect(0, 6, 3, h - 12, 3, 3);
}
int pad = 8;
icons.paint(g, currency, pad - 2, (h - ICON) / 2, ICON, this);
int tx = pad + ICON + 6;
Font bold = FontManager.getRunescapeBoldFont();
Font small = FontManager.getRunescapeSmallFont();
g.setFont(bold);
FontMetrics bfm = g.getFontMetrics();
String value = CurrencyCard.number(Math.max(0, amount));
int line1 = 5 + bfm.getAscent();
g.setColor(Ui.GOLD);
Ui.drawRight(g, value, w - pad, line1);
g.setFont(FontManager.getRunescapeFont());
FontMetrics nfm = g.getFontMetrics();
g.setColor(Ui.TEXT);
int nameMax = w - pad - bfm.stringWidth(value) - 8 - tx;
g.drawString(clip(nfm, currency.label, nameMax), tx, line1);
g.setFont(small);
FontMetrics sfm = g.getFontMetrics();
int line2 = line1 + sfm.getAscent() + 6;
String plus = "+" + CurrencyCard.number(earned);
g.setColor(earned > 0 ? Ui.UP : Ui.FAINT);
g.drawString(plus, tx, line2);
int x = tx + sfm.stringWidth(plus);
if (spent < 0)
{
String minus = " -" + CurrencyCard.number(-spent);
g.setColor(Ui.DOWN);
g.drawString(minus, x, line2);
x += sfm.stringWidth(minus);
}
g.setColor(Ui.FAINT);
g.drawString(" " + filter.getLabel(), x, line2);
g.setColor(active ? Ui.UP : Ui.FAINT);
Ui.drawRight(g, active ? "now" : lastGain > 0 ? CurrencyCard.ago(lastGain) : "", w - pad, line2);
g.dispose();
}
}
private static String clip(FontMetrics fm, String s, int max)
{
if (fm.stringWidth(s) <= max)
{
return s;
}
for (int i = s.length() - 1; i > 0; i--)
{
String t = s.substring(0, i) + "..";
if (fm.stringWidth(t) <= max)
{
return t;
}
}
return "";
}
/** Coalesced refresh from any thread. */
public void refreshLater()
{
SwingUtilities.invokeLater(this::refresh);
}
}
@@ -0,0 +1,462 @@
package com.example.essentialsplus.data;
import com.example.essentialsplus.WealthCategory;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CopyOnWriteArrayList;
/**
* Persisted state of one character. Collections are replaced wholesale (never mutated in place,
* except history which is copy-on-write) so the UI and the save thread can read them safely.
*/
public class AccountData
{
public int schema = 3;
public String key;
public long accountHash;
public String profile;
public String name;
public long lastUpdated;
public boolean bankKnown;
public long[] current;
/** running sum of value changes caused only by GE price movements (held quantity x price change) */
public long market;
/** Last known item stacks per source ("bank", "inventory", ...): item id -> quantity. */
public Map<String, Map<Integer, Long>> containers = new ConcurrentHashMap<>();
public List<SourceValue> sources = new ArrayList<>();
public List<TopItem> topItems = new ArrayList<>();
public List<Snapshot> history = new CopyOnWriteArrayList<>();
/** Grand Exchange slot state (see GrandExchangeTracker) */
public GeSlot[] geSlots;
/** Items known to be collected already when GE tracking starts for this character (item id -> qty). */
public Map<Integer, Long> geSeedCollected;
/** Items that went to the bank while it was not visible; dropped when the bank is seen again. */
public Map<Integer, Long> bankPending = new java.util.HashMap<>();
/** 4-hour Grand Exchange buy limit tracker: canonical item id -> GeBuyLimit */
public Map<Integer, GeBuyLimit> geBuyLimits = new ConcurrentHashMap<>();
/** Snapshot of Grand Exchange slots for 4h buy limit tracking, modeled after LimitKeeper */
public GeBuyLimitSlot[] geBuyLimitSlots = new GeBuyLimitSlot[8];
/** Timestamp of last right-click Reset Hourly in Wealth Tracker for 'Now' */
public long resetHourlyNowAt;
/** Timestamp of last right-click Reset Hourly in Wealth Tracker for 'Session' */
public long resetHourlySessionAt;
public static class GeBuyLimitSlot
{
public int itemId;
public int price;
public int total;
public int quantity;
public int spent;
public GeBuyLimitSlot()
{
}
public GeBuyLimitSlot(int itemId, int price, int total, int quantity, int spent)
{
this.itemId = itemId;
this.price = price;
this.total = total;
this.quantity = quantity;
this.spent = spent;
}
public boolean isSameOffer(GeBuyLimitSlot other)
{
return other != null && other.itemId == itemId && other.price == price && other.total == total;
}
}
public transient long fileModified;
public long currentTotal()
{
long sum = 0;
if (current != null)
{
for (long x : current)
{
sum += x;
}
}
return sum;
}
public Snapshot lastSnapshot()
{
List<Snapshot> h = history;
return h.isEmpty() ? null : h.get(h.size() - 1);
}
public void normalize()
{
containers = containers == null ? new ConcurrentHashMap<>() : new ConcurrentHashMap<>(containers);
sources = sources == null ? new ArrayList<>() : sources;
topItems = topItems == null ? new ArrayList<>() : topItems;
bankPending = bankPending == null ? new java.util.HashMap<>() : bankPending;
geBuyLimits = geBuyLimits == null ? new ConcurrentHashMap<>() : new ConcurrentHashMap<>(geBuyLimits);
if (geBuyLimitSlots == null || geBuyLimitSlots.length != 8)
{
geBuyLimitSlots = geBuyLimitSlots == null ? new GeBuyLimitSlot[8] : java.util.Arrays.copyOf(geBuyLimitSlots, 8);
}
if (schema < 3)
{
clearBuyLimits();
schema = 3;
}
List<Snapshot> h = history == null ? new ArrayList<>() : history;
h.removeIf(s -> s == null);
h.forEach(Snapshot::normalize);
h.sort((a, b) -> Long.compare(a.t, b.t));
h = sanitizeHistory(h);
history = new CopyOnWriteArrayList<>(h);
if (current != null && current.length < WealthCategory.COUNT)
{
current = java.util.Arrays.copyOf(current, WealthCategory.COUNT);
}
}
public static final long MAX_SPIKE_WINDOW_MS = 5 * 60 * 1000L;
public static final long MIN_SPIKE_ABS_GP = 1_000_000L;
public static final double MIN_SPIKE_REL_RATIO = 0.08;
/**
* Checks if a single snapshot at (tSpike, vSpike) represents a transient outlier spike between
* (tBefore, vBefore) and (tAfter, vAfter) that jumped away and quickly returned to baseline.
*/
public static boolean isTransientSpike(long tBefore, long vBefore,
long tSpike, long vSpike,
long tAfter, long vAfter)
{
long legBefore = tSpike - tBefore;
long legAfter = tAfter - tSpike;
if (legBefore <= 0 || legAfter <= 0)
{
return false;
}
// The spike itself must be transient:
// Either the entire span is within MAX_SPIKE_WINDOW_MS (continuous session),
// OR across a session/logout gap (where one leg is long), the spike only persisted for
// a brief moment (Math.min(legBefore, legAfter) <= MAX_SPIKE_WINDOW_MS).
boolean transientDuration = (tAfter - tBefore <= MAX_SPIKE_WINDOW_MS)
|| (Math.min(legBefore, legAfter) <= MAX_SPIKE_WINDOW_MS);
if (!transientDuration)
{
return false;
}
long d1 = vSpike - vBefore;
long d2 = vAfter - vSpike;
// Must be opposite directions (a jump away and a return)
if ((d1 > 0 && d2 >= 0) || (d1 < 0 && d2 <= 0) || d1 == 0 || d2 == 0)
{
return false;
}
long absD1 = Math.abs(d1);
long absD2 = Math.abs(d2);
long spikeMag = Math.min(absD1, absD2);
long baseDelta = Math.abs(vAfter - vBefore);
long baseWealth = Math.max(10_000L, Math.min(Math.abs(vBefore), Math.abs(vAfter)));
// Magnitude check: must be at least 1M GP or at least 8% of baseline wealth
boolean significantSpike = spikeMag >= MIN_SPIKE_ABS_GP || (double) spikeMag / baseWealth >= MIN_SPIKE_REL_RATIO;
if (!significantSpike)
{
return false;
}
// Baseline consistency check: after the spike, the value returned close to the starting baseline.
return baseDelta <= 0.35 * spikeMag
|| baseDelta <= (long) (0.05 * Math.max(Math.abs(vBefore), Math.abs(vAfter)) + 500_000L);
}
/**
* Checks if two consecutive snapshots represent a 2-point transient outlier spike between
* (tBefore, vBefore) and (tAfter, vAfter).
*/
public static boolean isDoubleTransientSpike(long tBefore, long vBefore,
long tSpike1, long vSpike1,
long tSpike2, long vSpike2,
long tAfter, long vAfter)
{
long legBefore = tSpike1 - tBefore;
long legGlitch = tSpike2 - tSpike1;
long legAfter = tAfter - tSpike2;
if (legBefore <= 0 || legGlitch <= 0 || legAfter <= 0)
{
return false;
}
if (legGlitch > MAX_SPIKE_WINDOW_MS)
{
return false;
}
boolean transientDuration = (tAfter - tBefore <= MAX_SPIKE_WINDOW_MS)
|| (Math.min(legBefore, legAfter) + legGlitch <= MAX_SPIKE_WINDOW_MS);
if (!transientDuration)
{
return false;
}
long d1_1 = vSpike1 - vBefore;
long d1_2 = vSpike2 - vBefore;
long d2_1 = vAfter - vSpike1;
long d2_2 = vAfter - vSpike2;
// Both points must deviate in the same direction from baseline and return
boolean bothPositive = d1_1 > 0 && d1_2 > 0 && d2_1 < 0 && d2_2 < 0;
boolean bothNegative = d1_1 < 0 && d1_2 < 0 && d2_1 > 0 && d2_2 > 0;
if (!bothPositive && !bothNegative)
{
return false;
}
long spikeMag = Math.min(
Math.min(Math.abs(d1_1), Math.abs(d1_2)),
Math.min(Math.abs(d2_1), Math.abs(d2_2))
);
long baseDelta = Math.abs(vAfter - vBefore);
long baseWealth = Math.max(10_000L, Math.min(Math.abs(vBefore), Math.abs(vAfter)));
boolean significantSpike = spikeMag >= MIN_SPIKE_ABS_GP || (double) spikeMag / baseWealth >= MIN_SPIKE_REL_RATIO;
if (!significantSpike)
{
return false;
}
return baseDelta <= 0.35 * spikeMag
|| baseDelta <= (long) (0.05 * Math.max(Math.abs(vBefore), Math.abs(vAfter)) + 500_000L);
}
/**
* Checks if three consecutive snapshots represent a 3-point transient outlier spike between
* (tBefore, vBefore) and (tAfter, vAfter).
*/
public static boolean isTripleTransientSpike(long tBefore, long vBefore,
long tSpike1, long vSpike1,
long tSpike2, long vSpike2,
long tSpike3, long vSpike3,
long tAfter, long vAfter)
{
long legBefore = tSpike1 - tBefore;
long legGlitch1 = tSpike2 - tSpike1;
long legGlitch2 = tSpike3 - tSpike2;
long totalGlitch = tSpike3 - tSpike1;
long legAfter = tAfter - tSpike3;
if (legBefore <= 0 || legGlitch1 <= 0 || legGlitch2 <= 0 || legAfter <= 0)
{
return false;
}
if (totalGlitch > MAX_SPIKE_WINDOW_MS)
{
return false;
}
boolean transientDuration = (tAfter - tBefore <= MAX_SPIKE_WINDOW_MS)
|| (Math.min(legBefore, legAfter) + totalGlitch <= MAX_SPIKE_WINDOW_MS);
if (!transientDuration)
{
return false;
}
long d1_1 = vSpike1 - vBefore;
long d1_2 = vSpike2 - vBefore;
long d1_3 = vSpike3 - vBefore;
long d2_1 = vAfter - vSpike1;
long d2_2 = vAfter - vSpike2;
long d2_3 = vAfter - vSpike3;
boolean allPositive = d1_1 > 0 && d1_2 > 0 && d1_3 > 0 && d2_1 < 0 && d2_2 < 0 && d2_3 < 0;
boolean allNegative = d1_1 < 0 && d1_2 < 0 && d1_3 < 0 && d2_1 > 0 && d2_2 > 0 && d2_3 > 0;
if (!allPositive && !allNegative)
{
return false;
}
long spikeMag = Math.min(
Math.min(Math.abs(d1_1), Math.min(Math.abs(d1_2), Math.abs(d1_3))),
Math.min(Math.abs(d2_1), Math.min(Math.abs(d2_2), Math.abs(d2_3)))
);
long baseDelta = Math.abs(vAfter - vBefore);
long baseWealth = Math.max(10_000L, Math.min(Math.abs(vBefore), Math.abs(vAfter)));
boolean significantSpike = spikeMag >= MIN_SPIKE_ABS_GP || (double) spikeMag / baseWealth >= MIN_SPIKE_REL_RATIO;
if (!significantSpike)
{
return false;
}
return baseDelta <= 0.35 * spikeMag
|| baseDelta <= (long) (0.05 * Math.max(Math.abs(vBefore), Math.abs(vAfter)) + 500_000L);
}
/**
* Filters out momentary outlier spikes from the timeline (e.g. 1-minute spikes caused by
* transient container reloading, temporary GE duplication, or pricing glitches).
*/
public static List<Snapshot> sanitizeHistory(List<Snapshot> snapshots)
{
if (snapshots == null || snapshots.size() < 3)
{
return snapshots == null ? new ArrayList<>() : new ArrayList<>(snapshots);
}
List<Snapshot> list = new ArrayList<>(snapshots);
boolean changed = true;
int passes = 0;
while (changed && passes < 5)
{
changed = false;
passes++;
// Pass 1: check single-point spikes
for (int i = 1; i < list.size() - 1; i++)
{
Snapshot before = list.get(i - 1);
Snapshot cur = list.get(i);
Snapshot after = list.get(i + 1);
if (isTransientSpike(before.t, before.total(), cur.t, cur.total(), after.t, after.total()))
{
list.remove(i);
changed = true;
i--;
}
}
// Pass 2: check two-point spikes
for (int i = 1; i < list.size() - 2; i++)
{
Snapshot before = list.get(i - 1);
Snapshot s1 = list.get(i);
Snapshot s2 = list.get(i + 1);
Snapshot after = list.get(i + 2);
if (isDoubleTransientSpike(before.t, before.total(), s1.t, s1.total(), s2.t, s2.total(), after.t, after.total()))
{
list.remove(i + 1);
list.remove(i);
changed = true;
i--;
}
}
// Pass 3: check three-point spikes
for (int i = 1; i < list.size() - 3; i++)
{
Snapshot before = list.get(i - 1);
Snapshot s1 = list.get(i);
Snapshot s2 = list.get(i + 1);
Snapshot s3 = list.get(i + 2);
Snapshot after = list.get(i + 3);
if (isTripleTransientSpike(before.t, before.total(), s1.t, s1.total(), s2.t, s2.total(), s3.t, s3.total(), after.t, after.total()))
{
list.remove(i + 2);
list.remove(i + 1);
list.remove(i);
changed = true;
i--;
}
}
}
return list;
}
public static final long FOUR_HOURS_MS = 4 * 3600 * 1000L;
/**
* Records a purchase of an item, managing the 4-hour window rollover.
*/
public void recordBuy(int itemId, int quantity, long now)
{
if (quantity <= 0 || itemId <= 0)
{
return;
}
if (geBuyLimits == null)
{
geBuyLimits = new ConcurrentHashMap<>();
}
GeBuyLimit limit = geBuyLimits.computeIfAbsent(itemId, id -> new GeBuyLimit(id, 0, 0, 0));
synchronized (limit)
{
if (limit.windowStart == 0 || now - limit.windowStart >= FOUR_HOURS_MS)
{
limit.windowStart = now;
limit.bought = quantity;
}
else
{
limit.bought += quantity;
}
limit.lastTradeTime = now;
}
}
/**
* Computes the current 4-hour buy limit status for an item.
*/
public GeLimitInfo getBuyLimitInfo(int itemId, int itemGeLimit, long now)
{
GeBuyLimit limit = geBuyLimits != null ? geBuyLimits.get(itemId) : null;
if (limit == null || limit.windowStart == 0)
{
return new GeLimitInfo(itemId, itemGeLimit, 0, itemGeLimit, 0, false);
}
synchronized (limit)
{
long elapsed = now - limit.windowStart;
if (elapsed >= FOUR_HOURS_MS)
{
// Expired: full limit restored
return new GeLimitInfo(itemId, itemGeLimit, 0, itemGeLimit, 0, false);
}
else
{
int bought = limit.bought;
int remaining = Math.max(0, itemGeLimit > 0 ? itemGeLimit - bought : 0);
long resetInMs = FOUR_HOURS_MS - elapsed;
return new GeLimitInfo(itemId, itemGeLimit, bought, remaining, resetInMs, true);
}
}
}
/**
* Resets all tracked 4-hour buy limits and slot tracking for this character.
*/
public void clearBuyLimits()
{
if (geBuyLimits != null)
{
geBuyLimits.clear();
}
geBuyLimitSlots = new GeBuyLimitSlot[8];
}
public void resetHourly(boolean isSession)
{
long now = System.currentTimeMillis();
if (isSession)
{
resetHourlySessionAt = now;
}
else
{
resetHourlyNowAt = now;
}
}
}
@@ -0,0 +1,24 @@
package com.example.essentialsplus.data;
/**
* Tracks purchases of a specific item within the rolling 4-hour Grand Exchange buy limit window.
*/
public class GeBuyLimit
{
public int itemId;
public int bought;
public long windowStart;
public long lastTradeTime;
public GeBuyLimit()
{
}
public GeBuyLimit(int itemId, int bought, long windowStart, long lastTradeTime)
{
this.itemId = itemId;
this.bought = bought;
this.windowStart = windowStart;
this.lastTradeTime = lastTradeTime;
}
}
@@ -0,0 +1,24 @@
package com.example.essentialsplus.data;
/**
* Calculated status of an item's 4-hour Grand Exchange buy limit.
*/
public class GeLimitInfo
{
public final int itemId;
public final int limit;
public final int bought;
public final int remaining;
public final long resetInMs;
public final boolean windowActive;
public GeLimitInfo(int itemId, int limit, int bought, int remaining, long resetInMs, boolean windowActive)
{
this.itemId = itemId;
this.limit = limit;
this.bought = bought;
this.remaining = remaining;
this.resetInMs = resetInMs;
this.windowActive = windowActive;
}
}
@@ -0,0 +1,28 @@
package com.example.essentialsplus.data;
/** One Grand Exchange slot prepared for display. */
public class GeRow
{
public final String character;
public final int slot;
/** null = empty slot */
public final GeSlot offer;
public final String name;
public final int marketPrice;
public final GeLimitInfo limitInfo;
public GeRow(String character, int slot, GeSlot offer, String name, int marketPrice)
{
this(character, slot, offer, name, marketPrice, null);
}
public GeRow(String character, int slot, GeSlot offer, String name, int marketPrice, GeLimitInfo limitInfo)
{
this.character = character;
this.slot = slot;
this.offer = offer;
this.name = name;
this.marketPrice = marketPrice;
this.limitInfo = limitInfo;
}
}
@@ -0,0 +1,30 @@
package com.example.essentialsplus.data;
/** Last known state of one Grand Exchange slot, persisted so collections can be told apart from gains. */
public class GeSlot
{
public int item;
public boolean buy;
public long total;
public long price;
/** items / coins already taken out of the collection box (fallback tracking) */
public long collectedItems;
public long collectedCoins;
/** value held by the slot at the last update */
public long escrowItems;
public long escrowCoins;
public long collectItems;
public long collectCoins;
/** live offer details */
public long done;
public long spent;
public String state;
/** when the offer was placed (0 = before tracking started, see trackedSince) */
public long placedAt;
public long trackedSince;
public long trackedDone;
public long lastTradeAt;
}
@@ -0,0 +1,48 @@
package com.example.essentialsplus.data;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;
/** Item holdings over time, stored separately from the wealth timeline (&lt;key&gt;.items.json). */
public class ItemLedger
{
public List<ItemSnapshot> snapshots = new CopyOnWriteArrayList<>();
public transient long fileModified;
public transient boolean dirty;
public ItemSnapshot last()
{
List<ItemSnapshot> s = snapshots;
return s.isEmpty() ? null : s.get(s.size() - 1);
}
/** Latest snapshot taken at or before {@code t}, or the first one after it if none exists. */
public ItemSnapshot at(long t)
{
List<ItemSnapshot> s = snapshots;
ItemSnapshot best = null;
for (ItemSnapshot x : s)
{
if (x.t <= t)
{
best = x;
}
else
{
return best != null ? best : x;
}
}
return best;
}
public void normalize()
{
List<ItemSnapshot> s = snapshots == null ? new ArrayList<>() : new ArrayList<>(snapshots);
s.removeIf(x -> x == null || x.ids == null || x.q == null || x.p == null
|| x.ids.length != x.q.length || x.ids.length != x.p.length);
s.sort((a, b) -> Long.compare(a.t, b.t));
snapshots = new CopyOnWriteArrayList<>(s);
}
}
@@ -0,0 +1,20 @@
package com.example.essentialsplus.data;
/** One priced item stack for the item viewer. */
public class ItemRow
{
public final int id;
public final String name;
public final long qty;
public final long price;
public final long value;
public ItemRow(int id, String name, long qty, long price)
{
this.id = id;
this.name = name;
this.qty = qty;
this.price = price;
this.value = qty * price;
}
}
@@ -0,0 +1,44 @@
package com.example.essentialsplus.data;
import java.util.Arrays;
/**
* Holdings of a character at one moment: canonical item id, quantity and the GE price at that time.
* Allows splitting wealth changes into "market" (price) and "activity" (quantity) effects.
*/
public class ItemSnapshot
{
public long t;
public int[] ids;
public long[] q;
public int[] p;
/** pinned (session start) - kept by compaction */
public Boolean k;
public ItemSnapshot()
{
}
public ItemSnapshot(long t, int[] ids, long[] q, int[] p)
{
this.t = t;
this.ids = ids;
this.q = q;
this.p = p;
}
public boolean sameAs(ItemSnapshot other)
{
return other != null && Arrays.equals(ids, other.ids) && Arrays.equals(q, other.q) && Arrays.equals(p, other.p);
}
public boolean sameQuantities(ItemSnapshot other)
{
return other != null && Arrays.equals(ids, other.ids) && Arrays.equals(q, other.q);
}
public int indexOf(int id)
{
return ids == null ? -1 : Arrays.binarySearch(ids, id);
}
}
@@ -0,0 +1,148 @@
package com.example.essentialsplus.data;
import com.example.essentialsplus.WealthCategory;
import java.util.Arrays;
/**
* One point on the wealth timeline.
* <p>
* Raw points only have {@code t} and {@code v}. Aggregated points (hourly / daily buckets of older data)
* additionally hold open, close, low and high of the total and store the time-weighted average
* breakdown in {@code v}.
*/
public class Snapshot
{
public static final String HOUR = "h";
public static final String DAY = "d";
public long t;
public long[] v;
/** open / close / low / high total - only set on aggregates */
public Long o;
public Long c;
public Long lo;
public Long hi;
/** granularity of an aggregate: "h" or "d" */
public String g;
/** cumulative value change from GE price movements up to this point ({@link AccountData#market}); null on old data */
public Long m;
public Snapshot()
{
}
public Snapshot(long t, long[] v)
{
this.t = t;
this.v = v;
}
public long total()
{
long sum = 0;
if (v != null)
{
for (long x : v)
{
sum += x;
}
}
return sum;
}
public long get(WealthCategory category)
{
int i = category.ordinal();
return v != null && i < v.length ? v[i] : 0;
}
public long low()
{
return lo != null ? lo : total();
}
public long high()
{
return hi != null ? hi : total();
}
public long open()
{
return o != null ? o : total();
}
public long close()
{
return c != null ? c : total();
}
public boolean isAggregate()
{
return g != null || lo != null;
}
/** Pads arrays loaded from older files to the current category count. */
public void normalize()
{
if (v == null)
{
v = new long[WealthCategory.COUNT];
}
else if (v.length < WealthCategory.COUNT)
{
v = Arrays.copyOf(v, WealthCategory.COUNT);
}
if (lo != null && g == null)
{
g = DAY;
}
}
public long market()
{
return m != null ? m : 0;
}
/** Copy with {@code delta} removed from the total, spread over the categories by their share. */
public Snapshot shifted(long delta)
{
long[] nv = v == null ? new long[0] : v.clone();
if (delta != 0 && nv.length > 0)
{
long base = 0;
for (long x : nv)
{
base += Math.abs(x);
}
long left = delta;
int largest = 0;
for (int i = 0; i < nv.length; i++)
{
if (Math.abs(nv[i]) > Math.abs(nv[largest]))
{
largest = i;
}
if (base > 0)
{
long part = Math.round((double) delta * Math.abs(nv[i]) / base);
nv[i] -= part;
left -= part;
}
}
nv[largest] -= left;
}
Snapshot s = new Snapshot(t, nv);
s.g = g;
s.m = m;
s.o = o == null ? null : o - delta;
s.c = c == null ? null : c - delta;
s.lo = lo == null ? null : lo - delta;
s.hi = hi == null ? null : hi - delta;
return s;
}
public boolean sameValues(long[] other)
{
return Arrays.equals(v, other);
}
}
@@ -0,0 +1,20 @@
package com.example.essentialsplus.data;
/** Value of a single wealth source (e.g. "Looting bag") inside a category. */
public class SourceValue
{
public String name;
public int category;
public long value;
public SourceValue()
{
}
public SourceValue(String name, int category, long value)
{
this.name = name;
this.category = category;
this.value = value;
}
}
@@ -0,0 +1,21 @@
package com.example.essentialsplus.data;
public class TopItem
{
public int id;
public String name;
public long qty;
public long value;
public TopItem()
{
}
public TopItem(int id, String name, long qty, long value)
{
this.id = id;
this.name = name;
this.qty = qty;
this.value = value;
}
}
@@ -0,0 +1,256 @@
package com.example.essentialsplus.helpers;
import com.example.essentialsplus.EssentialsPlusConfig;
import java.lang.reflect.Field;
import java.lang.reflect.Modifier;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
import java.util.Set;
import java.util.WeakHashMap;
import javax.inject.Inject;
import javax.inject.Singleton;
import net.runelite.api.Actor;
import net.runelite.api.Client;
import net.runelite.api.GameState;
import net.runelite.api.NPC;
import net.runelite.api.Player;
import net.runelite.api.Projectile;
import net.runelite.api.events.AnimationChanged;
import net.runelite.api.events.GameStateChanged;
import net.runelite.api.events.GameTick;
import net.runelite.api.events.NpcDespawned;
import net.runelite.api.events.ProjectileMoved;
import net.runelite.api.gameval.AnimationID;
import net.runelite.api.gameval.SpotanimID;
import net.runelite.client.eventbus.Subscribe;
/**
* Which style each NPC attacking the local player used last. The style comes from the projectile (by its cache name,
* e.g. {@code CRYSTAL_HUNLLEF_RANGE_TRAVEL}), else the attack animation name, else melee when the NPC attacked from
* melee distance without a projectile.
*/
@Singleton
public class AttackStyles
{
/** How long a style stays shown after the NPC's last attack. */
private static final int SHOW_TICKS = 12;
/** Projectiles whose names do not say the style. */
private static final Map<Integer, CombatStyle> PROJECTILE_OVERRIDES = Map.of(
SpotanimID.CRYSTAL_HUNLLEF_PRAYER_TRAVEL, CombatStyle.MAGIC,
SpotanimID.CRYSTAL_HUNLLEF_PRAYER_TRAVEL_HM, CombatStyle.MAGIC,
SpotanimID.ECHO_HUNLLEF_ANTIPRAYER_TRAVEL, CombatStyle.MAGIC);
private final Client client;
private final EssentialsPlusConfig config;
private Map<Integer, CombatStyle> projectileStyles;
private Map<Integer, CombatStyle> animationStyles;
private final Map<NPC, Shown> styles = new HashMap<>();
/** Attack animations without a known style: melee if no projectile follows by the next tick. */
private final Map<NPC, Integer> unresolved = new HashMap<>();
private final Set<Projectile> seenProjectiles = Collections.newSetFromMap(new WeakHashMap<>());
private static final class Shown
{
final CombatStyle style;
final int tick;
Shown(CombatStyle style, int tick)
{
this.style = style;
this.tick = tick;
}
}
@Inject
AttackStyles(Client client, EssentialsPlusConfig config)
{
this.client = client;
this.config = config;
}
/** Last attack style of every NPC currently attacking the local player. */
public Map<NPC, CombatStyle> current()
{
Map<NPC, CombatStyle> out = new HashMap<>();
Player me = client.getLocalPlayer();
int tick = client.getTickCount();
for (Map.Entry<NPC, Shown> e : styles.entrySet())
{
NPC npc = e.getKey();
if (me != null && !npc.isDead() && npc.getInteracting() == me && tick - e.getValue().tick <= SHOW_TICKS)
{
out.put(npc, e.getValue().style);
}
}
return out;
}
@Subscribe
public void onAnimationChanged(AnimationChanged event)
{
if (!config.enemyAttackStyle() || !(event.getActor() instanceof NPC))
{
return;
}
NPC npc = (NPC) event.getActor();
int anim = npc.getAnimation();
Player me = client.getLocalPlayer();
if (anim == -1 || me == null || npc.getInteracting() != me || npc.isDead())
{
return;
}
CombatStyle style = animationStyles().get(anim);
if (style != null)
{
set(npc, style);
}
else
{
unresolved.putIfAbsent(npc, client.getTickCount());
}
}
@Subscribe
public void onProjectileMoved(ProjectileMoved event)
{
if (!config.enemyAttackStyle())
{
return;
}
Projectile p = event.getProjectile();
if (!seenProjectiles.add(p))
{
return;
}
Player me = client.getLocalPlayer();
Actor target = p.getTargetActor();
if (me == null || target != null && target != me)
{
return;
}
NPC source = p.getSourceActor() instanceof NPC ? (NPC) p.getSourceActor() : null;
if (source == null)
{
// no source: the NPC that just animated at us
for (NPC npc : unresolved.keySet())
{
source = npc;
break;
}
}
if (source == null || source.getInteracting() != me)
{
return;
}
unresolved.remove(source);
CombatStyle style = PROJECTILE_OVERRIDES.getOrDefault(p.getId(), projectileStyles().get(p.getId()));
if (style != null)
{
set(source, style);
}
}
@Subscribe
public void onGameTick(GameTick event)
{
int tick = client.getTickCount();
Player me = client.getLocalPlayer();
unresolved.entrySet().removeIf(e ->
{
if (tick - e.getValue() < 1)
{
return false;
}
NPC npc = e.getKey();
if (me != null && npc.getWorldArea() != null && me.getWorldArea() != null
&& npc.getWorldArea().isInMeleeDistance(me.getWorldArea()))
{
set(npc, CombatStyle.MELEE);
}
return true;
});
styles.values().removeIf(s -> tick - s.tick > SHOW_TICKS);
}
@Subscribe
public void onNpcDespawned(NpcDespawned event)
{
styles.remove(event.getNpc());
unresolved.remove(event.getNpc());
}
@Subscribe
public void onGameStateChanged(GameStateChanged event)
{
if (event.getGameState() != GameState.LOGGED_IN)
{
reset();
}
}
public void reset()
{
styles.clear();
unresolved.clear();
seenProjectiles.clear();
}
private void set(NPC npc, CombatStyle style)
{
styles.put(npc, new Shown(style, client.getTickCount()));
}
private Map<Integer, CombatStyle> projectileStyles()
{
if (projectileStyles == null)
{
projectileStyles = stylesByName(SpotanimID.class);
}
return projectileStyles;
}
private Map<Integer, CombatStyle> animationStyles()
{
if (animationStyles == null)
{
animationStyles = stylesByName(AnimationID.class);
}
return animationStyles;
}
/** Id -> style from the constant names of a gameval class; ids whose names disagree are left out. */
static Map<Integer, CombatStyle> stylesByName(Class<?> gameval)
{
Map<Integer, CombatStyle> out = new HashMap<>();
Set<Integer> conflicts = new java.util.HashSet<>();
for (Field f : gameval.getFields())
{
if (f.getType() != int.class || !Modifier.isStatic(f.getModifiers()))
{
continue;
}
CombatStyle style = CombatStyle.fromName(f.getName());
if (style == null)
{
continue;
}
try
{
int id = f.getInt(null);
CombatStyle prev = out.putIfAbsent(id, style);
if (prev != null && prev != style)
{
conflicts.add(id);
}
}
catch (IllegalAccessException ignored)
{
}
}
out.keySet().removeAll(conflicts);
return out;
}
}
@@ -0,0 +1,221 @@
package com.example.essentialsplus.helpers;
import com.example.essentialsplus.EssentialsPlusConfig;
import java.awt.BasicStroke;
import java.awt.Color;
import java.awt.Dimension;
import java.awt.FontMetrics;
import java.awt.Graphics2D;
import java.awt.RenderingHints;
import java.awt.image.BufferedImage;
import java.util.EnumMap;
import java.util.HashMap;
import java.util.LinkedHashSet;
import java.util.Map;
import java.util.Set;
import javax.inject.Inject;
import net.runelite.api.Actor;
import net.runelite.api.Client;
import net.runelite.api.NPC;
import net.runelite.api.Player;
import net.runelite.api.Point;
import net.runelite.client.game.SpriteManager;
import net.runelite.client.ui.overlay.Overlay;
import net.runelite.client.ui.overlay.OverlayLayer;
import net.runelite.client.ui.overlay.OverlayPosition;
import net.runelite.client.util.ImageUtil;
/**
* Countdown above the player's (and attacking NPCs') head, optionally with one pip per tick of the attack speed,
* and the style attacking NPCs used last (prayer icon or text) left of their number.
*/
public class AttackTimerOverlay extends Overlay
{
private static final int PIP = 8;
private static final int GAP = 2;
/** Height of an overhead prayer icon; the player's own timer moves above it. */
private static final int OVERHEAD_SHIFT = 26;
private static final Color EMPTY = new Color(0, 0, 0, 150);
private static final Color OUTLINE = new Color(0, 0, 0, 220);
private final Client client;
private final EssentialsPlusConfig config;
private final AttackTimers timers;
private final AttackStyles styles;
private final SpriteManager spriteManager;
/** Scaled prayer icons by size, then style. */
private final Map<Integer, Map<CombatStyle, BufferedImage>> icons = new HashMap<>();
/** Look of one timer kind, read from the config once per frame. */
private static final class Style
{
final boolean bar;
final TimerTextSize size;
final Color color;
final Color ready;
final int offset;
Style(boolean bar, TimerTextSize size, Color color, Color ready, int offset)
{
this.bar = bar;
this.size = size;
this.color = color;
this.ready = ready;
this.offset = offset;
}
}
@Inject
AttackTimerOverlay(Client client, EssentialsPlusConfig config, AttackTimers timers, AttackStyles styles, SpriteManager spriteManager)
{
this.client = client;
this.config = config;
this.timers = timers;
this.styles = styles;
this.spriteManager = spriteManager;
setPosition(OverlayPosition.DYNAMIC);
setLayer(OverlayLayer.ABOVE_SCENE);
}
@Override
public Dimension render(Graphics2D g)
{
boolean styleOn = config.enemyAttackStyle();
if (!config.attackTimer() && !config.enemyAttackTimer() && !styleOn)
{
return null;
}
g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
timers.onRender();
if (config.attackTimer())
{
Player me = client.getLocalPlayer();
AttackTimers.Timer t = timers.playerTimer();
if (me != null && t != null)
{
Style style = new Style(config.attackTimerBar(), config.attackTimerSize(), config.attackTimerColor(),
config.attackTimerReadyColor(), config.attackTimerOffset());
draw(g, me, t, style, null);
}
}
Map<NPC, AttackTimers.Timer> npcTimers = config.enemyAttackTimer() ? timers.npcTimers() : Map.of();
Map<NPC, CombatStyle> npcStyles = styleOn ? styles.current() : Map.of();
if (!npcTimers.isEmpty() || !npcStyles.isEmpty())
{
Style style = new Style(config.enemyAttackTimerBar(), config.enemyAttackTimerSize(), config.enemyAttackTimerColor(),
config.enemyAttackTimerReadyColor(), config.enemyAttackTimerOffset());
Set<NPC> npcs = new LinkedHashSet<>(npcTimers.keySet());
npcs.addAll(npcStyles.keySet());
for (NPC npc : npcs)
{
draw(g, npc, npcTimers.get(npc), style, npcStyles.get(npc));
}
}
return null;
}
private void draw(Graphics2D g, Actor actor, AttackTimers.Timer t, Style style, CombatStyle attackStyle)
{
if (t != null)
{
drawTimer(g, actor, t, style);
}
if (attackStyle != null)
{
drawStyle(g, actor, attackStyle);
}
}
private void drawTimer(Graphics2D g, Actor actor, AttackTimers.Timer t, Style style)
{
Point anchor = actor.getCanvasTextLocation(g, "", actor.getLogicalHeight() + style.offset);
if (anchor == null)
{
return;
}
int y = anchor.getY();
if (actor instanceof Player && ((Player) actor).getOverheadIcon() != null)
{
y -= OVERHEAD_SHIFT;
}
boolean ready = t.remaining == 0;
if (style.bar)
{
int width = t.speed * PIP + (t.speed - 1) * GAP;
int x0 = anchor.getX() - width / 2;
int barY = y - PIP;
// the pip of the running tick drains smoothly; later ticks stay full
double fill = t.remaining - t.tickProgress;
g.setStroke(new BasicStroke(1f));
for (int i = 0; i < t.speed; i++)
{
int x = x0 + i * (PIP + GAP);
g.setColor(EMPTY);
g.fillRect(x, barY, PIP, PIP);
double part = Math.max(0, Math.min(1, fill - i));
if (part > 0)
{
g.setColor(style.color);
g.fillRect(x, barY, (int) Math.round(PIP * part), PIP);
}
g.setColor(ready ? style.ready : OUTLINE);
g.drawRect(x, barY, PIP, PIP);
}
y = barY - 3;
}
g.setFont(style.size.font());
String text = String.valueOf(t.remaining);
int x = anchor.getX() - g.getFontMetrics().stringWidth(text) / 2;
shadowed(g, text, x, y, ready ? style.ready : style.color);
}
/** Attack style with its own size and height, centered above the head. */
private void drawStyle(Graphics2D g, Actor actor, CombatStyle attackStyle)
{
Point anchor = actor.getCanvasTextLocation(g, "", actor.getLogicalHeight() + config.enemyAttackStyleOffset());
if (anchor == null)
{
return;
}
TimerTextSize size = config.enemyAttackStyleSize();
if (config.enemyAttackStyleText())
{
g.setFont(size.font());
int x = anchor.getX() - g.getFontMetrics().stringWidth(attackStyle.label) / 2;
shadowed(g, attackStyle.label, x, anchor.getY(), attackStyle.color);
return;
}
BufferedImage icon = icon(attackStyle, size.iconSize());
if (icon != null)
{
g.drawImage(icon, anchor.getX() - icon.getWidth() / 2, anchor.getY() - icon.getHeight(), null);
}
}
private static void shadowed(Graphics2D g, String text, int x, int y, Color color)
{
g.setColor(Color.BLACK);
g.drawString(text, x + 1, y + 1);
g.setColor(color);
g.drawString(text, x, y);
}
private BufferedImage icon(CombatStyle style, int size)
{
Map<CombatStyle, BufferedImage> bySize = icons.computeIfAbsent(size, k -> new EnumMap<>(CombatStyle.class));
BufferedImage img = bySize.get(style);
if (img == null)
{
BufferedImage sprite = spriteManager.getSprite(style.prayerSprite, 0);
if (sprite == null)
{
return null;
}
img = ImageUtil.resizeImage(sprite, size, size);
bySize.put(style, img);
}
return img;
}
}
@@ -0,0 +1,565 @@
package com.example.essentialsplus.helpers;
import com.example.essentialsplus.EssentialsPlusConfig;
import com.example.essentialsplus.helpers.combat.AnimationData;
import com.example.essentialsplus.helpers.combat.AttackSpeed;
import com.example.essentialsplus.helpers.combat.Attacking;
import com.example.essentialsplus.helpers.combat.CombatUtils;
import com.example.essentialsplus.helpers.combat.Spellbook;
import com.example.essentialsplus.helpers.combat.variablespeed.VariableSpeed;
import com.example.essentialsplus.helpers.combat.variablespeed.state.TickCount;
import java.util.HashMap;
import java.util.Map;
import java.util.Set;
import java.util.regex.Pattern;
import javax.inject.Inject;
import javax.inject.Singleton;
import lombok.extern.slf4j.Slf4j;
import net.runelite.api.Actor;
import net.runelite.api.Client;
import net.runelite.api.GameState;
import net.runelite.api.NPC;
import net.runelite.api.Player;
import net.runelite.api.events.AnimationChanged;
import net.runelite.api.events.ChatMessage;
import net.runelite.api.events.FakeXpDrop;
import net.runelite.api.events.GameStateChanged;
import net.runelite.api.events.GameTick;
import net.runelite.api.events.HitsplatApplied;
import net.runelite.api.events.InteractingChanged;
import net.runelite.api.events.NpcDespawned;
import net.runelite.api.events.NpcSpawned;
import net.runelite.api.events.SoundEffectPlayed;
import net.runelite.api.events.StatChanged;
import net.runelite.api.events.VarbitChanged;
import net.runelite.api.gameval.VarPlayerID;
import net.runelite.api.gameval.VarbitID;
import net.runelite.client.eventbus.Subscribe;
import net.runelite.client.game.ItemManager;
import net.runelite.client.game.NPCManager;
/**
* Deterministic attack timer for the local player, incorporating weapon speeds, attack styles,
* manual casting, powered staves, boss mechanics, and food delays.
* NPC attack speeds are learned from intervals between their verified attack animations.
*/
@Slf4j
@Singleton
public class AttackTimers
{
public enum AttackState
{
NOT_ATTACKING,
DELAYED_FIRST_TICK,
DELAYED
}
static final int DEFAULT_SPEED = 4;
private static final int MAX_SPEED = 10;
private static final int TRUSTED_SAMPLES = 3;
private static final long TICK_NANOS = 600_000_000L;
private static final int CONFIRM_TICKS = 4;
private static final int UI_HIDE_DEBOUNCE_TICKS_MAX = 1;
private static final int SLOW_FOOD_ATTACK_DELAY_TICKS = 4;
private static final int DEFAULT_FOOD_ATTACK_DELAY_TICKS = 3;
private static final int FAST_EAT_ATTACK_DELAY_TICKS = 2;
private static final String GENERIC_EAT = "You eat";
private static final String VAMPYRIUM_EAT = "Your stomach doesn't like it... but it heals some health";
private static final String BARBARIAN_POTIONS = "You drink the lumpy potion";
private static final String JUG_OF_WINE = "You drink the wine";
private static final Pattern EAT_MESSAGE = Pattern.compile(
"^(" + GENERIC_EAT + "|" + BARBARIAN_POTIONS + "|" + JUG_OF_WINE + "|" + VAMPYRIUM_EAT + ")",
Pattern.MULTILINE | Pattern.CASE_INSENSITIVE);
private static final Pattern SLOW_FOOD = Pattern.compile("^(" + VAMPYRIUM_EAT + ")",
Pattern.MULTILINE | Pattern.CASE_INSENSITIVE);
private static final String FAST_GNOME_FOOD = "worm hole|tangled toads legs|veg ball|chocolate bomb|worm crunchies|toad crunchies|"
+ "choc chip crunchies|spicy crunchies|fruit batta|cheese and tomato batta|toad batta|vegetable batta|worm batta";
private static final String FAST_FOOD = "karambwan|halibut";
private static final Pattern FAST_EAT = Pattern.compile("(" + FAST_FOOD + "|" + FAST_GNOME_FOOD + ")",
Pattern.CASE_INSENSITIVE);
private static final Set<Integer> NON_ATTACK_ANIMS = Set.of(829, 424, 1156, 836);
private final Client client;
private final ItemManager itemManager;
private final NPCManager npcManager;
private final EssentialsPlusConfig config;
private final TickCount tc = new TickCount();
private final VariableSpeed variableSpeed = new VariableSpeed(tc);
private final AttackSpeed attackSpeed = new AttackSpeed(tc, variableSpeed);
public AttackState attackState = AttackState.NOT_ATTACKING;
public AttackState renderedState = AttackState.NOT_ATTACKING;
public int tickPeriod = 0;
public int attackDelayHoldoffTicks = 0;
private int uiHideDebounceTickCount = 0;
public int pendingEatDelayTicks = 0;
private Spellbook currentSpellBook = Spellbook.STANDARD;
private int lastUsedWeaponId = -1;
private Actor lastTarget = null;
private volatile long lastTickNanos = System.nanoTime();
// NPC Attack Timers state
private final Map<NPC, Integer> npcAttackTicks = new HashMap<>();
private final Map<NPC, Integer> npcPrevTicks = new HashMap<>();
private final Map<Integer, SpeedSamples> npcSpeeds = new HashMap<>();
private final Map<NPC, int[]> pendingNpcAnims = new HashMap<>();
private final Map<Long, int[]> animOutcomes = new HashMap<>();
private final java.util.List<AnimCheck> animChecks = new java.util.ArrayList<>();
private static final class AnimCheck
{
final long key;
final int tick;
boolean confirmed;
AnimCheck(long key, int tick)
{
this.key = key;
this.tick = tick;
}
}
@Inject
public AttackTimers(Client client, ItemManager itemManager, NPCManager npcManager, EssentialsPlusConfig config)
{
this.client = client;
this.itemManager = itemManager;
this.npcManager = npcManager;
this.config = config;
}
public static final class Timer
{
public final int remaining;
public final int speed;
public final double tickProgress;
public Timer(int remaining, int speed, double tickProgress)
{
this.remaining = remaining;
this.speed = speed;
this.tickProgress = tickProgress;
}
}
public Timer playerTimer()
{
if (attackState == AttackState.NOT_ATTACKING && uiHideDebounceTickCount <= 0)
{
return null;
}
int remaining = 1 + attackDelayHoldoffTicks;
if (remaining < 0)
{
return null;
}
int speed = Math.max(1, tickPeriod);
double progress = (remaining == 0) ? 0 : tickProgress();
return new Timer(remaining, speed, progress);
}
private double tickProgress()
{
return Math.min(1, Math.max(0, (System.nanoTime() - lastTickNanos) / (double) TICK_NANOS));
}
public void onRender()
{
final int delta = variableSpeed.shadowCrash.onRender(client, attackDelayHoldoffTicks, attackSpeed.isUsingMagic());
if (delta != 0)
{
attackDelayHoldoffTicks += delta;
if (attackDelayHoldoffTicks < 0)
{
attackState = AttackState.NOT_ATTACKING;
}
}
if (variableSpeed.maggotKing.onRender(client, itemManager)
|| variableSpeed.doomOfMokhaiotl.onRender(client, itemManager, currentSpellBook))
{
performAttack();
}
checkForLateWeaponSwaps();
renderedState = attackState;
}
private void setAttackDelay()
{
Player localPlayer = client.getLocalPlayer();
final AnimationData curAnimation = localPlayer != null ? AnimationData.fromId(localPlayer.getAnimation()) : null;
attackDelayHoldoffTicks = attackSpeed.compute(client, curAnimation, currentSpellBook, itemManager);
lastUsedWeaponId = CombatUtils.getWeaponId(client);
}
private void performAttack()
{
attackState = AttackState.DELAYED_FIRST_TICK;
setAttackDelay();
tickPeriod = attackDelayHoldoffTicks;
uiHideDebounceTickCount = UI_HIDE_DEBOUNCE_TICKS_MAX;
Player localPlayer = client.getLocalPlayer();
lastTarget = localPlayer != null ? localPlayer.getInteracting() : null;
}
private void checkForLateWeaponSwaps()
{
final boolean weaponMismatch = CombatUtils.getWeaponId(client) != lastUsedWeaponId;
if (inPreAttackWindow() && weaponMismatch)
{
performAttack();
}
}
private boolean inPreAttackWindow()
{
return attackState == AttackState.DELAYED_FIRST_TICK && renderedState != attackState;
}
private void applyAndClearEats()
{
int pendingEats = pendingEatDelayTicks;
attackDelayHoldoffTicks += pendingEats;
pendingEatDelayTicks -= pendingEats;
}
@Subscribe
public void onVarbitChanged(final VarbitChanged varbitChanged)
{
if (varbitChanged.getVarbitId() == VarbitID.SPELLBOOK)
{
currentSpellBook = Spellbook.fromVarbit(varbitChanged.getValue());
}
if (varbitChanged.getVarpId() == VarPlayerID.SA_ENERGY)
{
attackSpeed.varbitSpecialAttackChanged(varbitChanged.getValue());
}
}
@Subscribe
public void onSoundEffectPlayed(final SoundEffectPlayed event)
{
attackSpeed.onSoundEffectPlayed(client, event);
}
@Subscribe
public void onFakeXpDrop(final FakeXpDrop event)
{
if (attackSpeed.onXpDrop(event) && inPreAttackWindow())
{
performAttack();
}
}
@Subscribe
public void onStatChanged(final StatChanged event)
{
if (attackSpeed.onXpDrop(event) && inPreAttackWindow())
{
performAttack();
}
}
@Subscribe
public void onNpcSpawned(final NpcSpawned npcSpawned)
{
variableSpeed.onNpcSpawned(client, npcSpawned);
}
@Subscribe
public void onNpcDespawned(final NpcDespawned npcDespawned)
{
variableSpeed.onNpcDespawned(client, npcDespawned);
npcAttackTicks.remove(npcDespawned.getNpc());
npcPrevTicks.remove(npcDespawned.getNpc());
}
@Subscribe
public void onChatMessage(final ChatMessage event)
{
final String message = event.getMessage();
if (message != null && EAT_MESSAGE.matcher(message).find())
{
int delay;
if (FAST_EAT.matcher(message).find())
{
delay = FAST_EAT_ATTACK_DELAY_TICKS;
}
else if (SLOW_FOOD.matcher(message).find())
{
delay = SLOW_FOOD_ATTACK_DELAY_TICKS;
}
else
{
delay = DEFAULT_FOOD_ATTACK_DELAY_TICKS;
}
pendingEatDelayTicks += delay;
}
variableSpeed.onChatMessage(client, event);
if (message != null)
{
pendingEatDelayTicks += variableSpeed.maggotKing.onChatMessage(client, message);
}
}
@Subscribe
public void onInteractingChanged(InteractingChanged interactingChanged)
{
Actor source = interactingChanged.getSource();
Actor target = interactingChanged.getTarget();
Player p = client.getLocalPlayer();
if (source != null && source.equals(p) && target instanceof NPC)
{
if (attackState == AttackState.NOT_ATTACKING && Attacking.isPlayerAttacking(client, npcManager))
{
performAttack();
}
}
applyAndClearEats();
}
@Subscribe
public void onGameTick(GameTick tick)
{
lastTickNanos = System.nanoTime();
variableSpeed.onGameTick(client, tick);
final boolean isAttacking = Attacking.isPlayerAttacking(client, npcManager);
switch (attackState)
{
case NOT_ATTACKING:
if (isAttacking)
{
performAttack();
}
else
{
uiHideDebounceTickCount = Math.max(-20, uiHideDebounceTickCount - 1);
}
break;
case DELAYED_FIRST_TICK:
attackState = AttackState.DELAYED;
// fallthrough
case DELAYED:
if (attackDelayHoldoffTicks <= 0)
{
if (isAttacking)
{
performAttack();
}
else
{
attackState = AttackState.NOT_ATTACKING;
}
}
break;
}
applyAndClearEats();
attackDelayHoldoffTicks = Math.max(-20, attackDelayHoldoffTicks - 1);
attackSpeed.onTick();
// Update NPC attack timer learning
updateNpcTimers();
}
private void updateNpcTimers()
{
int tick = client.getTickCount();
animChecks.removeIf(c ->
{
if (!c.confirmed && tick - c.tick <= CONFIRM_TICKS)
{
return false;
}
animOutcomes.computeIfAbsent(c.key, k -> new int[2])[c.confirmed ? 0 : 1]++;
return true;
});
for (Map.Entry<NPC, int[]> pending : pendingNpcAnims.entrySet())
{
NPC npc = pending.getKey();
int animTick = pending.getValue()[0];
long animKey = (long) npc.getId() << 32 | pending.getValue()[1];
animChecks.add(new AnimCheck(animKey, animTick));
int[] outcome = animOutcomes.get(animKey);
if (outcome != null && outcome[1] >= 2 && outcome[1] > outcome[0] * 2)
{
continue;
}
SpeedSamples samples = npcSpeeds.computeIfAbsent(npc.getId(), id -> new SpeedSamples());
Integer last = npcAttackTicks.get(npc);
if (last != null)
{
int gap = animTick - last;
if (gap <= 0)
{
continue;
}
if (samples.count() >= TRUSTED_SAMPLES && gap < samples.speed())
{
continue;
}
samples.add(gap);
npcPrevTicks.put(npc, last);
}
npcAttackTicks.put(npc, animTick);
}
pendingNpcAnims.clear();
npcAttackTicks.values().removeIf(t -> tick - t > MAX_SPEED + 2);
npcPrevTicks.keySet().retainAll(npcAttackTicks.keySet());
}
@Subscribe
public void onAnimationChanged(AnimationChanged event)
{
if (!config.enemyAttackTimer())
{
return;
}
Actor actor = event.getActor();
int anim = actor.getAnimation();
if (anim == -1 || NON_ATTACK_ANIMS.contains(anim))
{
return;
}
Player me = client.getLocalPlayer();
if (actor instanceof NPC && me != null && actor.getInteracting() == me && !actor.isDead())
{
pendingNpcAnims.putIfAbsent((NPC) actor, new int[]{client.getTickCount(), anim});
}
}
@Subscribe
public void onHitsplatApplied(HitsplatApplied event)
{
if (event.getActor() == client.getLocalPlayer() && !event.getHitsplat().isMine())
{
for (AnimCheck c : animChecks)
{
if (!c.confirmed)
{
c.confirmed = true;
break;
}
}
}
}
@Subscribe
public void onGameStateChanged(GameStateChanged event)
{
if (event.getGameState() != GameState.LOGGED_IN)
{
reset();
}
}
public void reset()
{
attackState = AttackState.NOT_ATTACKING;
renderedState = AttackState.NOT_ATTACKING;
tickPeriod = 0;
attackDelayHoldoffTicks = 0;
uiHideDebounceTickCount = 0;
pendingEatDelayTicks = 0;
lastUsedWeaponId = -1;
lastTarget = null;
tc.reset();
attackSpeed.reset();
npcAttackTicks.clear();
npcPrevTicks.clear();
pendingNpcAnims.clear();
animChecks.clear();
}
public Map<NPC, Timer> npcTimers()
{
Map<NPC, Timer> out = new HashMap<>();
Player me = client.getLocalPlayer();
for (Map.Entry<NPC, Integer> e : npcAttackTicks.entrySet())
{
NPC npc = e.getKey();
if (npc.isDead() || me == null || npc.getInteracting() != me)
{
continue;
}
int attackTick = e.getValue();
if (attackTick >= client.getTickCount())
{
Integer prev = npcPrevTicks.get(npc);
attackTick = prev == null ? -1 : prev;
}
Timer t = npcTimer(attackTick, npcSpeed(npc.getId()));
if (t != null)
{
out.put(npc, t);
}
}
return out;
}
private Timer npcTimer(int attackTick, int speed)
{
if (attackTick < 0)
{
return null;
}
int remaining = speed - (client.getTickCount() - attackTick - 1);
if (remaining < 0)
{
return null;
}
return new Timer(remaining, speed, remaining == 0 ? 0 : tickProgress());
}
int npcSpeed(int npcId)
{
SpeedSamples s = npcSpeeds.get(npcId);
return s == null ? DEFAULT_SPEED : s.speed();
}
static final class SpeedSamples
{
private final int[] counts = new int[MAX_SPEED + 1];
private int total;
void add(int gap)
{
if (gap >= 1 && gap <= MAX_SPEED)
{
counts[gap]++;
total++;
}
}
int count()
{
return total;
}
int speed()
{
int best = DEFAULT_SPEED;
int bestCount = 0;
for (int gap = 1; gap <= MAX_SPEED; gap++)
{
if (counts[gap] > bestCount)
{
best = gap;
bestCount = counts[gap];
}
}
return best;
}
}
}
@@ -0,0 +1,63 @@
package com.example.essentialsplus.helpers;
import java.awt.Color;
import net.runelite.api.gameval.SpriteID;
/** Attack style of an NPC attack, with the protection prayer that blocks it. */
public enum CombatStyle
{
MELEE("Melee", new Color(0xE05050), SpriteID.Prayeron.PROTECT_FROM_MELEE),
RANGED("Range", new Color(0x6BD66B), SpriteID.Prayeron.PROTECT_FROM_MISSILES),
MAGIC("Mage", new Color(0x5AA9FF), SpriteID.Prayeron.PROTECT_FROM_MAGIC);
public final String label;
public final Color color;
public final int prayerSprite;
CombatStyle(String label, Color color, int prayerSprite)
{
this.label = label;
this.color = color;
this.prayerSprite = prayerSprite;
}
/**
* Style from a cache name such as {@code HUNLLEF_ATTACK_RANGED} or {@code ZEBAK_MAGE_PROJANIM_INITIAL};
* null when the name mentions no style or more than one.
*/
static CombatStyle fromName(String name)
{
CombatStyle found = null;
for (String token : name.toUpperCase(java.util.Locale.ROOT).split("_"))
{
CombatStyle s = fromToken(token);
if (s == null)
{
continue;
}
if (found != null && found != s)
{
return null;
}
found = s;
}
return found;
}
private static CombatStyle fromToken(String token)
{
if (token.startsWith("MELEE"))
{
return MELEE;
}
if (token.startsWith("RANGE") || token.startsWith("ARROW") || token.startsWith("DART") || token.startsWith("JAVELIN"))
{
return RANGED;
}
if (token.startsWith("MAGIC") || token.startsWith("MAGE") && !token.startsWith("MAGENTA"))
{
return MAGIC;
}
return null;
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,105 @@
package com.example.essentialsplus.helpers;
import com.example.essentialsplus.EssentialsPlusConfig;
import com.example.essentialsplus.EssentialsPlusPlugin;
import java.awt.Dimension;
import java.awt.Font;
import java.awt.FontMetrics;
import java.awt.Graphics2D;
import java.awt.Rectangle;
import java.awt.RenderingHints;
import java.util.List;
import javax.inject.Inject;
import net.runelite.api.widgets.WidgetItem;
import net.runelite.client.ui.FontManager;
import net.runelite.client.ui.overlay.OverlayLayer;
import net.runelite.client.ui.overlay.OverlayPosition;
import net.runelite.client.ui.overlay.WidgetItemOverlay;
import net.runelite.client.ui.overlay.components.TextComponent;
/**
* Overlay displaying charge counts / statuses on item icons in Inventory, Equipment and (optionally) Bank.
*/
public class ItemChargesOverlay extends WidgetItemOverlay
{
private final EssentialsPlusConfig config;
private final EssentialsPlusPlugin plugin;
private final ItemCharges itemCharges;
@Inject
public ItemChargesOverlay(EssentialsPlusConfig config, EssentialsPlusPlugin plugin, ItemCharges itemCharges)
{
this.config = config;
this.plugin = plugin;
this.itemCharges = itemCharges;
showOnInventory();
showOnEquipment();
showOnBank();
}
@Override
public void renderItemOverlay(Graphics2D g, int itemId, WidgetItem widgetItem)
{
if (!config.itemCharges())
{
return;
}
// Check if on bank and if bank display is allowed
boolean showBank = plugin.getHelperValue("itemChargesBank", Boolean.class, true);
if (!showBank && widgetItem.getWidget() != null)
{
int group = net.runelite.api.widgets.WidgetUtil.componentToInterface(widgetItem.getWidget().getId());
if (group == net.runelite.api.gameval.InterfaceID.BANKMAIN || group == 12) // 12 = InterfaceID.BANK
{
return;
}
}
List<ItemCharges.ChargeValue> charges = itemCharges.chargesFor(itemId);
if (charges == null || charges.isEmpty())
{
return;
}
g.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON);
TimerTextSize size = plugin.getHelperValue("itemChargesSize", TimerTextSize.class, TimerTextSize.SMALL);
Font font = (size == TimerTextSize.SMALL) ? FontManager.getRunescapeSmallFont() : size.font();
g.setFont(font);
Rectangle bounds = widgetItem.getCanvasBounds();
if (bounds == null)
{
return;
}
// Single charge or Top charge (top-left: bounds.x - 1, bounds.y + 11)
ItemCharges.ChargeValue primary = charges.get(0);
if (primary != null && primary.getText() != null && !primary.getText().isEmpty())
{
TextComponent textComponent = new TextComponent();
textComponent.setFont(font);
textComponent.setColor(primary.getColor());
textComponent.setText(primary.getText());
textComponent.setPosition(new java.awt.Point(bounds.x - 1, bounds.y + 11));
textComponent.render(g);
}
// Secondary charge (bottom-left: bounds.x - 1, bounds.y + bounds.height - 2)
if (charges.size() > 1)
{
ItemCharges.ChargeValue secondary = charges.get(1);
if (secondary != null && secondary.getText() != null && !secondary.getText().isEmpty())
{
TextComponent secondaryComponent = new TextComponent();
secondaryComponent.setFont(font);
secondaryComponent.setColor(secondary.getColor());
secondaryComponent.setText(secondary.getText());
secondaryComponent.setPosition(new java.awt.Point(bounds.x - 1, bounds.y + bounds.height - 2));
secondaryComponent.render(g);
}
}
}
}
@@ -0,0 +1,114 @@
package com.example.essentialsplus.helpers;
import com.example.essentialsplus.EssentialsPlusConfig;
import java.awt.Color;
import java.awt.Dimension;
import java.awt.Graphics2D;
import javax.inject.Inject;
import net.runelite.api.Client;
import net.runelite.api.ItemComposition;
import net.runelite.api.MenuEntry;
import net.runelite.api.gameval.ItemID;
import net.runelite.api.widgets.Widget;
import net.runelite.api.widgets.WidgetUtil;
import net.runelite.client.game.ItemManager;
import net.runelite.client.ui.overlay.Overlay;
import net.runelite.client.ui.overlay.OverlayLayer;
import net.runelite.client.ui.overlay.OverlayPosition;
import net.runelite.client.ui.overlay.tooltip.Tooltip;
import net.runelite.client.ui.overlay.tooltip.TooltipManager;
import net.runelite.client.util.ColorUtil;
import net.runelite.client.util.QuantityFormatter;
/** Tooltip line with the GE and/or high alch value of the inventory item under the mouse. */
public class ItemPriceOverlay extends Overlay
{
private static final int INVENTORY = 149;
private static final int BANK_INVENTORY = 15;
private static final Color GE = new Color(0xFFDC33);
private static final Color HA = new Color(0xFF9040);
private final Client client;
private final EssentialsPlusConfig config;
private final ItemManager itemManager;
private final TooltipManager tooltipManager;
@Inject
ItemPriceOverlay(Client client, EssentialsPlusConfig config, ItemManager itemManager, TooltipManager tooltipManager)
{
this.client = client;
this.config = config;
this.itemManager = itemManager;
this.tooltipManager = tooltipManager;
setPosition(OverlayPosition.DYNAMIC);
setLayer(OverlayLayer.ABOVE_WIDGETS);
}
@Override
public Dimension render(Graphics2D g)
{
if (!config.itemPrices() || (!config.itemPricesGe() && !config.itemPricesHa()) || client.isMenuOpen())
{
return null;
}
MenuEntry[] entries = client.getMenuEntries();
if (entries.length == 0)
{
return null;
}
Widget w = entries[entries.length - 1].getWidget();
if (w == null)
{
return null;
}
int group = WidgetUtil.componentToInterface(w.getId());
if (group != INVENTORY && group != BANK_INVENTORY)
{
return null;
}
int id = w.getItemId();
if (id <= 0 || id == ItemID.COINS || id == ItemID.PLATINUM)
{
return null;
}
long qty = Math.max(1, w.getItemQuantity());
String text = text(itemManager.canonicalize(id), qty);
if (!text.isEmpty())
{
tooltipManager.add(new Tooltip(text));
}
return null;
}
private String text(int id, long qty)
{
StringBuilder sb = new StringBuilder();
if (config.itemPricesGe())
{
long ge = (long) itemManager.getItemPrice(id) * qty;
if (ge > 0)
{
sb.append("GE: ").append(ColorUtil.wrapWithColorTag(gp(ge), GE));
}
}
if (config.itemPricesHa())
{
ItemComposition comp = itemManager.getItemComposition(id);
long ha = comp == null ? 0 : (long) comp.getHaPrice() * qty;
if (ha > 0)
{
if (sb.length() > 0)
{
sb.append(" ");
}
sb.append("HA: ").append(ColorUtil.wrapWithColorTag(gp(ha), HA));
}
}
return sb.toString();
}
private static String gp(long v)
{
return QuantityFormatter.quantityToStackSize(v) + " gp";
}
}
@@ -0,0 +1,52 @@
package com.example.essentialsplus.helpers;
import java.awt.Font;
import net.runelite.client.ui.FontManager;
/** Font of the attack timer numbers. */
public enum TimerTextSize
{
SMALL("Small"),
NORMAL("Normal"),
LARGE("Large");
private final String label;
TimerTextSize(String label)
{
this.label = label;
}
public Font font()
{
switch (this)
{
case SMALL:
return FontManager.getRunescapeSmallFont();
case LARGE:
return FontManager.getRunescapeBoldFont().deriveFont(24f);
default:
return FontManager.getRunescapeBoldFont();
}
}
/** Icon edge length in pixels. */
public int iconSize()
{
switch (this)
{
case SMALL:
return 16;
case LARGE:
return 32;
default:
return 22;
}
}
@Override
public String toString()
{
return label;
}
}
@@ -0,0 +1,184 @@
package com.example.essentialsplus.helpers;
import com.example.essentialsplus.EssentialsPlusConfig;
import com.example.essentialsplus.EssentialsPlusPlugin;
import java.awt.Color;
import java.awt.Dimension;
import java.awt.FontMetrics;
import java.awt.Graphics2D;
import java.awt.RenderingHints;
import javax.inject.Inject;
import net.runelite.api.Client;
import net.runelite.api.Perspective;
import net.runelite.api.Point;
import net.runelite.api.coords.LocalPoint;
import net.runelite.client.ui.overlay.Overlay;
import net.runelite.client.ui.overlay.OverlayLayer;
import net.runelite.client.ui.overlay.OverlayPosition;
/**
* Overlay displaying:
* - Remaining seconds until stump respawn
* - Remaining chop duration on high tier trees (Maple+)
* - Optional progress bar and customizable styling
*/
public class TreeCooldownOverlay extends Overlay
{
private static final Color BAR_BG = new Color(0, 0, 0, 160);
private static final Color BAR_BORDER = new Color(0, 0, 0, 220);
private static final int BAR_WIDTH = 32;
private static final int BAR_HEIGHT = 4;
private final Client client;
private final EssentialsPlusConfig config;
private final EssentialsPlusPlugin plugin;
private final TreeCooldowns treeCooldowns;
@Inject
public TreeCooldownOverlay(Client client, EssentialsPlusConfig config, EssentialsPlusPlugin plugin, TreeCooldowns treeCooldowns)
{
this.client = client;
this.config = config;
this.plugin = plugin;
this.treeCooldowns = treeCooldowns;
setPosition(OverlayPosition.DYNAMIC);
setLayer(OverlayLayer.ABOVE_SCENE);
}
@Override
public Dimension render(Graphics2D g)
{
if (!config.treeCooldowns())
{
return null;
}
g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
boolean showRespawn = plugin.getHelperValue("treeCooldownRespawn", Boolean.class, true);
boolean showChop = plugin.getHelperValue("treeCooldownChop", Boolean.class, true);
boolean showBar = plugin.getHelperValue("treeCooldownBar", Boolean.class, false);
TimerTextSize size = plugin.getHelperValue("treeCooldownSize", TimerTextSize.class, TimerTextSize.NORMAL);
int offset = plugin.getHelperValue("treeCooldownOffset", Integer.class, 50);
Color textColor = plugin.getHelperValue("treeCooldownColor", Color.class, new Color(0x8FBF3F));
Color readyColor = plugin.getHelperValue("treeCooldownReadyColor", Color.class, new Color(0x66FF66));
int plane = client.getPlane();
if (showRespawn)
{
for (TreeCooldowns.RespawnTimer timer : treeCooldowns.getActiveRespawnTimers())
{
if (timer.worldPoint.getPlane() != plane)
{
continue;
}
int sec = timer.getRemainingSeconds();
if (sec <= 0)
{
continue;
}
Point anchor = null;
if (timer.stumpObject != null)
{
anchor = timer.stumpObject.getCanvasTextLocation(g, "", offset);
}
if (anchor == null)
{
LocalPoint lp = LocalPoint.fromWorld(client, timer.worldPoint);
if (lp != null)
{
anchor = Perspective.getCanvasTextLocation(client, g, lp, "", offset);
}
}
if (anchor == null)
{
continue;
}
Color c = (sec <= 2) ? readyColor : textColor;
drawTimer(g, anchor, sec, c, size, showBar, timer.getProgress());
}
}
if (showChop)
{
for (TreeCooldowns.ChopTimer timer : treeCooldowns.getActiveChopTimers())
{
if (timer.worldPoint.getPlane() != plane)
{
continue;
}
int sec = timer.getRemainingSeconds();
if (sec <= 0)
{
continue;
}
Point anchor = null;
if (timer.treeObject != null)
{
anchor = timer.treeObject.getCanvasTextLocation(g, "", offset + 80);
}
if (anchor == null)
{
LocalPoint lp = LocalPoint.fromWorld(client, timer.worldPoint);
if (lp != null)
{
anchor = Perspective.getCanvasTextLocation(client, g, lp, "", offset + 80);
}
}
if (anchor == null)
{
continue;
}
Color c = (sec <= 5) ? readyColor : textColor;
drawTimer(g, anchor, sec, c, size, showBar, timer.getProgress());
}
}
return null;
}
private void drawTimer(Graphics2D g, Point anchor, int seconds, Color color, TimerTextSize size, boolean showBar, double progress)
{
g.setFont(size.font());
String text = String.valueOf(seconds);
FontMetrics fm = g.getFontMetrics();
int textX = anchor.getX() - fm.stringWidth(text) / 2;
int textY = anchor.getY();
shadowed(g, text, textX, textY, color);
if (showBar)
{
int barX = anchor.getX() - BAR_WIDTH / 2;
int barY = textY + 4;
g.setColor(BAR_BG);
g.fillRect(barX, barY, BAR_WIDTH, BAR_HEIGHT);
int fill = (int) Math.round(BAR_WIDTH * Math.max(0.0, Math.min(1.0, progress)));
if (fill > 0)
{
g.setColor(color);
g.fillRect(barX, barY, fill, BAR_HEIGHT);
}
g.setColor(BAR_BORDER);
g.drawRect(barX, barY, BAR_WIDTH, BAR_HEIGHT);
}
}
private static void shadowed(Graphics2D g, String text, int x, int y, Color color)
{
g.setColor(Color.BLACK);
g.drawString(text, x + 1, y + 1);
g.setColor(color);
g.drawString(text, x, y);
}
}
@@ -0,0 +1,790 @@
package com.example.essentialsplus.helpers;
import com.example.essentialsplus.EssentialsPlusConfig;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import javax.inject.Inject;
import javax.inject.Singleton;
import net.runelite.api.Client;
import net.runelite.api.GameObject;
import net.runelite.api.GameState;
import net.runelite.api.ObjectComposition;
import net.runelite.api.Perspective;
import net.runelite.api.Player;
import net.runelite.api.ScriptID;
import net.runelite.api.Tile;
import net.runelite.api.coords.LocalPoint;
import net.runelite.api.coords.WorldPoint;
import net.runelite.api.events.GameObjectDespawned;
import net.runelite.api.events.GameObjectSpawned;
import net.runelite.api.events.GameStateChanged;
import net.runelite.api.events.GameTick;
import net.runelite.api.events.ScriptPreFired;
import net.runelite.api.gameval.AnimationID;
import net.runelite.api.gameval.ObjectID;
import net.runelite.client.eventbus.Subscribe;
/**
* Tracks tree cooldowns:
* 1. Remaining chop duration for long-lived trees (Maple+, despawnSec > 30) with regeneration when idle.
* 2. Respawn timers on stumps felled nearby (exact tick synchronization via game script and object events).
*/
@Singleton
public class TreeCooldowns
{
/** Woodcutting animations for standard, gilded, infernal, crystal, 3rd age, trailblazer, and forestry 2H axes. */
public static final Set<Integer> WOODCUT_ANIMS = Set.of(
AnimationID.HUMAN_WOODCUTTING_BRONZE_AXE,
AnimationID.HUMAN_WOODCUTTING_IRON_AXE,
AnimationID.HUMAN_WOODCUTTING_STEEL_AXE,
AnimationID.HUMAN_WOODCUTTING_BLACK_AXE,
AnimationID.HUMAN_WOODCUTTING_MITHRIL_AXE,
AnimationID.HUMAN_WOODCUTTING_ADAMANT_AXE,
AnimationID.HUMAN_WOODCUTTING_RUNE_AXE,
AnimationID.HUMAN_WOODCUTTING_GILDED_AXE,
AnimationID.HUMAN_WOODCUTTING_DRAGON_AXE,
AnimationID.HUMAN_WOODCUTTING_TRAILBLAZER_AXE_NO_INFERNAL,
AnimationID.HUMAN_WOODCUTTING_INFERNAL_AXE,
AnimationID.HUMAN_WOODCUTTING_3A_AXE,
AnimationID.HUMAN_WOODCUTTING_CRYSTAL_AXE,
AnimationID.HUMAN_WOODCUTTING_TRAILBLAZER_RELOADED_AXE_NO_INFERNAL,
AnimationID.HUMAN_WOODCUTTING_TRAILBLAZER_AXE,
AnimationID.HUMAN_WOODCUTTING_TRAILBLAZER_RELOADED_AXE,
AnimationID.FORESTRY_2H_AXE_CHOPPING_BRONZE,
AnimationID.FORESTRY_2H_AXE_CHOPPING_IRON,
AnimationID.FORESTRY_2H_AXE_CHOPPING_STEEL,
AnimationID.FORESTRY_2H_AXE_CHOPPING_BLACK,
AnimationID.FORESTRY_2H_AXE_CHOPPING_MITHRIL,
AnimationID.FORESTRY_2H_AXE_CHOPPING_ADAMANT,
AnimationID.FORESTRY_2H_AXE_CHOPPING_RUNE,
AnimationID.FORESTRY_2H_AXE_CHOPPING_DRAGON,
AnimationID.FORESTRY_2H_AXE_CHOPPING_CRYSTAL,
AnimationID.FORESTRY_2H_AXE_CHOPPING_CRYSTAL_INACTIVE,
AnimationID.FORESTRY_2H_AXE_CHOPPING_3A
);
/** Classification of tree species with their official despawn and respawn periods. */
public enum TreeType
{
OAK("Oak", 27, 8.4, 14),
WILLOW("Willow", 30, 8.4, 14),
TEAK("Teak", 30, 9.0, 15),
MAPLE("Maple", 60, 35.4, 59),
HOLLOW("Hollow", 36, 25.8, 43),
MAHOGANY("Mahogany", 60, 8.4, 14),
ARCTIC_PINE("Arctic pine", 84, 8.4, 14),
YEW("Yew", 114, 59.4, 99),
CAMPHOR("Camphor", 120, 59.4, 99),
MAGIC("Magic", 234, 119.4, 199),
IRONWOOD("Ironwood", 240, 119.4, 199),
REDWOOD("Redwood", 264, 119.4, 199),
ROSEWOOD("Rosewood", 275, 123.0, 205),
NORMAL("Tree", 0, 10.0, 16);
public final String displayName;
public final int despawnSec;
public final double respawnSec;
public final int respawnTicks;
TreeType(String displayName, int despawnSec, double respawnSec, int respawnTicks)
{
this.displayName = displayName;
this.despawnSec = despawnSec;
this.respawnSec = respawnSec;
this.respawnTicks = respawnTicks;
}
public int getDespawnTicks()
{
return (int) Math.round(despawnSec / 0.6);
}
public boolean hasChopTimer()
{
return despawnSec > 30;
}
public static TreeType fromRespawnTicks(int ticks)
{
for (TreeType t : values())
{
if (t.respawnTicks == ticks)
{
return t;
}
}
if (ticks <= 14) return OAK;
if (ticks <= 16) return NORMAL;
if (ticks <= 45) return HOLLOW;
if (ticks <= 65) return MAPLE;
if (ticks <= 105) return YEW;
if (ticks <= 150) return ROSEWOOD;
return MAGIC;
}
}
/** Information about a tree currently in scene. */
public static class TrackedTree
{
public final GameObject gameObject;
public final TreeType treeType;
public final WorldPoint worldPoint;
public final int sizeX;
public final int sizeY;
public int choppedTicks;
public int lastChoppedTick;
public TrackedTree(GameObject gameObject, TreeType treeType)
{
this.gameObject = gameObject;
this.treeType = treeType;
this.worldPoint = gameObject.getWorldLocation();
this.sizeX = gameObject.sizeX();
this.sizeY = gameObject.sizeY();
this.choppedTicks = 0;
this.lastChoppedTick = -1;
}
}
/** Respawn timer for a felled tree stump. */
public static class RespawnTimer
{
public final TreeType treeType;
public final WorldPoint worldPoint;
public final long startTimeMs;
public final long deadlineMs;
public final int totalTicks;
public final int startTick;
public GameObject stumpObject;
public RespawnTimer(TreeType treeType, WorldPoint worldPoint, long startTimeMs, long deadlineMs, int totalTicks, int startTick)
{
this.treeType = treeType;
this.worldPoint = worldPoint;
this.startTimeMs = startTimeMs;
this.deadlineMs = deadlineMs;
this.totalTicks = totalTicks;
this.startTick = startTick;
}
public int getRemainingSeconds()
{
long remaining = deadlineMs - System.currentTimeMillis();
return (int) Math.max(0, Math.ceil(remaining / 1000.0));
}
public double getProgress()
{
long total = deadlineMs - startTimeMs;
if (total <= 0)
{
return 1.0;
}
long elapsed = System.currentTimeMillis() - startTimeMs;
return Math.max(0.0, Math.min(1.0, (double) elapsed / total));
}
}
/** Active chop timer for a tree being harvested. */
public static class ChopTimer
{
public final TreeType treeType;
public final WorldPoint worldPoint;
public final GameObject treeObject;
public final int remainingTicks;
public final int totalTicks;
public ChopTimer(TreeType treeType, WorldPoint worldPoint, GameObject treeObject, int remainingTicks, int totalTicks)
{
this.treeType = treeType;
this.worldPoint = worldPoint;
this.treeObject = treeObject;
this.remainingTicks = remainingTicks;
this.totalTicks = totalTicks;
}
public int getRemainingSeconds()
{
return (int) Math.max(0, Math.round(remainingTicks * 0.6));
}
public double getProgress()
{
if (totalTicks <= 0)
{
return 1.0;
}
return Math.max(0.0, Math.min(1.0, (double) remainingTicks / totalTicks));
}
}
private final Client client;
private final EssentialsPlusConfig config;
private final Map<WorldPoint, TrackedTree> trackedTrees = new ConcurrentHashMap<>();
private final Map<WorldPoint, RespawnTimer> respawnTimers = new ConcurrentHashMap<>();
private final Map<Integer, TreeType> knownTreeIds = new ConcurrentHashMap<>();
private final Map<Integer, TreeType> knownStumpIds = new ConcurrentHashMap<>();
private final Set<Integer> nonTreeIds = Collections.synchronizedSet(new HashSet<>());
private TrackedTree activeTree;
private int lastLoginTick;
@Inject
public TreeCooldowns(Client client, EssentialsPlusConfig config)
{
this.client = client;
this.config = config;
initKnownIds();
}
private void initKnownIds()
{
// Common stumps
registerStump(ObjectID.OAKTREE_STUMP, TreeType.OAK);
registerStump(ObjectID.OAK_TREE_STUMP, TreeType.OAK);
registerStump(ObjectID.TREE_OAK_STUMP01, TreeType.OAK);
registerStump(ObjectID.WILLOW_TREE_STUMP, TreeType.WILLOW);
registerStump(ObjectID.WILLOW_TREE2OR3OR4_STUMP, TreeType.WILLOW);
registerStump(ObjectID.WILLOW_TREE_STUMP_NEW, TreeType.WILLOW);
registerStump(ObjectID.TEAK_STUMP, TreeType.TEAK);
registerStump(ObjectID.TEAK_TREE_STUMP, TreeType.TEAK);
registerStump(ObjectID.PRIF_TEAK_STUMP, TreeType.TEAK);
registerStump(ObjectID.TEAKTREE_UPDATE_STUMP, TreeType.TEAK);
registerStump(ObjectID.MAPLE_TREE_STUMP, TreeType.MAPLE);
registerStump(ObjectID.MAPLE_TREE_STUMP_NEW, TreeType.MAPLE);
registerStump(ObjectID.MAPLETREE_UPDATE_STUMP, TreeType.MAPLE);
registerStump(ObjectID.HOLLOW_TREE_STUMP, TreeType.HOLLOW);
registerStump(ObjectID.HOLLOW_TREE_STUMP_BIG, TreeType.HOLLOW);
registerStump(ObjectID.MAHOGANY_STUMP, TreeType.MAHOGANY);
registerStump(ObjectID.MAHOGANY_TREE_STUMP, TreeType.MAHOGANY);
registerStump(ObjectID.PRIF_MAHOGANY_STUMP, TreeType.MAHOGANY);
registerStump(ObjectID.MAHOGANYTREE_UPDATE_STUMP, TreeType.MAHOGANY);
registerStump(ObjectID.ARCTIC_PINE_TREE_STUMP, TreeType.ARCTIC_PINE);
registerStump(ObjectID.YEWTREE_STUMP, TreeType.YEW);
registerStump(ObjectID.YEW_TREE_STUMP, TreeType.YEW);
registerStump(ObjectID.YEW_TREE_STUMP_NEW, TreeType.YEW);
registerStump(ObjectID.PRIF_YEWTREE_STUMP, TreeType.YEW);
registerStump(ObjectID.TREE_YEW_STUMP01, TreeType.YEW);
registerStump(ObjectID.YEWTREE_UPDATE_STUMP, TreeType.YEW);
registerStump(ObjectID.YEWTREE_UPDATE2_STUMP, TreeType.YEW);
registerStump(ObjectID.CAMPHOR_TREE_UPDATE_STUMP, TreeType.CAMPHOR);
registerStump(ObjectID.CAMPHOR_TREE_STUMP, TreeType.CAMPHOR);
registerStump(ObjectID.MAGIC_TREE_STUMP, TreeType.MAGIC);
registerStump(ObjectID.MAGIC_TREE_STUMP_NEW, TreeType.MAGIC);
registerStump(ObjectID.IRONWOOD_TREE_UPDATE_STUMP, TreeType.IRONWOOD);
registerStump(ObjectID.IRONWOOD_TREE_STUMP, TreeType.IRONWOOD);
registerStump(ObjectID.REDWOODTREE_L_CUT, TreeType.REDWOOD);
registerStump(ObjectID.REDWOODTREE_R_CUT, TreeType.REDWOOD);
registerStump(ObjectID.ROSEWOOD_TREE_UPDATE_STUMP, TreeType.ROSEWOOD);
registerStump(ObjectID.ROSEWOOD_TREE_STUMP, TreeType.ROSEWOOD);
registerStump(ObjectID.TREESTUMP, TreeType.NORMAL);
registerStump(ObjectID.TREESTUMP2, TreeType.NORMAL);
registerStump(ObjectID.TREESTUMP2_LIGHT, TreeType.NORMAL);
registerStump(ObjectID.TREESTUMP2_GREEN, TreeType.NORMAL);
registerStump(ObjectID.TREESTUMP2_SMALL, TreeType.NORMAL);
registerStump(ObjectID.TREESTUMP2_BIG, TreeType.NORMAL);
registerStump(ObjectID.DEADTREE1_STUMP, TreeType.NORMAL);
registerStump(ObjectID.DEADTREE2_STUMP, TreeType.NORMAL);
registerStump(ObjectID.DEADTREE3_STUMP, TreeType.NORMAL);
registerStump(ObjectID.DEADTREE4_STUMP, TreeType.NORMAL);
registerStump(ObjectID.DEADTREE6_STUMP, TreeType.NORMAL);
registerStump(ObjectID.EVERGREEN_LARGE_STUMP, TreeType.NORMAL);
registerStump(ObjectID.ACHEY_TREE_STUMP, TreeType.NORMAL);
registerStump(ObjectID.JUNGLESTUMP_KHARAZI, TreeType.NORMAL);
// Common trees
registerTree(ObjectID.OAKTREE, TreeType.OAK);
registerTree(ObjectID.WILLOWTREE, TreeType.WILLOW);
registerTree(ObjectID.TEAKTREE, TreeType.TEAK);
registerTree(ObjectID.TEAKTREE_UPDATE, TreeType.TEAK);
registerTree(ObjectID.MAPLETREE, TreeType.MAPLE);
registerTree(ObjectID.MAPLETREE_UPDATE, TreeType.MAPLE);
registerTree(ObjectID.HOLLOW_TREE, TreeType.HOLLOW);
registerTree(ObjectID.MAHOGANYTREE, TreeType.MAHOGANY);
registerTree(ObjectID.MAHOGANYTREE_UPDATE, TreeType.MAHOGANY);
registerTree(ObjectID.YEWTREE, TreeType.YEW);
registerTree(ObjectID.YEWTREE_UPDATE, TreeType.YEW);
registerTree(ObjectID.YEWTREE_UPDATE2, TreeType.YEW);
registerTree(ObjectID.CAMPHOR_TREE, TreeType.CAMPHOR);
registerTree(ObjectID.MAGICTREE, TreeType.MAGIC);
registerTree(ObjectID.IRONWOOD_TREE, TreeType.IRONWOOD);
registerTree(ObjectID.REDWOODTREE_L, TreeType.REDWOOD);
registerTree(ObjectID.REDWOODTREE_R, TreeType.REDWOOD);
registerTree(ObjectID.ROSEWOOD_TREE, TreeType.ROSEWOOD);
registerTree(ObjectID.TREE, TreeType.NORMAL);
}
private void registerTree(int id, TreeType type)
{
knownTreeIds.put(id, type);
}
private void registerStump(int id, TreeType type)
{
knownStumpIds.put(id, type);
}
public void reset()
{
trackedTrees.clear();
respawnTimers.clear();
activeTree = null;
}
@Subscribe
public void onGameStateChanged(GameStateChanged event)
{
if (event.getGameState() != GameState.LOGGED_IN)
{
reset();
}
else
{
lastLoginTick = client.getTickCount();
scanScene();
}
}
@Subscribe
public void onGameTick(GameTick event)
{
if (!config.treeCooldowns())
{
return;
}
Player player = client.getLocalPlayer();
if (player == null)
{
return;
}
int currentTick = client.getTickCount();
boolean isChopping = WOODCUT_ANIMS.contains(player.getAnimation());
if (isChopping)
{
TrackedTree tree = findChoppedTree(player);
if (tree != null)
{
tree.choppedTicks++;
tree.lastChoppedTick = currentTick;
activeTree = tree;
}
}
else
{
activeTree = null;
}
// Depletion regeneration for idle trees
long now = System.currentTimeMillis();
for (TrackedTree tree : trackedTrees.values())
{
if (tree.choppedTicks > 0 && tree.lastChoppedTick != currentTick)
{
tree.choppedTicks = Math.max(0, tree.choppedTicks - 1);
}
}
// Prune expired respawn timers
respawnTimers.values().removeIf(timer -> now >= timer.deadlineMs + 2000L);
}
@Subscribe
public void onScriptPreFired(ScriptPreFired scriptPreFired)
{
if (!config.treeCooldowns() || scriptPreFired.getScriptId() != ScriptID.ADD_OVERLAYTIMER_LOC)
{
return;
}
Object[] args = scriptPreFired.getScriptEvent().getArguments();
if (args.length < 6)
{
return;
}
int locCoord = (int) args[1];
int locId = (int) args[2];
int ticks = (int) args[5];
WorldPoint worldPoint = WorldPoint.fromCoord(locCoord);
TreeType treeType = knownStumpIds.get(locId);
if (treeType == null)
{
TrackedTree prev = trackedTrees.get(worldPoint);
treeType = (prev != null) ? prev.treeType : TreeType.fromRespawnTicks(ticks);
}
long now = System.currentTimeMillis();
long durationMs = (long) ticks * 600L;
RespawnTimer timer = new RespawnTimer(
treeType,
worldPoint,
now,
now + durationMs,
ticks,
client.getTickCount()
);
respawnTimers.put(worldPoint, timer);
}
@Subscribe
public void onGameObjectSpawned(GameObjectSpawned event)
{
if (!config.treeCooldowns())
{
return;
}
GameObject go = event.getGameObject();
if (go == null)
{
return;
}
TreeType treeType = resolveTreeType(go);
if (treeType != null)
{
TrackedTree tree = new TrackedTree(go, treeType);
trackedTrees.put(go.getWorldLocation(), tree);
// Regrowth: remove any active respawn timer on this tile or within bounds
removeRespawnTimersAt(go.getWorldLocation(), go.sizeX(), go.sizeY());
return;
}
TreeType stumpType = resolveStumpType(go);
if (stumpType != null)
{
WorldPoint pt = go.getWorldLocation();
RespawnTimer timer = respawnTimers.get(pt);
if (timer != null)
{
timer.stumpObject = go;
}
else
{
// Witnessed felling in player's vicinity
Player player = client.getLocalPlayer();
int ticksSinceLoad = client.getTickCount() - lastLoginTick;
if (player != null && ticksSinceLoad > 2 && player.getWorldLocation().distanceTo(pt) <= 16)
{
TrackedTree prev = trackedTrees.remove(pt);
TreeType actualType = (prev != null) ? prev.treeType : stumpType;
long now = System.currentTimeMillis();
long durationMs = (long) (actualType.respawnSec * 1000L);
RespawnTimer newTimer = new RespawnTimer(
actualType,
pt,
now,
now + durationMs,
actualType.respawnTicks,
client.getTickCount()
);
newTimer.stumpObject = go;
respawnTimers.put(pt, newTimer);
}
}
}
}
@Subscribe
public void onGameObjectDespawned(GameObjectDespawned event)
{
if (!config.treeCooldowns())
{
return;
}
GameObject go = event.getGameObject();
if (go == null)
{
return;
}
WorldPoint pt = go.getWorldLocation();
TrackedTree tree = trackedTrees.remove(pt);
if (tree != null)
{
if (!respawnTimers.containsKey(pt))
{
long now = System.currentTimeMillis();
long durationMs = (long) (tree.treeType.respawnSec * 1000L);
RespawnTimer timer = new RespawnTimer(
tree.treeType,
pt,
now,
now + durationMs,
tree.treeType.respawnTicks,
client.getTickCount()
);
respawnTimers.put(pt, timer);
}
}
// If stump despawned, remove respawn timer
if (resolveStumpType(go) != null)
{
respawnTimers.remove(pt);
}
}
private void removeRespawnTimersAt(WorldPoint origin, int sizeX, int sizeY)
{
for (int x = 0; x < sizeX; x++)
{
for (int y = 0; y < sizeY; y++)
{
respawnTimers.remove(new WorldPoint(origin.getX() + x, origin.getY() + y, origin.getPlane()));
}
}
}
private TrackedTree findChoppedTree(Player player)
{
WorldPoint playerLoc = player.getWorldLocation();
// Prefer continuing with active tree if still adjacent
if (activeTree != null && isAdjacent(playerLoc, activeTree))
{
return activeTree;
}
// Find closest adjacent tree aligned with player orientation
TrackedTree bestTree = null;
int bestScore = Integer.MAX_VALUE;
int orientation = player.getOrientation(); // 0 = South, 512 = West, 1024 = North, 1536 = East
double facingAngle = orientation * (2.0 * Math.PI / 2048.0);
double facingDx = -Math.sin(facingAngle);
double facingDy = -Math.cos(facingAngle);
for (TrackedTree tree : trackedTrees.values())
{
if (tree.worldPoint.getPlane() != playerLoc.getPlane())
{
continue;
}
if (isAdjacent(playerLoc, tree))
{
double centerX = tree.worldPoint.getX() + (tree.sizeX - 1) / 2.0;
double centerY = tree.worldPoint.getY() + (tree.sizeY - 1) / 2.0;
double dx = centerX - playerLoc.getX();
double dy = centerY - playerLoc.getY();
// Dot product with facing direction
double dot = dx * facingDx + dy * facingDy;
int score = (int) Math.round((1.0 - dot) * 100.0);
if (score < bestScore)
{
bestScore = score;
bestTree = tree;
}
}
}
return bestTree;
}
private static boolean isAdjacent(WorldPoint playerLoc, TrackedTree tree)
{
int minX = tree.worldPoint.getX();
int maxX = tree.worldPoint.getX() + tree.sizeX - 1;
int minY = tree.worldPoint.getY();
int maxY = tree.worldPoint.getY() + tree.sizeY - 1;
int px = playerLoc.getX();
int py = playerLoc.getY();
int dx = Math.max(0, Math.max(minX - px, px - maxX));
int dy = Math.max(0, Math.max(minY - py, py - maxY));
return dx <= 1 && dy <= 1;
}
private void scanScene()
{
Tile[][][] tiles = client.getScene().getTiles();
int plane = client.getPlane();
if (tiles == null || plane < 0 || plane >= tiles.length)
{
return;
}
Tile[][] planeTiles = tiles[plane];
for (Tile[] row : planeTiles)
{
if (row == null) continue;
for (Tile tile : row)
{
if (tile == null) continue;
GameObject[] objects = tile.getGameObjects();
if (objects != null)
{
for (GameObject go : objects)
{
if (go != null)
{
TreeType treeType = resolveTreeType(go);
if (treeType != null)
{
trackedTrees.put(go.getWorldLocation(), new TrackedTree(go, treeType));
}
}
}
}
}
}
}
private TreeType resolveTreeType(GameObject obj)
{
int id = obj.getId();
if (nonTreeIds.contains(id))
{
return null;
}
TreeType known = knownTreeIds.get(id);
if (known != null)
{
return known;
}
ObjectComposition comp = client.getObjectDefinition(id);
if (comp == null || comp.getName() == null)
{
nonTreeIds.add(id);
return null;
}
String name = comp.getName().toLowerCase();
if (name.contains("stump"))
{
return null;
}
boolean hasChop = false;
String[] actions = comp.getActions();
if (actions != null)
{
for (String a : actions)
{
if (a != null && a.equalsIgnoreCase("Chop down"))
{
hasChop = true;
break;
}
}
}
if (!hasChop && !name.contains("tree") && !name.contains("redwood"))
{
nonTreeIds.add(id);
return null;
}
TreeType matched = matchTreeName(name);
if (matched != null)
{
knownTreeIds.put(id, matched);
return matched;
}
nonTreeIds.add(id);
return null;
}
private TreeType resolveStumpType(GameObject obj)
{
int id = obj.getId();
TreeType known = knownStumpIds.get(id);
if (known != null)
{
return known;
}
ObjectComposition comp = client.getObjectDefinition(id);
if (comp == null || comp.getName() == null)
{
return null;
}
String name = comp.getName().toLowerCase();
if (name.contains("stump"))
{
TreeType matched = matchTreeName(name);
TreeType result = (matched != null) ? matched : TreeType.NORMAL;
knownStumpIds.put(id, result);
return result;
}
return null;
}
private static TreeType matchTreeName(String name)
{
if (name.contains("redwood")) return TreeType.REDWOOD;
if (name.contains("magic")) return TreeType.MAGIC;
if (name.contains("yew")) return TreeType.YEW;
if (name.contains("camphor")) return TreeType.CAMPHOR;
if (name.contains("ironwood")) return TreeType.IRONWOOD;
if (name.contains("rosewood")) return TreeType.ROSEWOOD;
if (name.contains("arctic pine")) return TreeType.ARCTIC_PINE;
if (name.contains("mahogany")) return TreeType.MAHOGANY;
if (name.contains("maple")) return TreeType.MAPLE;
if (name.contains("hollow")) return TreeType.HOLLOW;
if (name.contains("teak")) return TreeType.TEAK;
if (name.contains("willow")) return TreeType.WILLOW;
if (name.contains("oak")) return TreeType.OAK;
if (name.contains("tree") || name.contains("evergreen")) return TreeType.NORMAL;
return null;
}
public Collection<RespawnTimer> getActiveRespawnTimers()
{
return respawnTimers.values();
}
public Collection<ChopTimer> getActiveChopTimers()
{
List<ChopTimer> result = new ArrayList<>();
for (TrackedTree tree : trackedTrees.values())
{
if (tree.treeType.hasChopTimer() && tree.choppedTicks > 0)
{
int totalTicks = tree.treeType.getDespawnTicks();
int remaining = totalTicks - tree.choppedTicks;
if (remaining > 0)
{
result.add(new ChopTimer(tree.treeType, tree.worldPoint, tree.gameObject, remaining, totalTicks));
}
}
}
return result;
}
}
@@ -0,0 +1,445 @@
package com.example.essentialsplus.helpers.combat;
import com.google.common.collect.ImmutableMap;
import com.google.common.collect.ImmutableSet;
import java.security.InvalidParameterException;
import java.util.Arrays;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
import lombok.Getter;
import net.runelite.api.gameval.AnimationID;
import org.apache.commons.lang3.StringUtils;
public enum AnimationData
{
// MELEE
MELEE_VIGGORAS_CHAINMACE(AnimationID.WILD_CAVE_CHAINMACE_CRUSH, AttackStyle.MELEE),
MELEE_DAGGER_SLASH(AnimationID.HUMAN_DDAGGER_LUNGE, AttackStyle.MELEE),
MELEE_SPEAR_STAB(AnimationID.HUMAN_DSPEAR_STAB, AttackStyle.MELEE),
MELEE_SWORD_STAB(AnimationID.HUMAN_SWORD_STAB, AttackStyle.MELEE),
MELEE_SCIM_SLASH(AnimationID.HUMAN_SWORD_SLASH, AttackStyle.MELEE),
MELEE_LANCE_STAB(AnimationID.HUMAN_DHUNTER_LANCE_ATTACK, AttackStyle.MELEE),
MELEE_LANCE_CRUSH(AnimationID.HUMAN_DHUNTER_LANCE_CRUSH, AttackStyle.MELEE),
MELEE_LANCE_SLASH(AnimationID.HUMAN_DHUNTER_LANCE_SLASH, AttackStyle.MELEE),
MELEE_FANG_STAB(AnimationID.HUMAN_OSMUMTENS_FANG, AttackStyle.MELEE),
MELEE_FANG_SPEC(AnimationID.OLAF2_BRINE_SABRE_SPECIAL, AttackStyle.MELEE, MetaData.SPECIAL_ATTACK.d),
MELEE_GENERIC_SLASH(AnimationID.HUMAN_AXE_CHOP, AttackStyle.MELEE),
MELEE_BATTLEAXE_SLASH(AnimationID.HUMAN_AXE_HACK, AttackStyle.MELEE),
MELEE_MACE_STAB(AnimationID.HUMAN_BLUNT_SPIKE, AttackStyle.MELEE),
MELEE_BATTLEAXE_CRUSH(AnimationID.HUMAN_BLUNT_POUND, AttackStyle.MELEE),
MELEE_2H_CRUSH(AnimationID.HUMAN_DHSWORD_CHOP, AttackStyle.MELEE),
MELEE_2H_SLASH(AnimationID.HUMAN_DHSWORD_SLASH, AttackStyle.MELEE),
MELEE_STAFF_CRUSH_2(AnimationID.HUMAN_STAFF_PUMMEL, AttackStyle.MELEE),
MELEE_STAFF_CRUSH_3(AnimationID.HUMAN_STAFFORB_PUMMEL, AttackStyle.MELEE),
MELEE_PUNCH(AnimationID.HUMAN_UNARMEDPUNCH, AttackStyle.MELEE),
MELEE_KICK(AnimationID.HUMAN_UNARMEDKICK, AttackStyle.MELEE),
MELEE_STAFF_STAB(AnimationID.HUMAN_SPEAR_SPIKE, AttackStyle.MELEE),
MELEE_SPEAR_CRUSH(AnimationID.HUMAN_SPEAR_LUNGE, AttackStyle.MELEE),
MELEE_STAFF_SLASH(AnimationID.HUMAN_SCYTHE_SWEEP, AttackStyle.MELEE),
MELEE_DLONG_SPEC(AnimationID.CLEAVE, AttackStyle.MELEE, MetaData.SPECIAL_ATTACK.d),
MELEE_DRAGON_MACE_SPEC(AnimationID.SHATTER, AttackStyle.MELEE, MetaData.SPECIAL_ATTACK.d),
MELEE_DRAGON_DAGGER_SPEC(AnimationID.PUNCTURE, AttackStyle.MELEE, MetaData.SPECIAL_ATTACK.d),
MELEE_DRAGON_WARHAMMER_SPEC(AnimationID.DRAGON_WARHAMMER_SA_PLAYER, AttackStyle.MELEE, MetaData.SPECIAL_ATTACK.d),
MELEE_ABYSSAL_WHIP(AnimationID.SLAYER_ABYSSAL_WHIP_ATTACK, AttackStyle.MELEE),
MELEE_GRANITE_MAUL(AnimationID.SLAYER_GRANITE_MAUL_ATTACK, AttackStyle.MELEE),
MELEE_GRANITE_MAUL_SPEC(AnimationID.SLAYER_GRANITE_MAUL_SPECIAL_ATTACK, AttackStyle.MELEE, MetaData.SPECIAL_ATTACK.d),
MELEE_DHAROKS_GREATAXE_CRUSH(AnimationID.BARROW_DHAROK_SLASH, AttackStyle.MELEE),
MELEE_DHAROKS_GREATAXE_SLASH(AnimationID.BARROW_DHAROK_CRUSH, AttackStyle.MELEE),
MELEE_AHRIMS_STAFF_CRUSH(AnimationID.BARROWS_QUARTERSTAFF_ATTACK, AttackStyle.MELEE),
MELEE_OBBY_MAUL_CRUSH(AnimationID.DRAGON_PICKAXE_ANIM, AttackStyle.MELEE),
MELEE_ABYSSAL_DAGGER_STAB(AnimationID.ABYSSAL_DAGGER_LUNGE, AttackStyle.MELEE),
MELEE_ABYSSAL_BLUDGEON_CRUSH(AnimationID.ABYSSAL_BLUDGEON_CRUSH, AttackStyle.MELEE),
MELEE_ABYSSAL_BLUDGEON_SPEC(AnimationID.ABYSSAL_BLUDGEON_SPECIAL_ATTACK, AttackStyle.MELEE, MetaData.SPECIAL_ATTACK.d),
MELEE_LEAF_BLADED_BATTLEAXE_CRUSH(AnimationID.BATTLEAXE_CRUSH, AttackStyle.MELEE),
MELEE_INQUISITORS_MACE(AnimationID.HUMAN_INQUISITORS_MACE_CRUSH, AttackStyle.MELEE),
MELEE_BARRELCHEST_ANCHOR_CRUSH(AnimationID.BRAIN_PLAYER_ANCHOR_ATTACK, AttackStyle.MELEE),
MELEE_BARRELCHEST_ANCHOR_CRUSH_SPEC(AnimationID.BRAIN_PLAYER_ANCHOR_SPECIAL_ATTACK, AttackStyle.MELEE, MetaData.SPECIAL_ATTACK.d),
MELEE_LEAF_BLADED_BATTLEAXE_SLASH(AnimationID.GODWARS_GODSWORD_ZAMORAK_PLAYER, AttackStyle.MELEE),
MELEE_GODSWORD_SLASH(AnimationID.DH_SWORD_UPDATE_SLASH, AttackStyle.MELEE),
MELEE_GODSWORD_CRUSH(AnimationID.DH_SWORD_UPDATE_SMASH, AttackStyle.MELEE),
MELEE_GODSWORD_DEFENSIVE(AnimationID.DH_SWORD_UPDATE_BLOCK, AttackStyle.MELEE),
MELEE_RUNE_CLAWS_SPEC(AnimationID.IMPALE, AttackStyle.MELEE, MetaData.SPECIAL_ATTACK.d),
MELEE_DRAGON_CLAWS_SPEC(AnimationID.HUMAN_DRAGON_CLAWS_SPEC, AttackStyle.MELEE, MetaData.SPECIAL_ATTACK.d),
MELEE_VLS_SPEC(AnimationID.HUMAN_DRAGON_SWORD_SPEC, AttackStyle.MELEE, MetaData.SPECIAL_ATTACK.d),
MELEE_ELDER_MAUL(AnimationID.HUMAN_ELDER_MAUL_ATTACK, AttackStyle.MELEE),
MELEE_ZAMORAK_GODSWORD_SPEC(AnimationID.ZGS_SPECIAL_PLAYER, AttackStyle.MELEE, MetaData.SPECIAL_ATTACK.d),
MELEE_ELDER_MAUL_SPEC(AnimationID.HUMAN_ELDER_MAUL_SPEC, AttackStyle.MELEE),
MELEE_ZAMORAK_GODSWORD_OR_SPEC(AnimationID.ZGS_SPECIAL_ORNATE_PLAYER, AttackStyle.MELEE, MetaData.SPECIAL_ATTACK.d),
MELEE_SARADOMIN_GODSWORD_SPEC(AnimationID.SGS_SPECIAL_PLAYER, AttackStyle.MELEE, MetaData.SPECIAL_ATTACK.d),
MELEE_SARADOMIN_GODSWORD_OR_SPEC(AnimationID.SGS_SPECIAL_ORNATE_PLAYER, AttackStyle.MELEE, MetaData.SPECIAL_ATTACK.d),
MELEE_BANDOS_GODSWORD_SPEC(AnimationID.BGS_SPECIAL_PLAYER, AttackStyle.MELEE, MetaData.SPECIAL_ATTACK.d),
MELEE_BANDOS_GODSWORD_OR_SPEC(AnimationID.BGS_SPECIAL_ORNATE_PLAYER, AttackStyle.MELEE, MetaData.SPECIAL_ATTACK.d),
MELEE_ARMADYL_GODSWORD_SPEC(AnimationID.AGS_SPECIAL_PLAYER, AttackStyle.MELEE, MetaData.SPECIAL_ATTACK.d),
MELEE_ARMADYL_GODSWORD_OR_SPEC(AnimationID.AGS_SPECIAL_ORNATE_PLAYER, AttackStyle.MELEE, MetaData.SPECIAL_ATTACK.d),
MELEE_SCYTHE(AnimationID.SCYTHE_OF_VITUR_ATTACK, AttackStyle.MELEE),
MELEE_GHAZI_RAPIER_STAB(AnimationID.GHRAZI_RAPIER_ATTACK, AttackStyle.MELEE),
MELEE_ANCIENT_GODSWORD_SPEC(AnimationID.NGS_SPECIAL_PLAYER, AttackStyle.MELEE, MetaData.SPECIAL_ATTACK.d),
MELEE_CRYSTAL_HALBERD_SPEC(AnimationID.DRAGON_HALBERD_SPECIAL_ATTACK, AttackStyle.MELEE, MetaData.SPECIAL_ATTACK.d),
MELEE_SOULREAPER_AXE(AnimationID.ANCIENT_AXE_CRUSH, AttackStyle.MELEE, MetaData.SPECIAL_ATTACK.d),
MELEE_SOULREAPER_AXE_SPEC(AnimationID.ANCIENT_AXE_SPECIAL, AttackStyle.MELEE, MetaData.SPECIAL_ATTACK.d),
MELEE_GUTHANS_LUNGE(AnimationID.BARROWS_WAR_SPEAR_STAB, AttackStyle.MELEE),
MELEE_GUTHANS_SWIPE(AnimationID.BARROWS_WAR_SPEAR_SLASH, AttackStyle.MELEE),
MELEE_GUTHANS_POUNDMA(AnimationID.BARROWS_WAR_SPEAR_CRUSH, AttackStyle.MELEE),
MELEE_TORAG_HAMMERS(AnimationID.BARROW_TORAG_CRUSH, AttackStyle.MELEE),
MELEE_VERACS_FLAIL(AnimationID.BARROW_GUTHAN_CRUSH, AttackStyle.MELEE),
MELEE_BLISTERWOOD_FLAIL_CRUSH(AnimationID.IVANDIS_FLAIL_ATTACK, AttackStyle.MELEE),
MELEE_BONE_DAGGER_SPEC(AnimationID.DTTD_PLAYER_STAB_BONE_DAGGER, AttackStyle.MELEE, MetaData.SPECIAL_ATTACK.d),
MELEE_DUAL_MACUAHUITL(AnimationID.PMOON_MACUAHUITL_CRUSH, AttackStyle.MELEE),
MELEE_BLUE_MOON_SPEAR_SPEC(AnimationID.HUMAN_ZAMORAKSPEAR_LUNGE, AttackStyle.MELEE, MetaData.SPECIAL_ATTACK.d),
MELEE_BLUE_MOON_SPEAR(AnimationID.HUMAN_ZAMORAKSPEAR_STAB, AttackStyle.MELEE),
MELEE_DHINS(AnimationID.HUMAN_DINHS_BULWARK_BASH, AttackStyle.MELEE),
MELEE_URSINE_CHAINMACE_SPEC(AnimationID.HUMAN_SPECIAL02_URSINE, AttackStyle.MELEE, MetaData.SPECIAL_ATTACK.d),
MELEE_ANCIENT_MACE_SPEC(AnimationID.SLICE_PLAYER_MACE_SPECIAL_ATTACK, AttackStyle.MELEE, MetaData.SPECIAL_ATTACK.d),
MELEE_DSCIM_SPEC(AnimationID.SP_ATTACK_DRAGON_SCIMITAR, AttackStyle.MELEE, MetaData.SPECIAL_ATTACK.d),
MELEE_D2H_SPEC(AnimationID.DRAGON_TWO_HANDED_SWORD, AttackStyle.MELEE, MetaData.SPECIAL_ATTACK.d),
MELEE_ARCLIGHT_SPEC(AnimationID.DARK_SPEC_PLAYER, AttackStyle.MELEE, MetaData.SPECIAL_ATTACK.d),
MELEE_SARA_SWORD_SPEC(AnimationID.SARADOMIN_SWORD_SPECIAL_PLAYER, AttackStyle.MELEE, MetaData.SPECIAL_ATTACK.d),
MELEE_RED_KERIS_SPEC(AnimationID.TOA_KERIS_PARTISAN_SPECIAL01, AttackStyle.MELEE, MetaData.SPECIAL_ATTACK.d),
MELEE_SALAMANDER(AnimationID.HUMAN_ATTACK_SALAMANDER, AttackStyle.MELEE),
MELEE_INFERNAL_TECPATL(AnimationID.TECPATL_STAB, AttackStyle.MELEE),
MELEE_HALLOWED_FLAIL(AnimationID.HUMAN_WEAPONS_HALLOWED_FLAIL01_ATTACK01, AttackStyle.MELEE),
MELEE_FELLING_AXE(AnimationID.FORESTRY_2H_AXE_ATTACK, AttackStyle.MELEE),
MELEE_HALLOWFELL(AnimationID.HUMAN_HALLOWFELL_SLASH, AttackStyle.MELEE),
// RANGED
RANGED_CHINCHOMPA(AnimationID.HUMAN_CHINCHOMPA_ATTACK_PVN, AttackStyle.RANGED),
RANGED_SHORTBOW(AnimationID.HUMAN_BOW, AttackStyle.RANGED, MetaData.STANDARD_BOW_ATTACK.d),
RANGED_RUNE_KNIFE_PVP(AnimationID.HUMAN_STAKE2, AttackStyle.RANGED),
RANGED_MAGIC_SHORTBOW_SPEC(AnimationID.SNAPSHOT, AttackStyle.RANGED, MetaData.STANDARD_BOW_ATTACK.d | MetaData.SPECIAL_ATTACK.d),
RANGED_CROSSBOW_PVP(AnimationID.XBOWS_HUMAN_FIRE_AND_RELOAD, AttackStyle.RANGED),
RANGED_BLOWPIPE(AnimationID.SNAKEBOSS_BLOWPIPE_ATTACK, AttackStyle.RANGED),
RANGED_DARTS(AnimationID.II_HUMAN_DART_THROW_PVN, AttackStyle.RANGED),
RANGED_BALLISTA(AnimationID.BALLISTA_ATTACK, AttackStyle.RANGED),
RANGED_BALLISTA_SPEC(AnimationID.BALLISTA_SPECIAL_ATTACK_PVN, AttackStyle.RANGED, MetaData.SPECIAL_ATTACK.d),
RANGED_RUNE_THROWNAXE_SPEC(AnimationID.CHAINHIT, AttackStyle.RANGED, MetaData.SPECIAL_ATTACK.d),
RANGED_DRAGON_THROWNAXE_SPEC(AnimationID.HUMAN_DRAGON_TAXE_SPEC, AttackStyle.RANGED, MetaData.SPECIAL_ATTACK.d),
RANGED_RUNE_CROSSBOW(AnimationID.XBOWS_HUMAN_FIRE_AND_RELOAD_PVN, AttackStyle.RANGED),
RANGED_RUNE_CROSSBOW_OR(AnimationID.HUMAN_XBOWS_LEAGUE03_ATTACK_PVN, AttackStyle.RANGED),
RANGED_BALLISTA_2(AnimationID.BALLISTA_ATTACK_PVN, AttackStyle.RANGED),
RANGED_RUNE_KNIFE(AnimationID.HUMAN_STAKE2_PVN, AttackStyle.RANGED),
RANGED_DRAGON_KNIFE(AnimationID.HUMAN_DRAGON_KNIFE, AttackStyle.RANGED),
RANGED_DRAGON_KNIFE_SPEC(AnimationID.HUMAN_DRAGON_TKNIVES_SPEC, AttackStyle.RANGED, MetaData.SPECIAL_ATTACK.d),
RANGED_DRAGON_KNIFE_POISONED(AnimationID.HUMAN_DRAGON_KNIFE_P, AttackStyle.RANGED),
RANGED_DRAGON_KNIFE_POISONED_SPEC(AnimationID.HUMAN_DRAGON_TKNIVES_SPEC_POISON, AttackStyle.RANGED, MetaData.SPECIAL_ATTACK.d),
RANGED_ZARYTE_CROSSBOW(AnimationID.ZCB_ATTACK_PVN, AttackStyle.RANGED),
RANGED_ZARYTE_CROSSBOW_PVP(AnimationID.ZCB_ATTACK, AttackStyle.RANGED),
RANGED_BLAZING_BLOWPIPE(AnimationID.SNAKEBOSS_BLOWPIPE_ATTACK_ORNAMENT, AttackStyle.RANGED),
RANGED_VENATOR_BOW(AnimationID.HUMAN_WEAPON_BOW_VENATOR01_SHOOT, AttackStyle.RANGED, MetaData.STANDARD_BOW_ATTACK.d),
RANGED_KARIL_CROSSBOW(AnimationID.BARROWS_REPEATING_CROSSBOW_FIRE, AttackStyle.RANGED),
RANGED_ATLATL(AnimationID.HUMAN_ATLATL_ATTACK_RANGED_01, AttackStyle.RANGED, MetaData.STANDARD_BOW_ATTACK.d),
RANGED_ATLATL_SPEC(AnimationID.HUMAN_SPECIAL_ATLATL_01, AttackStyle.RANGED, MetaData.STANDARD_BOW_ATTACK.d | MetaData.SPECIAL_ATTACK.d),
RANGED_TONALZTICS(AnimationID.HUMAN_GLAIVE_RALOS01_CHARGED_THROW, AttackStyle.RANGED),
RANGED_TONALZTICS_SPEC(AnimationID.HUMAN_GLAIVE_RALOS01_CHARGED_SPECIAL, AttackStyle.RANGED, MetaData.SPECIAL_ATTACK.d),
RANGED_WEBWEAVER_SPEC(AnimationID.HUMAN_SPECIAL01_WEBWEAVER, AttackStyle.RANGED, MetaData.STANDARD_BOW_ATTACK.d | MetaData.SPECIAL_ATTACK.d),
RANGED_BONE_CROSSBOW_SPEC(AnimationID.DTTD_PLAYER_FIRE_BONE_CROSSBOW_PVN, AttackStyle.RANGED, MetaData.SPECIAL_ATTACK.d),
RANGED_SCORCHING_BOW_SPEC(AnimationID.HUMAN_WEAPON_BOW_SCORCHED_01_SPEC, AttackStyle.RANGED, MetaData.STANDARD_BOW_ATTACK.d | MetaData.SPECIAL_ATTACK.d),
// MAGIC
MAGIC_GOD_SPELL(AnimationID.HUMAN_CASTING, AttackStyle.MAGIC, Spellbook.STANDARD, MetaData.NO_DATA.d),
MAGIC_IBAN_BLAST(AnimationID.HUMAN_CASTIBANBLAST, AttackStyle.MAGIC, Spellbook.STANDARD, MetaData.NO_DATA.d),
MAGIC_SLAYER_DART(AnimationID.SLAYER_MAGICDART_CAST, AttackStyle.MAGIC, Spellbook.STANDARD, MetaData.NO_DATA.d),
MAGIC_STANDARD_BIND(AnimationID.HUMAN_CASTENTANGLE, AttackStyle.MAGIC, Spellbook.STANDARD, MetaData.NO_DATA.d),
MAGIC_STANDARD_BIND_STAFF(AnimationID.HUMAN_CASTENTANGLE_STAFF, AttackStyle.MAGIC, Spellbook.STANDARD, MetaData.NO_DATA.d),
MAGIC_STANDARD_CONFUSE(AnimationID.HUMAN_CASTCONFUSE, AttackStyle.MAGIC, Spellbook.STANDARD, MetaData.NO_DATA.d),
MAGIC_STANDARD_CONFUSE_STAFF(AnimationID.HUMAN_CASTCONFUSE_STAFF, AttackStyle.MAGIC, Spellbook.STANDARD, MetaData.NO_DATA.d),
MAGIC_STANDARD_CONFUSE_STAFF_WALK(AnimationID.HUMAN_CASTCONFUSE_STAFF_WALKMERGE, AttackStyle.MAGIC, Spellbook.STANDARD, MetaData.NO_DATA.d),
MAGIC_STANDARD_CONFUSE_WALK(AnimationID.HUMAN_CASTCONFUSE_WALKMERGE, AttackStyle.MAGIC, Spellbook.STANDARD, MetaData.NO_DATA.d),
MAGIC_STANDARD_CRUMBLE_UNDEAD(AnimationID.HUMAN_CASTCRUMBLEUNDEAD, AttackStyle.MAGIC, Spellbook.STANDARD, MetaData.NO_DATA.d),
MAGIC_STANDARD_CRUMBLE_UNDEAD_HOLDING_STAFF(AnimationID.HUMAN_CASTCRUMBLEUNDEAD_STAFF, AttackStyle.MAGIC, Spellbook.STANDARD, MetaData.NO_DATA.d),
MAGIC_STANDARD_ENFEEBLE(AnimationID.HUMAN_CASTENFEEBLE_STAFF, AttackStyle.MAGIC, Spellbook.STANDARD, MetaData.NO_DATA.d),
MAGIC_STANDARD_STRIKE_BOLT_BLAST(AnimationID.HUMAN_CASTSTRIKE, AttackStyle.MAGIC, Spellbook.STANDARD, MetaData.NO_DATA.d),
MAGIC_STANDARD_STRIKE_BOLT_BLAST_STAFF(AnimationID.HUMAN_CASTSTRIKE_STAFF, AttackStyle.MAGIC, Spellbook.STANDARD, MetaData.NO_DATA.d),
MAGIC_STANDARD_STRIKE_BOLT_BLAST_STAFF_WALK(AnimationID.HUMAN_CASTSTRIKE_STAFF_WALKMERGE, AttackStyle.MAGIC, Spellbook.STANDARD, MetaData.NO_DATA.d),
MAGIC_STANDARD_STRIKE_BOLT_BLAST_WALK(AnimationID.HUMAN_CASTSTRIKE_WALKMERGE, AttackStyle.MAGIC, Spellbook.STANDARD, MetaData.NO_DATA.d),
MAGIC_STANDARD_STUN(AnimationID.HUMAN_CASTSTUN_STAFF, AttackStyle.MAGIC, Spellbook.STANDARD, MetaData.NO_DATA.d),
MAGIC_STANDARD_SURGE(AnimationID.HUMAN_CAST_SURGE, AttackStyle.MAGIC, Spellbook.STANDARD, MetaData.NO_DATA.d),
MAGIC_STANDARD_SURGE_STAFF(AnimationID.HUMAN_CAST_SURGE_FAST, AttackStyle.MAGIC, Spellbook.STANDARD, MetaData.NO_DATA.d),
MAGIC_STANDARD_SURGE_STAFF_WALK(AnimationID.HUMAN_CAST_SURGE_WALKMERGE, AttackStyle.MAGIC, Spellbook.STANDARD, MetaData.NO_DATA.d),
MAGIC_STANDARD_VULNERABILITY_CURSE(AnimationID.HUMAN_CASTCURSE, AttackStyle.MAGIC, Spellbook.STANDARD, MetaData.NO_DATA.d),
MAGIC_STANDARD_VULNERABILITY_CURSE_STAFF(AnimationID.HUMAN_CASTCURSE_STAFF, AttackStyle.MAGIC, Spellbook.STANDARD, MetaData.NO_DATA.d),
MAGIC_STANDARD_VULNERABILITY_CURSE_STAFF_WALK(AnimationID.HUMAN_CASTCURSE_STAFF_WALKMERGE, AttackStyle.MAGIC, Spellbook.STANDARD, MetaData.NO_DATA.d),
MAGIC_STANDARD_VULNERABILITY_CURSE_WALK(AnimationID.HUMAN_CASTCURSE_WALKMERGE, AttackStyle.MAGIC, Spellbook.STANDARD, MetaData.NO_DATA.d),
MAGIC_STANDARD_WAVE(AnimationID.HUMAN_CASTWAVE, AttackStyle.MAGIC, Spellbook.STANDARD, MetaData.NO_DATA.d),
MAGIC_STANDARD_WAVE_STAFF(AnimationID.HUMAN_CASTWAVE_STAFF, AttackStyle.MAGIC, Spellbook.STANDARD, MetaData.NO_DATA.d),
MAGIC_STANDARD_WAVE_STAFF_WALK(AnimationID.HUMAN_CASTWAVE_STAFF_WALKMERGE, AttackStyle.MAGIC, Spellbook.STANDARD, MetaData.NO_DATA.d),
MAGIC_STANDARD_WAVE_WALK(AnimationID.HUMAN_CASTWAVE_WALKMERGE, AttackStyle.MAGIC, Spellbook.STANDARD, MetaData.NO_DATA.d),
MAGIC_STANDARD_WEAKEN(AnimationID.HUMAN_CASTWEAKEN, AttackStyle.MAGIC, Spellbook.STANDARD, MetaData.NO_DATA.d),
MAGIC_STANDARD_WEAKEN_STAFF(AnimationID.HUMAN_CASTWEAKEN_STAFF, AttackStyle.MAGIC, Spellbook.STANDARD, MetaData.NO_DATA.d),
MAGIC_STANDARD_WEAKEN_STAFF_WALK(AnimationID.HUMAN_CASTWEAKEN_STAFF_WALKMERGE, AttackStyle.MAGIC, Spellbook.STANDARD, MetaData.NO_DATA.d),
MAGIC_STANDARD_WEAKEN_WALK(AnimationID.HUMAN_CASTWEAKEN_WALKMERGE, AttackStyle.MAGIC, Spellbook.STANDARD, MetaData.NO_DATA.d),
MAGIC_ANCIENT_MULTI_TARGET(AnimationID.ZAROS_VERTICAL_CASTING_WALKMERGE, AttackStyle.MAGIC, Spellbook.ANCIENT, MetaData.NO_DATA.d),
MAGIC_ANCIENT_MULTI_TARGET_PVP(AnimationID.ZAROS_VERTICAL_CASTING, AttackStyle.MAGIC, Spellbook.ANCIENT, MetaData.NO_DATA.d),
MAGIC_ANCIENT_SINGLE_TARGET(AnimationID.ZAROS_CASTING_WALKMERGE, AttackStyle.MAGIC, Spellbook.ANCIENT, MetaData.NO_DATA.d),
MAGIC_ANCIENT_SINGLE_TARGET_PVP(AnimationID.ZAROS_CASTING, AttackStyle.MAGIC, Spellbook.ANCIENT, MetaData.NO_DATA.d),
MAGIC_ARCEUUS_DEMONBANE(AnimationID.HUMAN_SPELLCAST_DEMONBANE, AttackStyle.MAGIC, Spellbook.ARCEUUS, MetaData.NO_DATA.d),
MAGIC_ARCEUUS_GRASP(AnimationID.HUMAN_SPELLCAST_GRASP, AttackStyle.MAGIC, Spellbook.ARCEUUS, MetaData.NO_DATA.d),
MAGIC_ACCURSED_SCEPTRE_SPEC(AnimationID.HUMAN_SPECIAL_ACCURSED, AttackStyle.MAGIC, MetaData.SPECIAL_ATTACK.d),
MAGIC_EYE_OF_AYAK(AnimationID.HUMAN_EYE_OF_AYAK_NORMAL, AttackStyle.MAGIC, MetaData.NO_DATA.d),
MAGIC_EYE_OF_AYAK_SPEC(AnimationID.HUMAN_EYE_OF_AYAK_SPECIAL, AttackStyle.MAGIC, MetaData.SPECIAL_ATTACK.d),
MAGIC_TUMEKENS_SHADOW(AnimationID.TOA_SOT_CAST_B, AttackStyle.MAGIC, MetaData.NO_DATA.d),
MAGIC_VOLATILE_NIGHTMARE_STAFF_SPEC(AnimationID.NIGHTMARE_STAFF_SPECIAL, AttackStyle.MAGIC, MetaData.SPECIAL_ATTACK.d),
MAGIC_WARPED_SCEPTRE(AnimationID.POG_WARPED_SCEPTRE_ATTACK, AttackStyle.MAGIC, MetaData.NO_DATA.d),
// Misc Non-attacks
MAGIC_IMBUE(AnimationID.HUMAN_CASTBONESTOBANANAS, AttackStyle.NON_ATTACK),
SPELLBOOK_SWAP(AnimationID.DREAM_PLAYER_SPELLBOOK_SWAP, AttackStyle.NON_ATTACK),
LUNAR_GROUP(AnimationID.QUEST_LUNAR_SPELL_CAST_SPELL_ON_GROUP, AttackStyle.NON_ATTACK),
LUNAR_OTHER(AnimationID.QUEST_LUNAR_PUSHING_MAGIC_ANIMATION, AttackStyle.NON_ATTACK),
NPC_CONTACT(AnimationID.LUNAR_HUMAN_MAGIC_SUMMON2, AttackStyle.NON_ATTACK),
VENGEANCE_1(AnimationID.VENGEANCE_SPELL_ANIM_NOSTALLING, AttackStyle.NON_ATTACK),
VENGEANCE_2(AnimationID.VENGEANCE_SPELL_ANIM_STALLING, AttackStyle.NON_ATTACK),
REANIMATION(AnimationID.ARCEUUS_NECROMANCY_PLAYERANIM, AttackStyle.NON_ATTACK),
DEMONIC_OFFERING(AnimationID.HUMAN_CAST_OFFERING, AttackStyle.NON_ATTACK),
SHADOW_VEIL(AnimationID.HUMAN_SPELLCAST_SHADOWVEIL, AttackStyle.NON_ATTACK),
MARK_OF_DARKNESS(AnimationID.HUMAN_CAST_SELFIMBUE, AttackStyle.NON_ATTACK),
PICK_POCKETING(AnimationID.HUMAN_PICKPOCKET, AttackStyle.NON_ATTACK),
SUMMON_THRALL(AnimationID.HUMAN_SPELLCAST_RESURRECT, AttackStyle.NON_ATTACK),
LUNAR_TELEPORT(AnimationID.HUMAN_TELEPORT_OTHER_IMPACT, AttackStyle.NON_ATTACK),
MONSTER_EXAMINE(AnimationID.DREAM_PLAYER_MONSTEREXAM_STATSPY, AttackStyle.NON_ATTACK),
HUMIDIFY(AnimationID.DREAM_PLAYER_HUMIDIFY_SPELL, AttackStyle.NON_ATTACK),
GEOMANCY(AnimationID.LUNAR_HUMAN_MAGIC_GEOMANCY, AttackStyle.NON_ATTACK),
DREAM(AnimationID.FOSSIL_LOC_CLAM_IDLE_SHUT, AttackStyle.NON_ATTACK),
ROCKSLUG_BAG_OF_SALT(AnimationID.SLAYER_SALT_SPRINKLE, AttackStyle.NON_ATTACK),
DESSET_LIZARD_ICE_COOLER(AnimationID.HUMAN_CHINCHOMPA_ATTACK, AttackStyle.NON_ATTACK),
FLECTHING_KNIFE(AnimationID.HUMAN_FLETCHING, AttackStyle.NON_ATTACK),
FLECTHING_KEBBIT(AnimationID.HUMAN_CRAFTING_SPIKEDVAMBRACES, AttackStyle.NON_ATTACK),
FLECTHING_CHISEL(AnimationID.HUMAN_FLETCHING_HUNTINGBOLTS, AttackStyle.NON_ATTACK),
FLECTHING_DART_TIP_1(AnimationID.HUMAN_FLETCHING_ADD_DART_FEATHERS_BRONZE, AttackStyle.NON_ATTACK),
FLECTHING_DART_TIP_2(AnimationID.HUMAN_FLETCHING_ADD_DART_FEATHERS_IRON, AttackStyle.NON_ATTACK),
FLECTHING_DART_TIP_3(AnimationID.HUMAN_FLETCHING_ADD_DART_FEATHERS_STEEL, AttackStyle.NON_ATTACK),
FLECTHING_DART_TIP_4(AnimationID.HUMAN_FLETCHING_ADD_DART_FEATHERS_MITHRIL, AttackStyle.NON_ATTACK),
FLECTHING_DART_TIP_5(AnimationID.HUMAN_FLETCHING_ADD_DART_FEATHERS_ADAMANT, AttackStyle.NON_ATTACK),
FLECTHING_DART_TIP_6(AnimationID.HUMAN_FLETCHING_ADD_DART_FEATHERS_RUNE, AttackStyle.NON_ATTACK),
FLECTHING_DART_TIP_7(AnimationID.HUMAN_FLETCHING_ADD_DART_FEATHERS_DRAGON, AttackStyle.NON_ATTACK),
FLECTHING_DART_TIP_8(AnimationID.HUMAN_FLETCHING_ADD_DART_FEATHERS_AMETHYST, AttackStyle.NON_ATTACK),
HERB_TAR(AnimationID.HUMAN_SALAMANDER_TAR_GRIND, AttackStyle.NON_ATTACK),
SETUP_HUNTER_TRAP(AnimationID.HUMAN_LAYTRAP, AttackStyle.NON_ATTACK),
RESET_SNARE_TRAP(AnimationID.HUMAN_HUNTING_DISMANTLE_NET, AttackStyle.NON_ATTACK),
RESET_BOX_TRAP(AnimationID.HUNTING_SETTING_TRAP_SMALL, AttackStyle.NON_ATTACK),
DESERT_AMMY(AnimationID.TELEPORT_NARDAH_HUMAN, AttackStyle.NON_ATTACK),
EAT_FOOD_OR_POTION(AnimationID.HUMAN_EAT, AttackStyle.NON_ATTACK),
OVERLOAD_HIT(AnimationID.HUMAN_KILLERWATT_ELECTRICSHOCK, AttackStyle.NON_ATTACK),
TAKING_HIT_1HANDED_UNARMED(AnimationID.HUMAN_AXE_BLOCK, AttackStyle.NON_ATTACK),
TAKING_HIT_2H_SWORD(AnimationID.HUMAN_DHSWORD_BLOCK, AttackStyle.NON_ATTACK),
TAKING_HIT_ANCHOR(AnimationID.BRAIN_PLAYER_ANCHOR_DEFEND, AttackStyle.NON_ATTACK),
TAKING_HIT_BLISTERWOOD_FLAIL(AnimationID.IVANDIS_FLAIL_DEFEND, AttackStyle.NON_ATTACK),
TAKING_HIT_BLOWPIPE(AnimationID.HUMAN_SPEAR_BLOCK, AttackStyle.NON_ATTACK),
TAKING_HIT_BULWARK(AnimationID.HUMAN_DINHS_BULWARK_BLOCK, AttackStyle.NON_ATTACK),
TAKING_HIT_CHAINMACE(AnimationID.WILD_CAVE_CHAINMACE_DEFEND, AttackStyle.NON_ATTACK),
TAKING_HIT_CHIN_CHOMPA(AnimationID.HUMAN_CHINCHOMPA_DEFEND, AttackStyle.NON_ATTACK),
TAKING_HIT_DAGGER(AnimationID.HUMAN_DDAGGER_BLOCK, AttackStyle.NON_ATTACK),
TAKING_HIT_DEFENDER(AnimationID.WARGUILD_PARRY_DEFEND, AttackStyle.NON_ATTACK),
TAKING_HIT_FANG(AnimationID.HUMAN_SWORD_DEF, AttackStyle.NON_ATTACK),
TAKING_HIT_GODSWORD(AnimationID.DH_SWORD_UPDATE_DEFEND, AttackStyle.NON_ATTACK),
TAKING_HIT_KERIS(AnimationID.HUMAN_DSPEAR_BLOCK, AttackStyle.NON_ATTACK),
TAKING_HIT_LARGE_STAFF(AnimationID.HUMAN_STAFFORB_BLOCK, AttackStyle.NON_ATTACK),
TAKING_HIT_MACE(AnimationID.HUMAN_BLUNT_BLOCK, AttackStyle.NON_ATTACK),
TAKING_HIT_OBBY_MAUL(AnimationID.SLAYER_GRANITE_MAUL_DEFEND, AttackStyle.NON_ATTACK),
TAKING_HIT_SCYTHE(AnimationID.HUMAN_SCYTHE_BLOCK, AttackStyle.NON_ATTACK),
TAKING_HIT_SHIELD(AnimationID.HUMAN_SHIELD_DEFENCE, AttackStyle.NON_ATTACK),
TAKING_HIT_SPEAR(AnimationID.HUMAN_ZAMORAKSPEAR_BLOCK, AttackStyle.NON_ATTACK),
TAKING_HIT_STAFF(AnimationID.HUMAN_STAFF_BLOCK, AttackStyle.NON_ATTACK),
TAKING_HIT_UNARMED(AnimationID.HUMAN_UNARMEDBLOCK, AttackStyle.NON_ATTACK),
TAKING_HIT_VERACS_FLAIL(AnimationID.BARROW_GUTHAN_DEFEND, AttackStyle.NON_ATTACK),
TAKING_HIT_WHIP(AnimationID.SLAYER_ABYSSAL_WHIP_DEFEND, AttackStyle.NON_ATTACK),
TAKING_HIT_KISTEN(AnimationID.HUMAN_WEAPONS_CRIMSON_KISTEN_DEF, AttackStyle.NON_ATTACK),
TAKING_HIT_HALLOWFELL(AnimationID.HUMAN_HALLOWFELL_DEFEND, AttackStyle.NON_ATTACK),
LOW_ALCH(AnimationID.HUMAN_CASTLOWLVLALCHEMY, AttackStyle.NON_ATTACK),
HIGH_ALCH(AnimationID.HUMAN_CASTHIGHLVLALCHEMY, AttackStyle.NON_ATTACK);
private static final Map<Integer, AnimationData> DATA;
private static final Map<Spellbook, Set<AnimationData>> SPELL_BOOK_ANIMATIONS;
private static final Map<Integer, AnimationData> NOT_ATTACKS;
private static final Set<Integer> STANDARD_BOW_ATTACKS;
public final int animationId;
public final long metaData;
@Getter
public final AttackStyle attackStyle;
@Getter
private final Spellbook spellbook;
AnimationData(int animationId, AttackStyle attackStyle)
{
if (attackStyle == null)
{
throw new InvalidParameterException("Attack Style must be valid for AnimationData");
}
this.animationId = animationId;
this.attackStyle = attackStyle;
this.metaData = MetaData.NO_DATA.d;
this.spellbook = null;
}
AnimationData(int animationId, AttackStyle attackStyle, long data)
{
if (attackStyle == null)
{
throw new InvalidParameterException("Attack Style must be valid for AnimationData");
}
this.animationId = animationId;
this.attackStyle = attackStyle;
this.metaData = data;
this.spellbook = null;
}
AnimationData(int animationId, AttackStyle attackStyle, Spellbook book, long data)
{
if (attackStyle == null)
{
throw new InvalidParameterException("Attack Style must be valid for AnimationData");
}
this.animationId = animationId;
this.attackStyle = attackStyle;
this.metaData = data;
this.spellbook = book;
}
static
{
ImmutableMap.Builder<Integer, AnimationData> builder = new ImmutableMap.Builder<>();
ImmutableMap.Builder<Integer, AnimationData> notAttacksBuilder = new ImmutableMap.Builder<>();
Map<Spellbook, Set<AnimationData>> spellBookBuilder = new HashMap<>();
ImmutableSet.Builder<Integer> standardBowBuilder = new ImmutableSet.Builder<>();
for (Spellbook s : Spellbook.values())
{
spellBookBuilder.put(s, new HashSet<>());
}
for (AnimationData data : values())
{
builder.put(data.animationId, data);
if (data.spellbook != null)
{
if (data.attackStyle != AttackStyle.MAGIC)
{
throw new InvalidParameterException("Spell book should only be magic animations");
}
spellBookBuilder.get(data.spellbook).add(data);
}
if (data.attackStyle == AttackStyle.NON_ATTACK)
{
notAttacksBuilder.put(data.animationId, data);
}
if (MetaData.hasFlagSet(data.metaData, MetaData.STANDARD_BOW_ATTACK))
{
standardBowBuilder.add(data.animationId);
}
}
DATA = builder.build();
NOT_ATTACKS = notAttacksBuilder.build();
SPELL_BOOK_ANIMATIONS = spellBookBuilder;
STANDARD_BOW_ATTACKS = standardBowBuilder.build();
}
public static AnimationData fromId(int animationId)
{
return DATA.get(animationId);
}
public static Set<AnimationData> getAnimationsForSpellbook(Spellbook s)
{
return SPELL_BOOK_ANIMATIONS.get(s);
}
public static boolean isManualCasting(AnimationData animationData)
{
if (animationData != null && animationData.spellbook != null)
{
return animationData.attackStyle == AttackStyle.MAGIC &&
SPELL_BOOK_ANIMATIONS.get(animationData.spellbook).contains(animationData);
}
return false;
}
public static boolean isBlockListAnimation(int animationId)
{
return NOT_ATTACKS.containsKey(animationId);
}
@Override
public String toString()
{
String[] words = super.toString().toLowerCase().split("_");
Arrays.stream(words)
.map(StringUtils::capitalize).collect(Collectors.toList()).toArray(words);
return String.join(" ", words);
}
public boolean matchesSpellbook(Spellbook s)
{
if (this.spellbook != null)
{
return this.spellbook == s;
}
return false;
}
public boolean isStandardBowAttack()
{
return STANDARD_BOW_ATTACKS.contains(this.animationId);
}
public boolean isBlockListAnimation()
{
return NOT_ATTACKS.containsKey(this.animationId);
}
public enum AttackStyle
{
MELEE,
RANGED,
MAGIC,
NON_ATTACK;
@Override
public String toString()
{
return StringUtils.capitalize(super.toString().toLowerCase());
}
}
public enum MetaData
{
NO_DATA(),
SPECIAL_ATTACK(1),
STANDARD_BOW_ATTACK(2);
MetaData(int bitshift)
{
this.d = 1 << bitshift;
}
MetaData()
{
this.d = 0;
}
public static boolean hasFlagSet(long input, MetaData data)
{
return (input & data.d) == data.d;
}
private final long d;
@Override
public String toString()
{
return StringUtils.capitalize(super.toString().toLowerCase());
}
}
}
@@ -0,0 +1,8 @@
package com.example.essentialsplus.helpers.combat;
public enum AttackProcedure
{
POWERED_STAVE,
MANUAL_AUTO_CAST,
MELEE_OR_RANGE
}
@@ -0,0 +1,129 @@
package com.example.essentialsplus.helpers.combat;
import com.example.essentialsplus.helpers.combat.variablespeed.VariableSpeed;
import com.example.essentialsplus.helpers.combat.variablespeed.state.TickCount;
import com.google.common.collect.ImmutableMap;
import java.util.ArrayDeque;
import java.util.Map;
import lombok.Getter;
import net.runelite.api.Client;
import net.runelite.api.events.FakeXpDrop;
import net.runelite.api.events.SoundEffectPlayed;
import net.runelite.api.events.StatChanged;
import net.runelite.api.gameval.ItemID;
import net.runelite.client.game.ItemManager;
public class AttackSpeed
{
private final ArrayDeque<Integer> specialPercentageEvents = new ArrayDeque<>();
private final Damage damage = new Damage();
@Getter
private int dmgDealt = -1;
private final TickCount tickCount;
private final VariableSpeed variableSpeed;
@Getter
private int soundEffectTick = -1;
@Getter
private int soundEffectId = -1;
@Getter
private boolean isUsingMagic = false;
public AttackSpeed(final TickCount tc, final VariableSpeed vs)
{
this.tickCount = tc;
this.variableSpeed = vs;
}
public int compute(final Client client, final AnimationData curAnimation, final Spellbook spellbook, final ItemManager itemManager)
{
final int weaponId = CombatUtils.getWeaponId(client);
final PoweredStaves stave = PoweredStaves.getPoweredStaves(weaponId, curAnimation);
final int specDelta = CombatUtils.getLastDelta(specialPercentageEvents);
dmgDealt = damage.compute(tickCount);
if (stave != null && stave.getAnimations() != null && stave.getAnimations().contains(curAnimation))
{
isUsingMagic = true;
return variableSpeed.compute(client, curAnimation, AttackProcedure.POWERED_STAVE, spellbook, dmgDealt, specDelta, 4);
}
if (matchesSpellbook(client, curAnimation, spellbook) && ManualCasting.is(client, curAnimation, soundEffectTick, soundEffectId))
{
isUsingMagic = true;
return variableSpeed.compute(client, curAnimation, AttackProcedure.MANUAL_AUTO_CAST, spellbook, dmgDealt, specDelta, getMagicBaseSpeed(weaponId));
}
isUsingMagic = false;
final int aspeed = CombatUtils.getWeaponSpeed(client, itemManager, weaponId);
return variableSpeed.compute(client, curAnimation, AttackProcedure.MELEE_OR_RANGE, spellbook, dmgDealt, specDelta, aspeed);
}
public void onTick()
{
while (specialPercentageEvents.size() > 5)
{
specialPercentageEvents.removeFirst();
}
damage.onTick();
}
public void varbitSpecialAttackChanged(final int value)
{
specialPercentageEvents.addLast(value);
}
public boolean onXpDrop(final FakeXpDrop event)
{
return damage.onXpDrop(event, tickCount);
}
public boolean onXpDrop(final StatChanged event)
{
return damage.onXpDrop(event, tickCount);
}
public void onSoundEffectPlayed(final Client client, final SoundEffectPlayed event)
{
soundEffectTick = client.getTickCount();
soundEffectId = event.getSoundId();
}
private static final Map<Integer, Integer> NON_STANDARD_MAGIC_WEAPON_SPEEDS = new ImmutableMap.Builder<Integer, Integer>()
.put(ItemID.TWINFLAME_STAFF, 6)
.build();
private static int getMagicBaseSpeed(final int weaponId)
{
if (weaponId == ItemID.NIGHTMARE_STAFF_HARMONISED)
{
return 4;
}
return NON_STANDARD_MAGIC_WEAPON_SPEEDS.getOrDefault(weaponId, 5);
}
private boolean matchesSpellbook(final Client client, final AnimationData curAnimation, final Spellbook currentSpellBook)
{
if (curAnimation != null && curAnimation.getAttackStyle() == AnimationData.AttackStyle.MAGIC && curAnimation.getSpellbook() == null)
{
return false;
}
if (curAnimation != null && curAnimation.matchesSpellbook(currentSpellBook))
{
return true;
}
if (client.getTickCount() == soundEffectTick)
{
return CastingSoundData.getSpellBookFromId(soundEffectId) == currentSpellBook;
}
return false;
}
public void reset()
{
dmgDealt = -1;
soundEffectId = -1;
soundEffectTick = -1;
isUsingMagic = false;
specialPercentageEvents.clear();
damage.reset();
}
}
@@ -0,0 +1,34 @@
package com.example.essentialsplus.helpers.combat;
import lombok.Getter;
import net.runelite.api.Skill;
public enum AttackStyle
{
ACCURATE("Accurate", Skill.ATTACK),
AGGRESSIVE("Aggressive", Skill.STRENGTH),
DEFENSIVE("Defensive", Skill.DEFENCE),
CONTROLLED("Controlled", Skill.ATTACK, Skill.STRENGTH, Skill.DEFENCE),
RANGING("Ranging", Skill.RANGED),
LONGRANGE("Longrange", Skill.RANGED, Skill.DEFENCE),
CASTING("Casting", Skill.MAGIC),
DEFENSIVE_CASTING("Defensive Casting", Skill.MAGIC, Skill.DEFENCE),
OTHER("Other");
@Getter
private final String name;
@Getter
private final Skill[] skills;
AttackStyle(String name, Skill... skills)
{
this.name = name;
this.skills = skills;
}
@Override
public String toString()
{
return this.name;
}
}
@@ -0,0 +1,16 @@
package com.example.essentialsplus.helpers.combat;
public enum AttackType
{
CRUSH,
SLASH,
STAB,
RANGED,
MAGIC,
NONE;
public boolean IsMelee()
{
return this.equals(CRUSH) || this.equals(SLASH) || this.equals(STAB);
}
}
@@ -0,0 +1,118 @@
package com.example.essentialsplus.helpers.combat;
import com.google.common.collect.ImmutableSet;
import java.util.Arrays;
import java.util.Set;
import lombok.Getter;
import net.runelite.api.Actor;
import net.runelite.api.Client;
import net.runelite.api.NPC;
import net.runelite.api.Player;
import net.runelite.api.gameval.NpcID;
import net.runelite.api.gameval.SpotanimID;
import net.runelite.client.game.NPCManager;
public class Attacking
{
private static final Set<AnimationData> UNRELIABLE_ANIMATIONS = new ImmutableSet.Builder<AnimationData>()
.add(AnimationData.RANGED_BLOWPIPE).add(AnimationData.RANGED_BLAZING_BLOWPIPE)
.add(AnimationData.MAGIC_EYE_OF_AYAK).add(AnimationData.MAGIC_EYE_OF_AYAK_SPEC).build();
private static final Set<Integer> SPECIAL_NPCS = new ImmutableSet.Builder<Integer>()
.add(NpcID.POH_COMBAT_DUMMY_UPGRADED_UNDEADSLAYER_NPC, NpcID.POH_COMBAT_DUMMY_UPGRADED_ETHER_NPC,
NpcID.POH_COMBAT_DUMMY_UPGRADED_KQ_NPC, NpcID.POH_COMBAT_DUMMY_UPGRADED_KURASK_NPC,
NpcID.POH_COMBAT_DUMMY_UPGRADED_VAMPIRE_NPC, NpcID.POH_COMBAT_DUMMY_UPGRADED_VORKATH_NPC,
NpcID.POH_COMBAT_DUMMY_NPC, NpcID.POH_COMBAT_DUMMY_UNDEADSLAYER_NPC,
NpcID.POH_COMBAT_DUMMY_UPGRADED_NPC, NpcID.NIGHTMARE_TOTEM_1_READY, NpcID.NIGHTMARE_TOTEM_2_READY,
NpcID.NIGHTMARE_TOTEM_3_READY, NpcID.NIGHTMARE_TOTEM_4_READY)
.build();
public static boolean isPlayerAttacking(final Client client, final NPCManager npcManager)
{
final Player localPlayer = client.getLocalPlayer();
if (localPlayer == null)
{
return false;
}
final int animationId = localPlayer.getAnimation();
if (AnimationData.isBlockListAnimation(animationId))
{
return false;
}
final boolean notWalking = animationId != -1 || getSalamanderAttack(client);
final Actor target = localPlayer.getInteracting();
if (target instanceof NPC)
{
final NPC npc = (NPC) target;
final boolean containsAttackOption = npc.getComposition() != null
&& npc.getComposition().getActions() != null
&& Arrays.asList(npc.getComposition().getActions()).contains("Attack");
Integer health = null;
if (npcManager != null)
{
try
{
health = npcManager.getHealth(npc.getId());
}
catch (Exception ignored)
{
}
}
final boolean hasHealthAndLevel = health != null && health > 0 && target.getCombatLevel() > 0;
final boolean attackingNPC = hasHealthAndLevel || SPECIAL_NPCS.contains(npc.getId()) || containsAttackOption;
return attackingNPC && notWalking;
}
if (target instanceof Player)
{
return notWalking;
}
if (target == null)
{
return false;
}
final AnimationData fromId = AnimationData.fromId(animationId);
if (UNRELIABLE_ANIMATIONS.contains(fromId))
{
return false;
}
return fromId != null;
}
public static Attack playerAttack(final Client client)
{
final Player localPlayer = client.getLocalPlayer();
if (localPlayer == null)
{
return new Attack(-1, false, null);
}
final int animationId = localPlayer.getAnimation();
final Actor target = localPlayer.getInteracting();
return new Attack(animationId, getSalamanderAttack(client), target);
}
private static boolean getSalamanderAttack(final Client client)
{
Player localPlayer = client.getLocalPlayer();
return localPlayer != null && localPlayer.hasSpotAnim(SpotanimID.FIREBREATH);
}
public static final class Attack
{
@Getter
private final int animationId;
@Getter
private final boolean salamander;
@Getter
private final Actor target;
public Attack(final int animationId, final boolean salamander, final Actor target)
{
this.animationId = animationId;
this.salamander = salamander;
this.target = target;
}
}
}
@@ -0,0 +1,85 @@
package com.example.essentialsplus.helpers.combat;
import com.google.common.collect.ImmutableMap;
public enum CastingSoundData
{
STANDARD_BIND(101, Spellbook.STANDARD),
STANDARD_CONFUSE(119, Spellbook.STANDARD),
STANDARD_CRUMBLE_UNDEAD(122, Spellbook.STANDARD),
STANDARD_CURSE(127, Spellbook.STANDARD),
STANDARD_EARTH_BLAST(128, Spellbook.STANDARD),
STANDARD_EARTH_BOLT(130, Spellbook.STANDARD),
STANDARD_EARTH_STRIKE(132, Spellbook.STANDARD),
STANDARD_EARTH_SURGE(4025, Spellbook.STANDARD),
STANDARD_EARTH_WAVE(134, Spellbook.STANDARD),
STANDARD_ENFEEBLE(148, Spellbook.STANDARD),
STANDARD_ENTANGLE(151, Spellbook.STANDARD),
STANDARD_FIRE_BLAST(155, Spellbook.STANDARD),
STANDARD_FIRE_BOLT(157, Spellbook.STANDARD),
STANDARD_FIRE_STRIKE(160, Spellbook.STANDARD),
STANDARD_FIRE_SURGE(4032, Spellbook.STANDARD),
STANDARD_IBANS_BLAST_FIRE_WAVE(162, Spellbook.STANDARD),
STANDARD_SLAYER_DART(1718, Spellbook.STANDARD),
STANDARD_SNARE(3003, Spellbook.STANDARD),
STANDARD_STUN(3004, Spellbook.STANDARD),
STANDARD_VULNERABILITY(3009, Spellbook.STANDARD),
STANDARD_WATER_BLAST(207, Spellbook.STANDARD),
STANDARD_WATER_BOLT(209, Spellbook.STANDARD),
STANDARD_WATER_STRIKE(221, Spellbook.STANDARD),
STANDARD_WATER_SURGE(4030, Spellbook.STANDARD),
STANDARD_WATER_WAVE(213, Spellbook.STANDARD),
STANDARD_WEAKEN(3011, Spellbook.STANDARD),
STANDARD_WIND_BLAST(216, Spellbook.STANDARD),
STANDARD_WIND_BOLT(218, Spellbook.STANDARD),
STANDARD_WIND_STRIKE(220, Spellbook.STANDARD),
STANDARD_WIND_SURGE(4028, Spellbook.STANDARD),
STANDARD_WIND_WAVE(222, Spellbook.STANDARD),
ANCIENT_BLOOD_SPELL(106, Spellbook.ANCIENT),
ANCIENT_ICE_SPELL(171, Spellbook.ANCIENT),
ANCIENT_SHADOW_SPELL(178, Spellbook.ANCIENT),
ANCIENT_SMOKE_SPELL(183, Spellbook.ANCIENT),
ARCEUUS_DARK_DEMONBANE(5053, Spellbook.ARCEUUS),
ARCEUUS_GHOSTLY_GRASP(5042, Spellbook.ARCEUUS),
ARCEUUS_INFERIOR_DEMONBANE(5038, Spellbook.ARCEUUS),
ARCEUUS_SKELETAL_GRASP(5026, Spellbook.ARCEUUS),
ARCEUUS_SUPERIOR_DEMONBANE(5027, Spellbook.ARCEUUS),
ARCEUUS_UNDEAD_GRASP(5030, Spellbook.ARCEUUS);
private final int id;
private final Spellbook spellbook;
CastingSoundData(int id, Spellbook b)
{
this.id = id;
this.spellbook = b;
}
private static final ImmutableMap<Integer, CastingSoundData> SOUNDS;
static
{
ImmutableMap.Builder<Integer, CastingSoundData> builder = new ImmutableMap.Builder<>();
for (CastingSoundData data : values())
{
builder.put(data.id, data);
}
SOUNDS = builder.build();
}
public static boolean isCastingSound(int id)
{
return SOUNDS.containsKey(id);
}
public static Spellbook getSpellBookFromId(int id)
{
if (!SOUNDS.containsKey(id))
{
return null;
}
return SOUNDS.get(id).spellbook;
}
}
@@ -0,0 +1,196 @@
package com.example.essentialsplus.helpers.combat;
import com.google.common.collect.ImmutableMap;
import java.util.ArrayDeque;
import java.util.Map;
import java.util.Set;
import net.runelite.api.Actor;
import net.runelite.api.Client;
import net.runelite.api.EquipmentInventorySlot;
import net.runelite.api.Item;
import net.runelite.api.ItemContainer;
import net.runelite.api.NPC;
import net.runelite.api.WorldView;
import net.runelite.api.coords.LocalPoint;
import net.runelite.api.coords.WorldPoint;
import net.runelite.api.gameval.InventoryID;
import net.runelite.api.gameval.ItemID;
import net.runelite.api.gameval.VarPlayerID;
import net.runelite.api.gameval.VarbitID;
import net.runelite.client.game.ItemEquipmentStats;
import net.runelite.client.game.ItemManager;
import net.runelite.client.game.ItemStats;
import org.apache.commons.lang3.ArrayUtils;
public class CombatUtils
{
public static int getItemIdFromContainer(final ItemContainer container, final int slotID)
{
if (container == null)
{
return -1;
}
final Item item = container.getItem(slotID);
return (item != null) ? item.getId() : -1;
}
public static int getWeaponIdRaw(final Client client)
{
return getItemIdFromContainer(client.getItemContainer(InventoryID.WORN),
EquipmentInventorySlot.WEAPON.getSlotIdx());
}
public static WorldPoint getLocalLocation(final Client client)
{
final LocalPoint localPoint = client.getLocalPlayer().getLocalLocation();
return WorldPoint.fromLocalInstance(client, localPoint);
}
public static AttackStyle getAttackStyle(final Client client)
{
final AttackStyle[] attackStyles = getWeaponType(client).getAttackStyles(client);
int currentAttackStyleVarbit = client.getVarpValue(VarPlayerID.COM_MODE);
final int castingMode = client.getVarbitValue(VarbitID.AUTOCAST_DEFMODE);
if (currentAttackStyleVarbit < attackStyles.length)
{
if (currentAttackStyleVarbit == 4)
{
currentAttackStyleVarbit += castingMode;
}
return attackStyles[currentAttackStyleVarbit];
}
return AttackStyle.ACCURATE;
}
public static WeaponType getWeaponType(final Client client)
{
final int currentEquippedWeaponTypeVarbit = client.getVarbitValue(VarbitID.COMBAT_WEAPON_CATEGORY);
return WeaponType.getWeaponType(currentEquippedWeaponTypeVarbit);
}
public static AttackType getAttackType(final Client client)
{
final WeaponType weaponType = getWeaponType(client);
final int currentAttackStyleVarbit = client.getVarpValue(VarPlayerID.COM_MODE);
if (currentAttackStyleVarbit < weaponType.getAttackTypes().length)
{
return weaponType.getAttackTypes()[currentAttackStyleVarbit];
}
return null;
}
public static int getTargetId(final Client client)
{
final NPC target = getTargetNPC(client);
int targetId = 0;
if (target != null)
{
targetId = target.getId();
}
return targetId;
}
public static NPC getTargetNPC(final Client client)
{
final Actor target = client.getLocalPlayer().getInteracting();
if (target != null && (target instanceof NPC))
{
return (NPC) target;
}
return null;
}
public static boolean isInRegionId(final Client client, final int id)
{
final int[] regions = regions(client);
if (regions == null || regions.length == 0)
{
return false;
}
return ArrayUtils.contains(regions, id);
}
public static boolean isInRegionId(final Client client, final Set<Integer> ids)
{
final int[] regions = regions(client);
if (regions == null || regions.length == 0)
{
return false;
}
for (final int id : regions)
{
if (ids.contains(id))
{
return true;
}
}
return false;
}
private static int[] regions(final Client client)
{
final WorldView wv = client.getTopLevelWorldView();
if (wv == null)
{
return null;
}
return wv.getMapRegions();
}
public static int getLastDelta(final ArrayDeque<Integer> events)
{
int i = 0, last = -1, secondLast = -1;
final var it = events.descendingIterator();
while (it.hasNext())
{
if (i == 0)
{
last = it.next();
}
else if (i == 1)
{
secondLast = it.next();
}
else
{
break;
}
i++;
}
return last - secondLast;
}
private static final Map<Integer, Integer> WEAPON_ID_MAPPING_WORKAROUNDS = new ImmutableMap.Builder<Integer, Integer>()
.put(ItemID.VENATOR_BOW_ORNAMENT, ItemID.VENATOR_BOW)
.build();
private static final Map<Integer, Integer> NON_STANDARD_ATTACK_SPEEDS = new ImmutableMap.Builder<Integer, Integer>()
.put(ItemID.HALLOWFELL, 6)
.build();
public static int getWeaponId(final Client client)
{
final int weaponId = getWeaponIdRaw(client);
return WEAPON_ID_MAPPING_WORKAROUNDS.getOrDefault(weaponId, weaponId);
}
public static ItemStats getWeaponStats(final Client client, final ItemManager itemManager, final int weaponId)
{
if (NON_STANDARD_ATTACK_SPEEDS.containsKey(weaponId))
{
return new ItemStats(true, -1, -1,
ItemEquipmentStats.builder().aspeed(NON_STANDARD_ATTACK_SPEEDS.get(weaponId)).build());
}
return itemManager.getItemStats(weaponId);
}
public static int getWeaponSpeed(final Client client, final ItemManager itemManager, final int weaponId)
{
final ItemStats weaponStats = getWeaponStats(client, itemManager, weaponId);
if (weaponStats == null || weaponStats.getEquipment() == null)
{
return 4;
}
return weaponStats.getEquipment().getAspeed();
}
}
@@ -0,0 +1,73 @@
package com.example.essentialsplus.helpers.combat;
import com.example.essentialsplus.helpers.combat.variablespeed.state.TickCount;
import java.util.ArrayDeque;
import net.runelite.api.Skill;
import net.runelite.api.events.FakeXpDrop;
import net.runelite.api.events.StatChanged;
public class Damage
{
private static final double MODIFIER = 1;
private static final double GLOBAL_MODIFIER = 1;
private final ArrayDeque<Integer> hpExpEarned = new ArrayDeque<>();
private final ArrayDeque<Integer> hpExpEarnedTickCount = new ArrayDeque<>();
public boolean onXpDrop(StatChanged event, TickCount tc)
{
if (event.getSkill() != Skill.HITPOINTS)
{
return false;
}
hpExpEarnedTickCount.addLast(tc.get());
hpExpEarned.addLast(event.getXp());
return true;
}
public boolean onXpDrop(FakeXpDrop event, TickCount tc)
{
if (event.getSkill() != Skill.HITPOINTS)
{
return false;
}
hpExpEarnedTickCount.addLast(tc.get());
hpExpEarnedTickCount.addLast(tc.get());
hpExpEarned.addLast(0);
hpExpEarned.addLast(event.getXp());
return true;
}
public int compute(TickCount tc)
{
if (hpExpEarnedTickCount.isEmpty())
{
return -1;
}
final int lastTc = hpExpEarnedTickCount.getLast();
if (!tc.isWithinNTicks(lastTc, 1))
{
return 0;
}
final double xp = (double) CombatUtils.getLastDelta(hpExpEarned);
return (int) Math.round(xp * (3.0d / 4.0d) * MODIFIER * GLOBAL_MODIFIER);
}
public void onTick()
{
while (hpExpEarnedTickCount.size() > 5)
{
hpExpEarnedTickCount.removeFirst();
}
while (hpExpEarned.size() > 5)
{
hpExpEarned.removeFirst();
}
}
public void reset()
{
hpExpEarned.clear();
hpExpEarnedTickCount.clear();
}
}
@@ -0,0 +1,15 @@
package com.example.essentialsplus.helpers.combat;
import net.runelite.api.Client;
public class ManualCasting
{
public static boolean is(final Client client, final AnimationData curId, final int soundEffectTick, final int soundEffectId)
{
final boolean castingFromSound = client.getTickCount() == soundEffectTick
? CastingSoundData.isCastingSound(soundEffectId)
: false;
final boolean castingFromAnimation = AnimationData.isManualCasting(curId);
return castingFromSound || castingFromAnimation;
}
}
@@ -0,0 +1,127 @@
package com.example.essentialsplus.helpers.combat;
import com.google.common.collect.ImmutableMap;
import com.google.common.collect.ImmutableSet;
import java.util.Arrays;
import java.util.Set;
import java.util.stream.Collectors;
import lombok.Getter;
import net.runelite.api.gameval.ItemID;
import org.apache.commons.lang3.StringUtils;
public enum PoweredStaves
{
WEAPON_ACCURSED(Set.of(AnimationData.MAGIC_STANDARD_WAVE_STAFF, AnimationData.MAGIC_STANDARD_WAVE_STAFF_WALK,
AnimationData.MAGIC_ACCURSED_SCEPTRE_SPEC), ItemID.WILD_CAVE_ACCURSED_CHARGED),
WEAPON_GAUNTLET_STAFF(Set.of(AnimationData.MAGIC_STANDARD_WAVE_STAFF, AnimationData.MAGIC_STANDARD_WAVE_STAFF_WALK),
ItemID.GAUNTLET_MAGIC_T1, ItemID.GAUNTLET_MAGIC_T2, ItemID.GAUNTLET_MAGIC_T3, ItemID.GAUNTLET_MAGIC_T1_HM,
ItemID.GAUNTLET_MAGIC_T2_HM, ItemID.GAUNTLET_MAGIC_T3_HM),
WEAPON_BONE_STAFF(AnimationData.MELEE_GENERIC_SLASH, ItemID.RAT_BONE_STAFF),
WEAPON_DAWNBRINGER(Set.of(AnimationData.MAGIC_STANDARD_WAVE_STAFF, AnimationData.MAGIC_STANDARD_WAVE_STAFF_WALK),
ItemID.VERZIK_SPECIAL_WEAPON),
WEAPON_HARM(Set.of(AnimationData.MAGIC_STANDARD_STRIKE_BOLT_BLAST_STAFF, AnimationData.MAGIC_STANDARD_WAVE_STAFF,
AnimationData.MAGIC_STANDARD_SURGE_STAFF, AnimationData.MAGIC_STANDARD_WAVE_STAFF_WALK,
AnimationData.MAGIC_STANDARD_STRIKE_BOLT_BLAST_STAFF_WALK, AnimationData.MAGIC_STANDARD_SURGE_STAFF_WALK),
ItemID.NIGHTMARE_STAFF_HARMONISED),
WEAPON_SANG(Set.of(AnimationData.MAGIC_STANDARD_WAVE_STAFF, AnimationData.MAGIC_STANDARD_WAVE_STAFF_WALK),
ItemID.SANGUINESTI_STAFF, ItemID.SANGUINESTI_STAFF_OR),
WEAPON_STARTER_STAFF(ItemID.DEADMAN_STARTER_STAFF, ItemID.DEADMAN_APOCALYPSE_STAFF),
WEAPON_SWAMP(Set.of(AnimationData.MAGIC_STANDARD_WAVE_STAFF, AnimationData.MAGIC_STANDARD_WAVE_STAFF_WALK),
ItemID.TOXIC_TOTS_CHARGED, ItemID.TOXIC_TOTS_I_CHARGED, ItemID.TOXIC_TOTS_CHARGED_ORN,
ItemID.TOXIC_TOTS_I_CHARGED_ORN),
WEAPON_THAMMARON(Set.of(AnimationData.MAGIC_STANDARD_WAVE_STAFF, AnimationData.MAGIC_STANDARD_WAVE_STAFF_WALK),
ItemID.WILD_CAVE_SCEPTRE_CHARGED),
WEAPON_TRIDENT(Set.of(AnimationData.MAGIC_STANDARD_WAVE_STAFF, AnimationData.MAGIC_STANDARD_WAVE_STAFF_WALK),
ItemID.TOTS, ItemID.TOTS_CHARGED, ItemID.TOTS_I_CHARGED, ItemID.TOTS_CHARGED_ORN, ItemID.TOTS_ORN,
ItemID.TOTS_I_CHARGED_ORN),
WEAPON_WARPED_SCEPTRE(AnimationData.MAGIC_WARPED_SCEPTRE, ItemID.WARPED_SCEPTRE);
@Getter
private final Set<Integer> ids;
@Getter
private final Set<AnimationData> animations;
private static Set<Integer> ListToSet(int... id)
{
ImmutableSet.Builder<Integer> builder = new ImmutableSet.Builder<>();
if (id.length == 0)
{
return builder.build();
}
for (int i : id)
{
builder.add(i);
}
return builder.build();
}
PoweredStaves(int... id)
{
this.ids = ListToSet(id);
this.animations = null;
}
PoweredStaves(AnimationData spell, int... id)
{
this.ids = ListToSet(id);
this.animations = Set.of(spell);
}
PoweredStaves(Set<AnimationData> spell, int... id)
{
this.ids = ListToSet(id);
this.animations = spell;
}
protected static final int UNKNOWN_SPELL = 0xDEADBEEF;
protected static final ImmutableMap<Integer, ImmutableMap<Integer, PoweredStaves>> POWERED_STAVES;
static
{
ImmutableMap.Builder<Integer, ImmutableMap<Integer, PoweredStaves>> builder = new ImmutableMap.Builder<>();
for (PoweredStaves p : values())
{
for (int id : p.ids)
{
ImmutableMap.Builder<Integer, PoweredStaves> spellMap = new ImmutableMap.Builder<>();
if (p.animations == null)
{
spellMap.put(UNKNOWN_SPELL, p);
}
else
{
for (AnimationData spell : p.animations)
{
spellMap.put(spell.animationId, p);
}
}
builder.put(id, spellMap.build());
}
}
POWERED_STAVES = builder.build();
}
public static PoweredStaves getPoweredStaves(int weaponId, AnimationData animation)
{
ImmutableMap<Integer, PoweredStaves> weaponMap = POWERED_STAVES.get(weaponId);
if (weaponMap == null || animation == null)
{
return null;
}
if (weaponMap.containsKey(UNKNOWN_SPELL))
{
return weaponMap.get(UNKNOWN_SPELL);
}
return weaponMap.get(animation.animationId);
}
@Override
public String toString()
{
String[] words = super.toString().toLowerCase().split("_");
Arrays.stream(words).map(StringUtils::capitalize).collect(Collectors.toList()).toArray(words);
return String.join(" ", words);
}
}
@@ -0,0 +1,35 @@
package com.example.essentialsplus.helpers.combat;
import com.google.common.collect.ImmutableMap;
public enum Spellbook
{
STANDARD(0),
ANCIENT(1),
LUNAR(2),
ARCEUUS(3);
private final int id;
Spellbook(int id)
{
this.id = id;
}
private static final ImmutableMap<Integer, Spellbook> BOOKS;
static
{
ImmutableMap.Builder<Integer, Spellbook> builder = new ImmutableMap.Builder<>();
for (Spellbook data : values())
{
builder.put(data.id, data);
}
BOOKS = builder.build();
}
public static Spellbook fromVarbit(int varbit)
{
return BOOKS.get(varbit);
}
}
@@ -0,0 +1,133 @@
package com.example.essentialsplus.helpers.combat;
import static com.example.essentialsplus.helpers.combat.AttackStyle.ACCURATE;
import static com.example.essentialsplus.helpers.combat.AttackStyle.AGGRESSIVE;
import static com.example.essentialsplus.helpers.combat.AttackStyle.CASTING;
import static com.example.essentialsplus.helpers.combat.AttackStyle.DEFENSIVE;
import static com.example.essentialsplus.helpers.combat.AttackStyle.DEFENSIVE_CASTING;
import static com.example.essentialsplus.helpers.combat.AttackStyle.OTHER;
import static com.example.essentialsplus.helpers.combat.AttackType.CRUSH;
import static com.example.essentialsplus.helpers.combat.AttackType.MAGIC;
import static com.example.essentialsplus.helpers.combat.AttackType.NONE;
import static com.example.essentialsplus.helpers.combat.AttackType.RANGED;
import static com.example.essentialsplus.helpers.combat.AttackType.SLASH;
import static com.example.essentialsplus.helpers.combat.AttackType.STAB;
import com.google.common.collect.ImmutableMap;
import java.util.Map;
import lombok.Getter;
import net.runelite.api.Client;
import net.runelite.api.EnumID;
import net.runelite.api.ParamID;
import net.runelite.api.StructComposition;
public enum WeaponType
{
UNARMED(CRUSH, CRUSH, null, CRUSH),
AXE(SLASH, SLASH, CRUSH, SLASH),
BLUNT(CRUSH, CRUSH, null, CRUSH),
BOW(RANGED, RANGED, null, RANGED),
CLAW(SLASH, SLASH, STAB, SLASH),
HEAVY_RANGED(RANGED, RANGED, null, RANGED),
SALAMANDER(SLASH, RANGED, MAGIC, null),
CHINCHOMPA(RANGED, RANGED, null, RANGED),
GUN(NONE, CRUSH, null, null),
SLASH_SWORD(SLASH, SLASH, STAB, SLASH),
TWO_HANDED_SWORD(SLASH, SLASH, CRUSH, SLASH),
PICKAXE(STAB, STAB, CRUSH, STAB),
POLEARM(STAB, SLASH, null, STAB),
POLESTAFF(CRUSH, CRUSH, null, CRUSH),
SCYTHE(SLASH, SLASH, CRUSH, SLASH),
SPEAR(STAB, SLASH, CRUSH, STAB),
SPIKED(CRUSH, CRUSH, STAB, CRUSH),
STAB_SWORD(STAB, STAB, SLASH, STAB),
STAFF(CRUSH, CRUSH, null, CRUSH, MAGIC, MAGIC),
THROWN(RANGED, RANGED, null, RANGED),
WHIP(SLASH, SLASH, null, SLASH),
BLADED_STAFF(STAB, SLASH, null, CRUSH, MAGIC, MAGIC),
TWO_HANDED_SWORD_GODSWORD(STAB, SLASH, null, CRUSH, MAGIC, MAGIC),
POWERED_STAFF(SLASH, SLASH, CRUSH, SLASH),
BANNER(MAGIC, MAGIC, null, MAGIC),
POLEARM_CRYSTAL_HALBERD(STAB, SLASH, CRUSH, STAB),
BLUDGEON(STAB, SLASH, null, STAB),
BULWARK(CRUSH, CRUSH, null, CRUSH),
POWERED_WAND(CRUSH, null, null, NONE),
PARTISAN(MAGIC, MAGIC, null, MAGIC),
PARTISAN_2(STAB, STAB, CRUSH, STAB),
PLACE_HOLDER_0(null, null, null),
PLACE_HOLDER_1(null, null, null),
PLACE_HOLDER_2(null, null, null),
PLACE_HOLDER_3(null, null, null),
FLAIL(SLASH, SLASH, null, SLASH);
private static final Map<Integer, WeaponType> WEAPON_TYPES;
@Getter
private final AttackType[] attackTypes;
static
{
ImmutableMap.Builder<Integer, WeaponType> builder = new ImmutableMap.Builder<>();
for (WeaponType weaponType : values())
{
builder.put(weaponType.ordinal(), weaponType);
}
WEAPON_TYPES = builder.build();
}
WeaponType(AttackType... attackTypes)
{
this.attackTypes = attackTypes;
}
public AttackStyle[] getAttackStyles(Client client)
{
int weaponType = this.ordinal();
int weaponStyleEnum = client.getEnum(EnumID.WEAPON_STYLES).getIntValue(weaponType);
if (weaponStyleEnum == -1)
{
if (weaponType == 22)
{
return new AttackStyle[]{
ACCURATE, AGGRESSIVE, null, DEFENSIVE, CASTING, DEFENSIVE_CASTING,
};
}
if (weaponType == 30)
{
return new AttackStyle[]{
ACCURATE, AGGRESSIVE, AGGRESSIVE, DEFENSIVE,
};
}
return new AttackStyle[0];
}
int[] weaponStyleStructs = client.getEnum(weaponStyleEnum).getIntVals();
AttackStyle[] styles = new AttackStyle[weaponStyleStructs.length];
int i = 0;
for (int style : weaponStyleStructs)
{
StructComposition attackStyleStruct = client.getStructComposition(style);
String attackStyleName = attackStyleStruct.getStringValue(ParamID.ATTACK_STYLE_NAME);
AttackStyle attackStyle = AttackStyle.valueOf(attackStyleName.toUpperCase());
if (attackStyle == OTHER)
{
++i;
continue;
}
if (i == 5 && attackStyle == DEFENSIVE)
{
attackStyle = DEFENSIVE_CASTING;
}
styles[i++] = attackStyle;
}
return styles;
}
public static WeaponType getWeaponType(int id)
{
return WEAPON_TYPES.get(id);
}
}
@@ -0,0 +1,30 @@
package com.example.essentialsplus.helpers.combat.variablespeed;
import com.example.essentialsplus.helpers.combat.AnimationData;
import com.example.essentialsplus.helpers.combat.AttackProcedure;
import com.example.essentialsplus.helpers.combat.AttackType;
import com.example.essentialsplus.helpers.combat.CombatUtils;
import com.example.essentialsplus.helpers.combat.Spellbook;
import net.runelite.api.Client;
import net.runelite.api.coords.WorldPoint;
import net.runelite.api.gameval.NpcID;
public class Amoxliatl implements IVariableSpeed
{
private static final int AMOXLIATL_REGION_ID = 5446;
@Override
public int apply(final Client client, final AnimationData curAnimation, final AttackProcedure atkType,
final Spellbook spellbook, final int damageDealt, final int lastSpecDelta, final int baseSpeed,
final int curSpeed)
{
final WorldPoint location = CombatUtils.getLocalLocation(client);
final int targetId = CombatUtils.getTargetId(client);
final AttackType attkType = CombatUtils.getAttackType(client);
if (location.getRegionID() == AMOXLIATL_REGION_ID && targetId == NpcID.AMOXLIATL_ICE_BLOCK && attkType != null && attkType.IsMelee())
{
return 1;
}
return curSpeed;
}
}
@@ -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;
}
}
@@ -0,0 +1,127 @@
package com.example.essentialsplus.helpers.combat.variablespeed;
import com.example.essentialsplus.helpers.combat.AnimationData;
import com.example.essentialsplus.helpers.combat.AttackProcedure;
import com.example.essentialsplus.helpers.combat.Attacking;
import com.example.essentialsplus.helpers.combat.CombatUtils;
import com.example.essentialsplus.helpers.combat.Spellbook;
import com.example.essentialsplus.helpers.combat.variablespeed.state.TickCount;
import com.google.common.collect.ImmutableSet;
import java.util.Set;
import lombok.extern.slf4j.Slf4j;
import net.runelite.api.Client;
import net.runelite.api.NPC;
import net.runelite.api.gameval.AnimationID;
import net.runelite.api.gameval.ItemID;
import net.runelite.api.gameval.NpcID;
import net.runelite.client.game.ItemManager;
@Slf4j
public class DoomOfMokhaiotl implements IVariableSpeed
{
private static final Set<Integer> DOOM_REGION_IDS = new ImmutableSet.Builder<Integer>()
.add(5269)
.add(13668)
.add(14180)
.build();
private static final Set<Integer> COOLDOWN_OVERWRITES_IDS = new ImmutableSet.Builder<Integer>()
.add(NpcID.DOM_DEMONIC_ENERGY)
.add(NpcID.DOM_DEMONIC_ENERGY_GIANT_MAGE)
.add(NpcID.DOM_DEMONIC_ENERGY_GIANT_RANGE)
.add(NpcID.DOM_DEMONIC_ENERGY_MAGE)
.add(NpcID.DOM_DEMONIC_ENERGY_RANGE)
.add(NpcID.DOM_DEMONIC_ENERGY_MELEE)
.add(NpcID.DOM_SHOCKWAVE_PATH_NODE)
.build();
private static final Set<Integer> NO_COOLDOWN_WEAPON = new ImmutableSet.Builder<Integer>()
.add(ItemID.SILVERLIGHT)
.add(ItemID.DARKLIGHT)
.add(ItemID.ARCLIGHT)
.add(ItemID.EMBERLIGHT)
.add(ItemID.BONE_CLAWS)
.add(ItemID.SCORCHING_BOW)
.add(ItemID.HOLY_WATER)
.add(ItemID.EYE_OF_AYAK)
.build();
private final TickCount tickCount;
private int larvaeConsumed = -1;
private int shieldConsumed = -1;
public DoomOfMokhaiotl(final TickCount tc)
{
this.tickCount = tc;
}
public boolean onRender(final Client client, final ItemManager itemManager, final Spellbook spellbook)
{
if (!CombatUtils.isInRegionId(client, DOOM_REGION_IDS))
{
return false;
}
final var atk = Attacking.playerAttack(client);
final AnimationData anim = AnimationData.fromId(atk.getAnimationId());
if (anim == null || atk.getTarget() == null || !(atk.getTarget() instanceof NPC) || anim.isBlockListAnimation())
{
return false;
}
final NPC npc = (NPC) atk.getTarget();
final int npcId = npc.getId();
if (COOLDOWN_OVERWRITES_IDS.contains(npcId))
{
if (tickCount.isWithinNTicks(larvaeConsumed, 1))
{
return false;
}
final int weaponId = CombatUtils.getWeaponId(client);
final boolean isDemonbaneSpell = spellbook == Spellbook.ARCEUUS && AnimationData.isManualCasting(anim) && anim == AnimationData.MAGIC_ARCEUUS_DEMONBANE;
if (NO_COOLDOWN_WEAPON.contains(weaponId) || isDemonbaneSpell)
{
return false;
}
larvaeConsumed = tickCount.get();
return true;
}
else if (npcId == NpcID.DOM_BOSS)
{
if (tickCount.isWithinNTicks(shieldConsumed, 30))
{
return false;
}
final int animId = npc.getAnimation();
if (animId == AnimationID.DOM_BEAM_CHARGE_LOOP || animId == AnimationID.DOM_BEAM_CHARGE)
{
if (CombatUtils.getAttackType(client) != null && CombatUtils.getAttackType(client).IsMelee())
{
shieldConsumed = tickCount.get();
return true;
}
}
return false;
}
return false;
}
@Override
public int apply(final Client client, final AnimationData curAnimation, final AttackProcedure atkType,
final Spellbook spellbook, final int damageDealt, final int lastSpecDelta, final int baseSpeed,
final int curSpeed)
{
final int targetId = CombatUtils.getTargetId(client);
final boolean inDoom = CombatUtils.isInRegionId(client, DOOM_REGION_IDS);
if (inDoom && COOLDOWN_OVERWRITES_IDS.contains(targetId))
{
final int weaponId = CombatUtils.getWeaponId(client);
final boolean isDemonbaneSpell = spellbook == Spellbook.ARCEUUS && AnimationData.isManualCasting(curAnimation) && curAnimation == AnimationData.MAGIC_ARCEUUS_DEMONBANE;
if (NO_COOLDOWN_WEAPON.contains(weaponId) || isDemonbaneSpell)
{
return 1;
}
}
return curSpeed;
}
}
@@ -0,0 +1,21 @@
package com.example.essentialsplus.helpers.combat.variablespeed;
import com.example.essentialsplus.helpers.combat.AnimationData;
import com.example.essentialsplus.helpers.combat.AttackProcedure;
import com.example.essentialsplus.helpers.combat.Spellbook;
import net.runelite.api.Client;
public class EyeOfAyak implements IVariableSpeed
{
@Override
public int apply(final Client client, final AnimationData curAnimation, final AttackProcedure atkType,
final Spellbook spellbook, final int damageDealt, final int lastSpecDelta, final int baseSpeed,
final int curSpeed)
{
if (curAnimation == AnimationData.MAGIC_EYE_OF_AYAK_SPEC)
{
return 5;
}
return curSpeed;
}
}
@@ -0,0 +1,21 @@
package com.example.essentialsplus.helpers.combat.variablespeed;
import com.example.essentialsplus.helpers.combat.AnimationData;
import com.example.essentialsplus.helpers.combat.AttackProcedure;
import com.example.essentialsplus.helpers.combat.Spellbook;
import com.example.essentialsplus.helpers.combat.variablespeed.state.IStateTracker;
import net.runelite.api.Client;
public interface IVariableSpeed extends IStateTracker
{
int apply(
final Client client,
final AnimationData curAnimation,
final AttackProcedure atkType,
final Spellbook spellbook,
final int damageDealt,
final int lastSpecDelta,
final int baseSpeed,
final int curSpeed
);
}
@@ -0,0 +1,67 @@
package com.example.essentialsplus.helpers.combat.variablespeed;
import com.example.essentialsplus.helpers.combat.AnimationData;
import com.example.essentialsplus.helpers.combat.Attacking;
import com.example.essentialsplus.helpers.combat.CombatUtils;
import com.example.essentialsplus.helpers.combat.variablespeed.state.TickCount;
import lombok.extern.slf4j.Slf4j;
import net.runelite.api.Client;
import net.runelite.api.NPC;
import net.runelite.api.gameval.AnimationID;
import net.runelite.api.gameval.NpcID;
import net.runelite.client.game.ItemManager;
@Slf4j
public class MaggotKing
{
private static final int MAGGOT_KING_REGION_ID = 11645;
private final TickCount tickCount;
private int consumed = -1;
public MaggotKing(final TickCount tc)
{
this.tickCount = tc;
}
public boolean onRender(final Client client, final ItemManager itemManager)
{
if (!CombatUtils.isInRegionId(client, MAGGOT_KING_REGION_ID) || tickCount.isWithinNTicks(consumed, 1))
{
return false;
}
final var atk = Attacking.playerAttack(client);
final AnimationData anim = AnimationData.fromId(atk.getAnimationId());
if (anim == null || atk.getTarget() == null || !(atk.getTarget() instanceof NPC))
{
return false;
}
final NPC npc = (NPC) atk.getTarget();
if (npc.getId() != NpcID.UR_MAGGOT_LARVAE || !anim.isStandardBowAttack()
|| npc.getAnimation() != AnimationID.UR_MAGGOT_LARVAE_FLY)
{
return false;
}
consumed = tickCount.get();
return true;
}
public int onChatMessage(final Client client, final String message)
{
if (message == null)
{
return 0;
}
if (message.contains("The sticky acid hampers your ability to attack!"))
{
return 1;
}
else if (message.contains("The Maggot King's screech disrupts your concentration!"))
{
return 3;
}
return 0;
}
}
@@ -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;
}
}
@@ -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;
}
}
@@ -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;
}
}
@@ -0,0 +1,174 @@
package com.example.essentialsplus.helpers.combat.variablespeed;
import com.example.essentialsplus.helpers.combat.AnimationData;
import com.example.essentialsplus.helpers.combat.AttackProcedure;
import com.example.essentialsplus.helpers.combat.CombatUtils;
import com.example.essentialsplus.helpers.combat.Spellbook;
import com.google.common.collect.ImmutableSet;
import java.util.HashSet;
import java.util.Set;
import net.runelite.api.Client;
import net.runelite.api.NPC;
import net.runelite.api.coords.WorldPoint;
import net.runelite.api.events.GameTick;
import net.runelite.api.events.NpcDespawned;
import net.runelite.api.events.NpcSpawned;
import net.runelite.api.gameval.ItemID;
import net.runelite.api.gameval.NpcID;
public class RoyalTitans implements IVariableSpeed
{
private static final int ROYAL_TITANS_REGION_ID = 11669;
private static final int HP_FUDGE = 1;
private static final int ELEMENTAL_HP = 40 - HP_FUDGE;
private final Set<NPC> iceElementals = new HashSet<>();
private final Set<NPC> fireElementals = new HashSet<>();
private boolean removeDead = false;
private static final Set<AnimationData> ONE_SHOT_SPELLS = new ImmutableSet.Builder<AnimationData>()
.add(AnimationData.MAGIC_STANDARD_STRIKE_BOLT_BLAST)
.add(AnimationData.MAGIC_STANDARD_STRIKE_BOLT_BLAST_STAFF)
.add(AnimationData.MAGIC_STANDARD_STRIKE_BOLT_BLAST_STAFF_WALK)
.add(AnimationData.MAGIC_STANDARD_STRIKE_BOLT_BLAST_WALK)
.add(AnimationData.MAGIC_STANDARD_SURGE)
.add(AnimationData.MAGIC_STANDARD_SURGE_STAFF)
.add(AnimationData.MAGIC_STANDARD_SURGE_STAFF_WALK)
.add(AnimationData.MAGIC_STANDARD_WAVE)
.add(AnimationData.MAGIC_STANDARD_WAVE_STAFF)
.add(AnimationData.MAGIC_STANDARD_WAVE_STAFF_WALK)
.add(AnimationData.MAGIC_STANDARD_WAVE_WALK)
.build();
@Override
public int apply(final Client client, final AnimationData curAnimation, final AttackProcedure atkType,
final Spellbook spellbook, final int damageDealt, final int lastSpecDelta, final int baseSpeed,
final int curSpeed)
{
if (earlyExit(client, atkType, damageDealt, spellbook))
{
return curSpeed;
}
final int targetId = CombatUtils.getTargetId(client);
if (spellbook != Spellbook.STANDARD || !isElemental(targetId) || !ONE_SHOT_SPELLS.contains(curAnimation))
{
return curSpeed;
}
final NPC target = CombatUtils.getTargetNPC(client);
if (target == null)
{
return curSpeed;
}
final boolean wieldingTwinflame = CombatUtils.getWeaponId(client) == ItemID.TWINFLAME_STAFF;
final int computedDamage = wieldingTwinflame ? damageDealt + ((damageDealt * 4) / 10) : damageDealt;
if (computedDamage < ELEMENTAL_HP)
{
return curSpeed;
}
final var set = targetId == NpcID.RT_SUMMON_ELEMENTAL_FIRE ? fireElementals : iceElementals;
int count = 1;
final var reference = target.getWorldLocation();
for (final NPC elemental : set)
{
if (elemental == target)
{
continue;
}
final WorldPoint worldLocation = elemental.getWorldLocation();
final int distanceTo2D = reference.distanceTo2D(worldLocation);
if (distanceTo2D <= 1)
{
count++;
}
}
final boolean isTwinflameKillOnSecondProjectile = wieldingTwinflame &&
curAnimation == AnimationData.MAGIC_STANDARD_STRIKE_BOLT_BLAST_STAFF;
final int extraHitOffset = isTwinflameKillOnSecondProjectile ? 1 : 0;
final WorldPoint playerLoc = client.getLocalPlayer().getWorldLocation();
final int distance = playerLoc.distanceTo2D(reference);
final int hitDelay = 2 + (distance / 3) + extraHitOffset;
final int remainingAtImpact = Math.max(0, curSpeed - hitDelay);
final int remainingAfterReduction = Math.max(0, remainingAtImpact - count);
final int finalSpeed = Math.min(curSpeed, hitDelay + remainingAfterReduction);
removeDead = true;
return finalSpeed;
}
private static boolean earlyExit(final Client client, final AttackProcedure atkType, final int damageDealt,
final Spellbook spellbook)
{
return notInRegion(client)
|| atkType != AttackProcedure.MANUAL_AUTO_CAST
|| damageDealt <= 0
|| spellbook != Spellbook.STANDARD;
}
@Override
public void onGameTick(Client client, GameTick tick)
{
if (removeDead)
{
boolean removed = iceElementals.removeIf(NPC::isDead);
removed |= fireElementals.removeIf(NPC::isDead);
if (removed)
{
removeDead = false;
}
}
}
@Override
public void onNpcSpawned(final Client client, final NpcSpawned npcSpawned)
{
if (notInRegion(client))
{
return;
}
final NPC npc = npcSpawned.getNpc();
final int id = npc.getId();
if (id == NpcID.RT_SUMMON_ELEMENTAL_ICE)
{
iceElementals.add(npc);
}
else if (id == NpcID.RT_SUMMON_ELEMENTAL_FIRE)
{
fireElementals.add(npc);
}
}
@Override
public void onNpcDespawned(final Client client, final NpcDespawned npcDespawned)
{
if (notInRegion(client))
{
return;
}
final NPC npc = npcDespawned.getNpc();
final int id = npc.getId();
if (id == NpcID.RT_SUMMON_ELEMENTAL_ICE)
{
iceElementals.remove(npc);
}
else if (id == NpcID.RT_SUMMON_ELEMENTAL_FIRE)
{
fireElementals.remove(npc);
}
}
private static boolean isElemental(int id)
{
return id == NpcID.RT_SUMMON_ELEMENTAL_FIRE || id == NpcID.RT_SUMMON_ELEMENTAL_ICE;
}
private static boolean notInRegion(final Client client)
{
return CombatUtils.getLocalLocation(client).getRegionID() != ROYAL_TITANS_REGION_ID;
}
}
@@ -0,0 +1,44 @@
package com.example.essentialsplus.helpers.combat.variablespeed;
import com.example.essentialsplus.helpers.combat.AnimationData;
import com.example.essentialsplus.helpers.combat.AttackProcedure;
import com.example.essentialsplus.helpers.combat.CombatUtils;
import com.example.essentialsplus.helpers.combat.Spellbook;
import net.runelite.api.Client;
import net.runelite.api.coords.WorldPoint;
import net.runelite.api.gameval.ItemID;
import net.runelite.api.gameval.NpcID;
public class Scurrius implements IVariableSpeed
{
private static final int SCURRIUS_REGION_ID = 13210;
private static final int SCURRIUS_MIN_X = 3290;
private static final int SCURRIUS_MAX_X = 3307;
private static final int SCURRIUS_MIN_Y = 9859;
private static final int SCURRIUS_MAX_Y = 9876;
private static boolean attackingGiantRatWithBoneWeapon(final int equipped, final int regionId, final int x,
final int y, final int target)
{
final boolean correctWeapon = equipped == ItemID.RAT_BONE_STAFF || equipped == ItemID.RAT_BONE_MACE || equipped == ItemID.RAT_BONE_BOW;
final boolean correctCoords = x >= SCURRIUS_MIN_X && x <= SCURRIUS_MAX_X && y >= SCURRIUS_MIN_Y && y <= SCURRIUS_MAX_Y;
final boolean correctRegion = regionId == SCURRIUS_REGION_ID;
final boolean correctEnemy = target == NpcID.RAT_BOSS_GIANT_RAT;
return correctWeapon && correctCoords && correctRegion && correctEnemy;
}
@Override
public int apply(final Client client, final AnimationData curAnimation, final AttackProcedure atkType,
final Spellbook spellbook, final int damageDealt, final int lastSpecDelta, final int baseSpeed,
final int curSpeed)
{
final WorldPoint location = CombatUtils.getLocalLocation(client);
final int weaponId = CombatUtils.getWeaponId(client);
final int targetId = CombatUtils.getTargetId(client);
if (attackingGiantRatWithBoneWeapon(weaponId, location.getRegionID(), location.getX(), location.getY(), targetId))
{
return 1;
}
return curSpeed;
}
}
@@ -0,0 +1,257 @@
package com.example.essentialsplus.helpers.combat.variablespeed;
import com.example.essentialsplus.helpers.combat.variablespeed.state.MarkOfDarkness;
import com.example.essentialsplus.helpers.combat.variablespeed.state.TickCount;
import com.example.essentialsplus.helpers.combat.variablespeed.state.Yama;
import com.example.essentialsplus.helpers.combat.variablespeed.state.YamaPhase;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.Map;
import java.util.Set;
import lombok.extern.slf4j.Slf4j;
import net.runelite.api.Client;
import net.runelite.api.GraphicsObject;
import net.runelite.api.Player;
import net.runelite.api.WorldView;
import net.runelite.api.coords.LocalPoint;
import net.runelite.api.coords.WorldPoint;
@Slf4j
public class ShadowCrash
{
private static final int FIREBALL_ID = 3262;
private static final int TILE = 128;
private static final int HV_GAP_1 = 3;
private static final int HV_GAP_2 = 6;
private static final int HV_DELTA_1 = HV_GAP_1 * TILE;
private static final int HV_DELTA_2 = HV_GAP_2 * TILE;
private static final int D_GAP_1 = 2;
private static final int D_GAP_2 = 4;
private static final int D_DELTA_1 = D_GAP_1 * TILE;
private static final int D_DELTA_2 = D_GAP_2 * TILE;
private final TickCount tickCount;
private final Yama yama;
private final MarkOfDarkness mod;
private int consumed = -1;
private final Map<LocalPoint, GraphicsObject> fireballs;
private ArrayList<FireballLine> lines;
private WorldPoint player;
public ShadowCrash(Yama yama, MarkOfDarkness mod, TickCount tc)
{
this.yama = yama;
this.mod = mod;
this.tickCount = tc;
this.fireballs = new HashMap<>();
this.lines = new ArrayList<>();
}
public int onRender(final Client client, final int attackDelayHoldoffTicks, final boolean isUsingMagic)
{
if (yama == null || !yama.inYamaRegion || yama.phase() != YamaPhase.P3 || !mod.isActive())
{
return 0;
}
if (!tickCount.isWithinNTicks(consumed, 2) && shadowCrashSweetSpot(client))
{
consumed = tickCount.get();
if (attackDelayHoldoffTicks <= 1 && isUsingMagic)
{
return 0;
}
return -1;
}
return 0;
}
private boolean shadowCrashSweetSpot(final Client client)
{
getState(client);
if (lines.isEmpty())
{
return false;
}
for (var line : lines)
{
if (line.isActive())
{
return line.inSweetSpot(client, player);
}
}
return false;
}
private void getState(final Client client)
{
fireballs.clear();
lines.clear();
final WorldView tlwv = client.getTopLevelWorldView();
final Player localPlayer = client.getLocalPlayer();
if (localPlayer == null)
{
return;
}
final WorldView playerWv = localPlayer.getWorldView();
if (tlwv != null)
{
getFireballs(tlwv);
}
if (playerWv != null && playerWv != tlwv)
{
getFireballs(playerWv);
}
if (fireballs.size() < 3)
{
return;
}
player = localPlayer.getWorldLocation();
lines = getLines();
}
private void getFireballs(final WorldView wv)
{
for (GraphicsObject graphicsObject : wv.getGraphicsObjects())
{
if (graphicsObject.getId() == FIREBALL_ID)
{
fireballs.put(graphicsObject.getLocation(), graphicsObject);
}
}
}
private ArrayList<FireballLine> getLines()
{
final var result = new ArrayList<FireballLine>();
while (fireballs.size() >= 3)
{
LocalPoint first = null;
final Set<LocalPoint> fireballsLocations = fireballs.keySet();
for (var location : fireballsLocations)
{
if (first == null)
{
first = location;
}
else
{
if (first.getX() > location.getX())
{
first = location;
}
else if (first.getX() == location.getX())
{
if (first.getY() > location.getY())
{
first = location;
}
}
}
}
if (first == null)
{
return result;
}
var centre = first.plus(D_DELTA_1, D_DELTA_1);
var end = first.plus(D_DELTA_2, D_DELTA_2);
if (fireballsLocations.contains(centre) && fireballsLocations.contains(end))
{
result.add(new FireballLine(LineType.D2, centre, fireballs.get(centre)));
removeLine(first, centre, end);
continue;
}
centre = first.plus(HV_DELTA_1, 0);
end = first.plus(HV_DELTA_2, 0);
if (fireballsLocations.contains(centre) && fireballsLocations.contains(end))
{
result.add(new FireballLine(LineType.H, centre, fireballs.get(centre)));
removeLine(first, centre, end);
continue;
}
centre = first.plus(0, HV_DELTA_1);
end = first.plus(0, HV_DELTA_2);
if (fireballsLocations.contains(centre) && fireballsLocations.contains(end))
{
result.add(new FireballLine(LineType.V, centre, fireballs.get(centre)));
removeLine(first, centre, end);
continue;
}
centre = first.plus(D_DELTA_1, -D_DELTA_1);
end = first.plus(D_DELTA_2, -D_DELTA_2);
if (fireballsLocations.contains(centre) && fireballsLocations.contains(end))
{
result.add(new FireballLine(LineType.D1, centre, fireballs.get(centre)));
removeLine(first, centre, end);
continue;
}
return result;
}
return result;
}
private void removeLine(final LocalPoint a, final LocalPoint b, final LocalPoint c)
{
fireballs.remove(a);
fireballs.remove(b);
fireballs.remove(c);
}
public static boolean eq(final WorldPoint t, final int dx, final int dy, final WorldPoint other)
{
if (t == null || other == null)
{
return false;
}
return t.getX() + dx == other.getX() && t.getY() + dy == other.getY();
}
static class FireballLine
{
private final LineType type;
private final LocalPoint centre;
private final GraphicsObject cObject;
FireballLine(final LineType type, final LocalPoint centre, final GraphicsObject cObject)
{
this.centre = centre;
this.cObject = cObject;
this.type = type;
}
public boolean isActive()
{
final int animationFrame = cObject.getAnimationFrame();
return animationFrame >= 30 && animationFrame <= 40;
}
public boolean inSweetSpot(final Client client, final WorldPoint lp)
{
final WorldPoint wp = WorldPoint.fromLocal(client, centre);
switch (this.type)
{
case V:
return eq(wp, 1, 0, lp) || eq(wp, -1, 0, lp);
case H:
return eq(wp, 0, 1, lp) || eq(wp, 0, -1, lp);
case D1:
return eq(wp, 1, 1, lp) || eq(wp, -1, -1, lp);
case D2:
return eq(wp, -1, 1, lp) || eq(wp, 1, -1, lp);
}
return false;
}
}
enum LineType
{
V, H, D1, D2
}
}
@@ -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;
}
}
@@ -0,0 +1,184 @@
package com.example.essentialsplus.helpers.combat.variablespeed;
import com.example.essentialsplus.helpers.combat.AnimationData;
import com.example.essentialsplus.helpers.combat.AttackProcedure;
import com.example.essentialsplus.helpers.combat.AttackType;
import com.example.essentialsplus.helpers.combat.CombatUtils;
import com.example.essentialsplus.helpers.combat.Spellbook;
import com.example.essentialsplus.helpers.combat.WeaponType;
import com.example.essentialsplus.helpers.combat.variablespeed.state.TickCount;
import java.util.HashMap;
import java.util.Map;
import net.runelite.api.Client;
import net.runelite.api.NPC;
import net.runelite.api.events.GameTick;
import net.runelite.api.events.NpcDespawned;
import net.runelite.api.events.NpcSpawned;
import net.runelite.api.gameval.NpcID;
import net.runelite.api.gameval.SpotanimID;
public class TormentedDemons implements IVariableSpeed
{
private final TickCount tickCount;
private final Map<NPC, DemonData> tormentedDemons = new HashMap<>();
public TormentedDemons(TickCount tc)
{
this.tickCount = tc;
}
@Override
public int apply(final Client client, final AnimationData curAnimation, final AttackProcedure atkType,
final Spellbook spellbook, final int damageDealt, final int lastSpecDelta, final int baseSpeed,
final int curSpeed)
{
final int targetId = CombatUtils.getTargetId(client);
if (!isTormentedDemon(targetId))
{
return curSpeed;
}
final NPC target = CombatUtils.getTargetNPC(client);
if (!tormentedDemons.containsKey(target))
{
return curSpeed;
}
final DemonData targetDemon = tormentedDemons.get(target);
if (!targetDemon.isVulnerable(tickCount.get()))
{
return curSpeed;
}
if (targetDemon.vulnConsumed(tickCount.get()))
{
return curSpeed;
}
switch (atkType)
{
case POWERED_STAVE:
return curSpeed;
case MANUAL_AUTO_CAST:
break;
case MELEE_OR_RANGE:
final WeaponType wep = CombatUtils.getWeaponType(client);
final AttackType attkType = CombatUtils.getAttackType(client);
if (wep != WeaponType.HEAVY_RANGED && (attkType == null || attkType != AttackType.CRUSH))
{
return curSpeed;
}
break;
}
if (curSpeed > 4)
{
return 4;
}
return curSpeed;
}
private static boolean isTormentedDemon(int targetId)
{
return targetId == NpcID.TORMENTED_DEMON_1 || targetId == NpcID.TORMENTED_DEMON_2;
}
@Override
public void onGameTick(Client client, GameTick tick)
{
for (final Map.Entry<NPC, DemonData> td : tormentedDemons.entrySet())
{
final boolean isVulnerable = td.getKey().hasSpotAnim(SpotanimID.LUC2_UNDEAD_DEMON_EXPLOSION_FIRE_SPOT);
td.getValue().update(tickCount.get(), isVulnerable);
}
}
@Override
public void onNpcSpawned(final Client client, final NpcSpawned npcSpawned)
{
final NPC npc = npcSpawned.getNpc();
maybeInsertDemon(npc);
}
private void maybeInsertDemon(final NPC npc)
{
if (isTormentedDemon(npc.getId()))
{
final boolean isVulnerable = npc.hasSpotAnim(SpotanimID.LUC2_UNDEAD_DEMON_EXPLOSION_FIRE_SPOT);
tormentedDemons.put(npc, new DemonData(tickCount.get(), isVulnerable));
}
}
@Override
public void onNpcDespawned(final Client client, final NpcDespawned npcDespawned)
{
final NPC npc = npcDespawned.getNpc();
if (isTormentedDemon(npc.getId()))
{
tormentedDemons.remove(npc);
}
}
static class DemonData
{
private static final int VULN_TICKS_AFTER_END = 10;
private Integer vulnerableStart;
private Integer vulnerableFinish;
private int attacked;
DemonData(int tick, boolean vuln)
{
this.update(tick, vuln);
this.attacked = -1;
}
void update(int tick, boolean vuln)
{
if (vuln && this.vulnerableStart == null)
{
this.vulnerableStart = tick;
this.vulnerableFinish = tick;
}
else if (vuln && this.vulnerableStart != null)
{
this.vulnerableFinish = tick;
}
if (!vuln)
{
this.vulnerableStart = null;
}
if (this.attacked != -1 && this.attacked + 1 >= tick)
{
this.attacked = -1;
}
}
boolean isVulnerable(int tick)
{
if (this.vulnerableFinish == null)
{
return false;
}
return (this.vulnerableFinish + VULN_TICKS_AFTER_END) > tick;
}
boolean vulnConsumed(int tick)
{
if (this.attacked == tick || this.attacked == -1)
{
this.consumeVuln(tick);
return false;
}
else if (this.attacked > tick + VULN_TICKS_AFTER_END)
{
return true;
}
else
{
this.consumeVuln(tick);
return false;
}
}
void consumeVuln(int tick)
{
this.attacked = tick;
}
}
}
@@ -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);
}
}
}
@@ -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)
{
}
}
@@ -0,0 +1,47 @@
package com.example.essentialsplus.helpers.combat.variablespeed.state;
import com.example.essentialsplus.helpers.combat.CombatUtils;
import java.time.Duration;
import java.time.Instant;
import net.runelite.api.Client;
import net.runelite.api.Skill;
import net.runelite.api.events.ChatMessage;
import net.runelite.api.gameval.ItemID;
import net.runelite.client.util.RSTimeUnit;
public class MarkOfDarkness implements IStateTracker
{
private static final String MARK_OF_DARKNESS_MESSAGE = "You have placed a Mark of Darkness upon yourself.</col>";
private Instant modStartTime = Instant.now();
private Instant modEndTime = Instant.now();
@Override
public void onChatMessage(Client client, ChatMessage event)
{
final String message = event.getMessage();
if (message != null && message.endsWith(MARK_OF_DARKNESS_MESSAGE))
{
final Duration duration = getMarkOfDarknessDuration(client);
this.modStartTime = Instant.now();
this.modEndTime = modStartTime.plus(duration);
}
}
public boolean isActive()
{
final Instant n = Instant.now();
return this.modStartTime.isBefore(n) && this.modEndTime.isAfter(n);
}
private static Duration getMarkOfDarknessDuration(Client client)
{
final int magicLevel = client.getRealSkillLevel(Skill.MAGIC);
final Duration markOfDarknessDuration = Duration.of((long) magicLevel * 3, RSTimeUnit.GAME_TICKS);
if (CombatUtils.getWeaponId(client) == ItemID.PURGING_STAFF)
{
return markOfDarknessDuration.multipliedBy(5);
}
return markOfDarknessDuration;
}
}
@@ -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;
}
}
@@ -0,0 +1,177 @@
package com.example.essentialsplus.helpers.combat.variablespeed.state;
import com.example.essentialsplus.helpers.combat.CombatUtils;
import java.util.HashMap;
import java.util.Map;
import lombok.NonNull;
import net.runelite.api.Client;
import net.runelite.api.NPC;
import net.runelite.api.events.ChatMessage;
import net.runelite.api.events.GameTick;
import net.runelite.api.events.NpcSpawned;
import net.runelite.api.gameval.NpcID;
public class Yama implements IStateTracker
{
private static final int HP_FUDGE = 2;
private static final int VOID_FLARE_HP_P1_P2 = 140 - HP_FUDGE;
private static final int VOID_FLARE_HP_P3 = 71 - HP_FUDGE;
private static final int YAMA_REGION_ID = 6045;
private static final int YAMA_PHASE_TRANSITION_ANIMATION_ID = 12147;
public YamaData yama;
public boolean inYamaRegion;
public NPC lastTarget;
@Override
public void onGameTick(Client client, GameTick tick)
{
inYamaRegion = CombatUtils.isInRegionId(client, YAMA_REGION_ID);
if (!inYamaRegion)
{
yama = null;
return;
}
if (yama != null)
{
yama.determineYamaPhase();
}
}
@Override
public void onChatMessage(Client client, ChatMessage event)
{
if (!inYamaRegion || yama == null)
{
return;
}
if (event.getMessage().startsWith("Your Yama success count is:"))
{
yama.killed();
}
}
@Override
public void onNpcSpawned(final Client client, final NpcSpawned npcSpawned)
{
if (!inYamaRegion || yama != null)
{
return;
}
final NPC npc = npcSpawned.getNpc();
if (npc.getId() == NpcID.YAMA)
{
yama = new YamaData(npc);
}
}
public static NPC isEitherVoidFlare(NPC a, NPC b)
{
if (a != null && a.getId() == NpcID.YAMA_VOIDFLARE)
{
return a;
}
if (b != null && b.getId() == NpcID.YAMA_VOIDFLARE)
{
return b;
}
return null;
}
public void dealVoidFlareDamage(NPC target, int damageDealt)
{
if (yama == null)
{
return;
}
this.yama.dealVoidFlareDamage(target, damageDealt);
}
public boolean getVoidFlareDead(NPC target)
{
if (yama == null)
{
return false;
}
return this.yama.getVoidFlareDead(target);
}
public YamaPhase phase()
{
if (yama == null)
{
return YamaPhase.P1;
}
return this.yama.phase;
}
public static class YamaData
{
@NonNull
private final NPC yama;
private YamaPhase phase;
private int phaseChangeCooldown;
private final Map<NPC, Integer> voidFlares;
YamaData(@NonNull NPC yama)
{
this.yama = yama;
this.phase = YamaPhase.P1;
this.phaseChangeCooldown = 0;
this.voidFlares = new HashMap<>();
}
public boolean getVoidFlareDead(NPC target)
{
if (!this.voidFlares.containsKey(target))
{
return false;
}
return this.voidFlares.get(target) <= 0;
}
public void dealVoidFlareDamage(NPC target, int damageDealt)
{
if (this.voidFlares.containsKey(target))
{
this.voidFlares.put(target, this.voidFlares.get(target) - damageDealt);
}
else
{
final int hp = this.getVoidFlareHp() - damageDealt;
this.voidFlares.put(target, hp);
}
}
private void determineYamaPhase()
{
if (phaseChangeCooldown == 0 && this.yama.getAnimation() == YAMA_PHASE_TRANSITION_ANIMATION_ID)
{
this.phaseChangeCooldown = 20;
this.phase = this.phase.nextPhase();
}
if (this.phaseChangeCooldown > 0)
{
this.phaseChangeCooldown--;
}
}
private int getVoidFlareHp()
{
switch (this.phase)
{
case P1:
case P2:
return VOID_FLARE_HP_P1_P2;
default:
return VOID_FLARE_HP_P3;
}
}
private void killed()
{
this.phase = YamaPhase.P1;
this.voidFlares.clear();
}
}
}
@@ -0,0 +1,20 @@
package com.example.essentialsplus.helpers.combat.variablespeed.state;
public enum YamaPhase
{
P1, P2, P3;
public YamaPhase nextPhase()
{
switch (this)
{
case P1:
return P2;
case P2:
return P3;
case P3:
return P3;
}
return P1;
}
}
@@ -0,0 +1,27 @@
package com.example.essentialsplus.loot;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* Kills and drops of one source within a small time bucket. Recent events are kept per minute,
* older ones are merged into hourly and daily buckets (see {@link LootRecord#compactEvents}).
*/
public class LootEvent
{
/** bucket start (epoch millis) */
public long t;
/** kills in this bucket */
public int k;
/** item id -> quantity */
public Map<Integer, Long> i = new LinkedHashMap<>();
public LootEvent()
{
}
public LootEvent(long t)
{
this.t = t;
}
}
@@ -0,0 +1,246 @@
package com.example.essentialsplus.loot;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import lombok.Data;
import lombok.NoArgsConstructor;
import net.runelite.client.game.ItemManager;
@Data
@NoArgsConstructor
public class LootRecord
{
private String name;
private String type; // "NPC", "CHEST", "CLUE", "EVENT", "PLAYER"
private int kills;
private long firstSeen;
private long lastSeen;
private Map<Integer, TrackedLootItem> items = new LinkedHashMap<>();
private com.example.essentialsplus.loot.combat.CombatStats combatStats;
/** Time-bucketed kills/drops, used for time range filtering. */
private List<LootEvent> events = new ArrayList<>();
private static final long MINUTE = 60_000L;
private static final long HOUR = 60 * MINUTE;
private static final long DAY = 24 * HOUR;
public boolean hasCombatStats()
{
return combatStats != null && combatStats.getFightsCount() > 0;
}
public synchronized void addCombatFight(com.example.essentialsplus.loot.combat.FightSession session)
{
addCombatFight(session, 0);
}
public synchronized void addCombatFight(com.example.essentialsplus.loot.combat.FightSession session, long lootValue)
{
if (combatStats == null)
{
combatStats = new com.example.essentialsplus.loot.combat.CombatStats();
}
combatStats.addFight(session, lootValue);
}
public LootRecord(String name, String type)
{
this.name = name;
this.type = type != null ? type : "NPC";
this.kills = 0;
this.firstSeen = System.currentTimeMillis();
this.lastSeen = this.firstSeen;
}
public synchronized void addDrop(int itemId, String itemName, int quantity, int gePrice)
{
this.lastSeen = System.currentTimeMillis();
currentEvent(lastSeen).i.merge(itemId, (long) quantity, Long::sum);
TrackedLootItem item = items.computeIfAbsent(itemId, id -> new TrackedLootItem(id, itemName, 0, gePrice));
item.addQuantity(quantity);
if (gePrice > 0)
{
item.setGePrice(gePrice);
}
if (itemName != null && !itemName.isEmpty())
{
item.setName(itemName);
}
}
public synchronized void addKills(int count)
{
this.kills += count;
this.lastSeen = System.currentTimeMillis();
currentEvent(lastSeen).k += count;
}
public synchronized long getTotalValue()
{
long total = 0;
for (TrackedLootItem item : items.values())
{
total += item.getTotalPrice();
}
return total;
}
public synchronized long getTotalValue(ItemManager itemManager)
{
return getTotalValue();
}
public synchronized List<TrackedLootItem> getSortedItems()
{
List<TrackedLootItem> list = new ArrayList<>(items.values());
list.sort((a, b) -> Long.compare(b.getTotalPrice(), a.getTotalPrice()));
return list;
}
public synchronized List<TrackedLootItem> getSortedItems(ItemManager itemManager)
{
return getSortedItems();
}
public synchronized int getDistinctItemCount()
{
return items.size();
}
public synchronized long getTotalItemQuantity()
{
long sum = 0;
for (TrackedLootItem item : items.values())
{
sum += item.getQuantity();
}
return sum;
}
public synchronized List<LootEvent> getEvents()
{
if (events == null)
{
events = new ArrayList<>();
}
return events;
}
private LootEvent currentEvent(long now)
{
List<LootEvent> list = getEvents();
long bucket = now - now % MINUTE;
LootEvent last = list.isEmpty() ? null : list.get(list.size() - 1);
if (last == null || last.t != bucket)
{
last = new LootEvent(bucket);
list.add(last);
}
return last;
}
/** Merges events older than 2 days into hours and older than 60 days into days. */
public synchronized void compactEvents(long now)
{
List<LootEvent> list = getEvents();
if (list.size() < 2)
{
return;
}
List<LootEvent> out = new ArrayList<>(list.size());
for (LootEvent e : list)
{
long age = now - e.t;
long size = age > 60 * DAY ? DAY : age > 2 * DAY ? HOUR : MINUTE;
long bucket = e.t - e.t % size;
LootEvent last = out.isEmpty() ? null : out.get(out.size() - 1);
if (last != null && last.t == bucket)
{
last.k += e.k;
e.i.forEach((id, q) -> last.i.merge(id, q, Long::sum));
}
else
{
LootEvent copy = new LootEvent(bucket);
copy.k = e.k;
copy.i.putAll(e.i);
out.add(copy);
}
}
events = out;
}
/** The part of this record in the filter's range; for "Now" only the current streak of kills/drops. */
public synchronized LootRecord view(com.example.essentialsplus.ui.TimeFilter filter)
{
if (!filter.isCurrent())
{
return since(filter.start());
}
List<LootEvent> list = getEvents();
long start = com.example.essentialsplus.ui.TimeFilter.streakStart(list.size(), i -> list.get(i).t, i -> true, System.currentTimeMillis());
return since(start == Long.MAX_VALUE ? Long.MAX_VALUE / 2 : start);
}
/**
* A view of this record limited to kills, drops and fights at or after {@code start}.
* Returns this record itself for {@code start <= 0} (all time).
*/
public synchronized LootRecord since(long start)
{
if (start <= 0)
{
return this;
}
LootRecord r = new LootRecord();
r.name = name;
r.type = type;
r.firstSeen = 0;
r.lastSeen = 0;
for (LootEvent e : getEvents())
{
if (e.t + MINUTE <= start)
{
continue;
}
r.kills += e.k;
if (r.firstSeen == 0)
{
r.firstSeen = e.t;
}
r.lastSeen = Math.max(r.lastSeen, e.t);
for (Map.Entry<Integer, Long> d : e.i.entrySet())
{
TrackedLootItem base = items.get(d.getKey());
String itemName = base != null ? base.getName() : "Item " + d.getKey();
long price = base != null ? base.getGePrice() : 0;
r.items.computeIfAbsent(d.getKey(), id -> new TrackedLootItem(id, itemName, 0, price)).addQuantity(d.getValue());
}
}
if (combatStats != null)
{
List<com.example.essentialsplus.loot.combat.SingleFightRecord> fights = new ArrayList<>();
for (com.example.essentialsplus.loot.combat.SingleFightRecord f : combatStats.getFightHistory())
{
if (f.getTimestamp() >= start)
{
fights.add(f);
}
}
if (!fights.isEmpty())
{
r.combatStats = com.example.essentialsplus.loot.combat.CombatStats.fromFights(fights);
r.lastSeen = Math.max(r.lastSeen, fights.get(fights.size() - 1).getTimestamp());
}
}
return r;
}
/** True if nothing was recorded (e.g. a filtered view outside the range). */
public synchronized boolean isEmpty()
{
return kills <= 0 && items.isEmpty() && !hasCombatStats();
}
}
@@ -0,0 +1,456 @@
package com.example.essentialsplus.loot;
import com.example.essentialsplus.WealthStore;
import com.google.gson.Gson;
import com.google.gson.reflect.TypeToken;
import java.io.File;
import java.io.Reader;
import java.io.Writer;
import java.lang.reflect.Type;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.StandardCopyOption;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
import javax.inject.Inject;
import javax.inject.Singleton;
import javax.swing.SwingUtilities;
import lombok.extern.slf4j.Slf4j;
import net.runelite.api.ItemComposition;
import net.runelite.client.game.ItemManager;
import net.runelite.client.game.ItemStack;
@Slf4j
@Singleton
public class LootTrackerStore
{
private static final String FILE_NAME = "loot_tracker.json";
private static final Type RECORD_MAP_TYPE = new TypeToken<Map<String, LootRecord>>() {}.getType();
private final Gson gson;
private final ScheduledExecutorService executor;
private final Map<String, LootRecord> records = new ConcurrentHashMap<>();
private final List<Runnable> listeners = new CopyOnWriteArrayList<>();
private final Map<Long, Boolean> recentDropHashes = Collections.synchronizedMap(new LinkedHashMap<Long, Boolean>()
{
@Override
protected boolean removeEldestEntry(Map.Entry<Long, Boolean> eldest)
{
return size() > 128;
}
});
private ScheduledFuture<?> pendingSaveTask;
@Inject
public LootTrackerStore(Gson gson, ScheduledExecutorService executor)
{
this.gson = gson.newBuilder().setPrettyPrinting().disableHtmlEscaping().create();
this.executor = executor;
}
public void load()
{
File file = new File(WealthStore.DIR, FILE_NAME);
if (!file.exists())
{
return;
}
try (Reader reader = Files.newBufferedReader(file.toPath(), StandardCharsets.UTF_8))
{
Map<String, LootRecord> loaded = gson.fromJson(reader, RECORD_MAP_TYPE);
if (loaded != null)
{
records.clear();
records.putAll(loaded);
// Clean up orphaned Doom minions so they do not clutter the overview
LootRecord earth = records.remove("Volatile earth");
LootRecord larva = records.remove("Demonic larva");
if (earth != null || larva != null)
{
log.info("Cleaned orphaned Doom minions (Volatile earth / Demonic larva) from records");
saveDebounced();
}
notifyListeners();
}
}
catch (Exception e)
{
log.warn("Failed to load loot tracker records from {}", file, e);
}
}
public synchronized void save()
{
try
{
Files.createDirectories(WealthStore.DIR.toPath());
File target = new File(WealthStore.DIR, FILE_NAME);
File tmp = new File(WealthStore.DIR, FILE_NAME + ".tmp");
long now = System.currentTimeMillis();
for (LootRecord record : records.values())
{
record.compactEvents(now);
}
try (Writer writer = Files.newBufferedWriter(tmp.toPath(), StandardCharsets.UTF_8))
{
gson.toJson(records, RECORD_MAP_TYPE, writer);
}
Files.move(tmp.toPath(), target.toPath(), StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
}
catch (Exception e)
{
log.error("Failed to save loot tracker records", e);
}
}
public synchronized void saveDebounced()
{
if (pendingSaveTask != null && !pendingSaveTask.isDone())
{
return;
}
pendingSaveTask = executor.schedule(this::save, 3, TimeUnit.SECONDS);
}
public boolean isDuplicate(int tick, String source, Collection<ItemStack> items)
{
long hash = ((long) tick << 32) ^ ((long) source.hashCode() & 0xFFFFFFFFL);
if (items != null)
{
for (ItemStack item : items)
{
if (item != null)
{
hash = hash * 31 + ((long) item.getId() * 10007L + item.getQuantity());
}
}
}
return recentDropHashes.put(hash, Boolean.TRUE) != null;
}
public void addLoot(String source, String type, Collection<ItemStack> items, int amount, ItemManager itemManager, int currentTick)
{
if (source == null || source.trim().isEmpty() || items == null || items.isEmpty())
{
return;
}
if (isDuplicate(currentTick, source, items))
{
return;
}
String cleanSource = com.example.essentialsplus.loot.combat.CombatTracker.getCanonicalEncounterName(source.trim());
LootRecord record = records.computeIfAbsent(cleanSource, s -> new LootRecord(s, type));
// amount < 0: loot without a kill (e.g. Doom reward claim; kills come from CombatTracker)
if (amount >= 0)
{
record.addKills(Math.max(1, amount));
}
for (ItemStack item : items)
{
if (item == null || item.getId() <= 0 || item.getQuantity() <= 0)
{
continue;
}
int id = item.getId();
int qty = item.getQuantity();
String name = "Item " + id;
int gePrice = 0;
if (itemManager != null)
{
ItemComposition comp = itemManager.getItemComposition(id);
if (comp != null && comp.getName() != null)
{
name = comp.getName();
}
gePrice = itemManager.getItemPrice(id);
}
record.addDrop(id, name, qty, gePrice);
}
saveDebounced();
notifyListeners();
}
public void deleteRecord(String source)
{
if (source != null && records.remove(source) != null)
{
saveDebounced();
notifyListeners();
}
}
public void clearAll()
{
records.clear();
save();
notifyListeners();
}
public Collection<LootRecord> getRecords()
{
return records.values();
}
public LootRecord getRecord(String source)
{
if (source == null)
{
return null;
}
String canonical = com.example.essentialsplus.loot.combat.CombatTracker.getCanonicalEncounterName(source.trim());
LootRecord r = records.get(canonical);
if (r != null)
{
return r;
}
for (Map.Entry<String, LootRecord> entry : records.entrySet())
{
if (entry.getKey().equalsIgnoreCase(canonical))
{
return entry.getValue();
}
}
return null;
}
public LootRecord getOrCreateRecord(String source, String type)
{
if (source == null || source.trim().isEmpty())
{
return null;
}
String clean = com.example.essentialsplus.loot.combat.CombatTracker.getCanonicalEncounterName(source.trim());
LootRecord existing = getRecord(clean);
if (existing != null)
{
return existing;
}
LootRecord r = new LootRecord(clean, type != null ? type : "NPC");
records.put(clean, r);
return r;
}
/** Records limited to {@code start} (0 = all time); sources without activity in the range are left out. */
public List<LootRecord> getRecords(com.example.essentialsplus.ui.TimeFilter filter)
{
if (!filter.isCurrent())
{
return getRecords(filter.start());
}
List<LootRecord> out = new ArrayList<>();
for (LootRecord record : records.values())
{
LootRecord view = record.view(filter);
if (!view.isEmpty())
{
out.add(view);
}
}
return out;
}
public List<LootRecord> getRecords(long start)
{
List<LootRecord> out = new ArrayList<>();
for (LootRecord record : records.values())
{
LootRecord view = record.since(start);
if (start <= 0 || !view.isEmpty())
{
out.add(view);
}
}
return out;
}
/** Totals over a set of (possibly filtered) records. */
public static long totalValue(Collection<LootRecord> list)
{
long total = 0;
for (LootRecord record : list)
{
total += record.getTotalValue();
}
return total;
}
public static int totalKills(Collection<LootRecord> list)
{
int sum = 0;
for (LootRecord record : list)
{
sum += record.getKills();
}
return sum;
}
public static long totalSuppliesCost(Collection<LootRecord> list)
{
long sum = 0;
for (LootRecord record : list)
{
if (record.hasCombatStats())
{
sum += record.getCombatStats().getTotalSuppliesCost();
}
}
return sum;
}
public static double overallEfficiency(Collection<LootRecord> list)
{
long totalDuration = 0;
long totalIdle = 0;
for (LootRecord record : list)
{
if (record.hasCombatStats())
{
totalDuration += record.getCombatStats().getTotalDurationTicks();
totalIdle += record.getCombatStats().getTotalIdleTicks();
}
}
if (totalDuration <= 0)
{
return 100.0;
}
return Math.max(0.0, Math.min(100.0, (1.0 - (double) totalIdle / totalDuration) * 100.0));
}
public long getTotalValue()
{
long total = 0;
for (LootRecord record : records.values())
{
total += record.getTotalValue();
}
return total;
}
public long getTotalValue(ItemManager itemManager)
{
return getTotalValue();
}
public int getTotalKills()
{
int sum = 0;
for (LootRecord record : records.values())
{
sum += record.getKills();
}
return sum;
}
public long getTotalSuppliesCost()
{
long sum = 0;
for (LootRecord record : records.values())
{
if (record.hasCombatStats())
{
sum += record.getCombatStats().getTotalSuppliesCost();
}
}
return sum;
}
public long getTotalNetProfit()
{
return getTotalValue() - getTotalSuppliesCost();
}
public double getOverallEfficiency()
{
long totalDuration = 0;
long totalIdle = 0;
for (LootRecord record : records.values())
{
if (record.hasCombatStats())
{
totalDuration += record.getCombatStats().getTotalDurationTicks();
totalIdle += record.getCombatStats().getTotalIdleTicks();
}
}
if (totalDuration <= 0)
{
return 100.0;
}
return Math.max(0.0, Math.min(100.0, (1.0 - (double) totalIdle / totalDuration) * 100.0));
}
public List<TrackedLootItem> getTopOverallItems(int limit)
{
Map<Integer, TrackedLootItem> merged = new LinkedHashMap<>();
for (LootRecord r : records.values())
{
for (TrackedLootItem item : r.getItems().values())
{
TrackedLootItem existing = merged.computeIfAbsent(item.getId(), id -> new TrackedLootItem(id, item.getName(), 0, item.getGePrice()));
existing.addQuantity(item.getQuantity());
}
}
List<TrackedLootItem> list = new ArrayList<>(merged.values());
list.sort((a, b) -> Long.compare(b.getTotalPrice(), a.getTotalPrice()));
if (list.size() > limit)
{
list = new ArrayList<>(list.subList(0, limit));
}
return list;
}
public List<TrackedLootItem> getTopOverallItems(ItemManager itemManager, int limit)
{
return getTopOverallItems(limit);
}
public void addChangeListener(Runnable r)
{
listeners.add(r);
}
public void removeChangeListener(Runnable r)
{
listeners.remove(r);
}
public void notifyListeners()
{
SwingUtilities.invokeLater(() ->
{
for (Runnable r : listeners)
{
try
{
r.run();
}
catch (Exception e)
{
log.warn("Error running loot tracker listener", e);
}
}
});
}
}
@@ -0,0 +1,32 @@
package com.example.essentialsplus.loot;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import net.runelite.client.game.ItemManager;
@Data
@NoArgsConstructor
@AllArgsConstructor
public class TrackedLootItem
{
private int id;
private String name;
private long quantity;
private long gePrice;
public void addQuantity(long qty)
{
this.quantity += qty;
}
public long getTotalPrice()
{
return gePrice * quantity;
}
public long getTotalPrice(ItemManager itemManager)
{
return getTotalPrice();
}
}
@@ -0,0 +1,342 @@
package com.example.essentialsplus.loot.combat;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@NoArgsConstructor
public class CombatStats
{
// Cumulative totals across all kills of this enemy
private int fightsCount;
private long totalAttacksDealt;
private long totalDamageDealt;
private long totalHitsReceived;
private long totalDamageTaken;
private long totalDurationTicks;
private long totalIdleTicks;
private long totalTilesMoved;
private long totalHpHealed;
private long totalSuppliesCost;
private Map<String, Integer> totalFoodUsed = new LinkedHashMap<>();
private Map<String, Integer> totalPotionsUsed = new LinkedHashMap<>();
private Map<String, Integer> totalAmmoUsed = new LinkedHashMap<>();
// Cumulative Prayer & Weapon Metrics
private long totalPrayerBlockedHits;
private long totalPrayerMissedHits;
private long totalOffPrayerDamageTaken;
private long totalOnPrayerAttacks;
private long totalOffPrayerAttacks;
private long totalCorrectWeaponAttacks;
private long totalIncorrectWeaponAttacks;
// Delve metrics
private int maxDelve;
private int lastDelve;
// Most recent fight snapshot
private int lastAttacksDealt;
private int lastDamageDealt;
private int lastHitsReceived;
private int lastDamageTaken;
private int lastDurationTicks;
private int lastIdleTicks;
private int lastTilesMoved;
private int lastHpHealed;
private long lastSuppliesCost;
private double lastEfficiency;
private double lastPerformanceScore;
private Map<String, Integer> lastFoodUsed = new LinkedHashMap<>();
private Map<String, Integer> lastPotionsUsed = new LinkedHashMap<>();
private Map<String, Integer> lastAmmoUsed = new LinkedHashMap<>();
private int lastPrayerBlockedHits;
private int lastPrayerMissedHits;
private int lastOffPrayerDamageTaken;
private int lastOnPrayerAttacks;
private int lastOffPrayerAttacks;
private int lastCorrectWeaponAttacks;
private int lastIncorrectWeaponAttacks;
/** Individual fights kept per enemy; also bounds how far back time range filters see fights. */
private static final int MAX_HISTORY = 1000;
// List of all individual fights recorded
private List<SingleFightRecord> fightHistory = new ArrayList<>();
public synchronized void addFight(FightSession session)
{
addFight(session, 0);
}
public synchronized void addFight(FightSession session, long lootValue)
{
this.fightsCount++;
this.totalAttacksDealt += session.getAttacksDealt();
this.totalDamageDealt += session.getDamageDealt();
this.totalHitsReceived += session.getHitsReceived();
this.totalDamageTaken += session.getDamageTaken();
this.totalDurationTicks += session.getDurationTicks();
this.totalIdleTicks += session.getIdleTicks();
this.totalTilesMoved += session.getTilesMoved();
this.totalHpHealed += session.getHpHealed();
this.totalSuppliesCost += session.getSuppliesCost();
session.getFoodUsed().forEach((k, v) -> this.totalFoodUsed.merge(k, v, Integer::sum));
session.getPotionsUsed().forEach((k, v) -> this.totalPotionsUsed.merge(k, v, Integer::sum));
session.getAmmoUsed().forEach((k, v) -> this.totalAmmoUsed.merge(k, v, Integer::sum));
// Prayer & Weapon totals
this.totalPrayerBlockedHits += session.getPrayerBlockedHits();
this.totalPrayerMissedHits += session.getPrayerMissedHits();
this.totalOffPrayerDamageTaken += session.getOffPrayerDamageTaken();
this.totalOnPrayerAttacks += session.getOnPrayerAttacks();
this.totalOffPrayerAttacks += session.getOffPrayerAttacks();
this.totalCorrectWeaponAttacks += session.getCorrectWeaponAttacks();
this.totalIncorrectWeaponAttacks += session.getIncorrectWeaponAttacks();
// Store last fight
this.lastAttacksDealt = session.getAttacksDealt();
this.lastDamageDealt = session.getDamageDealt();
this.lastHitsReceived = session.getHitsReceived();
this.lastDamageTaken = session.getDamageTaken();
this.lastDurationTicks = session.getDurationTicks();
this.lastIdleTicks = session.getIdleTicks();
this.lastTilesMoved = session.getTilesMoved();
this.lastHpHealed = session.getHpHealed();
this.lastSuppliesCost = session.getSuppliesCost();
this.lastEfficiency = session.getEfficiencyPct();
this.lastPerformanceScore = session.getPerformanceScore();
this.lastFoodUsed = new LinkedHashMap<>(session.getFoodUsed());
this.lastPotionsUsed = new LinkedHashMap<>(session.getPotionsUsed());
this.lastAmmoUsed = new LinkedHashMap<>(session.getAmmoUsed());
this.lastPrayerBlockedHits = session.getPrayerBlockedHits();
this.lastPrayerMissedHits = session.getPrayerMissedHits();
this.lastOffPrayerDamageTaken = session.getOffPrayerDamageTaken();
this.lastOnPrayerAttacks = session.getOnPrayerAttacks();
this.lastOffPrayerAttacks = session.getOffPrayerAttacks();
this.lastCorrectWeaponAttacks = session.getCorrectWeaponAttacks();
this.lastIncorrectWeaponAttacks = session.getIncorrectWeaponAttacks();
int delve = session.getDelve();
if (delve > this.maxDelve)
{
this.maxDelve = delve;
}
this.lastDelve = delve;
// Create SingleFightRecord for history
SingleFightRecord single = new SingleFightRecord(
this.fightsCount,
System.currentTimeMillis(),
session.getDurationTicks(),
session.getIdleTicks(),
session.getEfficiencyPct(),
session.getGrade(),
session.getAttacksDealt(),
session.getDamageDealt(),
session.getHitsReceived(),
session.getDamageTaken(),
session.getHpHealed(),
session.getTilesMoved(),
session.getSuppliesCost(),
lootValue,
lootValue - session.getSuppliesCost(),
new LinkedHashMap<>(session.getFoodUsed()),
new LinkedHashMap<>(session.getPotionsUsed()),
new LinkedHashMap<>(session.getAmmoUsed()),
session.getPrayerBlockedHits(),
session.getPrayerMissedHits(),
session.getOffPrayerDamageTaken(),
session.getOnPrayerAttacks(),
session.getOffPrayerAttacks(),
session.getCorrectWeaponAttacks(),
session.getIncorrectWeaponAttacks(),
session.getPerformanceScore(),
delve
);
if (this.fightHistory == null)
{
this.fightHistory = new ArrayList<>();
}
this.fightHistory.add(single);
if (this.fightHistory.size() > MAX_HISTORY)
{
this.fightHistory.remove(0);
}
}
/** Rebuilds cumulative stats from a subset of fights (used for time range filtering). */
public static CombatStats fromFights(List<SingleFightRecord> fights)
{
CombatStats cs = new CombatStats();
for (SingleFightRecord f : fights)
{
cs.fightsCount++;
cs.totalAttacksDealt += f.getAttacksDealt();
cs.totalDamageDealt += f.getDamageDealt();
cs.totalHitsReceived += f.getHitsReceived();
cs.totalDamageTaken += f.getDamageTaken();
cs.totalDurationTicks += f.getDurationTicks();
cs.totalIdleTicks += f.getIdleTicks();
cs.totalTilesMoved += f.getTilesMoved();
cs.totalHpHealed += f.getHpHealed();
cs.totalSuppliesCost += f.getSuppliesCost();
if (f.getFoodUsed() != null)
{
f.getFoodUsed().forEach((k, v) -> cs.totalFoodUsed.merge(k, v, Integer::sum));
}
if (f.getPotionsUsed() != null)
{
f.getPotionsUsed().forEach((k, v) -> cs.totalPotionsUsed.merge(k, v, Integer::sum));
}
if (f.getAmmoUsed() != null)
{
f.getAmmoUsed().forEach((k, v) -> cs.totalAmmoUsed.merge(k, v, Integer::sum));
}
cs.totalPrayerBlockedHits += f.getPrayerBlockedHits();
cs.totalPrayerMissedHits += f.getPrayerMissedHits();
cs.totalOffPrayerDamageTaken += f.getOffPrayerDamageTaken();
cs.totalOnPrayerAttacks += f.getOnPrayerAttacks();
cs.totalOffPrayerAttacks += f.getOffPrayerAttacks();
cs.totalCorrectWeaponAttacks += f.getCorrectWeaponAttacks();
cs.totalIncorrectWeaponAttacks += f.getIncorrectWeaponAttacks();
cs.maxDelve = Math.max(cs.maxDelve, f.getDelve());
cs.lastAttacksDealt = f.getAttacksDealt();
cs.lastDamageDealt = f.getDamageDealt();
cs.lastHitsReceived = f.getHitsReceived();
cs.lastDamageTaken = f.getDamageTaken();
cs.lastDurationTicks = f.getDurationTicks();
cs.lastIdleTicks = f.getIdleTicks();
cs.lastTilesMoved = f.getTilesMoved();
cs.lastHpHealed = f.getHpHealed();
cs.lastSuppliesCost = f.getSuppliesCost();
cs.lastEfficiency = f.getEfficiency();
cs.lastPerformanceScore = f.getPerformanceScore();
cs.lastFoodUsed = f.getFoodUsed() == null ? new LinkedHashMap<>() : new LinkedHashMap<>(f.getFoodUsed());
cs.lastPotionsUsed = f.getPotionsUsed() == null ? new LinkedHashMap<>() : new LinkedHashMap<>(f.getPotionsUsed());
cs.lastAmmoUsed = f.getAmmoUsed() == null ? new LinkedHashMap<>() : new LinkedHashMap<>(f.getAmmoUsed());
cs.lastPrayerBlockedHits = f.getPrayerBlockedHits();
cs.lastPrayerMissedHits = f.getPrayerMissedHits();
cs.lastOffPrayerDamageTaken = f.getOffPrayerDamageTaken();
cs.lastOnPrayerAttacks = f.getOnPrayerAttacks();
cs.lastOffPrayerAttacks = f.getOffPrayerAttacks();
cs.lastCorrectWeaponAttacks = f.getCorrectWeaponAttacks();
cs.lastIncorrectWeaponAttacks = f.getIncorrectWeaponAttacks();
cs.lastDelve = f.getDelve();
}
cs.fightHistory = new ArrayList<>(fights);
return cs;
}
public List<SingleFightRecord> getFightHistory()
{
if (fightHistory == null)
{
fightHistory = new ArrayList<>();
}
return fightHistory;
}
public double getAvgEfficiency()
{
if (totalDurationTicks <= 0)
{
return 100.0;
}
double idleRatio = (double) totalIdleTicks / totalDurationTicks;
return Math.max(0.0, Math.min(100.0, (1.0 - idleRatio) * 100.0));
}
public double getAvgPrayerDefensePct()
{
long total = totalPrayerBlockedHits + totalPrayerMissedHits;
if (total <= 0)
{
return 100.0;
}
return ((double) totalPrayerBlockedHits / total) * 100.0;
}
public double getAvgOffPrayerAttackPct()
{
long total = totalOnPrayerAttacks + totalOffPrayerAttacks;
if (total <= 0)
{
return 100.0;
}
return ((double) totalOffPrayerAttacks / total) * 100.0;
}
public double getAvgCorrectWeaponPct()
{
long total = totalCorrectWeaponAttacks + totalIncorrectWeaponAttacks;
if (total <= 0)
{
return 100.0;
}
return ((double) totalCorrectWeaponAttacks / total) * 100.0;
}
public double getAvgPerformanceScore()
{
double uptime = getAvgEfficiency();
long totalIncoming = totalPrayerBlockedHits + totalPrayerMissedHits;
boolean defenseTested = (totalIncoming > 0);
double defScore = getAvgPrayerDefensePct();
long totalOverheadAttacks = totalOnPrayerAttacks + totalOffPrayerAttacks;
boolean overheadTested = (totalOverheadAttacks > 0 && totalOnPrayerAttacks > 0);
double offScore = getAvgOffPrayerAttackPct();
long totalWep = totalCorrectWeaponAttacks + totalIncorrectWeaponAttacks;
boolean wepTested = (totalWep > 0 && totalIncorrectWeaponAttacks > 0);
double wepScore = getAvgCorrectWeaponPct();
if (defenseTested && (overheadTested || wepTested))
{
double offense = (offScore * 0.5) + (wepScore * 0.5);
return Math.max(0.0, Math.min(100.0, (0.40 * uptime) + (0.35 * defScore) + (0.25 * offense)));
}
else if (defenseTested)
{
return Math.max(0.0, Math.min(100.0, (0.60 * uptime) + (0.40 * defScore)));
}
else if (overheadTested || wepTested)
{
double offense = (offScore * 0.5) + (wepScore * 0.5);
return Math.max(0.0, Math.min(100.0, (0.60 * uptime) + (0.40 * offense)));
}
return uptime;
}
public String getLastGrade()
{
return getGrade(lastPerformanceScore > 0 ? lastPerformanceScore : lastEfficiency);
}
public String getAvgGrade()
{
return getGrade(getAvgPerformanceScore());
}
public static String getGrade(double score)
{
if (score >= 95.0) return "S+";
if (score >= 90.0) return "A+";
if (score >= 80.0) return "A";
if (score >= 70.0) return "B";
if (score >= 55.0) return "C";
return "D";
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,279 @@
package com.example.essentialsplus.loot.combat;
import java.util.LinkedHashMap;
import java.util.Map;
import lombok.Getter;
import lombok.Setter;
import net.runelite.api.coords.WorldPoint;
@Getter
public class FightSession
{
private final String targetName;
@Setter
private int targetIndex;
private final int startTick;
private int targetCombatLevel;
@Setter
private int lastCombatTick;
@Setter
private int delve = 0;
private int attacksDealt;
private int damageDealt;
private int hitsReceived;
private int damageTaken;
private int durationTicks;
private int idleTicks;
private int tilesMoved;
private int hpHealed;
private long suppliesCost;
private int weaponSpeed = 4;
private int ticksSinceLastAttack = 0;
private int lastAttackTick = -1;
private WorldPoint lastLocation;
private final Map<String, Integer> foodUsed = new LinkedHashMap<>();
private final Map<String, Integer> potionsUsed = new LinkedHashMap<>();
private final Map<String, Integer> ammoUsed = new LinkedHashMap<>();
// Prayer & Weapon Performance metrics
private int prayerBlockedHits;
private int prayerMissedHits;
private int offPrayerDamageTaken;
private int onPrayerAttacks;
private int offPrayerAttacks;
private int correctWeaponAttacks;
private int incorrectWeaponAttacks;
public FightSession(String targetName, int targetIndex, int startTick, WorldPoint initialLocation, int initialWeaponSpeed)
{
this(targetName, targetIndex, startTick, initialLocation, initialWeaponSpeed, 0);
}
public FightSession(String targetName, int targetIndex, int startTick, WorldPoint initialLocation, int initialWeaponSpeed, int targetCombatLevel)
{
this.targetName = targetName;
this.targetIndex = targetIndex;
this.startTick = startTick;
this.lastCombatTick = startTick;
this.lastLocation = initialLocation;
this.weaponSpeed = Math.max(2, initialWeaponSpeed);
this.ticksSinceLastAttack = 0;
this.targetCombatLevel = targetCombatLevel;
}
public void recordAttack(int attackSpeed, int currentTick)
{
if (currentTick == lastAttackTick && currentTick > 0)
{
return; // Avoid duplicate attack recording on the exact same tick
}
this.lastAttackTick = currentTick;
this.attacksDealt++;
this.weaponSpeed = Math.max(2, attackSpeed);
this.ticksSinceLastAttack = 0;
}
public void recordAttackAgainstEnemy(boolean isOnPrayer, boolean isCorrectWeapon)
{
if (isOnPrayer)
{
this.onPrayerAttacks++;
}
else
{
this.offPrayerAttacks++;
}
if (isCorrectWeapon)
{
this.correctWeaponAttacks++;
}
else
{
this.incorrectWeaponAttacks++;
}
}
public void recordDamageDealt(int amount)
{
if (amount > 0)
{
this.damageDealt += amount;
}
}
public void recordHitReceived(int amount)
{
this.hitsReceived++;
if (amount > 0)
{
this.damageTaken += amount;
}
}
public void recordPrayerBlock()
{
this.prayerBlockedHits++;
}
public void recordPrayerMiss(int damageAmount)
{
this.prayerMissedHits++;
if (damageAmount > 0)
{
this.offPrayerDamageTaken += damageAmount;
}
}
public void resetTicksSinceLastAttack()
{
this.ticksSinceLastAttack = 0;
}
public void recordFood(String foodName, int heal, long cost)
{
recordFood(foodName, 1, heal, cost);
}
public void recordFood(String foodName, int quantity, int heal, long cost)
{
int q = Math.max(1, quantity);
this.foodUsed.merge(foodName, q, Integer::sum);
this.hpHealed += heal;
this.suppliesCost += cost;
}
public void recordPotion(String potionName, long costPerDose)
{
recordPotion(potionName, 1, costPerDose);
}
public void recordPotion(String potionName, int quantity, long costPerDose)
{
int q = Math.max(1, quantity);
this.potionsUsed.merge(potionName, q, Integer::sum);
this.suppliesCost += costPerDose;
}
public void recordAmmo(String ammoName, int count, long totalCost)
{
this.ammoUsed.merge(ammoName, count, Integer::sum);
this.suppliesCost += totalCost;
}
public void addHpHealed(int amount)
{
this.hpHealed += amount;
}
public void onGameTick(WorldPoint currentLocation)
{
this.durationTicks++;
if (lastLocation != null && currentLocation != null)
{
int dist = currentLocation.distanceTo(lastLocation);
if (dist > 0 && dist < 15)
{
this.tilesMoved += dist;
}
}
this.lastLocation = currentLocation;
// Tick elapsed
this.ticksSinceLastAttack++;
// If weapon takes 4 ticks, off cooldown starting at tick 5:
// When ticksSinceLastAttack > weaponSpeed (e.g. > 4), player was off cooldown and did not attack -> idle tick!
if (this.ticksSinceLastAttack > this.weaponSpeed)
{
this.idleTicks++;
}
}
public double getEfficiencyPct()
{
if (durationTicks <= 0)
{
return 100.0;
}
double idleRatio = (double) idleTicks / durationTicks;
return Math.max(0.0, Math.min(100.0, (1.0 - idleRatio) * 100.0));
}
public double getPrayerDefensePct()
{
int total = prayerBlockedHits + prayerMissedHits;
if (total <= 0)
{
return 100.0;
}
return ((double) prayerBlockedHits / total) * 100.0;
}
public double getOffPrayerAttackPct()
{
int total = onPrayerAttacks + offPrayerAttacks;
if (total <= 0)
{
return 100.0;
}
return ((double) offPrayerAttacks / total) * 100.0;
}
public double getCorrectWeaponPct()
{
int total = correctWeaponAttacks + incorrectWeaponAttacks;
if (total <= 0)
{
return 100.0;
}
return ((double) correctWeaponAttacks / total) * 100.0;
}
/**
* Calculates composite performance score taking uptime, defensive prayer, and offensive weapon/prayer into account.
*/
public double getPerformanceScore()
{
double uptime = getEfficiencyPct();
int totalIncoming = prayerBlockedHits + prayerMissedHits;
boolean defenseTested = (totalIncoming > 0);
double defScore = getPrayerDefensePct();
int totalOverheadAttacks = onPrayerAttacks + offPrayerAttacks;
boolean overheadTested = (totalOverheadAttacks > 0 && onPrayerAttacks > 0);
double offScore = getOffPrayerAttackPct();
int totalWep = correctWeaponAttacks + incorrectWeaponAttacks;
boolean wepTested = (totalWep > 0 && incorrectWeaponAttacks > 0);
double wepScore = getCorrectWeaponPct();
if (defenseTested && (overheadTested || wepTested))
{
double offense = (offScore * 0.5) + (wepScore * 0.5);
return Math.max(0.0, Math.min(100.0, (0.40 * uptime) + (0.35 * defScore) + (0.25 * offense)));
}
else if (defenseTested)
{
return Math.max(0.0, Math.min(100.0, (0.60 * uptime) + (0.40 * defScore)));
}
else if (overheadTested || wepTested)
{
double offense = (offScore * 0.5) + (wepScore * 0.5);
return Math.max(0.0, Math.min(100.0, (0.60 * uptime) + (0.40 * offense)));
}
return uptime;
}
public String getGrade()
{
return CombatStats.getGrade(getPerformanceScore());
}
}
@@ -0,0 +1,45 @@
package com.example.essentialsplus.loot.combat;
import java.util.LinkedHashMap;
import java.util.Map;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@NoArgsConstructor
@AllArgsConstructor
public class SingleFightRecord
{
private int fightNumber;
private long timestamp;
private int durationTicks;
private int idleTicks;
private double efficiency;
private String grade;
private int attacksDealt;
private int damageDealt;
private int hitsReceived;
private int damageTaken;
private int hpHealed;
private int tilesMoved;
private long suppliesCost;
private long lootValue;
private long netProfit;
private Map<String, Integer> foodUsed = new LinkedHashMap<>();
private Map<String, Integer> potionsUsed = new LinkedHashMap<>();
private Map<String, Integer> ammoUsed = new LinkedHashMap<>();
// Prayer & Weapon Performance metrics
private int prayerBlockedHits;
private int prayerMissedHits;
private int offPrayerDamageTaken;
private int onPrayerAttacks;
private int offPrayerAttacks;
private int correctWeaponAttacks;
private int incorrectWeaponAttacks;
private double performanceScore;
// Delve level (if Doom of Mokhaiotl or delve encounter)
private int delve;
}
@@ -0,0 +1,61 @@
package com.example.essentialsplus.loot.droptable;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/** One row of a wiki drop table ("dropsline" bucket). */
public class DropLine
{
private static final Pattern FRACTION = Pattern.compile("([\\d.]+)\\s*/\\s*([\\d.]+)");
public final String source;
public final String item;
public final String rarityText;
/** chance per roll, NaN when the wiki gives no number (e.g. "Varies") */
public final double chance;
public final int rolls;
public final int qtyLow;
public final int qtyHigh;
public DropLine(String source, String item, String rarityText, int rolls, int qtyLow, int qtyHigh)
{
this.source = source == null ? "" : source;
this.item = item == null ? "" : item;
this.rarityText = rarityText == null ? "" : rarityText;
this.chance = parseRarity(this.rarityText);
this.rolls = Math.max(1, rolls);
this.qtyLow = Math.max(0, qtyLow);
this.qtyHigh = Math.max(this.qtyLow, qtyHigh);
}
/** Expected number of times this line is hit per kill. */
public double hitsPerKill()
{
return Double.isNaN(chance) ? Double.NaN : Math.min(chance * rolls, rolls);
}
/** "Always" -> 1, "3/128" or "~1/1,024" -> fraction, anything else -> NaN. */
static double parseRarity(String text)
{
String t = text.replace(",", "").trim();
if (t.equalsIgnoreCase("always"))
{
return 1;
}
Matcher m = FRACTION.matcher(t);
if (!m.find())
{
return Double.NaN;
}
try
{
double num = Double.parseDouble(m.group(1));
double den = Double.parseDouble(m.group(2));
return den > 0 ? Math.min(1, num / den) : Double.NaN;
}
catch (NumberFormatException e)
{
return Double.NaN;
}
}
}
@@ -0,0 +1,159 @@
package com.example.essentialsplus.loot.droptable;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
/** The wiki drop table of one page, split into its variants (e.g. "Standard", "Wilderness Slayer Cave"). */
public class DropTable
{
public final String page;
public final List<Variant> variants;
public static final class Variant
{
public final String name;
public final List<DropLine> lines;
Variant(String name, List<DropLine> lines)
{
this.name = name;
this.lines = lines;
}
@Override
public String toString()
{
return name;
}
}
/** Combined wiki rate of one item. */
public static final class Rate
{
public final String item;
public final String text;
/** expected hits per kill (sum over all lines of the item), NaN if unknown */
public final double perKill;
Rate(String item, String text, double perKill)
{
this.item = item;
this.text = text;
this.perKill = perKill;
}
}
public DropTable(String page, List<DropLine> lines)
{
this.page = page;
this.variants = buildVariants(page, lines);
}
public boolean isEmpty()
{
return variants.isEmpty();
}
/**
* Groups lines by their "Dropped from" section. Small sections (a few extra drops such as Catacombs
* shards) are added on top of the main table; large ones are separate tables.
*/
static List<Variant> buildVariants(String page, List<DropLine> lines)
{
Map<String, List<DropLine>> groups = new LinkedHashMap<>();
for (DropLine l : lines)
{
groups.computeIfAbsent(l.source, k -> new ArrayList<>()).add(l);
}
List<Variant> out = new ArrayList<>();
if (groups.isEmpty())
{
return out;
}
int max = groups.values().stream().mapToInt(List::size).max().orElse(0);
List<String> mains = new ArrayList<>();
List<String> extras = new ArrayList<>();
for (Map.Entry<String, List<DropLine>> g : groups.entrySet())
{
boolean base = !g.getKey().contains("#");
(base || g.getValue().size() * 3 >= max ? mains : extras).add(g.getKey());
}
// a base section without '#' always comes first
mains.sort(Comparator.comparing(s -> s.contains("#")));
if (mains.isEmpty())
{
mains.add(extras.remove(0));
}
for (String m : mains)
{
out.add(new Variant(label(page, m), groups.get(m)));
}
String first = mains.get(0);
for (String e : extras)
{
List<DropLine> combined = new ArrayList<>(groups.get(first));
combined.addAll(groups.get(e));
out.add(new Variant(label(page, first) + " + " + label(page, e), combined));
}
return out;
}
private static String label(String page, String source)
{
int hash = source.indexOf('#');
if (hash >= 0 && hash + 1 < source.length())
{
return source.substring(hash + 1);
}
return source.isEmpty() ? page : "Standard";
}
/** Drop rate per item of the variant, keyed by lower-case item name. */
public static Map<String, Rate> rates(Variant variant)
{
Map<String, List<DropLine>> byItem = new LinkedHashMap<>();
for (DropLine l : variant.lines)
{
if (!l.item.isEmpty() && !l.item.equalsIgnoreCase("Nothing"))
{
byItem.computeIfAbsent(key(l.item), k -> new ArrayList<>()).add(l);
}
}
Map<String, Rate> out = new LinkedHashMap<>();
for (Map.Entry<String, List<DropLine>> e : byItem.entrySet())
{
List<DropLine> ls = e.getValue();
double hits = 0;
for (DropLine l : ls)
{
hits += l.hitsPerKill();
}
String text = ls.size() == 1 || Double.isNaN(hits) ? ls.get(0).rarityText : formatChance(hits);
out.put(e.getKey(), new Rate(ls.get(0).item, text, hits));
}
return out;
}
static String formatChance(double perKill)
{
if (perKill >= 1)
{
return "Always";
}
if (perKill <= 0)
{
return "?";
}
double inv = 1 / perKill;
return inv >= 100 ? String.format(Locale.ROOT, "1/%,d", Math.round(inv)) : String.format(Locale.ROOT, "1/%.1f", inv);
}
public static String key(String name)
{
return name.trim().toLowerCase(Locale.ROOT);
}
}
@@ -0,0 +1,320 @@
package com.example.essentialsplus.loot.gear;
import java.util.Locale;
/** How an attack is made: attack type, stance and (for magic) the spell or powered staff. */
public final class CombatChoice
{
public enum Type
{
STAB("Stab"), SLASH("Slash"), CRUSH("Crush"), RANGED("Ranged"), MAGIC("Magic");
public final String label;
Type(String label)
{
this.label = label;
}
public boolean melee()
{
return this == STAB || this == SLASH || this == CRUSH;
}
}
public enum Stance
{
ACCURATE("Accurate"), AGGRESSIVE("Aggressive"), CONTROLLED("Controlled"), DEFENSIVE("Defensive"),
RAPID("Rapid"), LONGRANGE("Longrange"), CASTING("Autocast"), MANUAL("Manual cast");
public final String label;
Stance(String label)
{
this.label = label;
}
}
/** A castable spell: base max hit (-1 when it scales with the magic level) and element. */
public static final class Spell
{
public final String name;
public final int base;
public final String element;
Spell(String name, int base, String element)
{
this.name = name;
this.base = base;
this.element = element;
}
}
public final Type type;
public final Stance stance;
/** null when not magic or when a powered staff attacks on its own */
public final Spell spell;
public CombatChoice(Type type, Stance stance, Spell spell)
{
this.type = type;
this.stance = stance;
this.spell = spell;
}
public String label()
{
if (type == Type.MAGIC)
{
return spell != null ? spell.name : "Powered staff";
}
return type.label + " · " + stance.label;
}
// COM_MODE button -> stance per weapon category (varbit COMBAT_WEAPON_CATEGORY), null = no button
private static final Stance A = Stance.ACCURATE, G = Stance.AGGRESSIVE, C = Stance.CONTROLLED, D = Stance.DEFENSIVE,
R = Stance.RAPID, L = Stance.LONGRANGE;
private static final Type ST = Type.STAB, SL = Type.SLASH, CR = Type.CRUSH, RA = Type.RANGED;
private static final Object[][][] CATEGORIES = {
/* 0 unarmed */ {{CR, A}, {CR, G}, null, {CR, D}},
/* 1 axe */ {{SL, A}, {SL, G}, {CR, G}, {SL, D}},
/* 2 blunt */ {{CR, A}, {CR, G}, null, {CR, D}},
/* 3 bow */ {{RA, A}, {RA, R}, null, {RA, L}},
/* 4 claw */ {{SL, A}, {SL, G}, {ST, C}, {SL, D}},
/* 5 crossbow */ {{RA, A}, {RA, R}, null, {RA, L}},
/* 6 salamander */ {{SL, G}, {RA, A}, null, null},
/* 7 chinchompa */ {{RA, A}, {RA, R}, null, {RA, L}},
/* 8 gun */ null,
/* 9 slash sword */ {{SL, A}, {SL, G}, {ST, C}, {SL, D}},
/* 10 2h sword */ {{SL, A}, {SL, G}, {CR, G}, {SL, D}},
/* 11 pickaxe */ {{ST, A}, {ST, G}, {CR, G}, {ST, D}},
/* 12 halberd */ {{ST, C}, {SL, G}, null, {ST, D}},
/* 13 polestaff */ {{CR, A}, {CR, G}, null, {CR, D}},
/* 14 scythe */ {{SL, A}, {SL, G}, {CR, G}, {SL, D}},
/* 15 spear */ {{ST, C}, {SL, C}, {CR, C}, {ST, D}},
/* 16 spiked */ {{CR, A}, {CR, G}, {ST, C}, {CR, D}},
/* 17 stab sword */ {{ST, A}, {ST, G}, {SL, G}, {ST, D}},
/* 18 staff */ {{CR, A}, {CR, G}, null, {CR, D}},
/* 19 thrown */ {{RA, A}, {RA, R}, null, {RA, L}},
/* 20 whip */ {{SL, A}, {SL, C}, null, {SL, D}},
/* 21 bladed staff */ {{ST, A}, {SL, G}, null, {CR, D}},
/* 22 */ null,
/* 23 powered staff */ null,
/* 24 banner */ {{ST, A}, {SL, G}, {CR, C}, {ST, D}},
/* 25 polearm */ {{ST, C}, {SL, G}, null, {ST, D}},
/* 26 bludgeon */ {{CR, G}, {CR, G}, null, {CR, G}},
/* 27 bulwark */ {{CR, A}, null, null, null},
/* 28 */ null,
/* 29 partisan */ {{ST, A}, {ST, G}, {CR, G}, {ST, D}},
};
/** The attack style the player has selected for the worn weapon, or null if the category is not known. */
public static CombatChoice fromVarbits(int category, int comMode, int autocastSpell, int magicLevel, GearItem weapon)
{
if (category == 23 || isPoweredStaff(weapon))
{
return new CombatChoice(Type.MAGIC, Stance.CASTING, null);
}
if ((category == 18 || category == 21) && comMode >= 4)
{
Spell spell = spell(autocastSpell);
return new CombatChoice(Type.MAGIC, Stance.CASTING, spell != null ? spell : bestStandardSpell(magicLevel));
}
if (category < 0 || category >= CATEGORIES.length || CATEGORIES[category] == null
|| comMode < 0 || comMode > 3 || CATEGORIES[category][comMode] == null)
{
return null;
}
Object[] entry = CATEGORIES[category][comMode];
return new CombatChoice((Type) entry[0], (Stance) entry[1], null);
}
/** Best guess for a weapon that is not worn: its main attack type with the damage stance. */
public static CombatChoice infer(GearItem weapon, int magicLevel, int autocastSpell)
{
switch (weaponClass(weapon))
{
case RANGED:
return new CombatChoice(Type.RANGED, Stance.RAPID, null);
case MAGIC:
if (isPoweredStaff(weapon))
{
return new CombatChoice(Type.MAGIC, Stance.CASTING, null);
}
Spell s = spell(autocastSpell);
return new CombatChoice(Type.MAGIC, Stance.CASTING, s != null ? s : bestStandardSpell(magicLevel));
default:
return new CombatChoice(bestMeleeType(weapon), Stance.AGGRESSIVE, null);
}
}
/** Melee choice of a weapon for one attack type (optimizer): aggressive when possible. */
public static CombatChoice melee(Type type)
{
return new CombatChoice(type, Stance.AGGRESSIVE, null);
}
public static Type bestMeleeType(GearItem w)
{
if (w == null)
{
return Type.CRUSH;
}
if (w.astab >= w.aslash && w.astab >= w.acrush)
{
return Type.STAB;
}
return w.aslash >= w.acrush ? Type.SLASH : Type.CRUSH;
}
/** RANGED, MAGIC or melee (STAB) class of a weapon. */
public static Type weaponClass(GearItem w)
{
if (w == null)
{
return Type.STAB;
}
if (isPoweredStaff(w))
{
return Type.MAGIC;
}
boolean rangedName = w.has("bow") || w.has("dart") || w.has("knife") || w.has("thrownaxe") || w.has("chinchompa")
|| w.has("ballista") || w.has("blowpipe") || w.has("javelin") || w.has("atlatl") || w.has("tonalztics");
int meleeMax = Math.max(w.astab, Math.max(w.aslash, w.acrush));
if (rangedName && w.arange > 0 || w.arange > 0 && w.arange > meleeMax && w.amagic <= 0)
{
return Type.RANGED;
}
boolean magicName = w.has("staff") || w.has("wand") || w.has("sceptre") || w.has("trident");
if (magicName && w.amagic >= meleeMax)
{
return Type.MAGIC;
}
return Type.STAB;
}
public static boolean isPoweredStaff(GearItem w)
{
return poweredBase(w, 99) != Integer.MIN_VALUE;
}
/** Base max hit of a powered staff at the given (boosted) magic level, MIN_VALUE if not one. */
public static int poweredBase(GearItem w, int magic)
{
if (w == null)
{
return Integer.MIN_VALUE;
}
String n = w.lower;
if (n.startsWith("trident of the seas"))
{
return magic / 3 - 5;
}
if (n.startsWith("trident of the swamp"))
{
return magic / 3 - 2;
}
if (n.startsWith("sanguinesti staff") || n.startsWith("holy sanguinesti staff"))
{
return magic / 3 - 1;
}
if (n.startsWith("tumeken's shadow"))
{
return magic / 3 + 1;
}
if (n.startsWith("thammaron's sceptre"))
{
return magic / 3 - 8;
}
if (n.startsWith("accursed sceptre"))
{
return magic / 3 - 6;
}
if (n.startsWith("warped sceptre"))
{
return (8 * magic + 96) / 37;
}
if (n.startsWith("crystal staff (basic)") || n.startsWith("corrupted staff (basic)"))
{
return 23;
}
if (n.startsWith("crystal staff (attuned)") || n.startsWith("corrupted staff (attuned)"))
{
return 31;
}
if (n.startsWith("crystal staff (perfected)") || n.startsWith("corrupted staff (perfected)"))
{
return 39;
}
return Integer.MIN_VALUE;
}
/** Spell of varbit AUTOCAST_SPELL, null for none or unknown. */
public static Spell spell(int autocast)
{
switch (autocast)
{
case 1: return new Spell("Wind Strike", 2, "Air");
case 2: return new Spell("Water Strike", 4, "Water");
case 3: return new Spell("Earth Strike", 6, "Earth");
case 4: return new Spell("Fire Strike", 8, "Fire");
case 5: return new Spell("Wind Bolt", 9, "Air");
case 6: return new Spell("Water Bolt", 10, "Water");
case 7: return new Spell("Earth Bolt", 11, "Earth");
case 8: return new Spell("Fire Bolt", 12, "Fire");
case 9: return new Spell("Wind Blast", 13, "Air");
case 10: return new Spell("Water Blast", 14, "Water");
case 11: return new Spell("Earth Blast", 15, "Earth");
case 12: return new Spell("Fire Blast", 16, "Fire");
case 13: return new Spell("Wind Wave", 17, "Air");
case 14: return new Spell("Water Wave", 18, "Water");
case 15: return new Spell("Earth Wave", 19, "Earth");
case 16: return new Spell("Fire Wave", 20, "Fire");
case 17: return new Spell("Crumble Undead", 15, null);
case 18: return new Spell("Magic Dart", -1, null);
case 19: return new Spell("Claws of Guthix", 20, null);
case 20: return new Spell("Flames of Zamorak", 20, null);
case 21: return new Spell("Saradomin Strike", 20, null);
case 31: return new Spell("Smoke Rush", 13, null);
case 32: return new Spell("Shadow Rush", 14, null);
case 33: return new Spell("Blood Rush", 15, null);
case 34: return new Spell("Ice Rush", 16, null);
case 35: return new Spell("Smoke Burst", 17, null);
case 36: return new Spell("Shadow Burst", 18, null);
case 37: return new Spell("Blood Burst", 21, null);
case 38: return new Spell("Ice Burst", 22, null);
case 39: return new Spell("Smoke Blitz", 23, null);
case 40: return new Spell("Shadow Blitz", 24, null);
case 41: return new Spell("Blood Blitz", 25, null);
case 42: return new Spell("Ice Blitz", 26, null);
case 43: return new Spell("Smoke Barrage", 27, null);
case 44: return new Spell("Shadow Barrage", 28, null);
case 45: return new Spell("Blood Barrage", 29, null);
case 46: return new Spell("Ice Barrage", 30, null);
case 47: return new Spell("Iban Blast", 25, null);
case 48: return new Spell("Wind Surge", 21, "Air");
case 49: return new Spell("Water Surge", 22, "Water");
case 50: return new Spell("Earth Surge", 23, "Earth");
case 51: return new Spell("Fire Surge", 24, "Fire");
default: return null;
}
}
/** Strongest fire spell of the standard spellbook at a magic level. */
public static Spell bestStandardSpell(int magic)
{
if (magic >= 95) return spell(51);
if (magic >= 75) return spell(16);
if (magic >= 59) return spell(12);
if (magic >= 35) return spell(8);
if (magic >= 13) return spell(4);
return spell(1);
}
@Override
public String toString()
{
return label().toLowerCase(Locale.ENGLISH);
}
}
@@ -0,0 +1,333 @@
package com.example.essentialsplus.loot.gear;
import com.example.essentialsplus.loot.gear.CombatChoice.Stance;
import com.example.essentialsplus.loot.gear.CombatChoice.Type;
/**
* Max hit, accuracy and DPS with the standard OSRS formulas. Covers prayers, stances, void, salve, slayer helm,
* dragon hunter, arclight, twisted bow, crystal armour, powered staves and elemental weaknesses; other special
* effects (special attacks, set effects, multi-hit weapons) are left out, so it is an estimate.
*/
public final class DpsCalc
{
private DpsCalc()
{
}
public static final class Result
{
public final CombatChoice choice;
public final int maxHit;
public final double hitChance;
public final double dps;
public final int speed;
public final long attackRoll;
public final long defenceRoll;
Result(CombatChoice choice, int maxHit, double hitChance, int speed, long attackRoll, long defenceRoll)
{
this.choice = choice;
this.maxHit = Math.max(0, maxHit);
this.hitChance = hitChance;
this.speed = Math.max(1, speed);
this.attackRoll = attackRoll;
this.defenceRoll = defenceRoll;
this.dps = hitChance * this.maxHit / 2.0 / (this.speed * 0.6);
}
/** Expected seconds to kill {@code hp}, NaN if no damage. */
public double secondsToKill(int hp)
{
return dps > 0 && hp > 0 ? hp / dps : Double.NaN;
}
}
/** Uses the selected style when the worn weapon is still worn, otherwise the weapon's likely style. */
public static CombatChoice choiceFor(Loadout loadout, Loadout worn, PlayerSetup p)
{
GearItem w = loadout.weapon();
if (loadout.id(GearItem.WEAPON) == worn.id(GearItem.WEAPON))
{
CombatChoice live = CombatChoice.fromVarbits(p.weaponCategory, p.comMode, p.autocastSpell, p.magic, w);
if (live != null)
{
return live;
}
}
return CombatChoice.infer(w, p.magic, p.autocastSpell);
}
public static Result calc(Loadout l, PlayerSetup p, Monster m, CombatChoice c)
{
switch (c.type)
{
case RANGED:
return ranged(l, p, m, c);
case MAGIC:
return magic(l, p, m, c);
default:
return melee(l, p, m, c);
}
}
static double hitChance(long att, long def)
{
if (att <= 0)
{
return 0;
}
if (att > def)
{
return 1.0 - (def + 2.0) / (2.0 * (att + 1.0));
}
return att / (2.0 * (def + 1.0));
}
private static long defenceRoll(Monster m, int level, int bonus)
{
return (long) (level + 9) * (bonus + 64);
}
private static int weaponSpeed(Loadout l)
{
GearItem w = l.weapon();
return w == null || w.aspeed <= 0 ? 4 : w.aspeed;
}
private static Result melee(Loadout l, PlayerSetup p, Monster m, CombatChoice c)
{
int[] b = l.bonuses();
int attBonus = c.type == Type.STAB ? b[0] : c.type == Type.SLASH ? b[1] : b[2];
int stanceAtt = c.stance == Stance.ACCURATE ? 3 : c.stance == Stance.CONTROLLED ? 1 : 0;
int stanceStr = c.stance == Stance.AGGRESSIVE ? 3 : c.stance == Stance.CONTROLLED ? 1 : 0;
int effAtt = (int) Math.floor(p.attack * p.meleeAcc) + stanceAtt + 8;
int effStr = (int) Math.floor(p.strength * p.meleeStr) + stanceStr + 8;
if (hasVoid(l, "melee"))
{
effAtt = effAtt * 11 / 10;
effStr = effStr * 11 / 10;
}
long att = (long) effAtt * (attBonus + 64);
int max = (effStr * (b[10] + 64) + 320) / 640;
// salve and slayer helm do not stack; salve wins
if (m.hasAttribute("undead") && l.slotHas(GearItem.AMULET, "salve amulet"))
{
boolean e = l.slotHas(GearItem.AMULET, "(e") ;
att = e ? att * 6 / 5 : att * 7 / 6;
max = e ? max * 6 / 5 : max * 7 / 6;
}
else if (p.onSlayerTask && (l.slotHas(GearItem.HEAD, "slayer helmet") || l.slotHas(GearItem.HEAD, "black mask")))
{
att = att * 7 / 6;
max = max * 7 / 6;
}
GearItem w = l.weapon();
if (w != null && m.hasAttribute("dragon") && w.has("dragon hunter lance"))
{
att = att * 6 / 5;
max = max * 6 / 5;
}
if (w != null && m.hasAttribute("demon") && (w.has("arclight") || w.has("emberlight")))
{
att = att * 17 / 10;
max = max * 17 / 10;
}
int defBonus = c.type == Type.STAB ? m.dstab : c.type == Type.SLASH ? m.dslash : m.dcrush;
long def = defenceRoll(m, m.defenceLevel, defBonus);
return new Result(c, max, hitChance(att, def), weaponSpeed(l), att, def);
}
/** Ammo type the weapon fires from the ammo slot: "arrow", "bolt", "javelin" or null (no ammo used). */
static String ammoFor(GearItem w)
{
if (w == null)
{
return null;
}
if (w.has("ballista"))
{
return "javelin";
}
if (w.has("crossbow") || w.has("karil"))
{
return w.has("karil") ? "bolt rack" : "bolt";
}
if (w.has("bow") && !w.has("crystal bow") && !w.has("bow of faerdhinen") && !w.has("blowpipe"))
{
return "arrow";
}
return null;
}
private static Result ranged(Loadout l, PlayerSetup p, Monster m, CombatChoice c)
{
int[] b = l.bonuses();
GearItem w = l.weapon();
GearItem ammo = l.get(GearItem.AMMO);
int rstr = b[11];
if (ammo != null)
{
String wanted = ammoFor(w);
if (wanted == null || !ammo.has(wanted))
{
// ammo the weapon cannot fire adds no ranged strength
rstr -= ammo.rstr;
}
}
int stance = c.stance == Stance.ACCURATE ? 3 : 0;
int effAtt = (int) Math.floor(p.ranged * p.rangedAcc) + stance + 8;
int effStr = (int) Math.floor(p.ranged * p.rangedStr) + stance + 8;
if (hasVoid(l, "ranger"))
{
effAtt = effAtt * 11 / 10;
effStr = isEliteVoid(l) ? effStr * 9 / 8 : effStr * 11 / 10;
}
long att = (long) effAtt * (b[4] + 64);
int max = (effStr * (rstr + 64) + 320) / 640;
if (w != null && (w.has("crystal bow") || w.has("bow of faerdhinen")))
{
int accPct = 0;
int dmgPct = 0;
if (l.slotHas(GearItem.HEAD, "crystal helm"))
{
accPct += 5;
dmgPct += 5;
}
if (l.slotHas(GearItem.BODY, "crystal body"))
{
accPct += 15;
dmgPct += 15;
}
if (l.slotHas(GearItem.LEGS, "crystal legs"))
{
accPct += 10;
dmgPct += 10;
}
att = att * (200 + accPct) / 200;
max = max * (200 + dmgPct) / 200;
}
boolean imbued = l.slotHas(GearItem.AMULET, "(i)") || l.slotHas(GearItem.AMULET, "(ei)");
if (m.hasAttribute("undead") && l.slotHas(GearItem.AMULET, "salve amulet") && imbued)
{
boolean e = l.slotHas(GearItem.AMULET, "(ei)");
att = e ? att * 6 / 5 : att * 7 / 6;
max = e ? max * 6 / 5 : max * 7 / 6;
}
else if (p.onSlayerTask && (l.slotHas(GearItem.HEAD, "slayer helmet (i)") || l.slotHas(GearItem.HEAD, "black mask (i)")))
{
att = att * 23 / 20;
max = max * 23 / 20;
}
if (w != null && w.has("twisted bow"))
{
int mg = Math.min(250, Math.max(m.magicLevel, m.magicAttack));
int accPct = Math.min(140, 140 + (3 * mg - 10) / 100 - (int) Math.pow(3 * mg / 10 - 100, 2) / 100);
int dmgPct = Math.min(250, 250 + (3 * mg - 14) / 100 - (int) Math.pow(3 * mg / 10 - 140, 2) / 100);
att = att * Math.max(0, accPct) / 100;
max = max * Math.max(0, dmgPct) / 100;
}
if (w != null && m.hasAttribute("dragon") && w.has("dragon hunter crossbow"))
{
att = att * 13 / 10;
max = max * 5 / 4;
}
int defBonus;
if (w != null && (w.has("crossbow") || w.has("ballista") || w.has("karil")))
{
defBonus = m.dheavy;
}
else if (w != null && (w.has("dart") || w.has("knife") || w.has("thrownaxe") || w.has("blowpipe") || w.has("chinchompa")))
{
defBonus = m.dlight;
}
else
{
defBonus = m.dstandard;
}
long def = defenceRoll(m, m.defenceLevel, defBonus);
int speed = weaponSpeed(l) - (c.stance == Stance.RAPID ? 1 : 0);
return new Result(c, max, hitChance(att, def), speed, att, def);
}
private static Result magic(Loadout l, PlayerSetup p, Monster m, CombatChoice c)
{
int[] b = l.bonuses();
GearItem w = l.weapon();
int base;
int speed;
String element = null;
if (c.spell == null)
{
base = CombatChoice.poweredBase(w, p.magic);
speed = weaponSpeed(l);
}
else
{
base = c.spell.base >= 0 ? c.spell.base : 10 + p.magic / 10;
element = c.spell.element;
speed = 5;
}
if (base == Integer.MIN_VALUE || base <= 0)
{
return new Result(c, 0, 0, speed, 0, 0);
}
boolean shadow = w != null && w.has("tumeken's shadow") && c.spell == null;
int magicAttack = shadow ? b[3] * 3 : b[3];
double dmgPct = (shadow ? l.magicDamage() * 3 : l.magicDamage()) + p.magicDmgPct;
int effAtt = (int) Math.floor(p.magic * p.magicAcc) + 9;
if (hasVoid(l, "mage"))
{
effAtt = effAtt * 29 / 20;
if (isEliteVoid(l))
{
dmgPct += 2.5;
}
}
long att = (long) effAtt * (magicAttack + 64);
int max = (int) Math.floor(base * (1 + dmgPct / 100.0));
boolean imbued = l.slotHas(GearItem.AMULET, "(i)") || l.slotHas(GearItem.AMULET, "(ei)");
if (m.hasAttribute("undead") && l.slotHas(GearItem.AMULET, "salve amulet") && imbued)
{
boolean e = l.slotHas(GearItem.AMULET, "(ei)");
att = e ? att * 6 / 5 : att * 23 / 20;
max = e ? max * 6 / 5 : max * 23 / 20;
}
else if (p.onSlayerTask && (l.slotHas(GearItem.HEAD, "slayer helmet (i)") || l.slotHas(GearItem.HEAD, "black mask (i)")))
{
att = att * 23 / 20;
max = max * 23 / 20;
}
if (element != null && m.elementalWeakness != null && element.equalsIgnoreCase(m.elementalWeakness) && m.elementalWeaknessPct > 0)
{
att = att * (100 + m.elementalWeaknessPct) / 100;
max = max + base * m.elementalWeaknessPct / 100;
}
long def = defenceRoll(m, m.magicLevel, m.dmagic);
return new Result(c, max, hitChance(att, def), speed, att, def);
}
private static boolean hasVoid(Loadout l, String helm)
{
return l.slotHas(GearItem.HEAD, "void " + helm + " helm")
&& (l.slotHas(GearItem.BODY, "void knight top") || l.slotHas(GearItem.BODY, "elite void top"))
&& (l.slotHas(GearItem.LEGS, "void knight robe") || l.slotHas(GearItem.LEGS, "elite void robe"))
&& l.slotHas(GearItem.GLOVES, "void knight gloves");
}
private static boolean isEliteVoid(Loadout l)
{
return l.slotHas(GearItem.BODY, "elite void top") && l.slotHas(GearItem.LEGS, "elite void robe");
}
}
@@ -0,0 +1,492 @@
package com.example.essentialsplus.loot.gear;
import com.example.essentialsplus.helpers.AttackStyles;
import com.example.essentialsplus.helpers.CombatStyle;
import com.example.essentialsplus.loot.combat.CombatTracker;
import com.example.essentialsplus.loot.combat.FightSession;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.Executor;
import java.util.function.Consumer;
import java.util.function.Supplier;
import net.runelite.api.Actor;
import net.runelite.api.Client;
import net.runelite.api.GameState;
import net.runelite.api.Item;
import net.runelite.api.ItemComposition;
import net.runelite.api.ItemContainer;
import net.runelite.api.NPC;
import net.runelite.api.Player;
import net.runelite.api.Prayer;
import net.runelite.api.Skill;
import net.runelite.api.gameval.InventoryID;
import net.runelite.api.gameval.VarPlayerID;
import net.runelite.api.gameval.VarbitID;
import net.runelite.client.game.ItemManager;
import net.runelite.client.game.ItemStats;
import net.runelite.client.util.Text;
/**
* Builds {@link FightSnapshot}s on the client thread: worn gear, owned equipment (worn, inventory, bank),
* levels/prayers/style, the current target with its wiki stats and the running fight session.
* The best-in-bank search runs on the background executor whenever gear or target change.
*/
public class FightService
{
private final Client client;
private final ItemManager itemManager;
private final CombatTracker combatTracker;
private final AttackStyles attackStyles;
private final MonsterService monsters;
private final Executor executor;
/** saved bank of the logged-in account (item id -> quantity), may be null */
private final Supplier<Map<Integer, Long>> bank;
private final List<Consumer<FightSnapshot>> listeners = new CopyOnWriteArrayList<>();
/** base item (source WORN) per canonical id, null value = not equipable */
private final Map<Integer, GearItem> itemCache = new java.util.HashMap<>();
private volatile boolean slayerTask;
private volatile boolean monsterLoaded;
private volatile boolean clearRequested;
private boolean containersChanged = true;
private PlayerSetup lastSetup;
private int[] lastWornIds;
private Loadout lastWorn;
private List<GearItem> pool = Collections.emptyList();
private long poolHash;
private int targetNpcIndex = -1;
private FightSnapshot.Target lastTarget;
private Monster lastMonster;
private volatile long gearVersion;
private volatile List<GearOptimizer.Option> best = Collections.emptyList();
private volatile long bestVersion = -1;
private volatile boolean bestReady;
private FightSnapshot last = FightSnapshot.LOGGED_OUT;
public FightService(Client client, ItemManager itemManager, CombatTracker combatTracker, AttackStyles attackStyles,
MonsterService monsters, Executor executor, Supplier<Map<Integer, Long>> bank)
{
this.client = client;
this.itemManager = itemManager;
this.combatTracker = combatTracker;
this.attackStyles = attackStyles;
this.monsters = monsters;
this.executor = executor;
this.bank = bank;
}
public void addListener(Consumer<FightSnapshot> l)
{
listeners.add(l);
l.accept(last);
}
public void removeListener(Consumer<FightSnapshot> l)
{
listeners.remove(l);
}
public FightSnapshot last()
{
return last;
}
public boolean isSlayerTask()
{
return slayerTask;
}
/** Slayer helmet / black mask bonus applies (set from the panel). */
public void setSlayerTask(boolean task)
{
slayerTask = task;
lastSetup = null;
}
/** Called from the wiki thread when monster stats arrive. */
public void onMonsterLoaded()
{
monsterLoaded = true;
}
public void onContainerChanged()
{
containersChanged = true;
}
/** Forgets the last target (panel "clear"). Any thread. */
public void clearTarget()
{
clearRequested = true;
}
public void reset()
{
lastSetup = null;
lastWornIds = null;
lastWorn = null;
lastTarget = null;
lastMonster = null;
targetNpcIndex = -1;
pool = Collections.emptyList();
poolHash = 0;
containersChanged = true;
itemCache.clear();
publish(FightSnapshot.LOGGED_OUT);
}
public void onGameTick()
{
Player me = client.getLocalPlayer();
if (client.getGameState() != GameState.LOGGED_IN || me == null)
{
if (last.loggedIn)
{
reset();
}
return;
}
boolean gearChanged = false;
PlayerSetup setup = readSetup();
if (lastSetup == null || !sameSetup(setup, lastSetup))
{
lastSetup = setup;
gearChanged = true;
}
Loadout worn = lastWorn;
if (containersChanged || worn == null)
{
worn = readWorn();
int[] ids = new int[GearItem.SLOTS];
for (int s = 0; s < GearItem.SLOTS; s++)
{
ids[s] = worn.id(s);
}
if (lastWornIds == null || !java.util.Arrays.equals(ids, lastWornIds))
{
lastWornIds = ids;
gearChanged = true;
}
lastWorn = worn;
long hash = ownedHash();
if (hash != poolHash || gearChanged)
{
poolHash = hash;
pool = readPool(worn);
gearChanged = true;
}
containersChanged = false;
}
if (clearRequested)
{
clearRequested = false;
lastTarget = null;
targetNpcIndex = -1;
gearChanged = true;
}
FightSnapshot.Target target = readTarget(me);
Monster monster = target != null ? target.monster : null;
if (monster != lastMonster || monsterLoaded)
{
monsterLoaded = false;
lastMonster = monster;
gearChanged = true;
}
if (gearChanged)
{
gearVersion++;
startSearch(gearVersion, worn, setup, target);
}
FightSession s = combatTracker.getActiveSession();
FightSnapshot.Session session = s != null ? new FightSnapshot.Session(s) : null;
boolean emit = gearChanged || bestReady || target != null && (target.live || last.target == null || last.target.live)
|| session != null || last.session != null || target == null && last.target != null;
bestReady = false;
if (emit)
{
List<GearOptimizer.Option> b = bestVersion == gearVersion ? best : last.best;
publish(new FightSnapshot(true, setup, worn, pool, target, session, b, gearVersion));
}
}
private void publish(FightSnapshot snap)
{
last = snap;
for (Consumer<FightSnapshot> l : listeners)
{
l.accept(snap);
}
}
private void startSearch(long version, Loadout worn, PlayerSetup setup, FightSnapshot.Target target)
{
List<GearItem> items = pool;
Monster m = target != null ? target.calcMonster() : null;
if (m == null || items.isEmpty())
{
best = Collections.emptyList();
bestVersion = version;
return;
}
executor.execute(() ->
{
List<GearOptimizer.Option> result = GearOptimizer.bestPerStyle(items, worn, setup, m);
if (version == gearVersion)
{
best = result;
bestVersion = version;
bestReady = true;
}
});
}
// ---------------------------------------------------------------- reading game state
private PlayerSetup readSetup()
{
double meleeAcc = pick(1.20, Prayer.PIETY, 1.15, Prayer.CHIVALRY, 1.15, Prayer.INCREDIBLE_REFLEXES,
1.10, Prayer.IMPROVED_REFLEXES, 1.05, Prayer.CLARITY_OF_THOUGHT);
double meleeStr = pick(1.23, Prayer.PIETY, 1.18, Prayer.CHIVALRY, 1.15, Prayer.ULTIMATE_STRENGTH,
1.10, Prayer.SUPERHUMAN_STRENGTH, 1.05, Prayer.BURST_OF_STRENGTH);
double rangedAcc = pick(1.20, Prayer.RIGOUR, 1.18, Prayer.DEADEYE, 1.15, Prayer.EAGLE_EYE,
1.10, Prayer.HAWK_EYE, 1.05, Prayer.SHARP_EYE);
double rangedStr = pick(1.23, Prayer.RIGOUR, 1.18, Prayer.DEADEYE, 1.15, Prayer.EAGLE_EYE,
1.10, Prayer.HAWK_EYE, 1.05, Prayer.SHARP_EYE);
double magicAcc = pick(1.25, Prayer.AUGURY, 1.18, Prayer.MYSTIC_VIGOUR, 1.15, Prayer.MYSTIC_MIGHT,
1.10, Prayer.MYSTIC_LORE, 1.05, Prayer.MYSTIC_WILL);
int magicDmg = client.isPrayerActive(Prayer.AUGURY) ? 4 : client.isPrayerActive(Prayer.MYSTIC_VIGOUR) ? 3
: client.isPrayerActive(Prayer.MYSTIC_MIGHT) ? 2 : client.isPrayerActive(Prayer.MYSTIC_LORE) ? 1 : 0;
return new PlayerSetup(
client.getBoostedSkillLevel(Skill.ATTACK),
client.getBoostedSkillLevel(Skill.STRENGTH),
client.getBoostedSkillLevel(Skill.DEFENCE),
client.getBoostedSkillLevel(Skill.RANGED),
client.getBoostedSkillLevel(Skill.MAGIC),
client.getBoostedSkillLevel(Skill.HITPOINTS),
meleeAcc, meleeStr, rangedAcc, rangedStr, magicAcc, magicDmg,
client.getVarbitValue(VarbitID.COMBAT_WEAPON_CATEGORY),
client.getVarpValue(VarPlayerID.COM_MODE),
client.getVarbitValue(VarbitID.AUTOCAST_SPELL),
slayerTask);
}
/** Multiplier of the first active prayer in (value, prayer) pairs, 1 if none. */
private double pick(Object... pairs)
{
for (int i = 0; i + 1 < pairs.length; i += 2)
{
if (client.isPrayerActive((Prayer) pairs[i + 1]))
{
return ((Number) pairs[i]).doubleValue();
}
}
return 1.0;
}
private static boolean sameSetup(PlayerSetup a, PlayerSetup b)
{
return a.attack == b.attack && a.strength == b.strength && a.defence == b.defence && a.ranged == b.ranged
&& a.magic == b.magic && a.meleeAcc == b.meleeAcc && a.meleeStr == b.meleeStr && a.rangedAcc == b.rangedAcc
&& a.rangedStr == b.rangedStr && a.magicAcc == b.magicAcc && a.magicDmgPct == b.magicDmgPct
&& a.weaponCategory == b.weaponCategory && a.comMode == b.comMode && a.autocastSpell == b.autocastSpell
&& a.onSlayerTask == b.onSlayerTask;
}
private Loadout readWorn()
{
GearItem[] items = new GearItem[GearItem.SLOTS];
ItemContainer c = client.getItemContainer(InventoryID.WORN);
if (c != null)
{
Item[] arr = c.getItems();
for (int s = 0; s < Math.min(arr.length, GearItem.SLOTS); s++)
{
if (arr[s] != null && arr[s].getId() > 0)
{
GearItem g = gearItem(arr[s].getId());
if (g == null)
{
// no bonuses known (e.g. new or cosmetic item): still show it
ItemComposition comp = itemManager.getItemComposition(arr[s].getId());
g = new GearItem(arr[s].getId(), comp.getName(), s, false, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, GearItem.Source.WORN);
}
items[s] = g;
}
}
}
return new Loadout(items);
}
private long ownedHash()
{
long h = 17;
ItemContainer inv = client.getItemContainer(InventoryID.INV);
if (inv != null)
{
for (Item i : inv.getItems())
{
h = h * 31 + i.getId();
}
}
Map<Integer, Long> b = bank.get();
if (b != null)
{
h = h * 31 + b.size();
for (Map.Entry<Integer, Long> e : b.entrySet())
{
h += e.getKey() * 1_000_003L ^ (e.getValue() > 0 ? 1 : 0);
}
}
return h;
}
private List<GearItem> readPool(Loadout worn)
{
Map<Integer, GearItem> out = new LinkedHashMap<>();
for (int s : GearItem.ALL_SLOTS)
{
GearItem g = worn.get(s);
if (g != null)
{
out.putIfAbsent(g.id, g.withSource(GearItem.Source.WORN));
}
}
ItemContainer inv = client.getItemContainer(InventoryID.INV);
if (inv != null)
{
for (Item i : inv.getItems())
{
addOwned(out, i.getId(), GearItem.Source.INVENTORY);
}
}
Map<Integer, Long> b = bank.get();
if (b != null)
{
for (Map.Entry<Integer, Long> e : b.entrySet())
{
if (e.getValue() != null && e.getValue() > 0)
{
addOwned(out, e.getKey(), GearItem.Source.BANK);
}
}
}
return Collections.unmodifiableList(new ArrayList<>(out.values()));
}
private void addOwned(Map<Integer, GearItem> out, int id, GearItem.Source source)
{
if (id <= 0)
{
return;
}
GearItem g = gearItem(id);
if (g != null && !out.containsKey(g.id))
{
out.put(g.id, g.withSource(source));
}
}
private GearItem gearItem(int rawId)
{
int id = itemManager.canonicalize(rawId);
if (itemCache.containsKey(id))
{
return itemCache.get(id);
}
GearItem g = null;
ItemStats stats = itemManager.getItemStats(id);
if (stats != null && stats.isEquipable() && stats.getEquipment() != null && stats.getEquipment().getSlot() >= 0
&& stats.getEquipment().getSlot() < GearItem.SLOTS)
{
ItemComposition comp = itemManager.getItemComposition(id);
g = GearItem.of(id, comp.getName(), stats.getEquipment(), GearItem.Source.WORN);
}
itemCache.put(id, g);
return g;
}
private FightSnapshot.Target readTarget(Player me)
{
NPC npc = null;
Actor interacting = me.getInteracting();
if (interacting instanceof NPC && ((NPC) interacting).getCombatLevel() > 0)
{
npc = (NPC) interacting;
}
FightSession session = combatTracker.getActiveSession();
if (npc == null && session != null)
{
npc = byIndex(session.getTargetIndex());
}
if (npc == null)
{
for (NPC n : client.getNpcs())
{
if (n != null && n.getInteracting() == me && n.getCombatLevel() > 0 && !n.isDead())
{
npc = n;
break;
}
}
}
if (npc == null)
{
if (lastTarget == null)
{
return null;
}
NPC old = byIndex(targetNpcIndex);
boolean dead = lastTarget.dead || old == null || old.isDead();
if (lastTarget.live || lastTarget.monster == null)
{
Monster m = lastTarget.monster != null ? lastTarget.monster : monsters.get(lastTarget.npcId, lastTarget.combatLevel);
lastTarget = new FightSnapshot.Target(lastTarget.npcId, lastTarget.name, lastTarget.combatLevel,
dead ? 0 : lastTarget.healthRatio, lastTarget.healthScale, m, m == null && monsters.isLoading(lastTarget.npcId),
false, dead, null);
}
return lastTarget;
}
targetNpcIndex = npc.getIndex();
Monster m = monsters.get(npc.getId(), npc.getCombatLevel());
CombatStyle style = attackStyles.current().get(npc);
String name = Text.removeTags(npc.getName());
lastTarget = new FightSnapshot.Target(npc.getId(), name == null ? "Unknown" : name, npc.getCombatLevel(),
npc.getHealthScale() > 0 ? npc.getHealthRatio() : -1, npc.getHealthScale(), m,
m == null && monsters.isLoading(npc.getId()), true, npc.isDead(), style);
return lastTarget;
}
private NPC byIndex(int index)
{
if (index < 0)
{
return null;
}
for (NPC n : client.getNpcs())
{
if (n != null && n.getIndex() == index)
{
return n;
}
}
return null;
}
}
@@ -0,0 +1,111 @@
package com.example.essentialsplus.loot.gear;
import com.example.essentialsplus.helpers.CombatStyle;
import com.example.essentialsplus.loot.combat.FightSession;
import java.util.Collections;
import java.util.List;
/** Everything the fight views show, captured on the client thread (immutable). */
public final class FightSnapshot
{
public static final FightSnapshot LOGGED_OUT = new FightSnapshot(false, PlayerSetup.DEFAULT, Loadout.EMPTY,
Collections.emptyList(), null, null, Collections.emptyList(), 0);
public static final class Target
{
public final int npcId;
public final String name;
public final int combatLevel;
/** -1 when no health bar is shown */
public final int healthRatio;
public final int healthScale;
/** null while loading or when the wiki has no stats */
public final Monster monster;
public final boolean loading;
/** being fought right now (false: last target, kept for planning) */
public final boolean live;
public final boolean dead;
/** attack style the NPC last used on the player, null if unknown */
public final CombatStyle attackStyle;
public Target(int npcId, String name, int combatLevel, int healthRatio, int healthScale, Monster monster,
boolean loading, boolean live, boolean dead, CombatStyle attackStyle)
{
this.npcId = npcId;
this.name = name;
this.combatLevel = combatLevel;
this.healthRatio = healthRatio;
this.healthScale = healthScale;
this.monster = monster;
this.loading = loading;
this.live = live;
this.dead = dead;
this.attackStyle = attackStyle;
}
/** Monster to calculate against: wiki stats or a dummy. */
public Monster calcMonster()
{
return monster != null ? monster : Monster.DUMMY;
}
}
/** Copy of the running fight session. */
public static final class Session
{
public final int durationTicks, idleTicks, attacks, damageDealt, hitsReceived, damageTaken, hpHealed;
public final int prayerBlocked, prayerMissed, onPrayerAttacks, offPrayerAttacks, correctWeapon, incorrectWeapon;
public final long suppliesCost;
public final double efficiency, performance, prayerDefencePct, offPrayerPct, correctWeaponPct;
public final String grade;
public Session(FightSession s)
{
durationTicks = s.getDurationTicks();
idleTicks = s.getIdleTicks();
attacks = s.getAttacksDealt();
damageDealt = s.getDamageDealt();
hitsReceived = s.getHitsReceived();
damageTaken = s.getDamageTaken();
hpHealed = s.getHpHealed();
prayerBlocked = s.getPrayerBlockedHits();
prayerMissed = s.getPrayerMissedHits();
onPrayerAttacks = s.getOnPrayerAttacks();
offPrayerAttacks = s.getOffPrayerAttacks();
correctWeapon = s.getCorrectWeaponAttacks();
incorrectWeapon = s.getIncorrectWeaponAttacks();
suppliesCost = s.getSuppliesCost();
efficiency = s.getEfficiencyPct();
performance = s.getPerformanceScore();
prayerDefencePct = s.getPrayerDefensePct();
offPrayerPct = s.getOffPrayerAttackPct();
correctWeaponPct = s.getCorrectWeaponPct();
grade = s.getGrade();
}
}
public final boolean loggedIn;
public final PlayerSetup setup;
public final Loadout worn;
/** Equipable items owned (worn, inventory, bank), one per item id */
public final List<GearItem> pool;
public final Target target;
public final Session session;
/** Best setup per style against the target, highest DPS first */
public final List<GearOptimizer.Option> best;
/** changes whenever gear, pool, levels or target stats change (not on hp / session updates) */
public final long gearVersion;
public FightSnapshot(boolean loggedIn, PlayerSetup setup, Loadout worn, List<GearItem> pool, Target target,
Session session, List<GearOptimizer.Option> best, long gearVersion)
{
this.loggedIn = loggedIn;
this.setup = setup;
this.worn = worn;
this.pool = pool;
this.target = target;
this.session = session;
this.best = best;
this.gearVersion = gearVersion;
}
}
@@ -0,0 +1,121 @@
package com.example.essentialsplus.loot.gear;
import java.util.Locale;
import net.runelite.client.game.ItemEquipmentStats;
/** An equipable item with its bonuses (immutable, safe on any thread). */
public final class GearItem
{
public static final int HEAD = 0;
public static final int CAPE = 1;
public static final int AMULET = 2;
public static final int WEAPON = 3;
public static final int BODY = 4;
public static final int SHIELD = 5;
public static final int LEGS = 7;
public static final int GLOVES = 9;
public static final int BOOTS = 10;
public static final int RING = 12;
public static final int AMMO = 13;
public static final int SLOTS = 14;
public static final int[] ALL_SLOTS = {HEAD, CAPE, AMULET, WEAPON, BODY, SHIELD, LEGS, GLOVES, BOOTS, RING, AMMO};
/** Where the item was found. */
public enum Source
{
WORN("Worn"),
INVENTORY("Inventory"),
BANK("Bank");
public final String label;
Source(String label)
{
this.label = label;
}
}
public final int id;
public final String name;
public final String lower;
public final int slot;
public final boolean twoHanded;
public final int astab, aslash, acrush, amagic, arange;
public final int dstab, dslash, dcrush, dmagic, drange;
public final int str, rstr, prayer, aspeed;
/** magic damage in percent */
public final double mdmg;
public final Source source;
public GearItem(int id, String name, int slot, boolean twoHanded,
int astab, int aslash, int acrush, int amagic, int arange,
int dstab, int dslash, int dcrush, int dmagic, int drange,
int str, int rstr, double mdmg, int prayer, int aspeed, Source source)
{
this.id = id;
this.name = name == null ? "Item " + id : name;
this.lower = this.name.toLowerCase(Locale.ENGLISH);
this.slot = slot;
this.twoHanded = twoHanded;
this.astab = astab;
this.aslash = aslash;
this.acrush = acrush;
this.amagic = amagic;
this.arange = arange;
this.dstab = dstab;
this.dslash = dslash;
this.dcrush = dcrush;
this.dmagic = dmagic;
this.drange = drange;
this.str = str;
this.rstr = rstr;
this.mdmg = mdmg;
this.prayer = prayer;
this.aspeed = aspeed;
this.source = source;
}
public static GearItem of(int id, String name, ItemEquipmentStats e, Source source)
{
return new GearItem(id, name, e.getSlot(), e.isTwoHanded(),
e.getAstab(), e.getAslash(), e.getAcrush(), e.getAmagic(), e.getArange(),
e.getDstab(), e.getDslash(), e.getDcrush(), e.getDmagic(), e.getDrange(),
e.getStr(), e.getRstr(), e.getMdmg(), e.getPrayer(), e.getAspeed(), source);
}
public GearItem withSource(Source s)
{
return s == source ? this : new GearItem(id, name, slot, twoHanded, astab, aslash, acrush, amagic, arange,
dstab, dslash, dcrush, dmagic, drange, str, rstr, mdmg, prayer, aspeed, s);
}
public int totalDefence()
{
return dstab + dslash + dcrush + dmagic + drange;
}
public boolean has(String part)
{
return lower.contains(part);
}
public static String slotName(int slot)
{
switch (slot)
{
case HEAD: return "Head";
case CAPE: return "Cape";
case AMULET: return "Neck";
case WEAPON: return "Weapon";
case BODY: return "Body";
case SHIELD: return "Shield";
case LEGS: return "Legs";
case GLOVES: return "Hands";
case BOOTS: return "Feet";
case RING: return "Ring";
case AMMO: return "Ammo";
default: return "Slot " + slot;
}
}
}
@@ -0,0 +1,158 @@
package com.example.essentialsplus.loot.gear;
import com.example.essentialsplus.loot.gear.CombatChoice.Type;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Comparator;
import java.util.List;
/** Picks the highest DPS items per combat style from the items the player owns. */
public final class GearOptimizer
{
private static final int WEAPONS_PER_STYLE = 8;
private static final int PASSES = 2;
private GearOptimizer()
{
}
public static final class Option
{
public final String label;
public final Loadout loadout;
public final DpsCalc.Result result;
Option(String label, Loadout loadout, DpsCalc.Result result)
{
this.label = label;
this.loadout = loadout;
this.result = result;
}
}
/** Best setup of each style (melee, ranged, magic) that has a weapon, highest DPS first. */
public static List<Option> bestPerStyle(Collection<GearItem> pool, Loadout worn, PlayerSetup p, Monster m)
{
List<Option> out = new ArrayList<>();
for (Type cls : new Type[]{Type.STAB, Type.RANGED, Type.MAGIC})
{
Option best = bestFor(cls, pool, worn, p, m);
if (best != null && best.result.dps > 0)
{
out.add(best);
}
}
out.sort(Comparator.comparingDouble((Option o) -> o.result.dps).reversed());
return out;
}
private static List<CombatChoice> choices(Type cls, GearItem w, PlayerSetup p)
{
List<CombatChoice> out = new ArrayList<>();
if (cls == Type.STAB)
{
if (w.astab > 0)
{
out.add(CombatChoice.melee(Type.STAB));
}
if (w.aslash > 0)
{
out.add(CombatChoice.melee(Type.SLASH));
}
if (w.acrush > 0)
{
out.add(CombatChoice.melee(Type.CRUSH));
}
if (out.isEmpty())
{
out.add(CombatChoice.melee(CombatChoice.bestMeleeType(w)));
}
}
else
{
out.add(CombatChoice.infer(w, p.magic, p.autocastSpell));
}
return out;
}
static Option bestFor(Type cls, Collection<GearItem> pool, Loadout worn, PlayerSetup p, Monster m)
{
// rank weapons on their own first so the full search stays small
List<Option> starts = new ArrayList<>();
for (GearItem w : pool)
{
if (w.slot != GearItem.WEAPON || CombatChoice.weaponClass(w) != cls)
{
continue;
}
Loadout l = worn.with(GearItem.WEAPON, w);
for (CombatChoice c : choices(cls, w, p))
{
starts.add(new Option(label(c), l, DpsCalc.calc(l, p, m, c)));
}
}
starts.sort(Comparator.comparingDouble((Option o) -> o.result.dps).reversed());
Option best = null;
for (int i = 0; i < Math.min(WEAPONS_PER_STYLE, starts.size()); i++)
{
Option o = improve(starts.get(i), pool, p, m);
if (best == null || o.result.dps > best.result.dps + 1e-9)
{
best = o;
}
}
return best;
}
private static Option improve(Option start, Collection<GearItem> pool, PlayerSetup p, Monster m)
{
CombatChoice c = start.result.choice;
Loadout l = start.loadout;
DpsCalc.Result r = start.result;
for (int pass = 0; pass < PASSES; pass++)
{
boolean changed = false;
for (int slot : GearItem.ALL_SLOTS)
{
if (slot == GearItem.WEAPON || slot == GearItem.SHIELD && l.weapon() != null && l.weapon().twoHanded)
{
continue;
}
for (GearItem g : pool)
{
if (g.slot != slot || g.id == l.id(slot))
{
continue;
}
Loadout candidate = l.with(slot, g);
DpsCalc.Result cr = DpsCalc.calc(candidate, p, m, c);
if (cr.dps > r.dps + 1e-9)
{
l = candidate;
r = cr;
changed = true;
}
}
}
if (!changed)
{
break;
}
}
return new Option(start.label, l, r);
}
private static String label(CombatChoice c)
{
switch (c.type)
{
case RANGED:
return "Ranged";
case MAGIC:
return "Magic";
default:
return "Melee (" + c.type.label.toLowerCase() + ")";
}
}
}

Some files were not shown because too many files have changed in this diff Show More