Compare commits
3
Commits
master
..
c0868c2703
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c0868c2703 | ||
|
|
ddcbd2303c | ||
|
|
15463c2e3c |
+10
@@ -0,0 +1,10 @@
|
||||
.gradle
|
||||
build
|
||||
.idea/
|
||||
.project
|
||||
.settings/
|
||||
.classpath
|
||||
nbactions.xml
|
||||
nb-configuration.xml
|
||||
nbproject/
|
||||
.aider*
|
||||
@@ -0,0 +1,55 @@
|
||||
# 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.
|
||||
- **Blast Furnace Helper** (`com.example.essentialsplus.helpers.BlastFurnace*`):
|
||||
- Markiert strikt sequenziell immer nur genau ein Ziel ("niemals mehrere Dinge gleichzeitig"):
|
||||
- Bei geöffneter Bank: Barren einlagern -> leere Vials einlagern -> bei <= 30 Energie Stamina Potion trinken (Priorität: 1-Dose Extended > 1-Dose Normal > 2-4 Dose Extended > 2-4 Dose Normal; angebrochene Tränke werden vor dem Erz-Abheben zurück in die Bank gelegt) -> Coal-Bag füllen im Inventar & Ziel-Erz/Kohle in der Bank abheben werden gleichzeitig markiert (sodass man Coal Bag füllen und direkt das Erz/Kohle anklicken kann; Mindestbestand im Ofen mit +1 Puffer, z. B. 4x Erz-Slots = 108 Kohle bei Runite mit Coal Bag; Priorität: Runite > Adamantite > Mithril > Gold > Iron/Steel > Silver > Bronze nach Smithing-Level & Bank-Bestand; Erze mit < 27 Stück in der Bank werden ignoriert, um halbe Touren zu verhindern; wenn alle Erze < 27 sind, ist der Helper fertig und markiert nichts mehr).
|
||||
- Sobald das Inventar voll ist, wird das Förderband ("Put ore on belt" / "Put coal on belt") sofort in der 3D-Welt markiert – auch bei noch geöffneter Bank, sodass man mit 1 Klick direkt vom Bankfenster zum Band laufen kann.
|
||||
- Intelligentes Warten am Bar Dispenser: Nach dem Auflegen von Erzen auf das Band wird der Bar Dispenser ("Wait for bars" / "Collect bars") markiert, bis die Barren geschmolzen und abgeholt sind, anstatt voreilig zur Bank-Chest zu leiten. Erst mit Barren im Inventar geht es zur Bank-Chest ("Bank bars").
|
||||
- Rand-Markierung ("Border-only"): Items in Bank & Inventar werden mit einem klaren Rahmen ohne deckenden Fill umrandet, sodass jedes Icon deutlich erkennbar bleibt.
|
||||
- Einstellungen und Beschriftungen vollständig auf Englisch (Menüs im Helpers-Card per Rechtsklick, Panel-Zeilen, Tooltips).
|
||||
- Info-Panel: Coal in furnace, Bars in furnace, Coffer + Restzeit und berechnete Barren aus Bank-Ores.
|
||||
- **Chat Helper** (`com.example.essentialsplus.helpers.ChatHelper*`):
|
||||
- Automatisches Aufräumen (Auto-Clear) alter Chat-Nachrichten nach konfigurierbarer Dauer (Standard 60s / 1 Min., 30s, 2m, 5m). Selektiver Schutz für private Nachrichten & Clan-/Friends-Chats.
|
||||
- Duplikat-Kompaktierung: Aufeinanderfolgende gleiche Meldungen werden zu `(x2, x3...)` komprimiert.
|
||||
- Spam- & Noise-Filter: Unterdrückt überflüssigen Systemlärm ("Nothing interesting happens", etc.) und Werbebots.
|
||||
- Namen-Highlighting: Eigener Spielername wird in öffentlichen/Clan-Chats hervorgehoben (Farbe & Fettdruck).
|
||||
- Rechtsklick-Aktionen: "Copy message" (kopiert bereinigten Text) & "Clear chat" auf Tabs / Kontextmenü.
|
||||
|
||||
@@ -0,0 +1,322 @@
|
||||
# Item Information – Zusammenlegung der Item-Helper
|
||||
|
||||
## Ziel
|
||||
|
||||
`Item prices`, `Item charges` und das neue Feature `Banked items` werden unter einem gemeinsamen Helper zusammengeführt.
|
||||
|
||||
Empfohlener Name:
|
||||
|
||||
**Item information**
|
||||
|
||||
Alternativen: `Item details`, `Item info`.
|
||||
|
||||
Der Nutzer soll einen einzigen Hauptschalter haben. Die einzelnen Informationsarten bleiben per Rechtsklick separat aktivierbar.
|
||||
|
||||
## Aktueller Zustand
|
||||
|
||||
### Item prices
|
||||
|
||||
Implementierung: `helpers/ItemPriceOverlay.java`
|
||||
|
||||
- Hover-Tooltip über Inventar- und Bank-Items
|
||||
- GE-Preis
|
||||
- High-Alchemy-Wert
|
||||
- Mengen werden berücksichtigt
|
||||
- Coins und Platinum-Tokens werden ignoriert
|
||||
- Optionen:
|
||||
- `Show GE price`
|
||||
- `Show HA price`
|
||||
|
||||
### Item charges
|
||||
|
||||
Implementierung:
|
||||
|
||||
- `helpers/ItemCharges.java`
|
||||
- `helpers/ItemChargesOverlay.java`
|
||||
|
||||
- Permanente Werte direkt auf Item-Icons
|
||||
- Inventar
|
||||
- Ausrüstung
|
||||
- optional Bank
|
||||
- Unterstützt viele Charge-/Füllstandsquellen, unter anderem:
|
||||
- Varbits
|
||||
- Barrows
|
||||
- Moon-Ausrüstung
|
||||
- Blowpipe mit mehreren Werten
|
||||
- Dragonfire-Schilde
|
||||
- Schmuck und Tränke
|
||||
- Bags, Pouches und Container
|
||||
- Master Scroll Book
|
||||
- weitere Spezialitems
|
||||
- Optionen:
|
||||
- `Show weapons and armor`
|
||||
- `Show looting and coal bags`
|
||||
- `Show charges in bank`
|
||||
- Textgröße
|
||||
- Low-Charge-Warnschwelle
|
||||
- Primärfarbe
|
||||
- Sekundärfarbe
|
||||
- Warnfarbe
|
||||
- gespeicherte Charges zurücksetzen
|
||||
- Coal-Bag-Daten vergessen
|
||||
|
||||
## Neues Feature: Banked items
|
||||
|
||||
Beim Hover über ein Item soll im Tooltip zusätzlich die Bankmenge erscheinen.
|
||||
|
||||
Beispiel:
|
||||
|
||||
```text
|
||||
GE: 12.3k gp HA: 4.8k gp
|
||||
Banked: 300
|
||||
```
|
||||
|
||||
Regeln:
|
||||
|
||||
- Die Bankmenge wird nur angezeigt, wenn sie bekannt ist.
|
||||
- Bei unbekanntem Bestand keine `Banked: 0`-Zeile anzeigen.
|
||||
- Noted und unnoted Items müssen zusammengefasst werden.
|
||||
- Die Zuordnung muss über `ItemManager.canonicalize()` erfolgen.
|
||||
- Bei geöffneter Bank ist der Live-Bankcontainer maßgeblich.
|
||||
- Bei geschlossener Bank wird der zuletzt bekannte Bankbestand verwendet.
|
||||
- Bankänderungen müssen über `ItemContainerChanged` berücksichtigt werden.
|
||||
- Die bestehende Bank-/Container-Speicherung des Plugins soll wiederverwendet werden.
|
||||
|
||||
## Zieloberfläche
|
||||
|
||||
In der Helpers-Karte gibt es nur noch eine Checkbox:
|
||||
|
||||
```text
|
||||
Item information
|
||||
```
|
||||
|
||||
Tooltip:
|
||||
|
||||
```text
|
||||
Shows item prices, banked quantities and charge information. Right-click for options.
|
||||
```
|
||||
|
||||
Die bisherigen Checkboxen `Item prices` und `Item charges` entfallen.
|
||||
|
||||
## Ziel-Rechtsklick-Menü
|
||||
|
||||
### Informationsarten
|
||||
|
||||
- `Show GE price`
|
||||
- `Show High Alchemy value`
|
||||
- `Show banked quantity`
|
||||
- `Show item charges`
|
||||
|
||||
### Charge-Kategorien
|
||||
|
||||
Nur relevant, wenn `Show item charges` aktiv ist:
|
||||
|
||||
- `Weapons and armor`
|
||||
- `Bags and containers`
|
||||
|
||||
Die bestehende Option `Show charges in bank` wird in den Bereich Anzeigeorte verschoben.
|
||||
|
||||
### Anzeigeorte
|
||||
|
||||
Preise und Bankmengen sind Hover-Informationen:
|
||||
|
||||
- `Show in inventory`
|
||||
- `Show in bank`
|
||||
|
||||
Charges sind Icon-Informationen:
|
||||
|
||||
- `Show charges in inventory`
|
||||
- `Show charges in equipment`
|
||||
- `Show charges in bank`
|
||||
|
||||
Falls eine zu große Anzahl an Optionen vermieden werden soll, kann `Show in inventory` entfallen, weil Inventar standardmäßig immer aktiv ist. Dann bleibt nur:
|
||||
|
||||
- `Show in bank`
|
||||
- `Show charges in equipment`
|
||||
|
||||
### Darstellung
|
||||
|
||||
- `Text size`
|
||||
- `Primary color`
|
||||
- `Secondary color`
|
||||
- `Warning color`
|
||||
|
||||
### Warnungen
|
||||
|
||||
`Low-charge warning` mit:
|
||||
|
||||
- `Off`
|
||||
- `≤ 10`
|
||||
- `≤ 50`
|
||||
- `≤ 100`
|
||||
- `≤ 500`
|
||||
- `≤ 1000`
|
||||
|
||||
### Datenverwaltung
|
||||
|
||||
- `Reset saved charges`
|
||||
- `Forget coal bag`
|
||||
|
||||
### Zurücksetzen
|
||||
|
||||
- `Reset appearance`
|
||||
|
||||
## Tooltip-Design
|
||||
|
||||
Der bestehende Preis-Tooltip soll zu einem gemeinsamen Tooltip-Builder erweitert werden.
|
||||
|
||||
Empfohlene Reihenfolge:
|
||||
|
||||
1. GE-Preis
|
||||
2. High-Alchemy-Wert
|
||||
3. Bankmenge
|
||||
|
||||
Beispiel mit allen Informationen:
|
||||
|
||||
```text
|
||||
GE: 12.3k gp HA: 4.8k gp
|
||||
Banked: 300
|
||||
```
|
||||
|
||||
Wenn nur einzelne Informationen aktiviert sind, werden nur diese Zeilen ausgegeben.
|
||||
|
||||
Die Charge-Werte bleiben auf den Item-Icons und werden nicht in den Hover-Tooltip verschoben, außer dies wird später ausdrücklich gewünscht.
|
||||
|
||||
## Empfohlene interne Struktur
|
||||
|
||||
### Gemeinsamer Helper-Schalter
|
||||
|
||||
Neuer Hauptschlüssel:
|
||||
|
||||
```text
|
||||
itemInformation
|
||||
```
|
||||
|
||||
Mögliche neue Unteroptionen:
|
||||
|
||||
```text
|
||||
itemInformationGe
|
||||
itemInformationHa
|
||||
itemInformationBanked
|
||||
itemInformationCharges
|
||||
itemInformationChargesWeapons
|
||||
itemInformationChargesBags
|
||||
itemInformationBank
|
||||
itemInformationEquipment
|
||||
itemInformationSize
|
||||
itemInformationColor
|
||||
itemInformationSecondaryColor
|
||||
itemInformationLowColor
|
||||
itemInformationLowAt
|
||||
```
|
||||
|
||||
Die genaue Benennung kann noch angepasst werden. Wichtig ist ein einheitliches Präfix.
|
||||
|
||||
### Overlay-Struktur
|
||||
|
||||
Empfohlene Varianten:
|
||||
|
||||
1. `ItemInformationOverlay` übernimmt den Hover-Tooltip und ruft Preis- und Banked-Provider auf.
|
||||
2. `ItemChargesOverlay` bleibt für die Icon-Anzeige bestehen.
|
||||
3. Beide verwenden denselben gemeinsamen Hauptschalter und dieselben zentralen Optionen.
|
||||
|
||||
Damit muss die komplexe Charge-Logik nicht unnötig mit der Tooltip-Logik vermischt werden.
|
||||
|
||||
Alternativ kann der bestehende `ItemPriceOverlay` umbenannt und erweitert werden. Inhaltlich sollte er aber als allgemeiner Item-Information-Tooltip behandelt werden.
|
||||
|
||||
### Bankbestand
|
||||
|
||||
Für den Bankbestand sollte keine neue parallele Speicherung eingeführt werden, wenn die vorhandenen Plugin-Daten verwendet werden können.
|
||||
|
||||
Benötigt wird eine Methode nach diesem Muster:
|
||||
|
||||
```java
|
||||
long getBankedQuantity(int itemId)
|
||||
```
|
||||
|
||||
Die Methode sollte:
|
||||
|
||||
- die Item-ID kanonisieren
|
||||
- noted/unnoted zusammenführen
|
||||
- die aktuelle Bank bevorzugen, wenn sie geöffnet ist
|
||||
- ansonsten den letzten bekannten Bestand verwenden
|
||||
- bei unbekanntem Item `0` oder einen klar unterscheidbaren „unknown“-Zustand liefern
|
||||
|
||||
Wichtig: `0` darf nicht automatisch als „unbekannt“ interpretiert werden. Ein bekannter leerer Bestand und ein unbekannter Bestand müssen unterscheidbar sein.
|
||||
|
||||
## Rückwärtskompatibilität
|
||||
|
||||
Bestehende Konfigurationswerte sollen zunächst gelesen werden können:
|
||||
|
||||
- `itemPrices`
|
||||
- `itemPricesGe`
|
||||
- `itemPricesHa`
|
||||
- `itemCharges`
|
||||
- `itemChargesWeapons`
|
||||
- `itemChargesBags`
|
||||
- `itemChargesBank`
|
||||
- `itemChargesSize`
|
||||
- `itemChargesColor`
|
||||
- `itemChargesSecondaryColor`
|
||||
- `itemChargesLowColor`
|
||||
- `itemChargesLowAt`
|
||||
|
||||
Mögliche Strategie:
|
||||
|
||||
- Neuer Hauptschalter verwendet neue Keys.
|
||||
- Falls neue Keys noch nicht existieren, werden die alten Werte als Fallback gelesen.
|
||||
- Alte Einstellungen werden nicht sofort gelöscht.
|
||||
- Ein späterer Migrationsschritt kann alte Keys übernehmen oder dauerhaft weiter unterstützen.
|
||||
|
||||
## Tests
|
||||
|
||||
Vor der Implementierung sollten die vorhandenen Tests ausgeführt werden. Relevant sind insbesondere:
|
||||
|
||||
- `ItemChargesTest`
|
||||
- `ItemChargesFullTest`
|
||||
- Bank-/Container-Tests
|
||||
- Tests für `ItemManager.canonicalize()`-Verhalten, falls vorhanden
|
||||
|
||||
Neue Tests sollten abdecken:
|
||||
|
||||
1. GE-Preis allein
|
||||
2. HA-Wert allein
|
||||
3. GE und HA zusammen
|
||||
4. Bankmenge bei normalem Item
|
||||
5. Bankmenge bei noted/unnoted Kombination
|
||||
6. unbekannter Bankbestand wird nicht angezeigt
|
||||
7. bekannter Bankbestand von `0`
|
||||
8. Bankbestand aktualisiert sich nach Containeränderung
|
||||
9. gemeinsamer Hauptschalter deaktiviert beide Overlay-Arten
|
||||
10. Charge-Unteroptionen bleiben funktionsfähig
|
||||
11. alte Konfigurationswerte werden korrekt übernommen
|
||||
12. Tooltip erscheint nicht bei Coins/Platinum, sofern das bestehende Verhalten beibehalten werden soll
|
||||
|
||||
## Offene Fragen vor der Implementierung
|
||||
|
||||
1. Soll der endgültige Name `Item information`, `Item details` oder `Item info` sein?
|
||||
2. Soll `Banked: 0` bei bekannt leerem Bestand angezeigt werden oder komplett entfallen?
|
||||
3. Soll der Bankbestand auch beim Hover über ein Item im Bankfenster selbst angezeigt werden?
|
||||
4. Soll die Bankmenge inklusive Items im Inventar angezeigt werden oder ausschließlich der Bestand in der Bank?
|
||||
5. Soll `Show in inventory` entfallen, weil Inventar immer aktiv sein soll?
|
||||
6. Sollen Preise und Bankmenge optional auch auf Item-Icons statt nur im Tooltip erscheinen?
|
||||
7. Soll die Anzeige von Charges im Bankfenster weiterhin separat schaltbar sein?
|
||||
8. Soll eine gemeinsame Farbe für alle Tooltip-Zeilen verwendet werden oder behalten GE/HA/Banked eigene Farben?
|
||||
9. Sollen die alten Config-Keys dauerhaft unterstützt oder nur einmalig migriert werden?
|
||||
10. Soll das neue Feature standardmäßig deaktiviert bleiben, wie die bisherigen Helper?
|
||||
|
||||
## Empfohlene Standardentscheidung
|
||||
|
||||
Für eine einfache und übersichtliche erste Version:
|
||||
|
||||
- Name: `Item information`
|
||||
- Ein Hauptschalter
|
||||
- GE, HA, Banked und Charges separat toggelbar
|
||||
- Inventar immer aktiv
|
||||
- Bank-Hover separat toggelbar
|
||||
- Equipment-Charges separat toggelbar
|
||||
- Banked zählt nur Bankbestand, nicht Inventar
|
||||
- unbekannter Bestand wird nicht angezeigt
|
||||
- vorhandene Charge-Unteroptionen bleiben erhalten
|
||||
- alte Config-Keys werden als Fallback unterstützt
|
||||
- Tooltip-Informationen bleiben getrennt von den Charge-Zahlen auf Item-Icons
|
||||
@@ -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.
|
||||
Binary file not shown.
@@ -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
|
||||
Executable
+61
@@ -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
@@ -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!"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Vendored
BIN
Binary file not shown.
+8
@@ -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
|
||||
@@ -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
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
rootProject.name = 'bank-highlighter'
|
||||
@@ -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 = "General plugin settings.",
|
||||
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 = "Automatically manages profiles, item highlights, 3D objects, ground tiles, and paths",
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,394 @@
|
||||
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.ComponentID;
|
||||
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;
|
||||
/** how long after a collection interface (GE, GE Collect, Bank) was open collections may be matched */
|
||||
private static final long COLLECT_WINDOW_MS = 30_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;
|
||||
private long lastCollectInterfaceAt;
|
||||
|
||||
void reset()
|
||||
{
|
||||
previousHoldings = null;
|
||||
recentCoinLoss = 0;
|
||||
lastCollectInterfaceAt = 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;
|
||||
|
||||
if (isOpen(client, InterfaceID.GE_OFFERS)
|
||||
|| isOpen(client, InterfaceID.GE_COLLECT)
|
||||
|| isOpen(client, InterfaceID.BANKMAIN)
|
||||
|| isOpen(client, InterfaceID.BANK_DEPOSITBOX))
|
||||
{
|
||||
lastCollectInterfaceAt = updateTime;
|
||||
}
|
||||
boolean collecting = (updateTime - lastCollectInterfaceAt <= COLLECT_WINDOW_MS);
|
||||
|
||||
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;
|
||||
int canItem = canonicalize.applyAsInt(fresh.item);
|
||||
fresh.baselineHoldings = holdings.getOrDefault(canItem, 0L);
|
||||
fresh.baselineCoins = holdings.getOrDefault(ItemID.COINS, 0L);
|
||||
fresh.baselineInitialized = true;
|
||||
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));
|
||||
}
|
||||
|
||||
// In addition to real-time gains, enforce holdings floor so partial collections are NEVER double-counted
|
||||
// even if an update tick was missed, the interface was closed before compute, or on client restarts.
|
||||
if (s.buy)
|
||||
{
|
||||
long currentHoldings = holdings.getOrDefault(item, 0L);
|
||||
if (!s.baselineInitialized)
|
||||
{
|
||||
s.baselineHoldings = Math.max(0L, currentHoldings - s.collectedItems);
|
||||
s.baselineInitialized = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
long netGain = Math.max(0L, currentHoldings - s.baselineHoldings);
|
||||
if (netGain > 0)
|
||||
{
|
||||
long floor = Math.min(grossItems, netGain);
|
||||
if (floor > s.collectedItems)
|
||||
{
|
||||
s.collectedItems = floor;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
long currentCoins = holdings.getOrDefault(ItemID.COINS, 0L);
|
||||
if (!s.baselineInitialized)
|
||||
{
|
||||
s.baselineCoins = Math.max(0L, currentCoins - s.collectedCoins);
|
||||
s.baselineInitialized = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
long netGain = Math.max(0L, currentCoins - s.baselineCoins);
|
||||
if (netGain > 0)
|
||||
{
|
||||
long floor = Math.min(grossCoins, netGain);
|
||||
if (floor > s.collectedCoins)
|
||||
{
|
||||
s.collectedCoins = floor;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 w0 = client.getWidget(group, 0);
|
||||
if (w0 != null && !w0.isHidden())
|
||||
{
|
||||
return true;
|
||||
}
|
||||
Widget w1 = client.getWidget(group, 1);
|
||||
if (w1 != null && !w1.isHidden())
|
||||
{
|
||||
return true;
|
||||
}
|
||||
if (group == InterfaceID.BANKMAIN)
|
||||
{
|
||||
Widget bank = client.getWidget(ComponentID.BANK_CONTAINER);
|
||||
return bank != null && !bank.isHidden();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -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.isCurrent()
|
||||
? 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,615 @@
|
||||
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 long price;
|
||||
public int total;
|
||||
public int quantity;
|
||||
public long spent;
|
||||
|
||||
public GeBuyLimitSlot()
|
||||
{
|
||||
}
|
||||
|
||||
public GeBuyLimitSlot(int itemId, long price, int total, int quantity, long 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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a sequence of consecutive snapshots starting at index 'start' with length 'count'
|
||||
* represents a transient plateau spike (e.g. 4 to 25 points / up to 35 minutes) between
|
||||
* before (start - 1) and after (start + count).
|
||||
*/
|
||||
public static boolean isPlateauSpike(List<Snapshot> list, int start, int count)
|
||||
{
|
||||
if (list == null || start + count >= list.size())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
Snapshot after = list.get(start + count);
|
||||
return isPlateauSpike(list, start, count, after.t, after.total());
|
||||
}
|
||||
|
||||
public static boolean isPlateauSpike(List<Snapshot> list, int start, int count, long tAfter, long vAfter)
|
||||
{
|
||||
if (list == null || start <= 0 || count < 1 || start + count > list.size())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
Snapshot before = list.get(start - 1);
|
||||
Snapshot first = list.get(start);
|
||||
Snapshot last = list.get(start + count - 1);
|
||||
|
||||
long legBefore = first.t - before.t;
|
||||
long legAfter = tAfter - last.t;
|
||||
if (legBefore <= 0 || legAfter <= 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
long maxPlateauDuration = 35 * 60 * 1000L; // 35 minutes max
|
||||
if (tAfter - before.t > maxPlateauDuration)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
long vBefore = before.total();
|
||||
|
||||
boolean positive = (first.total() - vBefore) > 0;
|
||||
long minDev = Long.MAX_VALUE;
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
long val = list.get(start + i).total();
|
||||
long d1 = val - vBefore;
|
||||
long d2 = vAfter - val;
|
||||
if (positive)
|
||||
{
|
||||
if (d1 <= 0 || d2 >= 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
if (d1 >= 0 || d2 <= 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
minDev = Math.min(minDev, Math.min(Math.abs(d1), Math.abs(d2)));
|
||||
}
|
||||
|
||||
long baseDelta = Math.abs(vAfter - vBefore);
|
||||
long baseWealth = Math.max(10_000L, Math.min(Math.abs(vBefore), Math.abs(vAfter)));
|
||||
boolean significant = minDev >= MIN_SPIKE_ABS_GP || (double) minDev / baseWealth >= MIN_SPIKE_REL_RATIO;
|
||||
if (!significant)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return baseDelta <= 0.35 * minDev
|
||||
|| 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--;
|
||||
}
|
||||
}
|
||||
|
||||
// Pass 4: check multi-point plateau spikes (4 to 25 consecutive points)
|
||||
for (int k = 25; k >= 4; k--)
|
||||
{
|
||||
for (int i = 1; i <= list.size() - k - 1; i++)
|
||||
{
|
||||
if (isPlateauSpike(list, i, k))
|
||||
{
|
||||
for (int j = 0; j < k; j++)
|
||||
{
|
||||
list.remove(i);
|
||||
}
|
||||
changed = true;
|
||||
i--;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Pass 5: check login step glitches (unverified cache before bank sync after a gap)
|
||||
for (int i = 1; i < list.size() - 2; i++)
|
||||
{
|
||||
Snapshot b = list.get(i - 1);
|
||||
Snapshot g = list.get(i);
|
||||
Snapshot a1 = list.get(i + 1);
|
||||
Snapshot a2 = list.get(i + 2);
|
||||
if (isLoginStepGlitch(b.t, b.total(), g.t, g.total(), a1.t, a1.total(), a2.t, a2.total()))
|
||||
{
|
||||
list.remove(i);
|
||||
changed = true;
|
||||
i--;
|
||||
}
|
||||
}
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if an isolated point immediately following a logout gap (>= 10 minutes) represents
|
||||
* an unverified cache artifact from before the bank was synced in that session.
|
||||
*/
|
||||
public static boolean isLoginStepGlitch(long tBefore, long vBefore,
|
||||
long tGlitch, long vGlitch,
|
||||
long tAfter1, long vAfter1,
|
||||
long tAfter2, long vAfter2)
|
||||
{
|
||||
if (tGlitch - tBefore < 10 * 60 * 1000L)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
long glitchDuration = tAfter1 - tGlitch;
|
||||
if (glitchDuration <= 0 || glitchDuration > 5 * 60 * 1000L)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if (tAfter2 - tAfter1 <= 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
long drop = Math.abs(vAfter1 - vGlitch);
|
||||
long baseWealth = Math.max(10_000L, Math.min(Math.abs(vGlitch), Math.abs(vAfter1)));
|
||||
boolean significant = drop >= MIN_SPIKE_ABS_GP || (double) drop / baseWealth >= MIN_SPIKE_REL_RATIO;
|
||||
if (!significant)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Sustained at after1 and after2
|
||||
if (Math.abs(vAfter2 - vAfter1) > Math.max(500_000L, (long) (0.35 * drop)))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Glitch point closely matches the old before value from before the logout gap
|
||||
if (Math.abs(vGlitch - vBefore) > Math.max(500_000L, (long) (0.35 * drop)))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
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 long marketPrice;
|
||||
public final GeLimitInfo limitInfo;
|
||||
|
||||
public GeRow(String character, int slot, GeSlot offer, String name, long marketPrice)
|
||||
{
|
||||
this(character, slot, offer, name, marketPrice, null);
|
||||
}
|
||||
|
||||
public GeRow(String character, int slot, GeSlot offer, String name, long marketPrice, GeLimitInfo limitInfo)
|
||||
{
|
||||
this.character = character;
|
||||
this.slot = slot;
|
||||
this.offer = offer;
|
||||
this.name = name;
|
||||
this.marketPrice = marketPrice;
|
||||
this.limitInfo = limitInfo;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
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;
|
||||
|
||||
/** Baseline holdings of the offer item across visible containers when offer was placed */
|
||||
public long baselineHoldings;
|
||||
public long baselineCoins;
|
||||
public boolean baselineInitialized;
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package com.example.essentialsplus.data;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.CopyOnWriteArrayList;
|
||||
|
||||
/** Item holdings over time, stored separately from the wealth timeline (<key>.items.json). */
|
||||
public class ItemLedger
|
||||
{
|
||||
public List<ItemSnapshot> snapshots = new CopyOnWriteArrayList<>();
|
||||
|
||||
public transient long fileModified;
|
||||
public transient boolean dirty;
|
||||
|
||||
public ItemSnapshot last()
|
||||
{
|
||||
List<ItemSnapshot> s = snapshots;
|
||||
return s.isEmpty() ? null : s.get(s.size() - 1);
|
||||
}
|
||||
|
||||
/** Latest snapshot taken at or before {@code t}, or the first one after it if none exists. */
|
||||
public ItemSnapshot at(long t)
|
||||
{
|
||||
List<ItemSnapshot> s = snapshots;
|
||||
ItemSnapshot best = null;
|
||||
for (ItemSnapshot x : s)
|
||||
{
|
||||
if (x.t <= t)
|
||||
{
|
||||
best = x;
|
||||
}
|
||||
else
|
||||
{
|
||||
return best != null ? best : x;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
public void normalize()
|
||||
{
|
||||
List<ItemSnapshot> s = snapshots == null ? new ArrayList<>() : new ArrayList<>(snapshots);
|
||||
s.removeIf(x -> x == null || x.ids == null || x.q == null || x.p == null
|
||||
|| x.ids.length != x.q.length || x.ids.length != x.p.length);
|
||||
s.sort((a, b) -> Long.compare(a.t, b.t));
|
||||
snapshots = new CopyOnWriteArrayList<>(s);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.example.essentialsplus.data;
|
||||
|
||||
/** One priced item stack for the item viewer. */
|
||||
public class ItemRow
|
||||
{
|
||||
public final int id;
|
||||
public final String name;
|
||||
public final long qty;
|
||||
public final long price;
|
||||
public final long value;
|
||||
|
||||
public ItemRow(int id, String name, long qty, long price)
|
||||
{
|
||||
this.id = id;
|
||||
this.name = name;
|
||||
this.qty = qty;
|
||||
this.price = price;
|
||||
this.value = qty * price;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package com.example.essentialsplus.data;
|
||||
|
||||
import java.util.Arrays;
|
||||
|
||||
/**
|
||||
* Holdings of a character at one moment: canonical item id, quantity and the GE price at that time.
|
||||
* Allows splitting wealth changes into "market" (price) and "activity" (quantity) effects.
|
||||
*/
|
||||
public class ItemSnapshot
|
||||
{
|
||||
public long t;
|
||||
public int[] ids;
|
||||
public long[] q;
|
||||
public long[] p;
|
||||
/** pinned (session start) - kept by compaction */
|
||||
public Boolean k;
|
||||
|
||||
public ItemSnapshot()
|
||||
{
|
||||
}
|
||||
|
||||
public ItemSnapshot(long t, int[] ids, long[] q, long[] 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,288 @@
|
||||
package com.example.essentialsplus.helpers;
|
||||
|
||||
import com.example.essentialsplus.helpers.combat.AttackStyle;
|
||||
import com.example.essentialsplus.helpers.combat.AttackType;
|
||||
import com.example.essentialsplus.helpers.combat.CombatUtils;
|
||||
import com.example.essentialsplus.helpers.combat.WeaponType;
|
||||
import java.util.Locale;
|
||||
import net.runelite.api.Client;
|
||||
import net.runelite.api.EquipmentInventorySlot;
|
||||
import net.runelite.api.Item;
|
||||
import net.runelite.api.ItemComposition;
|
||||
import net.runelite.api.ItemContainer;
|
||||
import net.runelite.api.gameval.InventoryID;
|
||||
import net.runelite.api.gameval.VarPlayerID;
|
||||
|
||||
/** Resolves the active tile range for equipped weapons. */
|
||||
public final class AttackDistance
|
||||
{
|
||||
private static final int MAX_RANGE = 10;
|
||||
|
||||
private AttackDistance()
|
||||
{
|
||||
}
|
||||
|
||||
public static RangeInfo getEquippedAttackRange(Client client)
|
||||
{
|
||||
if (client == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
ItemContainer equipment = client.getItemContainer(InventoryID.WORN);
|
||||
WeaponType weaponType = CombatUtils.getWeaponType(client);
|
||||
AttackStyle style = getAttackStyle(client);
|
||||
String styleName = style == null ? "Unknown" : style.getName();
|
||||
Item weapon = equipment == null ? null : equipment.getItem(EquipmentInventorySlot.WEAPON.getSlotIdx());
|
||||
if (weapon == null || weapon.getId() <= 0)
|
||||
{
|
||||
return weaponType == WeaponType.UNARMED ? new RangeInfo("Unarmed", 1, styleName) : null;
|
||||
}
|
||||
|
||||
ItemComposition composition = client.getItemDefinition(weapon.getId());
|
||||
if (composition == null || composition.getName() == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
String weaponName = composition.getName();
|
||||
String normalizedName = weaponName.toLowerCase(Locale.ROOT);
|
||||
AttackType attackType = getAttackType(client);
|
||||
|
||||
if ((weaponType == WeaponType.STAFF || weaponType == WeaponType.BLADED_STAFF)
|
||||
&& (style == AttackStyle.CASTING || style == AttackStyle.DEFENSIVE_CASTING || attackType == AttackType.MAGIC))
|
||||
{
|
||||
// Standard combat spells have a 10-tile range, including autocast.
|
||||
return new RangeInfo(weaponName, MAX_RANGE, styleName);
|
||||
}
|
||||
|
||||
if (isPoweredMagicWeapon(weaponType, normalizedName))
|
||||
{
|
||||
int baseRange = getPoweredMagicRange(normalizedName);
|
||||
int range = hasLongRangeStyle(client, weaponType, normalizedName, style)
|
||||
? Math.min(MAX_RANGE, baseRange + 2)
|
||||
: baseRange;
|
||||
return new RangeInfo(weaponName, range, styleName);
|
||||
}
|
||||
|
||||
if (isRangedWeapon(weaponType, normalizedName) || attackType == AttackType.RANGED)
|
||||
{
|
||||
int baseRange = getRangedBaseRange(normalizedName, weaponType);
|
||||
int range = hasLongRangeStyle(client, weaponType, normalizedName, style)
|
||||
? Math.min(MAX_RANGE, baseRange + 2)
|
||||
: baseRange;
|
||||
return new RangeInfo(weaponName, range, styleName);
|
||||
}
|
||||
|
||||
if (attackType != null && attackType != AttackType.NONE)
|
||||
{
|
||||
int range = normalizedName.contains("halberd") || weaponType == WeaponType.POLEARM_CRYSTAL_HALBERD ? 2 : 1;
|
||||
return new RangeInfo(weaponName, range, styleName);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static boolean isRangedWeapon(WeaponType weaponType, String name)
|
||||
{
|
||||
return weaponType == WeaponType.BOW
|
||||
|| weaponType == WeaponType.HEAVY_RANGED
|
||||
|| weaponType == WeaponType.THROWN
|
||||
|| weaponType == WeaponType.CHINCHOMPA
|
||||
|| weaponType == WeaponType.SALAMANDER
|
||||
|| name.contains("bow")
|
||||
|| name.contains("ballista")
|
||||
|| name.contains("seercull")
|
||||
|| name.contains("dart")
|
||||
|| name.contains("knife")
|
||||
|| name.contains("throwing axe")
|
||||
|| name.contains("chinchompa")
|
||||
|| name.contains("toktz-xil-ul")
|
||||
|| name.contains("blowpipe")
|
||||
|| name.contains("hunter") && name.contains("spear")
|
||||
|| name.contains("eclipse atlatl")
|
||||
|| name.contains("tonalztics")
|
||||
|| name.contains("morrigan") && (name.contains("javelin") || name.contains("axe"))
|
||||
|| name.contains("holy water")
|
||||
|| name.contains("mud pie");
|
||||
}
|
||||
|
||||
private static int getRangedBaseRange(String name, WeaponType weaponType)
|
||||
{
|
||||
if (weaponType == WeaponType.SALAMANDER)
|
||||
{
|
||||
return 1;
|
||||
}
|
||||
if (name.contains("dart"))
|
||||
{
|
||||
return 3;
|
||||
}
|
||||
if (name.contains("knife") || name.contains("throwing axe") || name.contains("holy water"))
|
||||
{
|
||||
return 4;
|
||||
}
|
||||
if (name.contains("phoenix crossbow") || name.contains("comp ogre bow")
|
||||
|| name.contains("blowpipe") || name.contains("hunter") && name.contains("spear"))
|
||||
{
|
||||
return 5;
|
||||
}
|
||||
if (name.contains("morrigan") && name.contains("javelin"))
|
||||
{
|
||||
return 5;
|
||||
}
|
||||
if (name.contains("dorgeshuun crossbow") || name.contains("cursed goblin bow")
|
||||
|| name.contains("venator bow") || name.contains("eclipse atlatl")
|
||||
|| name.contains("tonalztics") || name.contains("mud pie"))
|
||||
{
|
||||
return 6;
|
||||
}
|
||||
if (weaponType == WeaponType.CHINCHOMPA || name.contains("chinchompa"))
|
||||
{
|
||||
return 9;
|
||||
}
|
||||
if (name.contains("toktz-xil-ul"))
|
||||
{
|
||||
return 7;
|
||||
}
|
||||
if (name.contains("karil")
|
||||
|| (name.contains("hunter") && name.contains("crossbow") && !name.contains("dragon"))
|
||||
|| name.contains("armadyl crossbow")
|
||||
|| name.contains("zaryte crossbow"))
|
||||
{
|
||||
return 8;
|
||||
}
|
||||
if (name.contains("ballista")
|
||||
|| name.contains("3rd age bow")
|
||||
|| name.contains("third-age bow")
|
||||
|| name.contains("craw")
|
||||
|| name.contains("webweaver bow"))
|
||||
{
|
||||
return 9;
|
||||
}
|
||||
if (name.contains("seercull"))
|
||||
{
|
||||
return 8;
|
||||
}
|
||||
if (name.equals("crossbow"))
|
||||
{
|
||||
return 10;
|
||||
}
|
||||
if (name.contains("longbow")
|
||||
|| name.contains("comp bow")
|
||||
|| name.contains("composite bow")
|
||||
|| name.contains("ogre bow")
|
||||
|| name.contains("crystal bow")
|
||||
|| name.contains("corrupted bow")
|
||||
|| name.contains("dark bow")
|
||||
|| name.contains("scorching bow")
|
||||
|| name.contains("faerdhinen")
|
||||
|| name.contains("twisted bow"))
|
||||
{
|
||||
return 10;
|
||||
}
|
||||
if (weaponType == WeaponType.THROWN)
|
||||
{
|
||||
return 4;
|
||||
}
|
||||
return weaponType == WeaponType.CHINCHOMPA ? 9 : 7;
|
||||
}
|
||||
|
||||
private static boolean hasLongRangeStyle(Client client, WeaponType weaponType, String name, AttackStyle style)
|
||||
{
|
||||
if (style == AttackStyle.LONGRANGE)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// Chinchompas call their extended range style "Long fuse" rather than "Longrange".
|
||||
return weaponType != WeaponType.SALAMANDER
|
||||
&& client.getVarpValue(VarPlayerID.COM_MODE) == 2
|
||||
&& (isRangedWeapon(weaponType, name) || isPoweredMagicWeapon(weaponType, name));
|
||||
}
|
||||
|
||||
private static int getPoweredMagicRange(String name)
|
||||
{
|
||||
if (name.contains("eye of ayak"))
|
||||
{
|
||||
return 6;
|
||||
}
|
||||
if (name.contains("tumeken's heka") || name.contains("tumeken’s heka"))
|
||||
{
|
||||
return 6;
|
||||
}
|
||||
if (name.contains("bone staff") || name.contains("tumeken's shadow") || name.contains("tumeken’s shadow"))
|
||||
{
|
||||
return 8;
|
||||
}
|
||||
if (name.contains("crystal staff") || name.contains("corrupted staff"))
|
||||
{
|
||||
return 10;
|
||||
}
|
||||
return 7;
|
||||
}
|
||||
|
||||
private static boolean isPoweredMagicWeapon(WeaponType weaponType, String name)
|
||||
{
|
||||
return weaponType == WeaponType.POWERED_STAFF
|
||||
|| weaponType == WeaponType.POWERED_WAND
|
||||
|| name.contains("trident")
|
||||
|| name.contains("crystal staff")
|
||||
|| name.contains("corrupted staff")
|
||||
|| name.contains("starter staff")
|
||||
|| name.contains("tumeken's heka")
|
||||
|| name.contains("tumeken’s heka");
|
||||
}
|
||||
|
||||
private static AttackStyle getAttackStyle(Client client)
|
||||
{
|
||||
try
|
||||
{
|
||||
return CombatUtils.getAttackStyle(client);
|
||||
}
|
||||
catch (RuntimeException ex)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static AttackType getAttackType(Client client)
|
||||
{
|
||||
try
|
||||
{
|
||||
return CombatUtils.getAttackType(client);
|
||||
}
|
||||
catch (RuntimeException ex)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public static final class RangeInfo
|
||||
{
|
||||
private final String weaponName;
|
||||
private final int tiles;
|
||||
private final String styleName;
|
||||
|
||||
private RangeInfo(String weaponName, int tiles, String styleName)
|
||||
{
|
||||
this.weaponName = weaponName;
|
||||
this.tiles = tiles;
|
||||
this.styleName = styleName;
|
||||
}
|
||||
|
||||
public String getWeaponName()
|
||||
{
|
||||
return weaponName;
|
||||
}
|
||||
|
||||
public int getTiles()
|
||||
{
|
||||
return tiles;
|
||||
}
|
||||
|
||||
public String getStyleName()
|
||||
{
|
||||
return styleName;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
package com.example.essentialsplus.helpers;
|
||||
|
||||
import com.example.essentialsplus.EssentialsPlusConfig;
|
||||
import com.example.essentialsplus.EssentialsPlusPlugin;
|
||||
import java.awt.BasicStroke;
|
||||
import java.awt.Color;
|
||||
import java.awt.Dimension;
|
||||
import java.awt.Graphics2D;
|
||||
import java.awt.Polygon;
|
||||
import java.awt.RenderingHints;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import javax.inject.Inject;
|
||||
import net.runelite.api.Actor;
|
||||
import net.runelite.api.Client;
|
||||
import net.runelite.api.GameState;
|
||||
import net.runelite.api.NPC;
|
||||
import net.runelite.api.Perspective;
|
||||
import net.runelite.api.Player;
|
||||
import net.runelite.api.Scene;
|
||||
import net.runelite.api.Tile;
|
||||
import net.runelite.api.WorldView;
|
||||
import net.runelite.api.coords.LocalPoint;
|
||||
import net.runelite.api.coords.WorldArea;
|
||||
import net.runelite.api.coords.WorldPoint;
|
||||
import net.runelite.client.ui.overlay.Overlay;
|
||||
import net.runelite.client.ui.overlay.OverlayLayer;
|
||||
import net.runelite.client.ui.overlay.OverlayPosition;
|
||||
|
||||
/** Outlines the attack range of the equipped weapon. */
|
||||
public class AttackDistanceOverlay extends Overlay
|
||||
{
|
||||
private static final Color RANGE_BORDER = new Color(255, 190, 66, 180);
|
||||
|
||||
private final Client client;
|
||||
private final EssentialsPlusConfig config;
|
||||
private final EssentialsPlusPlugin plugin;
|
||||
private long lastCombatNanos;
|
||||
|
||||
@Inject
|
||||
AttackDistanceOverlay(Client client, EssentialsPlusConfig config, EssentialsPlusPlugin plugin)
|
||||
{
|
||||
this.client = client;
|
||||
this.config = config;
|
||||
this.plugin = plugin;
|
||||
setPosition(OverlayPosition.DYNAMIC);
|
||||
setLayer(OverlayLayer.ABOVE_SCENE);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Dimension render(Graphics2D graphics)
|
||||
{
|
||||
if (!config.attackDistance() || client.getGameState() != GameState.LOGGED_IN)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
Player player = client.getLocalPlayer();
|
||||
if (player == null || player.getWorldArea() == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
if (plugin.getHelperValue("attackDistanceHideOutOfCombat", Boolean.class, false) && !shouldShowAfterCombat(player))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
AttackDistance.RangeInfo range = AttackDistance.getEquippedAttackRange(client);
|
||||
if (range == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
WorldView worldView = player.getWorldView();
|
||||
Scene scene = worldView == null ? null : worldView.getScene();
|
||||
Tile[][][] tiles = scene == null ? null : scene.getTiles();
|
||||
int plane = worldView == null ? -1 : worldView.getPlane();
|
||||
if (tiles == null || plane < 0 || plane >= tiles.length || tiles[plane] == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
graphics.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
|
||||
Map<Edge, Integer> edges = new HashMap<>();
|
||||
WorldArea playerArea = player.getWorldArea();
|
||||
for (Tile[] row : tiles[plane])
|
||||
{
|
||||
if (row == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
for (Tile tile : row)
|
||||
{
|
||||
if (tile == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
WorldPoint location = tile.getWorldLocation();
|
||||
if (location == null || location.getPlane() != plane || playerArea.distanceTo(location) > range.getTiles())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
LocalPoint localPoint = tile.getLocalLocation();
|
||||
if (localPoint == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
Polygon polygon = Perspective.getCanvasTilePoly(client, localPoint);
|
||||
if (polygon == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
addEdges(edges, polygon);
|
||||
}
|
||||
}
|
||||
|
||||
graphics.setColor(RANGE_BORDER);
|
||||
graphics.setStroke(new BasicStroke(1.5f, BasicStroke.CAP_ROUND, BasicStroke.JOIN_ROUND));
|
||||
for (Map.Entry<Edge, Integer> entry : edges.entrySet())
|
||||
{
|
||||
if (entry.getValue() == 1)
|
||||
{
|
||||
Edge edge = entry.getKey();
|
||||
graphics.drawLine(edge.x1, edge.y1, edge.x2, edge.y2);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private boolean shouldShowAfterCombat(Player player)
|
||||
{
|
||||
long now = System.nanoTime();
|
||||
if (isInCombat(player))
|
||||
{
|
||||
lastCombatNanos = now;
|
||||
return true;
|
||||
}
|
||||
|
||||
return lastCombatNanos != 0 && now - lastCombatNanos < 30_000_000_000L;
|
||||
}
|
||||
|
||||
private boolean isInCombat(Player player)
|
||||
{
|
||||
Actor target = player.getInteracting();
|
||||
if (target != null && !target.isDead())
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
WorldView worldView = player.getWorldView();
|
||||
if (worldView == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
for (NPC npc : worldView.npcs())
|
||||
{
|
||||
if (npc != null && !npc.isDead() && npc.getInteracting() == player)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
for (Player other : worldView.players())
|
||||
{
|
||||
if (other != null && other != player && !other.isDead() && other.getInteracting() == player)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static void addEdges(Map<Edge, Integer> edges, Polygon polygon)
|
||||
{
|
||||
for (int i = 0; i < polygon.npoints; i++)
|
||||
{
|
||||
int next = (i + 1) % polygon.npoints;
|
||||
Edge edge = new Edge(polygon.xpoints[i], polygon.ypoints[i], polygon.xpoints[next], polygon.ypoints[next]);
|
||||
edges.merge(edge, 1, Integer::sum);
|
||||
}
|
||||
}
|
||||
|
||||
private static final class Edge
|
||||
{
|
||||
private final int x1;
|
||||
private final int y1;
|
||||
private final int x2;
|
||||
private final int y2;
|
||||
|
||||
private Edge(int x1, int y1, int x2, int y2)
|
||||
{
|
||||
if (x1 < x2 || (x1 == x2 && y1 <= y2))
|
||||
{
|
||||
this.x1 = x1;
|
||||
this.y1 = y1;
|
||||
this.x2 = x2;
|
||||
this.y2 = y2;
|
||||
}
|
||||
else
|
||||
{
|
||||
this.x1 = x2;
|
||||
this.y1 = y2;
|
||||
this.x2 = x1;
|
||||
this.y2 = y1;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object other)
|
||||
{
|
||||
if (this == other)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
if (!(other instanceof Edge))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
Edge edge = (Edge) other;
|
||||
return x1 == edge.x1 && y1 == edge.y1 && x2 == edge.x2 && y2 == edge.y2;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode()
|
||||
{
|
||||
return Objects.hash(x1, y1, x2, y2);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,196 @@
|
||||
package com.example.essentialsplus.helpers;
|
||||
|
||||
import com.example.essentialsplus.EssentialsPlusConfig;
|
||||
import com.example.essentialsplus.EssentialsPlusPlugin;
|
||||
import java.awt.Color;
|
||||
import java.awt.Graphics2D;
|
||||
import java.awt.Rectangle;
|
||||
import javax.inject.Inject;
|
||||
import net.runelite.api.gameval.InterfaceID;
|
||||
import net.runelite.api.gameval.ItemID;
|
||||
import net.runelite.api.widgets.Widget;
|
||||
import net.runelite.api.widgets.WidgetItem;
|
||||
import net.runelite.api.widgets.WidgetUtil;
|
||||
import net.runelite.client.ui.overlay.WidgetItemOverlay;
|
||||
|
||||
/**
|
||||
* Marks the active Blast Furnace item in bank and inventory: coal bag to fill, bars to deposit,
|
||||
* or the target ore in the bank to withdraw. Only a single item type is marked at any time,
|
||||
* with border-only highlighting to keep items clearly recognizable.
|
||||
*/
|
||||
public class BlastFurnaceItemsOverlay extends WidgetItemOverlay
|
||||
{
|
||||
private final EssentialsPlusConfig config;
|
||||
private final EssentialsPlusPlugin plugin;
|
||||
private final BlastFurnace blastFurnace;
|
||||
|
||||
@Inject
|
||||
public BlastFurnaceItemsOverlay(EssentialsPlusConfig config, EssentialsPlusPlugin plugin, BlastFurnace blastFurnace)
|
||||
{
|
||||
this.config = config;
|
||||
this.plugin = plugin;
|
||||
this.blastFurnace = blastFurnace;
|
||||
showOnInventory();
|
||||
showOnBank();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void renderItemOverlay(Graphics2D g, int itemId, WidgetItem widgetItem)
|
||||
{
|
||||
if (!config.blastFurnace() || !blastFurnace.isAtBlastFurnace())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Widget widget = widgetItem.getWidget();
|
||||
if (widget == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
boolean bank = isBank(widget);
|
||||
boolean bankOpen = blastFurnace.isBankOpen();
|
||||
|
||||
if (bankOpen)
|
||||
{
|
||||
// State 1: Deposit bars in inventory
|
||||
if (blastFurnace.inventoryHasBars())
|
||||
{
|
||||
if (bank || !plugin.getHelperValue("blastFurnaceMarkItems", Boolean.class, true))
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (!BlastFurnace.isBar(itemId))
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
// State 2: Deposit empty vials in inventory
|
||||
else if (blastFurnace.inventoryHasVials())
|
||||
{
|
||||
if (bank || !plugin.getHelperValue("blastFurnaceMarkItems", Boolean.class, true))
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (itemId != ItemID.VIAL_EMPTY)
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
// State 3: Deposit leftover stamina potion in inventory (if energy is already > 30)
|
||||
else if (!blastFurnace.needsStamina() && blastFurnace.inventoryHasStaminaPotion())
|
||||
{
|
||||
if (bank || !plugin.getHelperValue("blastFurnaceMarkItems", Boolean.class, true))
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (!BlastFurnace.isStaminaPotion(itemId))
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
// State 4: Drink stamina potion if energy <= 30
|
||||
else if (plugin.getHelperValue("blastFurnaceStamina", Boolean.class, true) && blastFurnace.needsStamina())
|
||||
{
|
||||
if (blastFurnace.inventoryHasStaminaPotion())
|
||||
{
|
||||
// Drink from inventory
|
||||
if (bank || !plugin.getHelperValue("blastFurnaceMarkItems", Boolean.class, true))
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (itemId != blastFurnace.getInventoryStaminaPotion())
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Withdraw from bank
|
||||
if (!bank || !plugin.getHelperValue("blastFurnaceMarkBankItems", Boolean.class, true))
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (itemId != blastFurnace.getTargetBankStaminaPotion())
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
// State 5: Inventory is full -> conveyor belt is marked in 3D world, nothing in bank/inventory
|
||||
else if (blastFurnace.isInventoryFull())
|
||||
{
|
||||
return;
|
||||
}
|
||||
// State 6: Fill coal bag in inventory and withdraw target ore/coal from bank simultaneously
|
||||
else
|
||||
{
|
||||
if (!bank)
|
||||
{
|
||||
// In inventory: highlight coal bag if it needs filling
|
||||
if (!plugin.getHelperValue("blastFurnaceMarkItems", Boolean.class, true))
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (!blastFurnace.coalBagNeedsFill())
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (itemId != ItemID.COAL_BAG && itemId != ItemID.COAL_BAG_OPEN)
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// In bank: highlight target ore / coal to withdraw
|
||||
if (!plugin.getHelperValue("blastFurnaceMarkBankItems", Boolean.class, true))
|
||||
{
|
||||
return;
|
||||
}
|
||||
int targetOre = blastFurnace.getTargetBankOre();
|
||||
if (targetOre == -1 || itemId != targetOre)
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Bank is closed:
|
||||
// If coal in bag and no ores in inventory (e.g. at belt to empty bag)
|
||||
if (blastFurnace.bagHasCoal() && !blastFurnace.inventoryHasOres() && blastFurnace.hasCoalBag())
|
||||
{
|
||||
if (bank || !plugin.getHelperValue("blastFurnaceMarkItems", Boolean.class, true))
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (itemId != ItemID.COAL_BAG && itemId != ItemID.COAL_BAG_OPEN)
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle bounds = widgetItem.getCanvasBounds();
|
||||
if (bounds == null || bounds.width < 4 || bounds.height < 4)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Color color = plugin.getHelperValue("blastFurnaceColor", Color.class, BlastFurnaceOverlay.DEFAULT_MARK_COLOR);
|
||||
g.setColor(color);
|
||||
g.drawRect(bounds.x, bounds.y, bounds.width - 1, bounds.height - 1);
|
||||
g.drawRect(bounds.x + 1, bounds.y + 1, bounds.width - 3, bounds.height - 3);
|
||||
}
|
||||
|
||||
private static boolean isBank(Widget widget)
|
||||
{
|
||||
int group = WidgetUtil.componentToInterface(widget.getId());
|
||||
return group == InterfaceID.BANKMAIN || group == 12; // 12 = InterfaceID.BANK
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
package com.example.essentialsplus.helpers;
|
||||
|
||||
import com.example.essentialsplus.EssentialsPlusConfig;
|
||||
import com.example.essentialsplus.EssentialsPlusPlugin;
|
||||
import com.example.essentialsplus.ui.Ui;
|
||||
import java.awt.Color;
|
||||
import java.awt.Dimension;
|
||||
import java.awt.Font;
|
||||
import java.awt.FontMetrics;
|
||||
import java.awt.Graphics2D;
|
||||
import java.awt.Shape;
|
||||
import javax.inject.Inject;
|
||||
import net.runelite.api.GameObject;
|
||||
import net.runelite.api.Point;
|
||||
import net.runelite.client.ui.overlay.Overlay;
|
||||
import net.runelite.client.ui.overlay.OverlayLayer;
|
||||
import net.runelite.client.ui.overlay.OverlayPosition;
|
||||
|
||||
/**
|
||||
* Marks the Blast Furnace objects that are worth clicking right now: the conveyor belt while
|
||||
* ores are waiting in the inventory, the bar dispenser when bars are ready and the bank chest
|
||||
* when the bars in the inventory should be banked.
|
||||
*/
|
||||
public class BlastFurnaceOverlay extends Overlay
|
||||
{
|
||||
public static final Color DEFAULT_MARK_COLOR = new Color(0xFFB300);
|
||||
public static final Color DEFAULT_READY_COLOR = new Color(0x66FF66);
|
||||
|
||||
private final EssentialsPlusConfig config;
|
||||
private final EssentialsPlusPlugin plugin;
|
||||
private final BlastFurnace blastFurnace;
|
||||
|
||||
@Inject
|
||||
public BlastFurnaceOverlay(EssentialsPlusConfig config, EssentialsPlusPlugin plugin, BlastFurnace blastFurnace)
|
||||
{
|
||||
this.config = config;
|
||||
this.plugin = plugin;
|
||||
this.blastFurnace = blastFurnace;
|
||||
setPosition(OverlayPosition.DYNAMIC);
|
||||
setLayer(OverlayLayer.ABOVE_SCENE);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Dimension render(Graphics2D g)
|
||||
{
|
||||
if (!config.blastFurnace() || !blastFurnace.isAtBlastFurnace())
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
boolean bankOpen = blastFurnace.isBankOpen();
|
||||
Color markColor = plugin.getHelperValue("blastFurnaceColor", Color.class, DEFAULT_MARK_COLOR);
|
||||
Color readyColor = plugin.getHelperValue("blastFurnaceReadyColor", Color.class, DEFAULT_READY_COLOR);
|
||||
boolean labels = plugin.getHelperValue("blastFurnaceLabels", Boolean.class, true);
|
||||
TimerTextSize size = plugin.getHelperValue("blastFurnaceTextSize", TimerTextSize.class, TimerTextSize.SMALL);
|
||||
|
||||
// 1. Bars in inventory -> bank chest (only if bank is closed)
|
||||
if (blastFurnace.inventoryHasBars())
|
||||
{
|
||||
if (!bankOpen && plugin.getHelperValue("blastFurnaceMarkBank", Boolean.class, true))
|
||||
{
|
||||
mark(g, blastFurnace.getBankChest(), markColor, labels ? "Bank bars" : null, size);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// 2. Full inventory or ores in inventory -> conveyor belt (ALLOWED EVEN IF BANK IS OPEN!)
|
||||
if (blastFurnace.isInventoryFull() || blastFurnace.inventoryHasOres())
|
||||
{
|
||||
if (plugin.getHelperValue("blastFurnaceMarkBelt", Boolean.class, true))
|
||||
{
|
||||
String label = blastFurnace.inventoryHasCoal() ? "Put coal on belt" : "Put ore on belt";
|
||||
mark(g, blastFurnace.getConveyorBelt(), markColor, labels ? label : null, size);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// While bank is open and inventory is not full, player is interacting with bank items
|
||||
if (bankOpen)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// 3. Bars ready to collect or waiting for ores to smelt -> bar dispenser!
|
||||
if (blastFurnace.isWaitingForBars())
|
||||
{
|
||||
if (plugin.getHelperValue("blastFurnaceMarkDispenser", Boolean.class, true))
|
||||
{
|
||||
boolean ready = blastFurnace.barsReady();
|
||||
Color color = ready ? readyColor : markColor;
|
||||
String label = ready ? "Collect bars" : "Wait for bars";
|
||||
mark(g, blastFurnace.getBarDispenser(), color, labels ? label : null, size);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// 4. Coal in coal bag to empty onto belt
|
||||
if (blastFurnace.bagHasCoal() && blastFurnace.hasCoalBag())
|
||||
{
|
||||
if (plugin.getHelperValue("blastFurnaceMarkBelt", Boolean.class, true))
|
||||
{
|
||||
mark(g, blastFurnace.getConveyorBelt(), markColor, labels ? "Put coal on belt" : null, size);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// 5. Otherwise open bank
|
||||
if (plugin.getHelperValue("blastFurnaceMarkBank", Boolean.class, true))
|
||||
{
|
||||
mark(g, blastFurnace.getBankChest(), markColor, labels ? "Open bank" : null, size);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private void mark(Graphics2D g, GameObject object, Color color, String label, TimerTextSize size)
|
||||
{
|
||||
if (object == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Shape shape = object.getClickbox();
|
||||
if (shape == null)
|
||||
{
|
||||
shape = object.getCanvasTilePoly();
|
||||
}
|
||||
if (shape != null)
|
||||
{
|
||||
g.setColor(color);
|
||||
g.draw(shape);
|
||||
g.setColor(Ui.alpha(color, 45));
|
||||
g.fill(shape);
|
||||
}
|
||||
|
||||
if (label == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Font font = size.font();
|
||||
g.setFont(font);
|
||||
Point anchor = object.getCanvasTextLocation(g, label, 30);
|
||||
if (anchor == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
FontMetrics fm = g.getFontMetrics();
|
||||
int x = anchor.getX() - fm.stringWidth(label) / 2;
|
||||
int y = anchor.getY();
|
||||
g.setColor(Color.BLACK);
|
||||
g.drawString(label, x + 1, y + 1);
|
||||
g.setColor(color);
|
||||
g.drawString(label, x, y);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
package com.example.essentialsplus.helpers;
|
||||
|
||||
import com.example.essentialsplus.EssentialsPlusConfig;
|
||||
import com.example.essentialsplus.EssentialsPlusPlugin;
|
||||
import com.example.essentialsplus.ui.Ui;
|
||||
import java.awt.Color;
|
||||
import java.awt.Dimension;
|
||||
import java.awt.Graphics2D;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import javax.inject.Inject;
|
||||
import net.runelite.client.ui.overlay.OverlayPanel;
|
||||
import net.runelite.client.ui.overlay.OverlayPosition;
|
||||
import net.runelite.client.ui.overlay.components.LineComponent;
|
||||
import net.runelite.client.ui.overlay.components.TitleComponent;
|
||||
|
||||
/**
|
||||
* Panel with the Blast Furnace numbers: coal in the furnace, bars waiting in the dispenser,
|
||||
* coffer and remaining time, and how many bars the ores in the bank can still be turned into.
|
||||
*/
|
||||
public class BlastFurnacePanelOverlay extends OverlayPanel
|
||||
{
|
||||
private final EssentialsPlusPlugin plugin;
|
||||
private final EssentialsPlusConfig config;
|
||||
private final BlastFurnace blastFurnace;
|
||||
|
||||
@Inject
|
||||
BlastFurnacePanelOverlay(EssentialsPlusPlugin plugin, EssentialsPlusConfig config, BlastFurnace blastFurnace)
|
||||
{
|
||||
super(plugin);
|
||||
this.plugin = plugin;
|
||||
this.config = config;
|
||||
this.blastFurnace = blastFurnace;
|
||||
setPosition(OverlayPosition.TOP_LEFT);
|
||||
panelComponent.setPreferredSize(new Dimension(190, 0));
|
||||
}
|
||||
|
||||
/** Moves the panel to the corner picked in the helper menu (kept out of render so dragging works). */
|
||||
public void applyPosition()
|
||||
{
|
||||
OverlayPosition position = plugin.getHelperValue("blastFurnacePosition", OverlayPosition.class, OverlayPosition.TOP_LEFT);
|
||||
if (position != null)
|
||||
{
|
||||
setPosition(position);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public Dimension render(Graphics2D graphics)
|
||||
{
|
||||
if (!config.blastFurnace() || !blastFurnace.isAtBlastFurnace())
|
||||
{
|
||||
return null;
|
||||
}
|
||||
if (!plugin.getHelperValue("blastFurnacePanel", Boolean.class, true))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
List<LineComponent> lines = new ArrayList<>();
|
||||
|
||||
if (plugin.getHelperValue("blastFurnaceShowCoal", Boolean.class, true))
|
||||
{
|
||||
int coal = blastFurnace.coalInFurnace();
|
||||
BlastFurnace.BarType target = blastFurnace.getTargetBarType();
|
||||
int needed = target != null ? blastFurnace.getRequiredFurnaceCoal(target) : 0;
|
||||
Color coalColor = (needed > 0 && coal < needed) ? Ui.WARN : Ui.TEXT;
|
||||
String text = Ui.gp(coal) + (needed > 0 ? " / " + needed : "");
|
||||
lines.add(line("Coal in furnace", text, coalColor));
|
||||
}
|
||||
|
||||
if (plugin.getHelperValue("blastFurnaceShowFurnaceBars", Boolean.class, true))
|
||||
{
|
||||
blastFurnace.barsInFurnace().forEach((type, amount) ->
|
||||
lines.add(line(type.getLabel() + " (Furnace)", Ui.gp(amount), Ui.UP)));
|
||||
}
|
||||
|
||||
long coffer = blastFurnace.cofferGp();
|
||||
if (plugin.getHelperValue("blastFurnaceShowCoffer", Boolean.class, true))
|
||||
{
|
||||
lines.add(line("Coffer", Ui.gp(coffer) + " gp", Ui.GOLD));
|
||||
}
|
||||
if (plugin.getHelperValue("blastFurnaceShowCofferTime", Boolean.class, true))
|
||||
{
|
||||
long millis = BlastFurnace.cofferMillis(coffer);
|
||||
Color color = millis < 5 * Ui.MINUTE ? Ui.DOWN : (millis < 30 * Ui.MINUTE ? Ui.WARN : Ui.TEXT);
|
||||
lines.add(line("Coffer time", duration(millis), color));
|
||||
}
|
||||
|
||||
if (plugin.getHelperValue("blastFurnaceShowBankBars", Boolean.class, true))
|
||||
{
|
||||
int limit = Math.max(0, plugin.getHelperValue("blastFurnaceBankLines", Integer.class, 3));
|
||||
List<BlastFurnace.Estimate> estimates = blastFurnace.remainingBars();
|
||||
for (int i = 0; i < Math.min(limit, estimates.size()); i++)
|
||||
{
|
||||
BlastFurnace.Estimate estimate = estimates.get(i);
|
||||
String value = Ui.gp(estimate.bars) + (estimate.coalLimited ? " (Coal)" : "");
|
||||
lines.add(line("Bank: " + estimate.type.getLabel(), value, estimate.coalLimited ? Ui.WARN : Ui.TEXT));
|
||||
}
|
||||
}
|
||||
|
||||
if (lines.isEmpty())
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
panelComponent.getChildren().add(TitleComponent.builder()
|
||||
.text("Blast Furnace")
|
||||
.color(Ui.ACCENT)
|
||||
.build());
|
||||
panelComponent.getChildren().addAll(lines);
|
||||
return super.render(graphics);
|
||||
}
|
||||
|
||||
private static LineComponent line(String left, String right, Color rightColor)
|
||||
{
|
||||
return LineComponent.builder()
|
||||
.left(left)
|
||||
.leftColor(Ui.MUTED)
|
||||
.right(right)
|
||||
.rightColor(rightColor)
|
||||
.build();
|
||||
}
|
||||
|
||||
private static String duration(long millis)
|
||||
{
|
||||
long seconds = Math.max(0, millis / 1000);
|
||||
long hours = seconds / 3600;
|
||||
long minutes = (seconds % 3600) / 60;
|
||||
if (hours > 0)
|
||||
{
|
||||
return hours + "h " + minutes + "m";
|
||||
}
|
||||
if (minutes > 0)
|
||||
{
|
||||
return minutes + "m " + (seconds % 60) + "s";
|
||||
}
|
||||
return seconds + "s";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,682 @@
|
||||
package com.example.essentialsplus.helpers;
|
||||
|
||||
import com.example.essentialsplus.EssentialsPlusConfig;
|
||||
import java.awt.Color;
|
||||
import java.awt.Toolkit;
|
||||
import java.awt.datatransfer.StringSelection;
|
||||
import java.time.Instant;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Arrays;
|
||||
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.ChatLineBuffer;
|
||||
import net.runelite.api.ChatMessageType;
|
||||
import net.runelite.api.Client;
|
||||
import net.runelite.api.GameState;
|
||||
import net.runelite.api.MenuAction;
|
||||
import net.runelite.api.MenuEntry;
|
||||
import net.runelite.api.MessageNode;
|
||||
import net.runelite.api.Player;
|
||||
import net.runelite.api.Point;
|
||||
import net.runelite.api.ScriptID;
|
||||
import net.runelite.api.events.ChatMessage;
|
||||
import net.runelite.api.events.GameStateChanged;
|
||||
import net.runelite.api.events.GameTick;
|
||||
import net.runelite.api.events.MenuEntryAdded;
|
||||
import net.runelite.api.events.MenuOpened;
|
||||
import net.runelite.api.events.ScriptCallbackEvent;
|
||||
import net.runelite.api.gameval.InterfaceID;
|
||||
import net.runelite.api.widgets.Widget;
|
||||
import net.runelite.api.widgets.WidgetUtil;
|
||||
import net.runelite.client.callback.ClientThread;
|
||||
import net.runelite.client.eventbus.Subscribe;
|
||||
import net.runelite.client.util.ColorUtil;
|
||||
import net.runelite.client.util.Text;
|
||||
|
||||
/**
|
||||
* Chat Helper:
|
||||
* - Auto-clears messages after a configurable duration (e.g. 1 minute) to keep the chat tidy.
|
||||
* - Collapses consecutive duplicate messages (x2, x3...).
|
||||
* - Filters repetitive game noise and spam messages.
|
||||
* - Highlights mentions of the player's name in speech chats.
|
||||
* - Adds right-click "Copy message" and "Clear chat" options.
|
||||
*/
|
||||
@Slf4j
|
||||
@Singleton
|
||||
public class ChatHelper
|
||||
{
|
||||
private static final Set<String> NOISE_MESSAGES = Set.of(
|
||||
"Nothing interesting happens.",
|
||||
"You can't reach that.",
|
||||
"You cannot reach that.",
|
||||
"I can't reach that!",
|
||||
"You can't do that here.",
|
||||
"You cannot do that here.",
|
||||
"You can't do that while moving.",
|
||||
"You cannot do that while moving.",
|
||||
"You can't do that right now.",
|
||||
"You cannot do that right now.",
|
||||
"You can't log out right now.",
|
||||
"You don't have enough inventory space.",
|
||||
"You do not have enough inventory space.",
|
||||
"There is no player by that name.",
|
||||
"That player is currently offline.",
|
||||
"You are not currently in a clan.",
|
||||
"You are not currently in a friends chat."
|
||||
);
|
||||
|
||||
private static final Pattern NOISE_PREFIX_PATTERN = Pattern.compile(
|
||||
"^(Your inventory is too full to hold any more|You need a higher level to|You need to be in a member's world to).*",
|
||||
Pattern.CASE_INSENSITIVE
|
||||
);
|
||||
|
||||
private static final Pattern PUBLIC_SPAM_PATTERN = Pattern.compile(
|
||||
"(?i).*(giveaway|free gp|quitting.*search|search on yt|search yt|sub to my yt|2b for|swap cc|dicing cc|roll 55|autoclicker|osrsgold|buy gp).*"
|
||||
);
|
||||
|
||||
private static class Duplicate
|
||||
{
|
||||
int messageId = -1;
|
||||
int count = 1;
|
||||
}
|
||||
|
||||
private final Client client;
|
||||
private final EssentialsPlusConfig config;
|
||||
private final ClientThread clientThread;
|
||||
|
||||
private final LinkedHashMap<String, Duplicate> duplicateChatCache = new LinkedHashMap<String, Duplicate>(100, 0.75f, true)
|
||||
{
|
||||
@Override
|
||||
protected boolean removeEldestEntry(Map.Entry<String, Duplicate> eldest)
|
||||
{
|
||||
return size() > 100;
|
||||
}
|
||||
};
|
||||
|
||||
private String lastConsecutiveKey = null;
|
||||
private int lastCheckEpoch = 0;
|
||||
|
||||
@Inject
|
||||
public ChatHelper(Client client, EssentialsPlusConfig config, ClientThread clientThread)
|
||||
{
|
||||
this.client = client;
|
||||
this.config = config;
|
||||
this.clientThread = clientThread;
|
||||
}
|
||||
|
||||
public void reset()
|
||||
{
|
||||
duplicateChatCache.clear();
|
||||
lastConsecutiveKey = null;
|
||||
lastCheckEpoch = 0;
|
||||
}
|
||||
|
||||
@Subscribe
|
||||
public void onGameStateChanged(GameStateChanged event)
|
||||
{
|
||||
if (event.getGameState() == GameState.LOGIN_SCREEN || event.getGameState() == GameState.HOPPING)
|
||||
{
|
||||
reset();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-clear expired chat messages based on configured retention time.
|
||||
*/
|
||||
@Subscribe
|
||||
public void onGameTick(GameTick event)
|
||||
{
|
||||
if (!config.chatHelper() || !config.chatAutoClear())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
int timeoutSeconds = config.chatAutoClearTime();
|
||||
if (timeoutSeconds <= 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
int now = (int) Instant.now().getEpochSecond();
|
||||
if (now - lastCheckEpoch < 1)
|
||||
{
|
||||
return;
|
||||
}
|
||||
lastCheckEpoch = now;
|
||||
|
||||
Map<Integer, ChatLineBuffer> chatLineMap = client.getChatLineMap();
|
||||
if (chatLineMap == null || chatLineMap.isEmpty())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
boolean removedAny = false;
|
||||
for (ChatLineBuffer buffer : chatLineMap.values())
|
||||
{
|
||||
if (buffer == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
MessageNode[] lines = buffer.getLines();
|
||||
if (lines == null || lines.length == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
MessageNode[] copy = lines.clone();
|
||||
for (MessageNode node : copy)
|
||||
{
|
||||
if (node == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
int ts = node.getTimestamp();
|
||||
if (ts > 0 && (now - ts) >= timeoutSeconds && shouldClearType(node.getType()))
|
||||
{
|
||||
buffer.removeMessageNode(node);
|
||||
removedAny = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (removedAny)
|
||||
{
|
||||
refreshChat();
|
||||
}
|
||||
}
|
||||
|
||||
private void refreshChat()
|
||||
{
|
||||
// Invalidate the cached chat before rebuilding the chatbox and split PMs.
|
||||
client.refreshChat();
|
||||
client.runScript(ScriptID.SPLITPM_CHANGED);
|
||||
}
|
||||
|
||||
private boolean shouldClearType(ChatMessageType type)
|
||||
{
|
||||
if (type == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
switch (type)
|
||||
{
|
||||
case GAMEMESSAGE:
|
||||
case ENGINE:
|
||||
case ITEM_EXAMINE:
|
||||
case NPC_EXAMINE:
|
||||
case OBJECT_EXAMINE:
|
||||
case SPAM:
|
||||
case BROADCAST:
|
||||
case CONSOLE:
|
||||
case WELCOME:
|
||||
case PLAYERRELATED:
|
||||
case TENSECTIMEOUT:
|
||||
case SNAPSHOTFEEDBACK:
|
||||
case FRIENDNOTIFICATION:
|
||||
case IGNORENOTIFICATION:
|
||||
return config.chatClearGame();
|
||||
|
||||
case PUBLICCHAT:
|
||||
case MODCHAT:
|
||||
case AUTOTYPER:
|
||||
case MODAUTOTYPER:
|
||||
case TRADEREQ:
|
||||
case TRADE:
|
||||
case TRADE_SENT:
|
||||
case CHALREQ_TRADE:
|
||||
return config.chatClearPublic();
|
||||
|
||||
case PRIVATECHAT:
|
||||
case PRIVATECHATOUT:
|
||||
case MODPRIVATECHAT:
|
||||
return config.chatClearPrivate();
|
||||
|
||||
case CLAN_CHAT:
|
||||
case CLAN_GUEST_CHAT:
|
||||
case FRIENDSCHAT:
|
||||
case CLAN_MESSAGE:
|
||||
case CLAN_GUEST_MESSAGE:
|
||||
case FRIENDSCHATNOTIFICATION:
|
||||
case CLAN_GIM_CHAT:
|
||||
case CLAN_GIM_MESSAGE:
|
||||
return config.chatClearClan();
|
||||
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tracks incoming chat messages for duplicate collapsing.
|
||||
*/
|
||||
@Subscribe
|
||||
public void onChatMessage(ChatMessage event)
|
||||
{
|
||||
if (!config.chatHelper() || !config.chatCollapseDuplicates())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
ChatMessageType type = event.getType();
|
||||
if (!isCollapsible(type))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
String name = event.getName() != null ? event.getName() : "";
|
||||
String message = event.getMessage();
|
||||
String key = name + ":" + type.ordinal() + ":" + message;
|
||||
|
||||
if (!key.equals(lastConsecutiveKey))
|
||||
{
|
||||
lastConsecutiveKey = key;
|
||||
duplicateChatCache.remove(key);
|
||||
}
|
||||
|
||||
Duplicate dup = duplicateChatCache.get(key);
|
||||
if (dup == null)
|
||||
{
|
||||
dup = new Duplicate();
|
||||
dup.count = 1;
|
||||
dup.messageId = event.getMessageNode() != null ? event.getMessageNode().getId() : -1;
|
||||
duplicateChatCache.put(key, dup);
|
||||
}
|
||||
else
|
||||
{
|
||||
dup.count++;
|
||||
dup.messageId = event.getMessageNode() != null ? event.getMessageNode().getId() : -1;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Intercepts chat line rendering to filter spam, collapse duplicates, and highlight names.
|
||||
*/
|
||||
@Subscribe
|
||||
public void onScriptCallbackEvent(ScriptCallbackEvent event)
|
||||
{
|
||||
if (!"chatFilterCheck".equals(event.getEventName()))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!config.chatHelper())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
int[] intStack = client.getIntStack();
|
||||
int intStackSize = client.getIntStackSize();
|
||||
Object[] objectStack = client.getObjectStack();
|
||||
int objectStackSize = client.getObjectStackSize();
|
||||
|
||||
if (intStackSize < 3 || objectStackSize < 1)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
int messageTypeVal = intStack[intStackSize - 2];
|
||||
int messageId = intStack[intStackSize - 1];
|
||||
String message = (String) objectStack[objectStackSize - 1];
|
||||
if (message == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// 1. Spam & Noise filtering
|
||||
if (config.chatFilterSpam() && isSpamOrNoise(messageTypeVal, message))
|
||||
{
|
||||
intStack[intStackSize - 3] = 0; // hide message
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. Duplicate collapsing
|
||||
if (config.chatCollapseDuplicates())
|
||||
{
|
||||
ChatMessageType type = ChatMessageType.of(messageTypeVal);
|
||||
if (isCollapsible(type))
|
||||
{
|
||||
MessageNode node = null;
|
||||
if (client.getMessages() != null)
|
||||
{
|
||||
node = (MessageNode) client.getMessages().get((long) messageId);
|
||||
}
|
||||
String name = node != null && node.getName() != null ? node.getName() : "";
|
||||
|
||||
String key = name + ":" + (type != null ? type.ordinal() : messageTypeVal) + ":" + message;
|
||||
Duplicate dup = duplicateChatCache.get(key);
|
||||
if (dup != null && dup.messageId != -1)
|
||||
{
|
||||
if (dup.messageId == messageId)
|
||||
{
|
||||
if (dup.count > 1)
|
||||
{
|
||||
message = message + " <col=888888>(x" + dup.count + ")</col>";
|
||||
objectStack[objectStackSize - 1] = message;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Hide earlier duplicate instances
|
||||
intStack[intStackSize - 3] = 0;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Recolor clan chat text without changing public, private, or game messages.
|
||||
if (isClanChatType(ChatMessageType.of(messageTypeVal)))
|
||||
{
|
||||
Color clanColor = config.chatClanMessageColor();
|
||||
String hex = ColorUtil.colorToHexCode(clanColor != null ? clanColor : new Color(0x66FFFF));
|
||||
message = "<col=" + hex + ">" + message + "</col>";
|
||||
objectStack[objectStackSize - 1] = message;
|
||||
}
|
||||
|
||||
// 4. Highlight own name
|
||||
if (config.chatHighlightName() && isPlayerSpeechType(messageTypeVal))
|
||||
{
|
||||
String highlighted = highlightPlayerName(message);
|
||||
if (highlighted != null)
|
||||
{
|
||||
objectStack[objectStackSize - 1] = highlighted;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isClanChatType(ChatMessageType type)
|
||||
{
|
||||
return type == ChatMessageType.CLAN_CHAT
|
||||
|| type == ChatMessageType.CLAN_GUEST_CHAT
|
||||
|| type == ChatMessageType.CLAN_GIM_CHAT;
|
||||
}
|
||||
|
||||
private boolean isCollapsible(ChatMessageType type)
|
||||
{
|
||||
if (type == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
switch (type)
|
||||
{
|
||||
case GAMEMESSAGE:
|
||||
case ENGINE:
|
||||
case SPAM:
|
||||
case PUBLICCHAT:
|
||||
case MODCHAT:
|
||||
case AUTOTYPER:
|
||||
case CLAN_CHAT:
|
||||
case FRIENDSCHAT:
|
||||
case ITEM_EXAMINE:
|
||||
case OBJECT_EXAMINE:
|
||||
case NPC_EXAMINE:
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isPlayerSpeechType(int messageTypeVal)
|
||||
{
|
||||
ChatMessageType type = ChatMessageType.of(messageTypeVal);
|
||||
if (type == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
switch (type)
|
||||
{
|
||||
case PUBLICCHAT:
|
||||
case MODCHAT:
|
||||
case FRIENDSCHAT:
|
||||
case CLAN_CHAT:
|
||||
case CLAN_GUEST_CHAT:
|
||||
case CLAN_GIM_CHAT:
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isSpamOrNoise(int messageTypeVal, String message)
|
||||
{
|
||||
ChatMessageType type = ChatMessageType.of(messageTypeVal);
|
||||
if (type == null || message == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
String clean = Text.removeTags(message).trim();
|
||||
|
||||
if (type == ChatMessageType.GAMEMESSAGE || type == ChatMessageType.ENGINE || type == ChatMessageType.SPAM)
|
||||
{
|
||||
if (NOISE_MESSAGES.contains(clean) || NOISE_PREFIX_PATTERN.matcher(clean).matches())
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
if (type == ChatMessageType.PUBLICCHAT || type == ChatMessageType.MODCHAT || type == ChatMessageType.AUTOTYPER)
|
||||
{
|
||||
if (PUBLIC_SPAM_PATTERN.matcher(clean).matches())
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private String highlightPlayerName(String message)
|
||||
{
|
||||
Player local = client.getLocalPlayer();
|
||||
if (local == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
String name = local.getName();
|
||||
if (name == null || name.isEmpty())
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
String lower = message.toLowerCase();
|
||||
String lowerName = name.toLowerCase();
|
||||
if (!lower.contains(lowerName))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
Color highlightColor = config.chatHighlightColor();
|
||||
String hex = ColorUtil.colorToHexCode(highlightColor != null ? highlightColor : new Color(0xFFD700));
|
||||
|
||||
Pattern pattern = Pattern.compile("(?i)(?<![a-zA-Z0-9_])" + Pattern.quote(name) + "(?![a-zA-Z0-9_])");
|
||||
return pattern.matcher(message).replaceAll("<col=" + hex + "><b>" + name + "</b></col>");
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds "Copy message" and "Clear chat" to right-click menus.
|
||||
*/
|
||||
@Subscribe
|
||||
public void onMenuEntryAdded(MenuEntryAdded event)
|
||||
{
|
||||
if (!config.chatHelper())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
int type = event.getType();
|
||||
if (type != MenuAction.CC_OP.getId() && type != MenuAction.CC_OP_LOW_PRIORITY.getId())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
int param1 = event.getActionParam1();
|
||||
int interfaceId = WidgetUtil.componentToInterface(param1);
|
||||
int childId = WidgetUtil.componentToId(param1);
|
||||
|
||||
if (interfaceId != InterfaceID.CHATBOX)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// 1. Copy message
|
||||
if (config.chatCopyToClipboard() && "Report".equals(event.getOption()))
|
||||
{
|
||||
Widget lineWidget = client.getWidget(interfaceId, childId);
|
||||
if (lineWidget != null && lineWidget.getParent() != null && lineWidget.getParent().getId() == InterfaceID.Chatbox.SCROLLAREA)
|
||||
{
|
||||
int lineIndex = (childId - WidgetUtil.componentToId(InterfaceID.Chatbox.LINE0)) * 4 + 1;
|
||||
if (lineIndex < 1)
|
||||
{
|
||||
return;
|
||||
}
|
||||
Widget textWidget = lineWidget.getParent().getChild(lineIndex);
|
||||
if (textWidget != null && textWidget.getText() != null)
|
||||
{
|
||||
addCopyEntry(textWidget.getText(), event.getTarget());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Clear chat on chat tab buttons
|
||||
String option = event.getOption();
|
||||
if (option != null && (option.startsWith("Switch tab") || option.startsWith("Filter")))
|
||||
{
|
||||
client.createMenuEntry(-2)
|
||||
.setOption("Clear chat")
|
||||
.setType(MenuAction.RUNELITE_HIGH_PRIORITY)
|
||||
.onClick(e -> clearAllChat());
|
||||
}
|
||||
}
|
||||
|
||||
/** Game messages and transparent chat text do not necessarily have a Report action. */
|
||||
@Subscribe(priority = -1)
|
||||
public void onMenuOpened(MenuOpened event)
|
||||
{
|
||||
if (!config.chatHelper() || !config.chatCopyToClipboard())
|
||||
{
|
||||
return;
|
||||
}
|
||||
MenuEntry[] entries = client.getMenu().getMenuEntries();
|
||||
if (Arrays.stream(entries).anyMatch(e -> "Copy message".equals(e.getOption())))
|
||||
{
|
||||
return;
|
||||
}
|
||||
// Do not intercept another interface or an overlay covering the chat.
|
||||
if (Arrays.stream(entries).anyMatch(e -> e.getWidget() != null
|
||||
&& WidgetUtil.componentToInterface(e.getWidget().getId()) != InterfaceID.CHATBOX))
|
||||
{
|
||||
return;
|
||||
}
|
||||
Widget scroll = client.getWidget(InterfaceID.Chatbox.SCROLLAREA);
|
||||
Point mouse = client.getMouseCanvasPosition();
|
||||
if (scroll == null || scroll.isHidden() || mouse == null
|
||||
|| !scroll.getBounds().contains(mouse.getX(), mouse.getY()))
|
||||
{
|
||||
return;
|
||||
}
|
||||
Widget[] children = scroll.getDynamicChildren();
|
||||
if (children == null || children.length == 0)
|
||||
{
|
||||
children = scroll.getChildren();
|
||||
}
|
||||
if (children == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
for (int i = 1; i < children.length; i += 4)
|
||||
{
|
||||
Widget message = children[i];
|
||||
if (message != null && !message.isHidden() && message.getText() != null
|
||||
&& !Text.removeTags(message.getText()).trim().isEmpty()
|
||||
&& message.getBounds().contains(mouse.getX(), mouse.getY()))
|
||||
{
|
||||
// A world-only menu here is click-through from transparent chat.
|
||||
boolean hasChatAction = Arrays.stream(entries).anyMatch(e ->
|
||||
WidgetUtil.componentToInterface(e.getParam1()) == InterfaceID.CHATBOX);
|
||||
if (!hasChatAction)
|
||||
{
|
||||
client.getMenu().setMenuEntries(Arrays.stream(entries)
|
||||
.filter(e -> e.getType() == MenuAction.CANCEL).toArray(MenuEntry[]::new));
|
||||
}
|
||||
addCopyEntry(message.getText(), "");
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void addCopyEntry(String text, String target)
|
||||
{
|
||||
// Capture the displayed text now: new messages can arrive while the menu is open.
|
||||
client.createMenuEntry(Math.min(1, client.getMenu().getMenuEntries().length))
|
||||
.setOption("Copy message")
|
||||
.setTarget(target)
|
||||
.setType(MenuAction.RUNELITE)
|
||||
.onClick(e -> copyToClipboard(text));
|
||||
}
|
||||
|
||||
void copyToClipboard(String text)
|
||||
{
|
||||
try
|
||||
{
|
||||
String cleanText = Text.removeTags(text);
|
||||
StringSelection selection = new StringSelection(cleanText);
|
||||
Toolkit.getDefaultToolkit().getSystemClipboard().setContents(selection, null);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
log.warn("Failed to copy chat text to clipboard", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Manually empties all chat line buffers and rebuilds the chatbox widget.
|
||||
*/
|
||||
public void clearAllChat()
|
||||
{
|
||||
clientThread.invokeLater(() ->
|
||||
{
|
||||
Map<Integer, ChatLineBuffer> chatLineMap = client.getChatLineMap();
|
||||
if (chatLineMap == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
for (ChatLineBuffer buffer : chatLineMap.values())
|
||||
{
|
||||
if (buffer == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
MessageNode[] lines = buffer.getLines();
|
||||
if (lines == null || lines.length == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
MessageNode[] copy = lines.clone();
|
||||
for (MessageNode node : copy)
|
||||
{
|
||||
if (node != null)
|
||||
{
|
||||
buffer.removeMessageNode(node);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
reset();
|
||||
refreshChat();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,349 @@
|
||||
package com.example.essentialsplus.helpers;
|
||||
|
||||
import com.example.essentialsplus.EssentialsPlusConfig;
|
||||
import com.example.essentialsplus.helpers.combat.Attacking;
|
||||
import java.awt.Color;
|
||||
import java.awt.Dimension;
|
||||
import java.awt.Graphics2D;
|
||||
import javax.inject.Inject;
|
||||
import javax.inject.Singleton;
|
||||
import net.runelite.api.Client;
|
||||
import net.runelite.api.GameState;
|
||||
import net.runelite.api.Player;
|
||||
import net.runelite.api.Skill;
|
||||
import net.runelite.api.SoundEffectVolume;
|
||||
import net.runelite.api.coords.LocalPoint;
|
||||
import net.runelite.api.coords.WorldPoint;
|
||||
import net.runelite.api.events.AnimationChanged;
|
||||
import net.runelite.api.events.GameTick;
|
||||
import net.runelite.api.events.ItemContainerChanged;
|
||||
import net.runelite.api.events.MenuOptionClicked;
|
||||
import net.runelite.api.events.WidgetClosed;
|
||||
import net.runelite.api.events.WidgetDrag;
|
||||
import net.runelite.api.events.WidgetLoaded;
|
||||
import net.runelite.api.gameval.InterfaceID;
|
||||
import net.runelite.api.gameval.InventoryID;
|
||||
import net.runelite.client.eventbus.Subscribe;
|
||||
import net.runelite.client.game.NPCManager;
|
||||
import net.runelite.client.ui.overlay.Overlay;
|
||||
import net.runelite.client.ui.overlay.OverlayLayer;
|
||||
import net.runelite.client.ui.overlay.OverlayPosition;
|
||||
|
||||
@Singleton
|
||||
public class IdleNotifier extends Overlay
|
||||
{
|
||||
private static final int FLASH_BORDER_SIZE = 28;
|
||||
private static final Color FLASH_COLOR = new Color(255, 152, 0, 135);
|
||||
|
||||
private final Client client;
|
||||
private final EssentialsPlusConfig config;
|
||||
private final NPCManager npcManager;
|
||||
|
||||
private long actionStartedAt;
|
||||
private long lastActionAt;
|
||||
private volatile long flashStartedAt;
|
||||
private long nextRepeatAt;
|
||||
private long repeatUntilAt;
|
||||
private WorldPoint lastPlayerLocation;
|
||||
private LocalPoint lastPlayerLocalLocation;
|
||||
private final int[] lastSkillExperience = new int[Skill.values().length];
|
||||
private boolean experienceInitialized;
|
||||
|
||||
@Inject
|
||||
public IdleNotifier(Client client, EssentialsPlusConfig config, NPCManager npcManager)
|
||||
{
|
||||
this.client = client;
|
||||
this.config = config;
|
||||
this.npcManager = npcManager;
|
||||
setPosition(OverlayPosition.DYNAMIC);
|
||||
setLayer(OverlayLayer.ALWAYS_ON_TOP);
|
||||
}
|
||||
|
||||
@Subscribe
|
||||
public void onAnimationChanged(AnimationChanged event)
|
||||
{
|
||||
Player player = client.getLocalPlayer();
|
||||
if (isEnabled() && player != null && event.getActor() == player && player.getAnimation() != -1)
|
||||
{
|
||||
recordAction(System.currentTimeMillis());
|
||||
}
|
||||
}
|
||||
|
||||
@Subscribe
|
||||
public void onMenuOptionClicked(MenuOptionClicked event)
|
||||
{
|
||||
recordUserInteraction();
|
||||
}
|
||||
|
||||
@Subscribe
|
||||
public void onWidgetDrag(WidgetDrag event)
|
||||
{
|
||||
recordUserInteraction();
|
||||
}
|
||||
|
||||
@Subscribe
|
||||
public void onItemContainerChanged(ItemContainerChanged event)
|
||||
{
|
||||
int containerId = event.getContainerId();
|
||||
if (containerId == InventoryID.INV || containerId == InventoryID.WORN || containerId == InventoryID.BANK)
|
||||
{
|
||||
recordUserInteraction();
|
||||
}
|
||||
}
|
||||
|
||||
@Subscribe
|
||||
public void onWidgetLoaded(WidgetLoaded event)
|
||||
{
|
||||
if (event.getGroupId() == InterfaceID.BANKMAIN)
|
||||
{
|
||||
recordUserInteraction();
|
||||
}
|
||||
}
|
||||
|
||||
@Subscribe
|
||||
public void onWidgetClosed(WidgetClosed event)
|
||||
{
|
||||
if (event.getGroupId() == InterfaceID.BANKMAIN)
|
||||
{
|
||||
recordUserInteraction();
|
||||
}
|
||||
}
|
||||
|
||||
@Subscribe
|
||||
public void onGameTick(GameTick event)
|
||||
{
|
||||
if (!isEnabled() || client.getGameState() != GameState.LOGGED_IN)
|
||||
{
|
||||
reset();
|
||||
return;
|
||||
}
|
||||
|
||||
Player player = client.getLocalPlayer();
|
||||
if (player == null)
|
||||
{
|
||||
reset();
|
||||
return;
|
||||
}
|
||||
|
||||
long now = System.currentTimeMillis();
|
||||
WorldPoint location = player.getWorldLocation();
|
||||
LocalPoint localLocation = player.getLocalLocation();
|
||||
LocalPoint destination = client.getLocalDestinationLocation();
|
||||
boolean moved = (location != null && lastPlayerLocation != null && !location.equals(lastPlayerLocation))
|
||||
|| (localLocation != null && lastPlayerLocalLocation != null && !localLocation.equals(lastPlayerLocalLocation))
|
||||
|| (localLocation != null && destination != null && !localLocation.equals(destination))
|
||||
|| hasMovementPose(player);
|
||||
lastPlayerLocation = location;
|
||||
lastPlayerLocalLocation = localLocation;
|
||||
boolean gainedSkillXp = hasSkillExperienceChanged();
|
||||
|
||||
boolean acting = player.getAnimation() != -1
|
||||
|| Attacking.isPlayerAttacking(client, npcManager)
|
||||
|| gainedSkillXp;
|
||||
if (moved)
|
||||
{
|
||||
cancelIdleCycle();
|
||||
}
|
||||
|
||||
if (acting)
|
||||
{
|
||||
recordAction(now);
|
||||
}
|
||||
else if (!moved)
|
||||
{
|
||||
finishIfIdle(now);
|
||||
repeatIfIdle(now);
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isEnabled()
|
||||
{
|
||||
return config.idleNotifier();
|
||||
}
|
||||
|
||||
private boolean hasMovementPose(Player player)
|
||||
{
|
||||
int pose = player.getPoseAnimation();
|
||||
return pose != -1 && (matchesMovementAnimation(pose, player.getWalkAnimation())
|
||||
|| matchesMovementAnimation(pose, player.getRunAnimation())
|
||||
|| matchesMovementAnimation(pose, player.getWalkRotateLeft())
|
||||
|| matchesMovementAnimation(pose, player.getWalkRotateRight())
|
||||
|| matchesMovementAnimation(pose, player.getWalkRotate180()));
|
||||
}
|
||||
|
||||
private boolean matchesMovementAnimation(int pose, int movementAnimation)
|
||||
{
|
||||
return movementAnimation != -1 && pose == movementAnimation;
|
||||
}
|
||||
|
||||
private boolean hasSkillExperienceChanged()
|
||||
{
|
||||
Skill[] skills = Skill.values();
|
||||
if (!experienceInitialized)
|
||||
{
|
||||
for (Skill skill : skills)
|
||||
{
|
||||
if (skill != Skill.OVERALL)
|
||||
{
|
||||
lastSkillExperience[skill.ordinal()] = client.getSkillExperience(skill);
|
||||
}
|
||||
}
|
||||
experienceInitialized = true;
|
||||
return false;
|
||||
}
|
||||
|
||||
boolean gainedXp = false;
|
||||
for (Skill skill : skills)
|
||||
{
|
||||
if (skill == Skill.OVERALL)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
int experience = client.getSkillExperience(skill);
|
||||
if (experience > lastSkillExperience[skill.ordinal()])
|
||||
{
|
||||
gainedXp = true;
|
||||
}
|
||||
lastSkillExperience[skill.ordinal()] = experience;
|
||||
}
|
||||
return gainedXp;
|
||||
}
|
||||
|
||||
private void recordAction(long now)
|
||||
{
|
||||
cancelRepeats();
|
||||
flashStartedAt = 0;
|
||||
if (actionStartedAt == 0)
|
||||
{
|
||||
actionStartedAt = now;
|
||||
}
|
||||
lastActionAt = now;
|
||||
}
|
||||
|
||||
private void recordUserInteraction()
|
||||
{
|
||||
if (isEnabled() && client.getGameState() == GameState.LOGGED_IN)
|
||||
{
|
||||
recordAction(System.currentTimeMillis());
|
||||
}
|
||||
}
|
||||
|
||||
private void cancelIdleCycle()
|
||||
{
|
||||
actionStartedAt = 0;
|
||||
lastActionAt = 0;
|
||||
flashStartedAt = 0;
|
||||
cancelRepeats();
|
||||
}
|
||||
|
||||
private void finishIfIdle(long now)
|
||||
{
|
||||
if (lastActionAt == 0 || now - lastActionAt < config.idleNotifierIdleSeconds() * 1000L)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (lastActionAt - actionStartedAt >= config.idleNotifierMinActionSeconds() * 1000L)
|
||||
{
|
||||
notifyIdle(now);
|
||||
if (config.idleNotifierRepeat())
|
||||
{
|
||||
long interval = config.idleNotifierRepeatIntervalSeconds() * 1000L;
|
||||
repeatUntilAt = now + config.idleNotifierRepeatDurationSeconds() * 1000L;
|
||||
nextRepeatAt = now + interval;
|
||||
}
|
||||
}
|
||||
actionStartedAt = 0;
|
||||
lastActionAt = 0;
|
||||
}
|
||||
|
||||
private void repeatIfIdle(long now)
|
||||
{
|
||||
if (!config.idleNotifierRepeat())
|
||||
{
|
||||
cancelRepeats();
|
||||
return;
|
||||
}
|
||||
|
||||
if (nextRepeatAt == 0 || nextRepeatAt > repeatUntilAt)
|
||||
{
|
||||
cancelRepeats();
|
||||
return;
|
||||
}
|
||||
|
||||
if (now >= nextRepeatAt)
|
||||
{
|
||||
notifyIdle(now);
|
||||
nextRepeatAt += config.idleNotifierRepeatIntervalSeconds() * 1000L;
|
||||
if (nextRepeatAt > repeatUntilAt)
|
||||
{
|
||||
cancelRepeats();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void notifyIdle(long now)
|
||||
{
|
||||
flashStartedAt = now;
|
||||
if (config.idleNotifierSound())
|
||||
{
|
||||
playConfiguredSound();
|
||||
}
|
||||
}
|
||||
|
||||
private void cancelRepeats()
|
||||
{
|
||||
nextRepeatAt = 0;
|
||||
repeatUntilAt = 0;
|
||||
}
|
||||
|
||||
public void reset()
|
||||
{
|
||||
actionStartedAt = 0;
|
||||
lastActionAt = 0;
|
||||
lastPlayerLocation = null;
|
||||
lastPlayerLocalLocation = null;
|
||||
flashStartedAt = 0;
|
||||
cancelRepeats();
|
||||
experienceInitialized = false;
|
||||
}
|
||||
|
||||
public void playConfiguredSound()
|
||||
{
|
||||
if (client.getPreferences() == null || client.getPreferences().getSoundEffectVolume() == SoundEffectVolume.MUTED)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
client.playSoundEffect(config.idleNotifierSoundId(), config.idleNotifierSoundVolume());
|
||||
}
|
||||
|
||||
@Override
|
||||
public Dimension render(Graphics2D graphics)
|
||||
{
|
||||
if (!isEnabled() || flashStartedAt == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
long elapsed = System.currentTimeMillis() - flashStartedAt;
|
||||
if (elapsed >= config.idleNotifierFlashDurationMs())
|
||||
{
|
||||
flashStartedAt = 0;
|
||||
return null;
|
||||
}
|
||||
if ((elapsed / config.idleNotifierBlinkIntervalMs()) % 2 != 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
int width = client.getCanvasWidth();
|
||||
int height = client.getCanvasHeight();
|
||||
graphics.setColor(FLASH_COLOR);
|
||||
graphics.fillRect(0, 0, width, FLASH_BORDER_SIZE);
|
||||
graphics.fillRect(0, height - FLASH_BORDER_SIZE, width, FLASH_BORDER_SIZE);
|
||||
graphics.fillRect(0, FLASH_BORDER_SIZE, FLASH_BORDER_SIZE, height - 2 * FLASH_BORDER_SIZE);
|
||||
graphics.fillRect(width - FLASH_BORDER_SIZE, FLASH_BORDER_SIZE, FLASH_BORDER_SIZE, height - 2 * FLASH_BORDER_SIZE);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,112 @@
|
||||
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 (!itemCharges.isEnabled())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if on bank or equipment and if display is allowed
|
||||
boolean showBank = plugin.getHelperValue("itemInformationChargesBank", Boolean.class,
|
||||
plugin.getHelperValue("itemChargesBank", Boolean.class, true));
|
||||
boolean showEquipment = plugin.getHelperValue("itemInformationEquipment", Boolean.class, true);
|
||||
if (widgetItem.getWidget() != null)
|
||||
{
|
||||
int group = net.runelite.api.widgets.WidgetUtil.componentToInterface(widgetItem.getWidget().getId());
|
||||
if (!showBank && (group == net.runelite.api.gameval.InterfaceID.BANKMAIN || group == 12)) // 12 = InterfaceID.BANK
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (!showEquipment && (group == net.runelite.api.gameval.InterfaceID.WORNITEMS || group == 387 || group == 84))
|
||||
{
|
||||
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("itemInformationSize", TimerTextSize.class,
|
||||
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,197 @@
|
||||
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.Graphics2D;
|
||||
import javax.inject.Inject;
|
||||
import javax.inject.Singleton;
|
||||
import net.runelite.api.Client;
|
||||
import net.runelite.api.ItemComposition;
|
||||
import net.runelite.api.MenuEntry;
|
||||
import net.runelite.api.gameval.InterfaceID;
|
||||
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;
|
||||
|
||||
/**
|
||||
* Unified hover tooltip overlay showing GE price, High Alchemy value, and Banked quantity
|
||||
* for inventory and (optionally) bank items hovered by the mouse.
|
||||
*/
|
||||
@Singleton
|
||||
public class ItemInformationOverlay 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 static final Color BANKED = new Color(0x90C0E0);
|
||||
|
||||
private final Client client;
|
||||
private final EssentialsPlusConfig config;
|
||||
private final EssentialsPlusPlugin plugin;
|
||||
private final ItemManager itemManager;
|
||||
private final TooltipManager tooltipManager;
|
||||
|
||||
@Inject
|
||||
public ItemInformationOverlay(Client client, EssentialsPlusConfig config, EssentialsPlusPlugin plugin,
|
||||
ItemManager itemManager, TooltipManager tooltipManager)
|
||||
{
|
||||
this.client = client;
|
||||
this.config = config;
|
||||
this.plugin = plugin;
|
||||
this.itemManager = itemManager;
|
||||
this.tooltipManager = tooltipManager;
|
||||
setPosition(OverlayPosition.DYNAMIC);
|
||||
setLayer(OverlayLayer.ABOVE_WIDGETS);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Dimension render(Graphics2D g)
|
||||
{
|
||||
if (plugin == null || !plugin.isItemInformationEnabled() || (client != null && client.isMenuOpen()))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
boolean showGe = plugin.getHelperValue("itemInformationGe", Boolean.class, true);
|
||||
boolean showHa = plugin.getHelperValue("itemInformationHa", Boolean.class, true);
|
||||
boolean showBanked = plugin.getHelperValue("itemInformationBanked", Boolean.class, true);
|
||||
|
||||
if (!showGe && !showHa && !showBanked)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (client == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
MenuEntry[] entries = client.getMenuEntries();
|
||||
if (entries == null || entries.length == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
Widget w = entries[entries.length - 1].getWidget();
|
||||
if (w == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
int group = WidgetUtil.componentToInterface(w.getId());
|
||||
boolean inInventory = (group == INVENTORY || group == BANK_INVENTORY);
|
||||
boolean inBank = (group == InterfaceID.BANKMAIN || group == 12);
|
||||
|
||||
if (!inInventory)
|
||||
{
|
||||
boolean allowBank = plugin.getHelperValue("itemInformationBank", Boolean.class, true);
|
||||
if (!inBank || !allowBank)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
int id = w.getItemId();
|
||||
if (id <= 0 || id == ItemID.COINS || id == 995 || id == ItemID.PLATINUM || id == 13204)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
long qty = Math.max(1, w.getItemQuantity());
|
||||
int canonicalId = canonicalize(id);
|
||||
String text = buildTooltipText(canonicalId, qty, showGe, showHa, showBanked);
|
||||
if (text != null && !text.isEmpty() && tooltipManager != null)
|
||||
{
|
||||
tooltipManager.add(new Tooltip(text));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public int canonicalize(int id)
|
||||
{
|
||||
return itemManager != null ? itemManager.canonicalize(id) : id;
|
||||
}
|
||||
|
||||
public long getGePrice(int id)
|
||||
{
|
||||
return itemManager != null ? itemManager.getItemPrice(id) : 0;
|
||||
}
|
||||
|
||||
public long getHaPrice(int id)
|
||||
{
|
||||
if (itemManager == null)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
ItemComposition comp = itemManager.getItemComposition(id);
|
||||
return comp == null ? 0 : comp.getHaPrice();
|
||||
}
|
||||
|
||||
public String buildTooltipText(int canonicalId, long qty, boolean showGe, boolean showHa, boolean showBanked)
|
||||
{
|
||||
StringBuilder priceSb = new StringBuilder();
|
||||
if (showGe)
|
||||
{
|
||||
long ge = getGePrice(canonicalId) * qty;
|
||||
if (ge > 0)
|
||||
{
|
||||
priceSb.append("GE: ").append(ColorUtil.wrapWithColorTag(gp(ge), GE));
|
||||
}
|
||||
}
|
||||
|
||||
if (showHa)
|
||||
{
|
||||
long ha = getHaPrice(canonicalId) * qty;
|
||||
if (ha > 0)
|
||||
{
|
||||
if (priceSb.length() > 0)
|
||||
{
|
||||
priceSb.append(" ");
|
||||
}
|
||||
priceSb.append("HA: ").append(ColorUtil.wrapWithColorTag(gp(ha), HA));
|
||||
}
|
||||
}
|
||||
|
||||
String bankedStr = null;
|
||||
if (showBanked && plugin != null)
|
||||
{
|
||||
long bankedQty = plugin.getBankedQuantity(canonicalId);
|
||||
if (bankedQty >= 0)
|
||||
{
|
||||
bankedStr = "Banked: " + ColorUtil.wrapWithColorTag(QuantityFormatter.formatNumber(bankedQty), BANKED);
|
||||
}
|
||||
}
|
||||
|
||||
StringBuilder full = new StringBuilder();
|
||||
if (priceSb.length() > 0)
|
||||
{
|
||||
full.append(priceSb);
|
||||
}
|
||||
if (bankedStr != null)
|
||||
{
|
||||
if (full.length() > 0)
|
||||
{
|
||||
full.append("</br>");
|
||||
}
|
||||
full.append(bankedStr);
|
||||
}
|
||||
|
||||
return full.toString();
|
||||
}
|
||||
|
||||
private static String gp(long v)
|
||||
{
|
||||
return QuantityFormatter.quantityToStackSize(v) + " gp";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.example.essentialsplus.helpers;
|
||||
|
||||
import com.example.essentialsplus.EssentialsPlusConfig;
|
||||
import com.example.essentialsplus.EssentialsPlusPlugin;
|
||||
import javax.inject.Inject;
|
||||
import javax.inject.Singleton;
|
||||
import net.runelite.api.Client;
|
||||
import net.runelite.client.game.ItemManager;
|
||||
import net.runelite.client.ui.overlay.tooltip.TooltipManager;
|
||||
|
||||
/**
|
||||
* Legacy wrapper for {@link ItemInformationOverlay}.
|
||||
*/
|
||||
@Deprecated
|
||||
@Singleton
|
||||
public class ItemPriceOverlay extends ItemInformationOverlay
|
||||
{
|
||||
@Inject
|
||||
ItemPriceOverlay(Client client, EssentialsPlusConfig config, EssentialsPlusPlugin plugin,
|
||||
ItemManager itemManager, TooltipManager tooltipManager)
|
||||
{
|
||||
super(client, config, plugin, itemManager, tooltipManager);
|
||||
}
|
||||
}
|
||||
@@ -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,581 @@
|
||||
package com.example.essentialsplus.helpers;
|
||||
|
||||
import com.example.essentialsplus.EssentialsPlusConfig;
|
||||
import java.awt.Color;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.AtomicMoveNotSupportedException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneId;
|
||||
import java.util.ArrayDeque;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Comparator;
|
||||
import java.util.Deque;
|
||||
import java.util.EnumSet;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.ThreadLocalRandom;
|
||||
import javax.inject.Inject;
|
||||
import javax.inject.Singleton;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import net.runelite.api.Client;
|
||||
import net.runelite.api.ChatMessageType;
|
||||
import net.runelite.api.Friend;
|
||||
import net.runelite.api.GameState;
|
||||
import net.runelite.api.MenuAction;
|
||||
import net.runelite.api.NameableContainer;
|
||||
import net.runelite.api.Skill;
|
||||
import net.runelite.api.World;
|
||||
import net.runelite.api.events.GameStateChanged;
|
||||
import net.runelite.api.events.GameTick;
|
||||
import net.runelite.api.events.MenuEntryAdded;
|
||||
import net.runelite.api.gameval.InterfaceID;
|
||||
import net.runelite.api.widgets.WidgetUtil;
|
||||
import net.runelite.client.RuneLite;
|
||||
import net.runelite.client.callback.ClientThread;
|
||||
import net.runelite.client.chat.ChatMessageBuilder;
|
||||
import net.runelite.client.chat.ChatMessageManager;
|
||||
import net.runelite.client.chat.QueuedMessage;
|
||||
import net.runelite.client.eventbus.Subscribe;
|
||||
import net.runelite.client.game.WorldService;
|
||||
import net.runelite.client.input.KeyManager;
|
||||
import net.runelite.client.util.HotkeyListener;
|
||||
import net.runelite.client.util.Text;
|
||||
import net.runelite.client.util.WorldUtil;
|
||||
import net.runelite.http.api.worlds.WorldResult;
|
||||
import net.runelite.http.api.worlds.WorldType;
|
||||
|
||||
/** Tracks world-hop attempts across RuneLite accounts on this installation. */
|
||||
@Slf4j
|
||||
@Singleton
|
||||
public class WorldHopTracker
|
||||
{
|
||||
private static final long HOUR_MILLIS = 60L * 60L * 1000L;
|
||||
private static final long RETENTION_MILLIS = 48L * HOUR_MILLIS;
|
||||
private static final int MAX_PLAYER_COUNT = 1950;
|
||||
private static final int MAX_SWITCHER_ATTEMPTS = 3;
|
||||
private static final Path HISTORY_FILE = RuneLite.RUNELITE_DIR.toPath()
|
||||
.resolve("plugin-data")
|
||||
.resolve("essentialsplus")
|
||||
.resolve("world-hops.txt");
|
||||
|
||||
private final EssentialsPlusConfig config;
|
||||
private final ChatMessageManager chatMessageManager;
|
||||
private final HotkeyListener nextWorldListener;
|
||||
private final HotkeyListener randomWorldListener;
|
||||
private final Deque<Long> timestamps = new ArrayDeque<>();
|
||||
private int pendingHopCount;
|
||||
private int pendingDailyHopCount;
|
||||
private World pendingHopTarget;
|
||||
private int switcherAttempts;
|
||||
private boolean hotkeysRegistered;
|
||||
|
||||
@Inject
|
||||
private Client client;
|
||||
|
||||
@Inject
|
||||
private ClientThread clientThread;
|
||||
|
||||
@Inject
|
||||
private KeyManager keyManager;
|
||||
|
||||
@Inject
|
||||
private WorldService worldService;
|
||||
|
||||
@Inject
|
||||
public WorldHopTracker(EssentialsPlusConfig config, ChatMessageManager chatMessageManager)
|
||||
{
|
||||
this.config = config;
|
||||
this.chatMessageManager = chatMessageManager;
|
||||
nextWorldListener = new HotkeyListener(config::worldHopNextKey)
|
||||
{
|
||||
@Override
|
||||
public void keyPressed(java.awt.event.KeyEvent e)
|
||||
{
|
||||
if (config.worldHopTracker())
|
||||
{
|
||||
super.keyPressed(e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void hotkeyPressed()
|
||||
{
|
||||
clientThread.invoke(() -> hopToNextWorld(false));
|
||||
}
|
||||
};
|
||||
randomWorldListener = new HotkeyListener(config::worldHopRandomKey)
|
||||
{
|
||||
@Override
|
||||
public void keyPressed(java.awt.event.KeyEvent e)
|
||||
{
|
||||
if (config.worldHopTracker())
|
||||
{
|
||||
super.keyPressed(e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void hotkeyPressed()
|
||||
{
|
||||
clientThread.invoke(() -> hopToNextWorld(true));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@Subscribe
|
||||
public void onMenuEntryAdded(MenuEntryAdded event)
|
||||
{
|
||||
if (!config.worldHopTracker()
|
||||
|| client.getGameState() != GameState.LOGGED_IN
|
||||
|| WidgetUtil.componentToInterface(event.getActionParam1()) != InterfaceID.FRIENDS
|
||||
|| !"Remove friend".equals(event.getOption()))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Friend friend = findFriend(event.getTarget());
|
||||
if (friend == null || getHopToWorld(friend.getWorld()) == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
client.createMenuEntry(-1)
|
||||
.setOption("Hop-to")
|
||||
.setTarget(event.getTarget())
|
||||
.setType(MenuAction.RUNELITE)
|
||||
.onClick(e -> clientThread.invoke(() -> hopToFriend(e.getTarget())));
|
||||
}
|
||||
|
||||
private Friend findFriend(String name)
|
||||
{
|
||||
if (name == null || name.isEmpty())
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
NameableContainer<Friend> friends = client.getFriendContainer();
|
||||
return friends == null ? null : friends.findByName(Text.removeTags(name));
|
||||
}
|
||||
|
||||
private void hopToFriend(String name)
|
||||
{
|
||||
Friend friend = findFriend(name);
|
||||
if (friend == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
net.runelite.http.api.worlds.World target = getHopToWorld(friend.getWorld());
|
||||
if (target == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
queueWorldHop(target);
|
||||
}
|
||||
|
||||
private net.runelite.http.api.worlds.World getHopToWorld(int worldId)
|
||||
{
|
||||
if (!config.worldHopTracker()
|
||||
|| client.getGameState() != GameState.LOGGED_IN
|
||||
|| worldId <= 0
|
||||
|| worldId == client.getWorld())
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
WorldResult result = worldService.getWorlds();
|
||||
if (result == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
net.runelite.http.api.worlds.World current = result.findWorld(client.getWorld());
|
||||
net.runelite.http.api.worlds.World target = result.findWorld(worldId);
|
||||
if (current == null || target == null || target.getPlayers() < 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
EnumSet<WorldType> targetTypes = target.getTypes();
|
||||
if (targetTypes.contains(WorldType.BOUNTY)
|
||||
|| (!current.getTypes().contains(WorldType.PVP) && targetTypes.contains(WorldType.PVP)))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
if (config.worldHopAvoidDangerous()
|
||||
&& (targetTypes.contains(WorldType.PVP)
|
||||
|| targetTypes.contains(WorldType.PVP_ARENA)
|
||||
|| targetTypes.contains(WorldType.HIGH_RISK)))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
int totalLevel = client.getTotalLevel();
|
||||
int f2pTotalLevel = Arrays.stream(Skill.values())
|
||||
.filter(skill -> !skill.isMembers())
|
||||
.mapToInt(client::getRealSkillLevel)
|
||||
.sum();
|
||||
return meetsSkillTotalRequirement(target, totalLevel, f2pTotalLevel) ? target : null;
|
||||
}
|
||||
|
||||
public void registerHotkeys()
|
||||
{
|
||||
if (hotkeysRegistered)
|
||||
{
|
||||
return;
|
||||
}
|
||||
keyManager.registerKeyListener(nextWorldListener);
|
||||
keyManager.registerKeyListener(randomWorldListener);
|
||||
hotkeysRegistered = true;
|
||||
}
|
||||
|
||||
public void unregisterHotkeys()
|
||||
{
|
||||
if (!hotkeysRegistered)
|
||||
{
|
||||
return;
|
||||
}
|
||||
keyManager.unregisterKeyListener(nextWorldListener);
|
||||
keyManager.unregisterKeyListener(randomWorldListener);
|
||||
hotkeysRegistered = false;
|
||||
}
|
||||
|
||||
public synchronized void loadHistory()
|
||||
{
|
||||
timestamps.clear();
|
||||
if (Files.exists(HISTORY_FILE))
|
||||
{
|
||||
try
|
||||
{
|
||||
for (String line : Files.readAllLines(HISTORY_FILE, StandardCharsets.UTF_8))
|
||||
{
|
||||
try
|
||||
{
|
||||
timestamps.addLast(Long.parseLong(line.trim()));
|
||||
}
|
||||
catch (NumberFormatException ignored)
|
||||
{
|
||||
// Ignore damaged or incomplete entries.
|
||||
}
|
||||
}
|
||||
prune(System.currentTimeMillis());
|
||||
persist();
|
||||
}
|
||||
catch (IOException ex)
|
||||
{
|
||||
log.warn("Unable to load world-hop history", ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Subscribe
|
||||
public synchronized void onGameStateChanged(GameStateChanged event)
|
||||
{
|
||||
GameState state = event.getGameState();
|
||||
if (state != GameState.LOGGED_IN && state != GameState.HOPPING)
|
||||
{
|
||||
pendingHopTarget = null;
|
||||
}
|
||||
if (state == GameState.HOPPING)
|
||||
{
|
||||
long now = System.currentTimeMillis();
|
||||
prune(now);
|
||||
timestamps.addLast(now);
|
||||
persist();
|
||||
pendingHopCount = countSince(now - HOUR_MILLIS);
|
||||
pendingDailyHopCount = countToday(now);
|
||||
return;
|
||||
}
|
||||
|
||||
if (pendingHopCount > 0 && (state == GameState.LOGGED_IN
|
||||
|| state == GameState.LOGIN_SCREEN || state == GameState.LOGIN_SCREEN_AUTHENTICATOR))
|
||||
{
|
||||
if (config.worldHopTracker())
|
||||
{
|
||||
String message = new ChatMessageBuilder()
|
||||
.append(new Color(0xFF9800), "Hop Tracker " + pendingHopCount + "/h | " + pendingDailyHopCount + "/day")
|
||||
.build();
|
||||
chatMessageManager.queue(QueuedMessage.builder()
|
||||
.type(ChatMessageType.CONSOLE)
|
||||
.runeLiteFormattedMessage(message)
|
||||
.build());
|
||||
}
|
||||
pendingHopCount = 0;
|
||||
pendingDailyHopCount = 0;
|
||||
}
|
||||
}
|
||||
|
||||
@Subscribe
|
||||
public void onGameTick(GameTick event)
|
||||
{
|
||||
if (pendingHopTarget == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (client.getGameState() != GameState.LOGGED_IN)
|
||||
{
|
||||
pendingHopTarget = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (client.getWidget(InterfaceID.Worldswitcher.BUTTONS) == null)
|
||||
{
|
||||
client.openWorldHopper();
|
||||
if (++switcherAttempts >= MAX_SWITCHER_ATTEMPTS)
|
||||
{
|
||||
pendingHopTarget = null;
|
||||
queueStatus("Couldn't open the world switcher.");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
client.hopToWorld(pendingHopTarget);
|
||||
pendingHopTarget = null;
|
||||
switcherAttempts = 0;
|
||||
}
|
||||
|
||||
private void hopToNextWorld(boolean random)
|
||||
{
|
||||
if (!config.worldHopTracker() || client.getGameState() != GameState.LOGGED_IN)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
WorldResult result = worldService.getWorlds();
|
||||
if (result == null || result.getWorlds().isEmpty())
|
||||
{
|
||||
worldService.refresh();
|
||||
queueStatus("World list is loading.");
|
||||
return;
|
||||
}
|
||||
|
||||
net.runelite.http.api.worlds.World current = result.findWorld(client.getWorld());
|
||||
if (current == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
List<net.runelite.http.api.worlds.World> worlds = new ArrayList<>(result.getWorlds());
|
||||
worlds.sort(Comparator.comparingInt(net.runelite.http.api.worlds.World::getId));
|
||||
int totalLevel = client.getTotalLevel();
|
||||
int f2pTotalLevel = Arrays.stream(Skill.values())
|
||||
.filter(skill -> !skill.isMembers())
|
||||
.mapToInt(client::getRealSkillLevel)
|
||||
.sum();
|
||||
EnumSet<WorldType> currentTypes = normalizedTypes(current, totalLevel, f2pTotalLevel);
|
||||
List<net.runelite.http.api.worlds.World> candidates = new ArrayList<>();
|
||||
for (net.runelite.http.api.worlds.World world : worlds)
|
||||
{
|
||||
if (world.getId() != client.getWorld()
|
||||
&& isAvailable(world)
|
||||
&& meetsSkillTotalRequirement(world, totalLevel, f2pTotalLevel)
|
||||
&& normalizedTypes(world, totalLevel, f2pTotalLevel).equals(currentTypes))
|
||||
{
|
||||
candidates.add(world);
|
||||
}
|
||||
}
|
||||
|
||||
if (candidates.isEmpty())
|
||||
{
|
||||
queueStatus("Couldn't find an eligible world.");
|
||||
return;
|
||||
}
|
||||
|
||||
net.runelite.http.api.worlds.World target;
|
||||
if (random)
|
||||
{
|
||||
target = candidates.get(ThreadLocalRandom.current().nextInt(candidates.size()));
|
||||
}
|
||||
else
|
||||
{
|
||||
int currentIndex = -1;
|
||||
for (int i = 0; i < worlds.size(); i++)
|
||||
{
|
||||
if (worlds.get(i).getId() == client.getWorld())
|
||||
{
|
||||
currentIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (currentIndex < 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
target = null;
|
||||
for (int step = 1; step <= worlds.size(); step++)
|
||||
{
|
||||
net.runelite.http.api.worlds.World candidate = worlds.get((currentIndex + step) % worlds.size());
|
||||
if (candidates.contains(candidate))
|
||||
{
|
||||
target = candidate;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (target == null)
|
||||
{
|
||||
queueStatus("Couldn't find an eligible world.");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
queueWorldHop(target);
|
||||
}
|
||||
|
||||
private void queueWorldHop(net.runelite.http.api.worlds.World target)
|
||||
{
|
||||
World hopTarget = client.createWorld();
|
||||
hopTarget.setId(target.getId());
|
||||
hopTarget.setAddress(target.getAddress());
|
||||
hopTarget.setActivity(target.getActivity());
|
||||
hopTarget.setLocation(target.getLocation());
|
||||
hopTarget.setPlayerCount(target.getPlayers());
|
||||
hopTarget.setTypes(WorldUtil.toWorldTypes(target.getTypes()));
|
||||
pendingHopTarget = hopTarget;
|
||||
switcherAttempts = 0;
|
||||
}
|
||||
|
||||
private EnumSet<WorldType> normalizedTypes(net.runelite.http.api.worlds.World world, int totalLevel, int f2pTotalLevel)
|
||||
{
|
||||
EnumSet<WorldType> types = EnumSet.noneOf(WorldType.class);
|
||||
types.addAll(world.getTypes());
|
||||
types.remove(WorldType.BOUNTY);
|
||||
if (types.contains(WorldType.SKILL_TOTAL) && meetsSkillTotalRequirement(world, totalLevel, f2pTotalLevel))
|
||||
{
|
||||
types.remove(WorldType.SKILL_TOTAL);
|
||||
}
|
||||
types.remove(WorldType.LAST_MAN_STANDING);
|
||||
if (config.worldHopAvoidDangerous())
|
||||
{
|
||||
types.remove(WorldType.PVP);
|
||||
types.remove(WorldType.PVP_ARENA);
|
||||
types.remove(WorldType.HIGH_RISK);
|
||||
}
|
||||
return types;
|
||||
}
|
||||
|
||||
private boolean meetsSkillTotalRequirement(net.runelite.http.api.worlds.World world, int totalLevel, int f2pTotalLevel)
|
||||
{
|
||||
EnumSet<WorldType> types = world.getTypes();
|
||||
if (!types.contains(WorldType.SKILL_TOTAL))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
String activity = world.getActivity();
|
||||
int separator = activity == null ? -1 : activity.indexOf(' ');
|
||||
if (separator <= 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
final int requiredTotal;
|
||||
try
|
||||
{
|
||||
requiredTotal = Integer.parseInt(activity.substring(0, separator));
|
||||
}
|
||||
catch (NumberFormatException ex)
|
||||
{
|
||||
log.warn("Failed to parse skill total requirement for world {}", world.getId(), ex);
|
||||
return false;
|
||||
}
|
||||
|
||||
int effectiveTotalLevel = types.contains(WorldType.MEMBERS) ? totalLevel : f2pTotalLevel;
|
||||
return effectiveTotalLevel >= requiredTotal;
|
||||
}
|
||||
|
||||
private boolean isAvailable(net.runelite.http.api.worlds.World world)
|
||||
{
|
||||
EnumSet<WorldType> types = world.getTypes();
|
||||
if (world.getPlayers() < 0 || world.getPlayers() >= MAX_PLAYER_COUNT || types.contains(WorldType.BOUNTY))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
return !config.worldHopAvoidDangerous()
|
||||
|| (!types.contains(WorldType.PVP) && !types.contains(WorldType.PVP_ARENA) && !types.contains(WorldType.HIGH_RISK));
|
||||
}
|
||||
|
||||
private void queueStatus(String message)
|
||||
{
|
||||
chatMessageManager.queue(QueuedMessage.builder()
|
||||
.type(ChatMessageType.CONSOLE)
|
||||
.value("Hop Tracker: " + message)
|
||||
.build());
|
||||
}
|
||||
|
||||
private int countToday(long now)
|
||||
{
|
||||
ZoneId zone = ZoneId.systemDefault();
|
||||
long startOfDay = Instant.ofEpochMilli(now)
|
||||
.atZone(zone)
|
||||
.toLocalDate()
|
||||
.atStartOfDay(zone)
|
||||
.toInstant()
|
||||
.toEpochMilli();
|
||||
return countSince(startOfDay);
|
||||
}
|
||||
|
||||
private void prune(long now)
|
||||
{
|
||||
long cutoff = now - RETENTION_MILLIS;
|
||||
timestamps.removeIf(timestamp -> timestamp < cutoff || timestamp > now);
|
||||
}
|
||||
|
||||
private int countSince(long cutoff)
|
||||
{
|
||||
int count = 0;
|
||||
for (long timestamp : timestamps)
|
||||
{
|
||||
if (timestamp >= cutoff)
|
||||
{
|
||||
count++;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
private void persist()
|
||||
{
|
||||
Path temporaryFile = null;
|
||||
try
|
||||
{
|
||||
Files.createDirectories(HISTORY_FILE.getParent());
|
||||
List<String> lines = new ArrayList<>(timestamps.size());
|
||||
for (long timestamp : timestamps)
|
||||
{
|
||||
lines.add(Long.toString(timestamp));
|
||||
}
|
||||
temporaryFile = Files.createTempFile(HISTORY_FILE.getParent(), "world-hops-", ".tmp");
|
||||
Files.write(temporaryFile, lines, StandardCharsets.UTF_8);
|
||||
try
|
||||
{
|
||||
Files.move(temporaryFile, HISTORY_FILE, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
|
||||
}
|
||||
catch (AtomicMoveNotSupportedException ignored)
|
||||
{
|
||||
Files.move(temporaryFile, HISTORY_FILE, StandardCopyOption.REPLACE_EXISTING);
|
||||
}
|
||||
}
|
||||
catch (IOException ex)
|
||||
{
|
||||
log.warn("Unable to save world-hop history", ex);
|
||||
if (temporaryFile != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
Files.deleteIfExists(temporaryFile);
|
||||
}
|
||||
catch (IOException ignored)
|
||||
{
|
||||
// Keep the original persistence error as the useful diagnostic.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,445 @@
|
||||
package com.example.essentialsplus.helpers.combat;
|
||||
|
||||
import com.google.common.collect.ImmutableMap;
|
||||
import com.google.common.collect.ImmutableSet;
|
||||
import java.security.InvalidParameterException;
|
||||
import java.util.Arrays;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
import lombok.Getter;
|
||||
import net.runelite.api.gameval.AnimationID;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
|
||||
public enum AnimationData
|
||||
{
|
||||
// MELEE
|
||||
MELEE_VIGGORAS_CHAINMACE(AnimationID.WILD_CAVE_CHAINMACE_CRUSH, AttackStyle.MELEE),
|
||||
MELEE_DAGGER_SLASH(AnimationID.HUMAN_DDAGGER_LUNGE, AttackStyle.MELEE),
|
||||
MELEE_SPEAR_STAB(AnimationID.HUMAN_DSPEAR_STAB, AttackStyle.MELEE),
|
||||
MELEE_SWORD_STAB(AnimationID.HUMAN_SWORD_STAB, AttackStyle.MELEE),
|
||||
MELEE_SCIM_SLASH(AnimationID.HUMAN_SWORD_SLASH, AttackStyle.MELEE),
|
||||
|
||||
MELEE_LANCE_STAB(AnimationID.HUMAN_DHUNTER_LANCE_ATTACK, AttackStyle.MELEE),
|
||||
MELEE_LANCE_CRUSH(AnimationID.HUMAN_DHUNTER_LANCE_CRUSH, AttackStyle.MELEE),
|
||||
MELEE_LANCE_SLASH(AnimationID.HUMAN_DHUNTER_LANCE_SLASH, AttackStyle.MELEE),
|
||||
|
||||
MELEE_FANG_STAB(AnimationID.HUMAN_OSMUMTENS_FANG, AttackStyle.MELEE),
|
||||
MELEE_FANG_SPEC(AnimationID.OLAF2_BRINE_SABRE_SPECIAL, AttackStyle.MELEE, MetaData.SPECIAL_ATTACK.d),
|
||||
|
||||
MELEE_GENERIC_SLASH(AnimationID.HUMAN_AXE_CHOP, AttackStyle.MELEE),
|
||||
|
||||
MELEE_BATTLEAXE_SLASH(AnimationID.HUMAN_AXE_HACK, AttackStyle.MELEE),
|
||||
MELEE_MACE_STAB(AnimationID.HUMAN_BLUNT_SPIKE, AttackStyle.MELEE),
|
||||
MELEE_BATTLEAXE_CRUSH(AnimationID.HUMAN_BLUNT_POUND, AttackStyle.MELEE),
|
||||
MELEE_2H_CRUSH(AnimationID.HUMAN_DHSWORD_CHOP, AttackStyle.MELEE),
|
||||
MELEE_2H_SLASH(AnimationID.HUMAN_DHSWORD_SLASH, AttackStyle.MELEE),
|
||||
MELEE_STAFF_CRUSH_2(AnimationID.HUMAN_STAFF_PUMMEL, AttackStyle.MELEE),
|
||||
MELEE_STAFF_CRUSH_3(AnimationID.HUMAN_STAFFORB_PUMMEL, AttackStyle.MELEE),
|
||||
MELEE_PUNCH(AnimationID.HUMAN_UNARMEDPUNCH, AttackStyle.MELEE),
|
||||
MELEE_KICK(AnimationID.HUMAN_UNARMEDKICK, AttackStyle.MELEE),
|
||||
MELEE_STAFF_STAB(AnimationID.HUMAN_SPEAR_SPIKE, AttackStyle.MELEE),
|
||||
MELEE_SPEAR_CRUSH(AnimationID.HUMAN_SPEAR_LUNGE, AttackStyle.MELEE),
|
||||
MELEE_STAFF_SLASH(AnimationID.HUMAN_SCYTHE_SWEEP, AttackStyle.MELEE),
|
||||
MELEE_DLONG_SPEC(AnimationID.CLEAVE, AttackStyle.MELEE, MetaData.SPECIAL_ATTACK.d),
|
||||
MELEE_DRAGON_MACE_SPEC(AnimationID.SHATTER, AttackStyle.MELEE, MetaData.SPECIAL_ATTACK.d),
|
||||
MELEE_DRAGON_DAGGER_SPEC(AnimationID.PUNCTURE, AttackStyle.MELEE, MetaData.SPECIAL_ATTACK.d),
|
||||
MELEE_DRAGON_WARHAMMER_SPEC(AnimationID.DRAGON_WARHAMMER_SA_PLAYER, AttackStyle.MELEE, MetaData.SPECIAL_ATTACK.d),
|
||||
MELEE_ABYSSAL_WHIP(AnimationID.SLAYER_ABYSSAL_WHIP_ATTACK, AttackStyle.MELEE),
|
||||
MELEE_GRANITE_MAUL(AnimationID.SLAYER_GRANITE_MAUL_ATTACK, AttackStyle.MELEE),
|
||||
MELEE_GRANITE_MAUL_SPEC(AnimationID.SLAYER_GRANITE_MAUL_SPECIAL_ATTACK, AttackStyle.MELEE, MetaData.SPECIAL_ATTACK.d),
|
||||
MELEE_DHAROKS_GREATAXE_CRUSH(AnimationID.BARROW_DHAROK_SLASH, AttackStyle.MELEE),
|
||||
MELEE_DHAROKS_GREATAXE_SLASH(AnimationID.BARROW_DHAROK_CRUSH, AttackStyle.MELEE),
|
||||
MELEE_AHRIMS_STAFF_CRUSH(AnimationID.BARROWS_QUARTERSTAFF_ATTACK, AttackStyle.MELEE),
|
||||
MELEE_OBBY_MAUL_CRUSH(AnimationID.DRAGON_PICKAXE_ANIM, AttackStyle.MELEE),
|
||||
MELEE_ABYSSAL_DAGGER_STAB(AnimationID.ABYSSAL_DAGGER_LUNGE, AttackStyle.MELEE),
|
||||
MELEE_ABYSSAL_BLUDGEON_CRUSH(AnimationID.ABYSSAL_BLUDGEON_CRUSH, AttackStyle.MELEE),
|
||||
MELEE_ABYSSAL_BLUDGEON_SPEC(AnimationID.ABYSSAL_BLUDGEON_SPECIAL_ATTACK, AttackStyle.MELEE, MetaData.SPECIAL_ATTACK.d),
|
||||
MELEE_LEAF_BLADED_BATTLEAXE_CRUSH(AnimationID.BATTLEAXE_CRUSH, AttackStyle.MELEE),
|
||||
MELEE_INQUISITORS_MACE(AnimationID.HUMAN_INQUISITORS_MACE_CRUSH, AttackStyle.MELEE),
|
||||
MELEE_BARRELCHEST_ANCHOR_CRUSH(AnimationID.BRAIN_PLAYER_ANCHOR_ATTACK, AttackStyle.MELEE),
|
||||
MELEE_BARRELCHEST_ANCHOR_CRUSH_SPEC(AnimationID.BRAIN_PLAYER_ANCHOR_SPECIAL_ATTACK, AttackStyle.MELEE, MetaData.SPECIAL_ATTACK.d),
|
||||
MELEE_LEAF_BLADED_BATTLEAXE_SLASH(AnimationID.GODWARS_GODSWORD_ZAMORAK_PLAYER, AttackStyle.MELEE),
|
||||
MELEE_GODSWORD_SLASH(AnimationID.DH_SWORD_UPDATE_SLASH, AttackStyle.MELEE),
|
||||
MELEE_GODSWORD_CRUSH(AnimationID.DH_SWORD_UPDATE_SMASH, AttackStyle.MELEE),
|
||||
MELEE_GODSWORD_DEFENSIVE(AnimationID.DH_SWORD_UPDATE_BLOCK, AttackStyle.MELEE),
|
||||
MELEE_RUNE_CLAWS_SPEC(AnimationID.IMPALE, AttackStyle.MELEE, MetaData.SPECIAL_ATTACK.d),
|
||||
MELEE_DRAGON_CLAWS_SPEC(AnimationID.HUMAN_DRAGON_CLAWS_SPEC, AttackStyle.MELEE, MetaData.SPECIAL_ATTACK.d),
|
||||
MELEE_VLS_SPEC(AnimationID.HUMAN_DRAGON_SWORD_SPEC, AttackStyle.MELEE, MetaData.SPECIAL_ATTACK.d),
|
||||
MELEE_ELDER_MAUL(AnimationID.HUMAN_ELDER_MAUL_ATTACK, AttackStyle.MELEE),
|
||||
MELEE_ZAMORAK_GODSWORD_SPEC(AnimationID.ZGS_SPECIAL_PLAYER, AttackStyle.MELEE, MetaData.SPECIAL_ATTACK.d),
|
||||
MELEE_ELDER_MAUL_SPEC(AnimationID.HUMAN_ELDER_MAUL_SPEC, AttackStyle.MELEE),
|
||||
MELEE_ZAMORAK_GODSWORD_OR_SPEC(AnimationID.ZGS_SPECIAL_ORNATE_PLAYER, AttackStyle.MELEE, MetaData.SPECIAL_ATTACK.d),
|
||||
MELEE_SARADOMIN_GODSWORD_SPEC(AnimationID.SGS_SPECIAL_PLAYER, AttackStyle.MELEE, MetaData.SPECIAL_ATTACK.d),
|
||||
MELEE_SARADOMIN_GODSWORD_OR_SPEC(AnimationID.SGS_SPECIAL_ORNATE_PLAYER, AttackStyle.MELEE, MetaData.SPECIAL_ATTACK.d),
|
||||
MELEE_BANDOS_GODSWORD_SPEC(AnimationID.BGS_SPECIAL_PLAYER, AttackStyle.MELEE, MetaData.SPECIAL_ATTACK.d),
|
||||
MELEE_BANDOS_GODSWORD_OR_SPEC(AnimationID.BGS_SPECIAL_ORNATE_PLAYER, AttackStyle.MELEE, MetaData.SPECIAL_ATTACK.d),
|
||||
MELEE_ARMADYL_GODSWORD_SPEC(AnimationID.AGS_SPECIAL_PLAYER, AttackStyle.MELEE, MetaData.SPECIAL_ATTACK.d),
|
||||
MELEE_ARMADYL_GODSWORD_OR_SPEC(AnimationID.AGS_SPECIAL_ORNATE_PLAYER, AttackStyle.MELEE, MetaData.SPECIAL_ATTACK.d),
|
||||
MELEE_SCYTHE(AnimationID.SCYTHE_OF_VITUR_ATTACK, AttackStyle.MELEE),
|
||||
MELEE_GHAZI_RAPIER_STAB(AnimationID.GHRAZI_RAPIER_ATTACK, AttackStyle.MELEE),
|
||||
MELEE_ANCIENT_GODSWORD_SPEC(AnimationID.NGS_SPECIAL_PLAYER, AttackStyle.MELEE, MetaData.SPECIAL_ATTACK.d),
|
||||
MELEE_CRYSTAL_HALBERD_SPEC(AnimationID.DRAGON_HALBERD_SPECIAL_ATTACK, AttackStyle.MELEE, MetaData.SPECIAL_ATTACK.d),
|
||||
MELEE_SOULREAPER_AXE(AnimationID.ANCIENT_AXE_CRUSH, AttackStyle.MELEE, MetaData.SPECIAL_ATTACK.d),
|
||||
MELEE_SOULREAPER_AXE_SPEC(AnimationID.ANCIENT_AXE_SPECIAL, AttackStyle.MELEE, MetaData.SPECIAL_ATTACK.d),
|
||||
MELEE_GUTHANS_LUNGE(AnimationID.BARROWS_WAR_SPEAR_STAB, AttackStyle.MELEE),
|
||||
MELEE_GUTHANS_SWIPE(AnimationID.BARROWS_WAR_SPEAR_SLASH, AttackStyle.MELEE),
|
||||
MELEE_GUTHANS_POUNDMA(AnimationID.BARROWS_WAR_SPEAR_CRUSH, AttackStyle.MELEE),
|
||||
MELEE_TORAG_HAMMERS(AnimationID.BARROW_TORAG_CRUSH, AttackStyle.MELEE),
|
||||
MELEE_VERACS_FLAIL(AnimationID.BARROW_GUTHAN_CRUSH, AttackStyle.MELEE),
|
||||
MELEE_BLISTERWOOD_FLAIL_CRUSH(AnimationID.IVANDIS_FLAIL_ATTACK, AttackStyle.MELEE),
|
||||
MELEE_BONE_DAGGER_SPEC(AnimationID.DTTD_PLAYER_STAB_BONE_DAGGER, AttackStyle.MELEE, MetaData.SPECIAL_ATTACK.d),
|
||||
MELEE_DUAL_MACUAHUITL(AnimationID.PMOON_MACUAHUITL_CRUSH, AttackStyle.MELEE),
|
||||
MELEE_BLUE_MOON_SPEAR_SPEC(AnimationID.HUMAN_ZAMORAKSPEAR_LUNGE, AttackStyle.MELEE, MetaData.SPECIAL_ATTACK.d),
|
||||
MELEE_BLUE_MOON_SPEAR(AnimationID.HUMAN_ZAMORAKSPEAR_STAB, AttackStyle.MELEE),
|
||||
MELEE_DHINS(AnimationID.HUMAN_DINHS_BULWARK_BASH, AttackStyle.MELEE),
|
||||
MELEE_URSINE_CHAINMACE_SPEC(AnimationID.HUMAN_SPECIAL02_URSINE, AttackStyle.MELEE, MetaData.SPECIAL_ATTACK.d),
|
||||
MELEE_ANCIENT_MACE_SPEC(AnimationID.SLICE_PLAYER_MACE_SPECIAL_ATTACK, AttackStyle.MELEE, MetaData.SPECIAL_ATTACK.d),
|
||||
MELEE_DSCIM_SPEC(AnimationID.SP_ATTACK_DRAGON_SCIMITAR, AttackStyle.MELEE, MetaData.SPECIAL_ATTACK.d),
|
||||
MELEE_D2H_SPEC(AnimationID.DRAGON_TWO_HANDED_SWORD, AttackStyle.MELEE, MetaData.SPECIAL_ATTACK.d),
|
||||
MELEE_ARCLIGHT_SPEC(AnimationID.DARK_SPEC_PLAYER, AttackStyle.MELEE, MetaData.SPECIAL_ATTACK.d),
|
||||
MELEE_SARA_SWORD_SPEC(AnimationID.SARADOMIN_SWORD_SPECIAL_PLAYER, AttackStyle.MELEE, MetaData.SPECIAL_ATTACK.d),
|
||||
MELEE_RED_KERIS_SPEC(AnimationID.TOA_KERIS_PARTISAN_SPECIAL01, AttackStyle.MELEE, MetaData.SPECIAL_ATTACK.d),
|
||||
MELEE_SALAMANDER(AnimationID.HUMAN_ATTACK_SALAMANDER, AttackStyle.MELEE),
|
||||
MELEE_INFERNAL_TECPATL(AnimationID.TECPATL_STAB, AttackStyle.MELEE),
|
||||
MELEE_HALLOWED_FLAIL(AnimationID.HUMAN_WEAPONS_HALLOWED_FLAIL01_ATTACK01, AttackStyle.MELEE),
|
||||
MELEE_FELLING_AXE(AnimationID.FORESTRY_2H_AXE_ATTACK, AttackStyle.MELEE),
|
||||
MELEE_HALLOWFELL(AnimationID.HUMAN_HALLOWFELL_SLASH, AttackStyle.MELEE),
|
||||
|
||||
// RANGED
|
||||
RANGED_CHINCHOMPA(AnimationID.HUMAN_CHINCHOMPA_ATTACK_PVN, AttackStyle.RANGED),
|
||||
RANGED_SHORTBOW(AnimationID.HUMAN_BOW, AttackStyle.RANGED, MetaData.STANDARD_BOW_ATTACK.d),
|
||||
RANGED_RUNE_KNIFE_PVP(AnimationID.HUMAN_STAKE2, AttackStyle.RANGED),
|
||||
RANGED_MAGIC_SHORTBOW_SPEC(AnimationID.SNAPSHOT, AttackStyle.RANGED, MetaData.STANDARD_BOW_ATTACK.d | MetaData.SPECIAL_ATTACK.d),
|
||||
RANGED_CROSSBOW_PVP(AnimationID.XBOWS_HUMAN_FIRE_AND_RELOAD, AttackStyle.RANGED),
|
||||
RANGED_BLOWPIPE(AnimationID.SNAKEBOSS_BLOWPIPE_ATTACK, AttackStyle.RANGED),
|
||||
RANGED_DARTS(AnimationID.II_HUMAN_DART_THROW_PVN, AttackStyle.RANGED),
|
||||
RANGED_BALLISTA(AnimationID.BALLISTA_ATTACK, AttackStyle.RANGED),
|
||||
RANGED_BALLISTA_SPEC(AnimationID.BALLISTA_SPECIAL_ATTACK_PVN, AttackStyle.RANGED, MetaData.SPECIAL_ATTACK.d),
|
||||
RANGED_RUNE_THROWNAXE_SPEC(AnimationID.CHAINHIT, AttackStyle.RANGED, MetaData.SPECIAL_ATTACK.d),
|
||||
RANGED_DRAGON_THROWNAXE_SPEC(AnimationID.HUMAN_DRAGON_TAXE_SPEC, AttackStyle.RANGED, MetaData.SPECIAL_ATTACK.d),
|
||||
RANGED_RUNE_CROSSBOW(AnimationID.XBOWS_HUMAN_FIRE_AND_RELOAD_PVN, AttackStyle.RANGED),
|
||||
RANGED_RUNE_CROSSBOW_OR(AnimationID.HUMAN_XBOWS_LEAGUE03_ATTACK_PVN, AttackStyle.RANGED),
|
||||
RANGED_BALLISTA_2(AnimationID.BALLISTA_ATTACK_PVN, AttackStyle.RANGED),
|
||||
RANGED_RUNE_KNIFE(AnimationID.HUMAN_STAKE2_PVN, AttackStyle.RANGED),
|
||||
RANGED_DRAGON_KNIFE(AnimationID.HUMAN_DRAGON_KNIFE, AttackStyle.RANGED),
|
||||
RANGED_DRAGON_KNIFE_SPEC(AnimationID.HUMAN_DRAGON_TKNIVES_SPEC, AttackStyle.RANGED, MetaData.SPECIAL_ATTACK.d),
|
||||
RANGED_DRAGON_KNIFE_POISONED(AnimationID.HUMAN_DRAGON_KNIFE_P, AttackStyle.RANGED),
|
||||
RANGED_DRAGON_KNIFE_POISONED_SPEC(AnimationID.HUMAN_DRAGON_TKNIVES_SPEC_POISON, AttackStyle.RANGED, MetaData.SPECIAL_ATTACK.d),
|
||||
RANGED_ZARYTE_CROSSBOW(AnimationID.ZCB_ATTACK_PVN, AttackStyle.RANGED),
|
||||
RANGED_ZARYTE_CROSSBOW_PVP(AnimationID.ZCB_ATTACK, AttackStyle.RANGED),
|
||||
RANGED_BLAZING_BLOWPIPE(AnimationID.SNAKEBOSS_BLOWPIPE_ATTACK_ORNAMENT, AttackStyle.RANGED),
|
||||
RANGED_VENATOR_BOW(AnimationID.HUMAN_WEAPON_BOW_VENATOR01_SHOOT, AttackStyle.RANGED, MetaData.STANDARD_BOW_ATTACK.d),
|
||||
RANGED_KARIL_CROSSBOW(AnimationID.BARROWS_REPEATING_CROSSBOW_FIRE, AttackStyle.RANGED),
|
||||
RANGED_ATLATL(AnimationID.HUMAN_ATLATL_ATTACK_RANGED_01, AttackStyle.RANGED, MetaData.STANDARD_BOW_ATTACK.d),
|
||||
RANGED_ATLATL_SPEC(AnimationID.HUMAN_SPECIAL_ATLATL_01, AttackStyle.RANGED, MetaData.STANDARD_BOW_ATTACK.d | MetaData.SPECIAL_ATTACK.d),
|
||||
RANGED_TONALZTICS(AnimationID.HUMAN_GLAIVE_RALOS01_CHARGED_THROW, AttackStyle.RANGED),
|
||||
RANGED_TONALZTICS_SPEC(AnimationID.HUMAN_GLAIVE_RALOS01_CHARGED_SPECIAL, AttackStyle.RANGED, MetaData.SPECIAL_ATTACK.d),
|
||||
RANGED_WEBWEAVER_SPEC(AnimationID.HUMAN_SPECIAL01_WEBWEAVER, AttackStyle.RANGED, MetaData.STANDARD_BOW_ATTACK.d | MetaData.SPECIAL_ATTACK.d),
|
||||
RANGED_BONE_CROSSBOW_SPEC(AnimationID.DTTD_PLAYER_FIRE_BONE_CROSSBOW_PVN, AttackStyle.RANGED, MetaData.SPECIAL_ATTACK.d),
|
||||
RANGED_SCORCHING_BOW_SPEC(AnimationID.HUMAN_WEAPON_BOW_SCORCHED_01_SPEC, AttackStyle.RANGED, MetaData.STANDARD_BOW_ATTACK.d | MetaData.SPECIAL_ATTACK.d),
|
||||
|
||||
// MAGIC
|
||||
MAGIC_GOD_SPELL(AnimationID.HUMAN_CASTING, AttackStyle.MAGIC, Spellbook.STANDARD, MetaData.NO_DATA.d),
|
||||
MAGIC_IBAN_BLAST(AnimationID.HUMAN_CASTIBANBLAST, AttackStyle.MAGIC, Spellbook.STANDARD, MetaData.NO_DATA.d),
|
||||
MAGIC_SLAYER_DART(AnimationID.SLAYER_MAGICDART_CAST, AttackStyle.MAGIC, Spellbook.STANDARD, MetaData.NO_DATA.d),
|
||||
MAGIC_STANDARD_BIND(AnimationID.HUMAN_CASTENTANGLE, AttackStyle.MAGIC, Spellbook.STANDARD, MetaData.NO_DATA.d),
|
||||
MAGIC_STANDARD_BIND_STAFF(AnimationID.HUMAN_CASTENTANGLE_STAFF, AttackStyle.MAGIC, Spellbook.STANDARD, MetaData.NO_DATA.d),
|
||||
MAGIC_STANDARD_CONFUSE(AnimationID.HUMAN_CASTCONFUSE, AttackStyle.MAGIC, Spellbook.STANDARD, MetaData.NO_DATA.d),
|
||||
MAGIC_STANDARD_CONFUSE_STAFF(AnimationID.HUMAN_CASTCONFUSE_STAFF, AttackStyle.MAGIC, Spellbook.STANDARD, MetaData.NO_DATA.d),
|
||||
MAGIC_STANDARD_CONFUSE_STAFF_WALK(AnimationID.HUMAN_CASTCONFUSE_STAFF_WALKMERGE, AttackStyle.MAGIC, Spellbook.STANDARD, MetaData.NO_DATA.d),
|
||||
MAGIC_STANDARD_CONFUSE_WALK(AnimationID.HUMAN_CASTCONFUSE_WALKMERGE, AttackStyle.MAGIC, Spellbook.STANDARD, MetaData.NO_DATA.d),
|
||||
MAGIC_STANDARD_CRUMBLE_UNDEAD(AnimationID.HUMAN_CASTCRUMBLEUNDEAD, AttackStyle.MAGIC, Spellbook.STANDARD, MetaData.NO_DATA.d),
|
||||
MAGIC_STANDARD_CRUMBLE_UNDEAD_HOLDING_STAFF(AnimationID.HUMAN_CASTCRUMBLEUNDEAD_STAFF, AttackStyle.MAGIC, Spellbook.STANDARD, MetaData.NO_DATA.d),
|
||||
MAGIC_STANDARD_ENFEEBLE(AnimationID.HUMAN_CASTENFEEBLE_STAFF, AttackStyle.MAGIC, Spellbook.STANDARD, MetaData.NO_DATA.d),
|
||||
MAGIC_STANDARD_STRIKE_BOLT_BLAST(AnimationID.HUMAN_CASTSTRIKE, AttackStyle.MAGIC, Spellbook.STANDARD, MetaData.NO_DATA.d),
|
||||
MAGIC_STANDARD_STRIKE_BOLT_BLAST_STAFF(AnimationID.HUMAN_CASTSTRIKE_STAFF, AttackStyle.MAGIC, Spellbook.STANDARD, MetaData.NO_DATA.d),
|
||||
MAGIC_STANDARD_STRIKE_BOLT_BLAST_STAFF_WALK(AnimationID.HUMAN_CASTSTRIKE_STAFF_WALKMERGE, AttackStyle.MAGIC, Spellbook.STANDARD, MetaData.NO_DATA.d),
|
||||
MAGIC_STANDARD_STRIKE_BOLT_BLAST_WALK(AnimationID.HUMAN_CASTSTRIKE_WALKMERGE, AttackStyle.MAGIC, Spellbook.STANDARD, MetaData.NO_DATA.d),
|
||||
MAGIC_STANDARD_STUN(AnimationID.HUMAN_CASTSTUN_STAFF, AttackStyle.MAGIC, Spellbook.STANDARD, MetaData.NO_DATA.d),
|
||||
MAGIC_STANDARD_SURGE(AnimationID.HUMAN_CAST_SURGE, AttackStyle.MAGIC, Spellbook.STANDARD, MetaData.NO_DATA.d),
|
||||
MAGIC_STANDARD_SURGE_STAFF(AnimationID.HUMAN_CAST_SURGE_FAST, AttackStyle.MAGIC, Spellbook.STANDARD, MetaData.NO_DATA.d),
|
||||
MAGIC_STANDARD_SURGE_STAFF_WALK(AnimationID.HUMAN_CAST_SURGE_WALKMERGE, AttackStyle.MAGIC, Spellbook.STANDARD, MetaData.NO_DATA.d),
|
||||
MAGIC_STANDARD_VULNERABILITY_CURSE(AnimationID.HUMAN_CASTCURSE, AttackStyle.MAGIC, Spellbook.STANDARD, MetaData.NO_DATA.d),
|
||||
MAGIC_STANDARD_VULNERABILITY_CURSE_STAFF(AnimationID.HUMAN_CASTCURSE_STAFF, AttackStyle.MAGIC, Spellbook.STANDARD, MetaData.NO_DATA.d),
|
||||
MAGIC_STANDARD_VULNERABILITY_CURSE_STAFF_WALK(AnimationID.HUMAN_CASTCURSE_STAFF_WALKMERGE, AttackStyle.MAGIC, Spellbook.STANDARD, MetaData.NO_DATA.d),
|
||||
MAGIC_STANDARD_VULNERABILITY_CURSE_WALK(AnimationID.HUMAN_CASTCURSE_WALKMERGE, AttackStyle.MAGIC, Spellbook.STANDARD, MetaData.NO_DATA.d),
|
||||
MAGIC_STANDARD_WAVE(AnimationID.HUMAN_CASTWAVE, AttackStyle.MAGIC, Spellbook.STANDARD, MetaData.NO_DATA.d),
|
||||
MAGIC_STANDARD_WAVE_STAFF(AnimationID.HUMAN_CASTWAVE_STAFF, AttackStyle.MAGIC, Spellbook.STANDARD, MetaData.NO_DATA.d),
|
||||
MAGIC_STANDARD_WAVE_STAFF_WALK(AnimationID.HUMAN_CASTWAVE_STAFF_WALKMERGE, AttackStyle.MAGIC, Spellbook.STANDARD, MetaData.NO_DATA.d),
|
||||
MAGIC_STANDARD_WAVE_WALK(AnimationID.HUMAN_CASTWAVE_WALKMERGE, AttackStyle.MAGIC, Spellbook.STANDARD, MetaData.NO_DATA.d),
|
||||
MAGIC_STANDARD_WEAKEN(AnimationID.HUMAN_CASTWEAKEN, AttackStyle.MAGIC, Spellbook.STANDARD, MetaData.NO_DATA.d),
|
||||
MAGIC_STANDARD_WEAKEN_STAFF(AnimationID.HUMAN_CASTWEAKEN_STAFF, AttackStyle.MAGIC, Spellbook.STANDARD, MetaData.NO_DATA.d),
|
||||
MAGIC_STANDARD_WEAKEN_STAFF_WALK(AnimationID.HUMAN_CASTWEAKEN_STAFF_WALKMERGE, AttackStyle.MAGIC, Spellbook.STANDARD, MetaData.NO_DATA.d),
|
||||
MAGIC_STANDARD_WEAKEN_WALK(AnimationID.HUMAN_CASTWEAKEN_WALKMERGE, AttackStyle.MAGIC, Spellbook.STANDARD, MetaData.NO_DATA.d),
|
||||
|
||||
MAGIC_ANCIENT_MULTI_TARGET(AnimationID.ZAROS_VERTICAL_CASTING_WALKMERGE, AttackStyle.MAGIC, Spellbook.ANCIENT, MetaData.NO_DATA.d),
|
||||
MAGIC_ANCIENT_MULTI_TARGET_PVP(AnimationID.ZAROS_VERTICAL_CASTING, AttackStyle.MAGIC, Spellbook.ANCIENT, MetaData.NO_DATA.d),
|
||||
MAGIC_ANCIENT_SINGLE_TARGET(AnimationID.ZAROS_CASTING_WALKMERGE, AttackStyle.MAGIC, Spellbook.ANCIENT, MetaData.NO_DATA.d),
|
||||
MAGIC_ANCIENT_SINGLE_TARGET_PVP(AnimationID.ZAROS_CASTING, AttackStyle.MAGIC, Spellbook.ANCIENT, MetaData.NO_DATA.d),
|
||||
|
||||
MAGIC_ARCEUUS_DEMONBANE(AnimationID.HUMAN_SPELLCAST_DEMONBANE, AttackStyle.MAGIC, Spellbook.ARCEUUS, MetaData.NO_DATA.d),
|
||||
MAGIC_ARCEUUS_GRASP(AnimationID.HUMAN_SPELLCAST_GRASP, AttackStyle.MAGIC, Spellbook.ARCEUUS, MetaData.NO_DATA.d),
|
||||
|
||||
MAGIC_ACCURSED_SCEPTRE_SPEC(AnimationID.HUMAN_SPECIAL_ACCURSED, AttackStyle.MAGIC, MetaData.SPECIAL_ATTACK.d),
|
||||
MAGIC_EYE_OF_AYAK(AnimationID.HUMAN_EYE_OF_AYAK_NORMAL, AttackStyle.MAGIC, MetaData.NO_DATA.d),
|
||||
MAGIC_EYE_OF_AYAK_SPEC(AnimationID.HUMAN_EYE_OF_AYAK_SPECIAL, AttackStyle.MAGIC, MetaData.SPECIAL_ATTACK.d),
|
||||
MAGIC_TUMEKENS_SHADOW(AnimationID.TOA_SOT_CAST_B, AttackStyle.MAGIC, MetaData.NO_DATA.d),
|
||||
MAGIC_VOLATILE_NIGHTMARE_STAFF_SPEC(AnimationID.NIGHTMARE_STAFF_SPECIAL, AttackStyle.MAGIC, MetaData.SPECIAL_ATTACK.d),
|
||||
MAGIC_WARPED_SCEPTRE(AnimationID.POG_WARPED_SCEPTRE_ATTACK, AttackStyle.MAGIC, MetaData.NO_DATA.d),
|
||||
|
||||
// Misc Non-attacks
|
||||
MAGIC_IMBUE(AnimationID.HUMAN_CASTBONESTOBANANAS, AttackStyle.NON_ATTACK),
|
||||
SPELLBOOK_SWAP(AnimationID.DREAM_PLAYER_SPELLBOOK_SWAP, AttackStyle.NON_ATTACK),
|
||||
LUNAR_GROUP(AnimationID.QUEST_LUNAR_SPELL_CAST_SPELL_ON_GROUP, AttackStyle.NON_ATTACK),
|
||||
LUNAR_OTHER(AnimationID.QUEST_LUNAR_PUSHING_MAGIC_ANIMATION, AttackStyle.NON_ATTACK),
|
||||
NPC_CONTACT(AnimationID.LUNAR_HUMAN_MAGIC_SUMMON2, AttackStyle.NON_ATTACK),
|
||||
VENGEANCE_1(AnimationID.VENGEANCE_SPELL_ANIM_NOSTALLING, AttackStyle.NON_ATTACK),
|
||||
VENGEANCE_2(AnimationID.VENGEANCE_SPELL_ANIM_STALLING, AttackStyle.NON_ATTACK),
|
||||
REANIMATION(AnimationID.ARCEUUS_NECROMANCY_PLAYERANIM, AttackStyle.NON_ATTACK),
|
||||
DEMONIC_OFFERING(AnimationID.HUMAN_CAST_OFFERING, AttackStyle.NON_ATTACK),
|
||||
SHADOW_VEIL(AnimationID.HUMAN_SPELLCAST_SHADOWVEIL, AttackStyle.NON_ATTACK),
|
||||
MARK_OF_DARKNESS(AnimationID.HUMAN_CAST_SELFIMBUE, AttackStyle.NON_ATTACK),
|
||||
PICK_POCKETING(AnimationID.HUMAN_PICKPOCKET, AttackStyle.NON_ATTACK),
|
||||
SUMMON_THRALL(AnimationID.HUMAN_SPELLCAST_RESURRECT, AttackStyle.NON_ATTACK),
|
||||
LUNAR_TELEPORT(AnimationID.HUMAN_TELEPORT_OTHER_IMPACT, AttackStyle.NON_ATTACK),
|
||||
MONSTER_EXAMINE(AnimationID.DREAM_PLAYER_MONSTEREXAM_STATSPY, AttackStyle.NON_ATTACK),
|
||||
HUMIDIFY(AnimationID.DREAM_PLAYER_HUMIDIFY_SPELL, AttackStyle.NON_ATTACK),
|
||||
GEOMANCY(AnimationID.LUNAR_HUMAN_MAGIC_GEOMANCY, AttackStyle.NON_ATTACK),
|
||||
DREAM(AnimationID.FOSSIL_LOC_CLAM_IDLE_SHUT, AttackStyle.NON_ATTACK),
|
||||
ROCKSLUG_BAG_OF_SALT(AnimationID.SLAYER_SALT_SPRINKLE, AttackStyle.NON_ATTACK),
|
||||
DESSET_LIZARD_ICE_COOLER(AnimationID.HUMAN_CHINCHOMPA_ATTACK, AttackStyle.NON_ATTACK),
|
||||
FLECTHING_KNIFE(AnimationID.HUMAN_FLETCHING, AttackStyle.NON_ATTACK),
|
||||
FLECTHING_KEBBIT(AnimationID.HUMAN_CRAFTING_SPIKEDVAMBRACES, AttackStyle.NON_ATTACK),
|
||||
FLECTHING_CHISEL(AnimationID.HUMAN_FLETCHING_HUNTINGBOLTS, AttackStyle.NON_ATTACK),
|
||||
FLECTHING_DART_TIP_1(AnimationID.HUMAN_FLETCHING_ADD_DART_FEATHERS_BRONZE, AttackStyle.NON_ATTACK),
|
||||
FLECTHING_DART_TIP_2(AnimationID.HUMAN_FLETCHING_ADD_DART_FEATHERS_IRON, AttackStyle.NON_ATTACK),
|
||||
FLECTHING_DART_TIP_3(AnimationID.HUMAN_FLETCHING_ADD_DART_FEATHERS_STEEL, AttackStyle.NON_ATTACK),
|
||||
FLECTHING_DART_TIP_4(AnimationID.HUMAN_FLETCHING_ADD_DART_FEATHERS_MITHRIL, AttackStyle.NON_ATTACK),
|
||||
FLECTHING_DART_TIP_5(AnimationID.HUMAN_FLETCHING_ADD_DART_FEATHERS_ADAMANT, AttackStyle.NON_ATTACK),
|
||||
FLECTHING_DART_TIP_6(AnimationID.HUMAN_FLETCHING_ADD_DART_FEATHERS_RUNE, AttackStyle.NON_ATTACK),
|
||||
FLECTHING_DART_TIP_7(AnimationID.HUMAN_FLETCHING_ADD_DART_FEATHERS_DRAGON, AttackStyle.NON_ATTACK),
|
||||
FLECTHING_DART_TIP_8(AnimationID.HUMAN_FLETCHING_ADD_DART_FEATHERS_AMETHYST, AttackStyle.NON_ATTACK),
|
||||
HERB_TAR(AnimationID.HUMAN_SALAMANDER_TAR_GRIND, AttackStyle.NON_ATTACK),
|
||||
SETUP_HUNTER_TRAP(AnimationID.HUMAN_LAYTRAP, AttackStyle.NON_ATTACK),
|
||||
RESET_SNARE_TRAP(AnimationID.HUMAN_HUNTING_DISMANTLE_NET, AttackStyle.NON_ATTACK),
|
||||
RESET_BOX_TRAP(AnimationID.HUNTING_SETTING_TRAP_SMALL, AttackStyle.NON_ATTACK),
|
||||
DESERT_AMMY(AnimationID.TELEPORT_NARDAH_HUMAN, AttackStyle.NON_ATTACK),
|
||||
EAT_FOOD_OR_POTION(AnimationID.HUMAN_EAT, AttackStyle.NON_ATTACK),
|
||||
OVERLOAD_HIT(AnimationID.HUMAN_KILLERWATT_ELECTRICSHOCK, AttackStyle.NON_ATTACK),
|
||||
TAKING_HIT_1HANDED_UNARMED(AnimationID.HUMAN_AXE_BLOCK, AttackStyle.NON_ATTACK),
|
||||
TAKING_HIT_2H_SWORD(AnimationID.HUMAN_DHSWORD_BLOCK, AttackStyle.NON_ATTACK),
|
||||
TAKING_HIT_ANCHOR(AnimationID.BRAIN_PLAYER_ANCHOR_DEFEND, AttackStyle.NON_ATTACK),
|
||||
TAKING_HIT_BLISTERWOOD_FLAIL(AnimationID.IVANDIS_FLAIL_DEFEND, AttackStyle.NON_ATTACK),
|
||||
TAKING_HIT_BLOWPIPE(AnimationID.HUMAN_SPEAR_BLOCK, AttackStyle.NON_ATTACK),
|
||||
TAKING_HIT_BULWARK(AnimationID.HUMAN_DINHS_BULWARK_BLOCK, AttackStyle.NON_ATTACK),
|
||||
TAKING_HIT_CHAINMACE(AnimationID.WILD_CAVE_CHAINMACE_DEFEND, AttackStyle.NON_ATTACK),
|
||||
TAKING_HIT_CHIN_CHOMPA(AnimationID.HUMAN_CHINCHOMPA_DEFEND, AttackStyle.NON_ATTACK),
|
||||
TAKING_HIT_DAGGER(AnimationID.HUMAN_DDAGGER_BLOCK, AttackStyle.NON_ATTACK),
|
||||
TAKING_HIT_DEFENDER(AnimationID.WARGUILD_PARRY_DEFEND, AttackStyle.NON_ATTACK),
|
||||
TAKING_HIT_FANG(AnimationID.HUMAN_SWORD_DEF, AttackStyle.NON_ATTACK),
|
||||
TAKING_HIT_GODSWORD(AnimationID.DH_SWORD_UPDATE_DEFEND, AttackStyle.NON_ATTACK),
|
||||
TAKING_HIT_KERIS(AnimationID.HUMAN_DSPEAR_BLOCK, AttackStyle.NON_ATTACK),
|
||||
TAKING_HIT_LARGE_STAFF(AnimationID.HUMAN_STAFFORB_BLOCK, AttackStyle.NON_ATTACK),
|
||||
TAKING_HIT_MACE(AnimationID.HUMAN_BLUNT_BLOCK, AttackStyle.NON_ATTACK),
|
||||
TAKING_HIT_OBBY_MAUL(AnimationID.SLAYER_GRANITE_MAUL_DEFEND, AttackStyle.NON_ATTACK),
|
||||
TAKING_HIT_SCYTHE(AnimationID.HUMAN_SCYTHE_BLOCK, AttackStyle.NON_ATTACK),
|
||||
TAKING_HIT_SHIELD(AnimationID.HUMAN_SHIELD_DEFENCE, AttackStyle.NON_ATTACK),
|
||||
TAKING_HIT_SPEAR(AnimationID.HUMAN_ZAMORAKSPEAR_BLOCK, AttackStyle.NON_ATTACK),
|
||||
TAKING_HIT_STAFF(AnimationID.HUMAN_STAFF_BLOCK, AttackStyle.NON_ATTACK),
|
||||
TAKING_HIT_UNARMED(AnimationID.HUMAN_UNARMEDBLOCK, AttackStyle.NON_ATTACK),
|
||||
TAKING_HIT_VERACS_FLAIL(AnimationID.BARROW_GUTHAN_DEFEND, AttackStyle.NON_ATTACK),
|
||||
TAKING_HIT_WHIP(AnimationID.SLAYER_ABYSSAL_WHIP_DEFEND, AttackStyle.NON_ATTACK),
|
||||
TAKING_HIT_KISTEN(AnimationID.HUMAN_WEAPONS_CRIMSON_KISTEN_DEF, AttackStyle.NON_ATTACK),
|
||||
TAKING_HIT_HALLOWFELL(AnimationID.HUMAN_HALLOWFELL_DEFEND, AttackStyle.NON_ATTACK),
|
||||
LOW_ALCH(AnimationID.HUMAN_CASTLOWLVLALCHEMY, AttackStyle.NON_ATTACK),
|
||||
HIGH_ALCH(AnimationID.HUMAN_CASTHIGHLVLALCHEMY, AttackStyle.NON_ATTACK);
|
||||
|
||||
private static final Map<Integer, AnimationData> DATA;
|
||||
private static final Map<Spellbook, Set<AnimationData>> SPELL_BOOK_ANIMATIONS;
|
||||
private static final Map<Integer, AnimationData> NOT_ATTACKS;
|
||||
private static final Set<Integer> STANDARD_BOW_ATTACKS;
|
||||
|
||||
public final int animationId;
|
||||
public final long metaData;
|
||||
@Getter
|
||||
public final AttackStyle attackStyle;
|
||||
@Getter
|
||||
private final Spellbook spellbook;
|
||||
|
||||
AnimationData(int animationId, AttackStyle attackStyle)
|
||||
{
|
||||
if (attackStyle == null)
|
||||
{
|
||||
throw new InvalidParameterException("Attack Style must be valid for AnimationData");
|
||||
}
|
||||
this.animationId = animationId;
|
||||
this.attackStyle = attackStyle;
|
||||
this.metaData = MetaData.NO_DATA.d;
|
||||
this.spellbook = null;
|
||||
}
|
||||
|
||||
AnimationData(int animationId, AttackStyle attackStyle, long data)
|
||||
{
|
||||
if (attackStyle == null)
|
||||
{
|
||||
throw new InvalidParameterException("Attack Style must be valid for AnimationData");
|
||||
}
|
||||
this.animationId = animationId;
|
||||
this.attackStyle = attackStyle;
|
||||
this.metaData = data;
|
||||
this.spellbook = null;
|
||||
}
|
||||
|
||||
AnimationData(int animationId, AttackStyle attackStyle, Spellbook book, long data)
|
||||
{
|
||||
if (attackStyle == null)
|
||||
{
|
||||
throw new InvalidParameterException("Attack Style must be valid for AnimationData");
|
||||
}
|
||||
this.animationId = animationId;
|
||||
this.attackStyle = attackStyle;
|
||||
this.metaData = data;
|
||||
this.spellbook = book;
|
||||
}
|
||||
|
||||
static
|
||||
{
|
||||
ImmutableMap.Builder<Integer, AnimationData> builder = new ImmutableMap.Builder<>();
|
||||
ImmutableMap.Builder<Integer, AnimationData> notAttacksBuilder = new ImmutableMap.Builder<>();
|
||||
Map<Spellbook, Set<AnimationData>> spellBookBuilder = new HashMap<>();
|
||||
ImmutableSet.Builder<Integer> standardBowBuilder = new ImmutableSet.Builder<>();
|
||||
|
||||
for (Spellbook s : Spellbook.values())
|
||||
{
|
||||
spellBookBuilder.put(s, new HashSet<>());
|
||||
}
|
||||
|
||||
for (AnimationData data : values())
|
||||
{
|
||||
builder.put(data.animationId, data);
|
||||
|
||||
if (data.spellbook != null)
|
||||
{
|
||||
if (data.attackStyle != AttackStyle.MAGIC)
|
||||
{
|
||||
throw new InvalidParameterException("Spell book should only be magic animations");
|
||||
}
|
||||
spellBookBuilder.get(data.spellbook).add(data);
|
||||
}
|
||||
if (data.attackStyle == AttackStyle.NON_ATTACK)
|
||||
{
|
||||
notAttacksBuilder.put(data.animationId, data);
|
||||
}
|
||||
|
||||
if (MetaData.hasFlagSet(data.metaData, MetaData.STANDARD_BOW_ATTACK))
|
||||
{
|
||||
standardBowBuilder.add(data.animationId);
|
||||
}
|
||||
}
|
||||
|
||||
DATA = builder.build();
|
||||
NOT_ATTACKS = notAttacksBuilder.build();
|
||||
SPELL_BOOK_ANIMATIONS = spellBookBuilder;
|
||||
STANDARD_BOW_ATTACKS = standardBowBuilder.build();
|
||||
}
|
||||
|
||||
public static AnimationData fromId(int animationId)
|
||||
{
|
||||
return DATA.get(animationId);
|
||||
}
|
||||
|
||||
public static Set<AnimationData> getAnimationsForSpellbook(Spellbook s)
|
||||
{
|
||||
return SPELL_BOOK_ANIMATIONS.get(s);
|
||||
}
|
||||
|
||||
public static boolean isManualCasting(AnimationData animationData)
|
||||
{
|
||||
if (animationData != null && animationData.spellbook != null)
|
||||
{
|
||||
return animationData.attackStyle == AttackStyle.MAGIC &&
|
||||
SPELL_BOOK_ANIMATIONS.get(animationData.spellbook).contains(animationData);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public static boolean isBlockListAnimation(int animationId)
|
||||
{
|
||||
return NOT_ATTACKS.containsKey(animationId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString()
|
||||
{
|
||||
String[] words = super.toString().toLowerCase().split("_");
|
||||
Arrays.stream(words)
|
||||
.map(StringUtils::capitalize).collect(Collectors.toList()).toArray(words);
|
||||
|
||||
return String.join(" ", words);
|
||||
}
|
||||
|
||||
public boolean matchesSpellbook(Spellbook s)
|
||||
{
|
||||
if (this.spellbook != null)
|
||||
{
|
||||
return this.spellbook == s;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public boolean isStandardBowAttack()
|
||||
{
|
||||
return STANDARD_BOW_ATTACKS.contains(this.animationId);
|
||||
}
|
||||
|
||||
public boolean isBlockListAnimation()
|
||||
{
|
||||
return NOT_ATTACKS.containsKey(this.animationId);
|
||||
}
|
||||
|
||||
public enum AttackStyle
|
||||
{
|
||||
MELEE,
|
||||
RANGED,
|
||||
MAGIC,
|
||||
NON_ATTACK;
|
||||
|
||||
@Override
|
||||
public String toString()
|
||||
{
|
||||
return StringUtils.capitalize(super.toString().toLowerCase());
|
||||
}
|
||||
}
|
||||
|
||||
public enum MetaData
|
||||
{
|
||||
NO_DATA(),
|
||||
SPECIAL_ATTACK(1),
|
||||
STANDARD_BOW_ATTACK(2);
|
||||
|
||||
MetaData(int bitshift)
|
||||
{
|
||||
this.d = 1 << bitshift;
|
||||
}
|
||||
MetaData()
|
||||
{
|
||||
this.d = 0;
|
||||
}
|
||||
|
||||
public static boolean hasFlagSet(long input, MetaData data)
|
||||
{
|
||||
return (input & data.d) == data.d;
|
||||
}
|
||||
|
||||
private final long d;
|
||||
|
||||
@Override
|
||||
public String toString()
|
||||
{
|
||||
return StringUtils.capitalize(super.toString().toLowerCase());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.example.essentialsplus.helpers.combat;
|
||||
|
||||
public enum AttackProcedure
|
||||
{
|
||||
POWERED_STAVE,
|
||||
MANUAL_AUTO_CAST,
|
||||
MELEE_OR_RANGE
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
package com.example.essentialsplus.helpers.combat;
|
||||
|
||||
import com.example.essentialsplus.helpers.combat.variablespeed.VariableSpeed;
|
||||
import com.example.essentialsplus.helpers.combat.variablespeed.state.TickCount;
|
||||
import com.google.common.collect.ImmutableMap;
|
||||
import java.util.ArrayDeque;
|
||||
import java.util.Map;
|
||||
import lombok.Getter;
|
||||
import net.runelite.api.Client;
|
||||
import net.runelite.api.events.FakeXpDrop;
|
||||
import net.runelite.api.events.SoundEffectPlayed;
|
||||
import net.runelite.api.events.StatChanged;
|
||||
import net.runelite.api.gameval.ItemID;
|
||||
import net.runelite.client.game.ItemManager;
|
||||
|
||||
public class AttackSpeed
|
||||
{
|
||||
private final ArrayDeque<Integer> specialPercentageEvents = new ArrayDeque<>();
|
||||
private final Damage damage = new Damage();
|
||||
@Getter
|
||||
private int dmgDealt = -1;
|
||||
private final TickCount tickCount;
|
||||
private final VariableSpeed variableSpeed;
|
||||
@Getter
|
||||
private int soundEffectTick = -1;
|
||||
@Getter
|
||||
private int soundEffectId = -1;
|
||||
@Getter
|
||||
private boolean isUsingMagic = false;
|
||||
|
||||
public AttackSpeed(final TickCount tc, final VariableSpeed vs)
|
||||
{
|
||||
this.tickCount = tc;
|
||||
this.variableSpeed = vs;
|
||||
}
|
||||
|
||||
public int compute(final Client client, final AnimationData curAnimation, final Spellbook spellbook, final ItemManager itemManager)
|
||||
{
|
||||
final int weaponId = CombatUtils.getWeaponId(client);
|
||||
final PoweredStaves stave = PoweredStaves.getPoweredStaves(weaponId, curAnimation);
|
||||
final int specDelta = CombatUtils.getLastDelta(specialPercentageEvents);
|
||||
dmgDealt = damage.compute(tickCount);
|
||||
if (stave != null && stave.getAnimations() != null && stave.getAnimations().contains(curAnimation))
|
||||
{
|
||||
isUsingMagic = true;
|
||||
return variableSpeed.compute(client, curAnimation, AttackProcedure.POWERED_STAVE, spellbook, dmgDealt, specDelta, 4);
|
||||
}
|
||||
|
||||
if (matchesSpellbook(client, curAnimation, spellbook) && ManualCasting.is(client, curAnimation, soundEffectTick, soundEffectId))
|
||||
{
|
||||
isUsingMagic = true;
|
||||
return variableSpeed.compute(client, curAnimation, AttackProcedure.MANUAL_AUTO_CAST, spellbook, dmgDealt, specDelta, getMagicBaseSpeed(weaponId));
|
||||
}
|
||||
|
||||
isUsingMagic = false;
|
||||
final int aspeed = CombatUtils.getWeaponSpeed(client, itemManager, weaponId);
|
||||
return variableSpeed.compute(client, curAnimation, AttackProcedure.MELEE_OR_RANGE, spellbook, dmgDealt, specDelta, aspeed);
|
||||
}
|
||||
|
||||
public void onTick()
|
||||
{
|
||||
while (specialPercentageEvents.size() > 5)
|
||||
{
|
||||
specialPercentageEvents.removeFirst();
|
||||
}
|
||||
damage.onTick();
|
||||
}
|
||||
|
||||
public void varbitSpecialAttackChanged(final int value)
|
||||
{
|
||||
specialPercentageEvents.addLast(value);
|
||||
}
|
||||
|
||||
public boolean onXpDrop(final FakeXpDrop event)
|
||||
{
|
||||
return damage.onXpDrop(event, tickCount);
|
||||
}
|
||||
|
||||
public boolean onXpDrop(final StatChanged event)
|
||||
{
|
||||
return damage.onXpDrop(event, tickCount);
|
||||
}
|
||||
|
||||
public void onSoundEffectPlayed(final Client client, final SoundEffectPlayed event)
|
||||
{
|
||||
soundEffectTick = client.getTickCount();
|
||||
soundEffectId = event.getSoundId();
|
||||
}
|
||||
|
||||
private static final Map<Integer, Integer> NON_STANDARD_MAGIC_WEAPON_SPEEDS = new ImmutableMap.Builder<Integer, Integer>()
|
||||
.put(ItemID.TWINFLAME_STAFF, 6)
|
||||
.build();
|
||||
|
||||
private static int getMagicBaseSpeed(final int weaponId)
|
||||
{
|
||||
if (weaponId == ItemID.NIGHTMARE_STAFF_HARMONISED)
|
||||
{
|
||||
return 4;
|
||||
}
|
||||
return NON_STANDARD_MAGIC_WEAPON_SPEEDS.getOrDefault(weaponId, 5);
|
||||
}
|
||||
|
||||
private boolean matchesSpellbook(final Client client, final AnimationData curAnimation, final Spellbook currentSpellBook)
|
||||
{
|
||||
if (curAnimation != null && curAnimation.getAttackStyle() == AnimationData.AttackStyle.MAGIC && curAnimation.getSpellbook() == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if (curAnimation != null && curAnimation.matchesSpellbook(currentSpellBook))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
if (client.getTickCount() == soundEffectTick)
|
||||
{
|
||||
return CastingSoundData.getSpellBookFromId(soundEffectId) == currentSpellBook;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public void reset()
|
||||
{
|
||||
dmgDealt = -1;
|
||||
soundEffectId = -1;
|
||||
soundEffectTick = -1;
|
||||
isUsingMagic = false;
|
||||
specialPercentageEvents.clear();
|
||||
damage.reset();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package com.example.essentialsplus.helpers.combat;
|
||||
|
||||
import lombok.Getter;
|
||||
import net.runelite.api.Skill;
|
||||
|
||||
public enum AttackStyle
|
||||
{
|
||||
ACCURATE("Accurate", Skill.ATTACK),
|
||||
AGGRESSIVE("Aggressive", Skill.STRENGTH),
|
||||
DEFENSIVE("Defensive", Skill.DEFENCE),
|
||||
CONTROLLED("Controlled", Skill.ATTACK, Skill.STRENGTH, Skill.DEFENCE),
|
||||
RANGING("Ranging", Skill.RANGED),
|
||||
LONGRANGE("Longrange", Skill.RANGED, Skill.DEFENCE),
|
||||
CASTING("Casting", Skill.MAGIC),
|
||||
DEFENSIVE_CASTING("Defensive Casting", Skill.MAGIC, Skill.DEFENCE),
|
||||
OTHER("Other");
|
||||
|
||||
@Getter
|
||||
private final String name;
|
||||
@Getter
|
||||
private final Skill[] skills;
|
||||
|
||||
AttackStyle(String name, Skill... skills)
|
||||
{
|
||||
this.name = name;
|
||||
this.skills = skills;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString()
|
||||
{
|
||||
return this.name;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.example.essentialsplus.helpers.combat;
|
||||
|
||||
public enum AttackType
|
||||
{
|
||||
CRUSH,
|
||||
SLASH,
|
||||
STAB,
|
||||
RANGED,
|
||||
MAGIC,
|
||||
NONE;
|
||||
|
||||
public boolean IsMelee()
|
||||
{
|
||||
return this.equals(CRUSH) || this.equals(SLASH) || this.equals(STAB);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package com.example.essentialsplus.helpers.combat;
|
||||
|
||||
import com.google.common.collect.ImmutableSet;
|
||||
import java.util.Arrays;
|
||||
import java.util.Set;
|
||||
import lombok.Getter;
|
||||
import net.runelite.api.Actor;
|
||||
import net.runelite.api.Client;
|
||||
import net.runelite.api.NPC;
|
||||
import net.runelite.api.Player;
|
||||
import net.runelite.api.gameval.NpcID;
|
||||
import net.runelite.api.gameval.SpotanimID;
|
||||
import net.runelite.client.game.NPCManager;
|
||||
|
||||
public class Attacking
|
||||
{
|
||||
private static final Set<AnimationData> UNRELIABLE_ANIMATIONS = new ImmutableSet.Builder<AnimationData>()
|
||||
.add(AnimationData.RANGED_BLOWPIPE).add(AnimationData.RANGED_BLAZING_BLOWPIPE)
|
||||
.add(AnimationData.MAGIC_EYE_OF_AYAK).add(AnimationData.MAGIC_EYE_OF_AYAK_SPEC).build();
|
||||
|
||||
private static final Set<Integer> SPECIAL_NPCS = new ImmutableSet.Builder<Integer>()
|
||||
.add(NpcID.POH_COMBAT_DUMMY_UPGRADED_UNDEADSLAYER_NPC, NpcID.POH_COMBAT_DUMMY_UPGRADED_ETHER_NPC,
|
||||
NpcID.POH_COMBAT_DUMMY_UPGRADED_KQ_NPC, NpcID.POH_COMBAT_DUMMY_UPGRADED_KURASK_NPC,
|
||||
NpcID.POH_COMBAT_DUMMY_UPGRADED_VAMPIRE_NPC, NpcID.POH_COMBAT_DUMMY_UPGRADED_VORKATH_NPC,
|
||||
NpcID.POH_COMBAT_DUMMY_NPC, NpcID.POH_COMBAT_DUMMY_UNDEADSLAYER_NPC,
|
||||
NpcID.POH_COMBAT_DUMMY_UPGRADED_NPC, NpcID.NIGHTMARE_TOTEM_1_READY, NpcID.NIGHTMARE_TOTEM_2_READY,
|
||||
NpcID.NIGHTMARE_TOTEM_3_READY, NpcID.NIGHTMARE_TOTEM_4_READY)
|
||||
.build();
|
||||
|
||||
public static boolean isPlayerAttacking(final Client client, final NPCManager npcManager)
|
||||
{
|
||||
final Player localPlayer = client.getLocalPlayer();
|
||||
if (localPlayer == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
final int animationId = localPlayer.getAnimation();
|
||||
if (AnimationData.isBlockListAnimation(animationId))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
final boolean notWalking = animationId != -1 || getSalamanderAttack(client);
|
||||
|
||||
final Actor target = localPlayer.getInteracting();
|
||||
if (target instanceof NPC)
|
||||
{
|
||||
final NPC npc = (NPC) target;
|
||||
final boolean containsAttackOption = npc.getComposition() != null
|
||||
&& npc.getComposition().getActions() != null
|
||||
&& Arrays.asList(npc.getComposition().getActions()).contains("Attack");
|
||||
Integer health = null;
|
||||
if (npcManager != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
health = npcManager.getHealth(npc.getId());
|
||||
}
|
||||
catch (Exception ignored)
|
||||
{
|
||||
}
|
||||
}
|
||||
final boolean hasHealthAndLevel = health != null && health > 0 && target.getCombatLevel() > 0;
|
||||
final boolean attackingNPC = hasHealthAndLevel || SPECIAL_NPCS.contains(npc.getId()) || containsAttackOption;
|
||||
return attackingNPC && notWalking;
|
||||
}
|
||||
if (target instanceof Player)
|
||||
{
|
||||
return notWalking;
|
||||
}
|
||||
if (target == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
final AnimationData fromId = AnimationData.fromId(animationId);
|
||||
if (UNRELIABLE_ANIMATIONS.contains(fromId))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
return fromId != null;
|
||||
}
|
||||
|
||||
public static Attack playerAttack(final Client client)
|
||||
{
|
||||
final Player localPlayer = client.getLocalPlayer();
|
||||
if (localPlayer == null)
|
||||
{
|
||||
return new Attack(-1, false, null);
|
||||
}
|
||||
final int animationId = localPlayer.getAnimation();
|
||||
final Actor target = localPlayer.getInteracting();
|
||||
return new Attack(animationId, getSalamanderAttack(client), target);
|
||||
}
|
||||
|
||||
private static boolean getSalamanderAttack(final Client client)
|
||||
{
|
||||
Player localPlayer = client.getLocalPlayer();
|
||||
return localPlayer != null && localPlayer.hasSpotAnim(SpotanimID.FIREBREATH);
|
||||
}
|
||||
|
||||
public static final class Attack
|
||||
{
|
||||
@Getter
|
||||
private final int animationId;
|
||||
@Getter
|
||||
private final boolean salamander;
|
||||
@Getter
|
||||
private final Actor target;
|
||||
|
||||
public Attack(final int animationId, final boolean salamander, final Actor target)
|
||||
{
|
||||
this.animationId = animationId;
|
||||
this.salamander = salamander;
|
||||
this.target = target;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package com.example.essentialsplus.helpers.combat;
|
||||
|
||||
import com.google.common.collect.ImmutableMap;
|
||||
|
||||
public enum CastingSoundData
|
||||
{
|
||||
STANDARD_BIND(101, Spellbook.STANDARD),
|
||||
STANDARD_CONFUSE(119, Spellbook.STANDARD),
|
||||
STANDARD_CRUMBLE_UNDEAD(122, Spellbook.STANDARD),
|
||||
STANDARD_CURSE(127, Spellbook.STANDARD),
|
||||
STANDARD_EARTH_BLAST(128, Spellbook.STANDARD),
|
||||
STANDARD_EARTH_BOLT(130, Spellbook.STANDARD),
|
||||
STANDARD_EARTH_STRIKE(132, Spellbook.STANDARD),
|
||||
STANDARD_EARTH_SURGE(4025, Spellbook.STANDARD),
|
||||
STANDARD_EARTH_WAVE(134, Spellbook.STANDARD),
|
||||
STANDARD_ENFEEBLE(148, Spellbook.STANDARD),
|
||||
STANDARD_ENTANGLE(151, Spellbook.STANDARD),
|
||||
STANDARD_FIRE_BLAST(155, Spellbook.STANDARD),
|
||||
STANDARD_FIRE_BOLT(157, Spellbook.STANDARD),
|
||||
STANDARD_FIRE_STRIKE(160, Spellbook.STANDARD),
|
||||
STANDARD_FIRE_SURGE(4032, Spellbook.STANDARD),
|
||||
STANDARD_IBANS_BLAST_FIRE_WAVE(162, Spellbook.STANDARD),
|
||||
STANDARD_SLAYER_DART(1718, Spellbook.STANDARD),
|
||||
STANDARD_SNARE(3003, Spellbook.STANDARD),
|
||||
STANDARD_STUN(3004, Spellbook.STANDARD),
|
||||
STANDARD_VULNERABILITY(3009, Spellbook.STANDARD),
|
||||
STANDARD_WATER_BLAST(207, Spellbook.STANDARD),
|
||||
STANDARD_WATER_BOLT(209, Spellbook.STANDARD),
|
||||
STANDARD_WATER_STRIKE(221, Spellbook.STANDARD),
|
||||
STANDARD_WATER_SURGE(4030, Spellbook.STANDARD),
|
||||
STANDARD_WATER_WAVE(213, Spellbook.STANDARD),
|
||||
STANDARD_WEAKEN(3011, Spellbook.STANDARD),
|
||||
STANDARD_WIND_BLAST(216, Spellbook.STANDARD),
|
||||
STANDARD_WIND_BOLT(218, Spellbook.STANDARD),
|
||||
STANDARD_WIND_STRIKE(220, Spellbook.STANDARD),
|
||||
STANDARD_WIND_SURGE(4028, Spellbook.STANDARD),
|
||||
STANDARD_WIND_WAVE(222, Spellbook.STANDARD),
|
||||
|
||||
ANCIENT_BLOOD_SPELL(106, Spellbook.ANCIENT),
|
||||
ANCIENT_ICE_SPELL(171, Spellbook.ANCIENT),
|
||||
ANCIENT_SHADOW_SPELL(178, Spellbook.ANCIENT),
|
||||
ANCIENT_SMOKE_SPELL(183, Spellbook.ANCIENT),
|
||||
|
||||
ARCEUUS_DARK_DEMONBANE(5053, Spellbook.ARCEUUS),
|
||||
ARCEUUS_GHOSTLY_GRASP(5042, Spellbook.ARCEUUS),
|
||||
ARCEUUS_INFERIOR_DEMONBANE(5038, Spellbook.ARCEUUS),
|
||||
ARCEUUS_SKELETAL_GRASP(5026, Spellbook.ARCEUUS),
|
||||
ARCEUUS_SUPERIOR_DEMONBANE(5027, Spellbook.ARCEUUS),
|
||||
ARCEUUS_UNDEAD_GRASP(5030, Spellbook.ARCEUUS);
|
||||
|
||||
private final int id;
|
||||
private final Spellbook spellbook;
|
||||
|
||||
CastingSoundData(int id, Spellbook b)
|
||||
{
|
||||
this.id = id;
|
||||
this.spellbook = b;
|
||||
}
|
||||
|
||||
private static final ImmutableMap<Integer, CastingSoundData> SOUNDS;
|
||||
|
||||
static
|
||||
{
|
||||
ImmutableMap.Builder<Integer, CastingSoundData> builder = new ImmutableMap.Builder<>();
|
||||
for (CastingSoundData data : values())
|
||||
{
|
||||
builder.put(data.id, data);
|
||||
}
|
||||
SOUNDS = builder.build();
|
||||
}
|
||||
|
||||
public static boolean isCastingSound(int id)
|
||||
{
|
||||
return SOUNDS.containsKey(id);
|
||||
}
|
||||
|
||||
public static Spellbook getSpellBookFromId(int id)
|
||||
{
|
||||
if (!SOUNDS.containsKey(id))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
return SOUNDS.get(id).spellbook;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
package com.example.essentialsplus.helpers.combat;
|
||||
|
||||
import com.google.common.collect.ImmutableMap;
|
||||
import java.util.ArrayDeque;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import net.runelite.api.Actor;
|
||||
import net.runelite.api.Client;
|
||||
import net.runelite.api.EquipmentInventorySlot;
|
||||
import net.runelite.api.Item;
|
||||
import net.runelite.api.ItemContainer;
|
||||
import net.runelite.api.NPC;
|
||||
import net.runelite.api.WorldView;
|
||||
import net.runelite.api.coords.LocalPoint;
|
||||
import net.runelite.api.coords.WorldPoint;
|
||||
import net.runelite.api.gameval.InventoryID;
|
||||
import net.runelite.api.gameval.ItemID;
|
||||
import net.runelite.api.gameval.VarPlayerID;
|
||||
import net.runelite.api.gameval.VarbitID;
|
||||
import net.runelite.client.game.ItemEquipmentStats;
|
||||
import net.runelite.client.game.ItemManager;
|
||||
import net.runelite.client.game.ItemStats;
|
||||
import org.apache.commons.lang3.ArrayUtils;
|
||||
|
||||
public class CombatUtils
|
||||
{
|
||||
public static int getItemIdFromContainer(final ItemContainer container, final int slotID)
|
||||
{
|
||||
if (container == null)
|
||||
{
|
||||
return -1;
|
||||
}
|
||||
final Item item = container.getItem(slotID);
|
||||
return (item != null) ? item.getId() : -1;
|
||||
}
|
||||
|
||||
public static int getWeaponIdRaw(final Client client)
|
||||
{
|
||||
return getItemIdFromContainer(client.getItemContainer(InventoryID.WORN),
|
||||
EquipmentInventorySlot.WEAPON.getSlotIdx());
|
||||
}
|
||||
|
||||
public static WorldPoint getLocalLocation(final Client client)
|
||||
{
|
||||
final LocalPoint localPoint = client.getLocalPlayer().getLocalLocation();
|
||||
return WorldPoint.fromLocalInstance(client, localPoint);
|
||||
}
|
||||
|
||||
public static AttackStyle getAttackStyle(final Client client)
|
||||
{
|
||||
final AttackStyle[] attackStyles = getWeaponType(client).getAttackStyles(client);
|
||||
int currentAttackStyleVarbit = client.getVarpValue(VarPlayerID.COM_MODE);
|
||||
final int castingMode = client.getVarbitValue(VarbitID.AUTOCAST_DEFMODE);
|
||||
if (currentAttackStyleVarbit < attackStyles.length)
|
||||
{
|
||||
if (currentAttackStyleVarbit == 4)
|
||||
{
|
||||
currentAttackStyleVarbit += castingMode;
|
||||
}
|
||||
return attackStyles[currentAttackStyleVarbit];
|
||||
}
|
||||
return AttackStyle.ACCURATE;
|
||||
}
|
||||
|
||||
public static WeaponType getWeaponType(final Client client)
|
||||
{
|
||||
final int currentEquippedWeaponTypeVarbit = client.getVarbitValue(VarbitID.COMBAT_WEAPON_CATEGORY);
|
||||
return WeaponType.getWeaponType(currentEquippedWeaponTypeVarbit);
|
||||
}
|
||||
|
||||
public static AttackType getAttackType(final Client client)
|
||||
{
|
||||
final WeaponType weaponType = getWeaponType(client);
|
||||
final int currentAttackStyleVarbit = client.getVarpValue(VarPlayerID.COM_MODE);
|
||||
if (currentAttackStyleVarbit < weaponType.getAttackTypes().length)
|
||||
{
|
||||
return weaponType.getAttackTypes()[currentAttackStyleVarbit];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public static int getTargetId(final Client client)
|
||||
{
|
||||
final NPC target = getTargetNPC(client);
|
||||
int targetId = 0;
|
||||
if (target != null)
|
||||
{
|
||||
targetId = target.getId();
|
||||
}
|
||||
return targetId;
|
||||
}
|
||||
|
||||
public static NPC getTargetNPC(final Client client)
|
||||
{
|
||||
final Actor target = client.getLocalPlayer().getInteracting();
|
||||
if (target != null && (target instanceof NPC))
|
||||
{
|
||||
return (NPC) target;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public static boolean isInRegionId(final Client client, final int id)
|
||||
{
|
||||
final int[] regions = regions(client);
|
||||
if (regions == null || regions.length == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
return ArrayUtils.contains(regions, id);
|
||||
}
|
||||
|
||||
public static boolean isInRegionId(final Client client, final Set<Integer> ids)
|
||||
{
|
||||
final int[] regions = regions(client);
|
||||
if (regions == null || regions.length == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
for (final int id : regions)
|
||||
{
|
||||
if (ids.contains(id))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static int[] regions(final Client client)
|
||||
{
|
||||
final WorldView wv = client.getTopLevelWorldView();
|
||||
if (wv == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
return wv.getMapRegions();
|
||||
}
|
||||
|
||||
public static int getLastDelta(final ArrayDeque<Integer> events)
|
||||
{
|
||||
int i = 0, last = -1, secondLast = -1;
|
||||
final var it = events.descendingIterator();
|
||||
while (it.hasNext())
|
||||
{
|
||||
if (i == 0)
|
||||
{
|
||||
last = it.next();
|
||||
}
|
||||
else if (i == 1)
|
||||
{
|
||||
secondLast = it.next();
|
||||
}
|
||||
else
|
||||
{
|
||||
break;
|
||||
}
|
||||
i++;
|
||||
}
|
||||
return last - secondLast;
|
||||
}
|
||||
|
||||
private static final Map<Integer, Integer> WEAPON_ID_MAPPING_WORKAROUNDS = new ImmutableMap.Builder<Integer, Integer>()
|
||||
.put(ItemID.VENATOR_BOW_ORNAMENT, ItemID.VENATOR_BOW)
|
||||
.build();
|
||||
|
||||
private static final Map<Integer, Integer> NON_STANDARD_ATTACK_SPEEDS = new ImmutableMap.Builder<Integer, Integer>()
|
||||
.put(ItemID.HALLOWFELL, 6)
|
||||
.build();
|
||||
|
||||
public static int getWeaponId(final Client client)
|
||||
{
|
||||
final int weaponId = getWeaponIdRaw(client);
|
||||
return WEAPON_ID_MAPPING_WORKAROUNDS.getOrDefault(weaponId, weaponId);
|
||||
}
|
||||
|
||||
public static ItemStats getWeaponStats(final Client client, final ItemManager itemManager, final int weaponId)
|
||||
{
|
||||
if (NON_STANDARD_ATTACK_SPEEDS.containsKey(weaponId))
|
||||
{
|
||||
return new ItemStats(true, -1, -1,
|
||||
ItemEquipmentStats.builder().aspeed(NON_STANDARD_ATTACK_SPEEDS.get(weaponId)).build());
|
||||
}
|
||||
return itemManager.getItemStats(weaponId);
|
||||
}
|
||||
|
||||
public static int getWeaponSpeed(final Client client, final ItemManager itemManager, final int weaponId)
|
||||
{
|
||||
final ItemStats weaponStats = getWeaponStats(client, itemManager, weaponId);
|
||||
if (weaponStats == null || weaponStats.getEquipment() == null)
|
||||
{
|
||||
return 4;
|
||||
}
|
||||
return weaponStats.getEquipment().getAspeed();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package com.example.essentialsplus.helpers.combat;
|
||||
|
||||
import com.example.essentialsplus.helpers.combat.variablespeed.state.TickCount;
|
||||
import java.util.ArrayDeque;
|
||||
import net.runelite.api.Skill;
|
||||
import net.runelite.api.events.FakeXpDrop;
|
||||
import net.runelite.api.events.StatChanged;
|
||||
|
||||
public class Damage
|
||||
{
|
||||
private static final double MODIFIER = 1;
|
||||
private static final double GLOBAL_MODIFIER = 1;
|
||||
|
||||
private final ArrayDeque<Integer> hpExpEarned = new ArrayDeque<>();
|
||||
private final ArrayDeque<Integer> hpExpEarnedTickCount = new ArrayDeque<>();
|
||||
|
||||
public boolean onXpDrop(StatChanged event, TickCount tc)
|
||||
{
|
||||
if (event.getSkill() != Skill.HITPOINTS)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
hpExpEarnedTickCount.addLast(tc.get());
|
||||
hpExpEarned.addLast(event.getXp());
|
||||
return true;
|
||||
}
|
||||
|
||||
public boolean onXpDrop(FakeXpDrop event, TickCount tc)
|
||||
{
|
||||
if (event.getSkill() != Skill.HITPOINTS)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
hpExpEarnedTickCount.addLast(tc.get());
|
||||
hpExpEarnedTickCount.addLast(tc.get());
|
||||
hpExpEarned.addLast(0);
|
||||
hpExpEarned.addLast(event.getXp());
|
||||
return true;
|
||||
}
|
||||
|
||||
public int compute(TickCount tc)
|
||||
{
|
||||
if (hpExpEarnedTickCount.isEmpty())
|
||||
{
|
||||
return -1;
|
||||
}
|
||||
final int lastTc = hpExpEarnedTickCount.getLast();
|
||||
if (!tc.isWithinNTicks(lastTc, 1))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
final double xp = (double) CombatUtils.getLastDelta(hpExpEarned);
|
||||
return (int) Math.round(xp * (3.0d / 4.0d) * MODIFIER * GLOBAL_MODIFIER);
|
||||
}
|
||||
|
||||
public void onTick()
|
||||
{
|
||||
while (hpExpEarnedTickCount.size() > 5)
|
||||
{
|
||||
hpExpEarnedTickCount.removeFirst();
|
||||
}
|
||||
while (hpExpEarned.size() > 5)
|
||||
{
|
||||
hpExpEarned.removeFirst();
|
||||
}
|
||||
}
|
||||
|
||||
public void reset()
|
||||
{
|
||||
hpExpEarned.clear();
|
||||
hpExpEarnedTickCount.clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.example.essentialsplus.helpers.combat;
|
||||
|
||||
import net.runelite.api.Client;
|
||||
|
||||
public class ManualCasting
|
||||
{
|
||||
public static boolean is(final Client client, final AnimationData curId, final int soundEffectTick, final int soundEffectId)
|
||||
{
|
||||
final boolean castingFromSound = client.getTickCount() == soundEffectTick
|
||||
? CastingSoundData.isCastingSound(soundEffectId)
|
||||
: false;
|
||||
final boolean castingFromAnimation = AnimationData.isManualCasting(curId);
|
||||
return castingFromSound || castingFromAnimation;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package com.example.essentialsplus.helpers.combat;
|
||||
|
||||
import com.google.common.collect.ImmutableMap;
|
||||
import com.google.common.collect.ImmutableSet;
|
||||
import java.util.Arrays;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
import lombok.Getter;
|
||||
import net.runelite.api.gameval.ItemID;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
|
||||
public enum PoweredStaves
|
||||
{
|
||||
WEAPON_ACCURSED(Set.of(AnimationData.MAGIC_STANDARD_WAVE_STAFF, AnimationData.MAGIC_STANDARD_WAVE_STAFF_WALK,
|
||||
AnimationData.MAGIC_ACCURSED_SCEPTRE_SPEC), ItemID.WILD_CAVE_ACCURSED_CHARGED),
|
||||
WEAPON_GAUNTLET_STAFF(Set.of(AnimationData.MAGIC_STANDARD_WAVE_STAFF, AnimationData.MAGIC_STANDARD_WAVE_STAFF_WALK),
|
||||
ItemID.GAUNTLET_MAGIC_T1, ItemID.GAUNTLET_MAGIC_T2, ItemID.GAUNTLET_MAGIC_T3, ItemID.GAUNTLET_MAGIC_T1_HM,
|
||||
ItemID.GAUNTLET_MAGIC_T2_HM, ItemID.GAUNTLET_MAGIC_T3_HM),
|
||||
WEAPON_BONE_STAFF(AnimationData.MELEE_GENERIC_SLASH, ItemID.RAT_BONE_STAFF),
|
||||
WEAPON_DAWNBRINGER(Set.of(AnimationData.MAGIC_STANDARD_WAVE_STAFF, AnimationData.MAGIC_STANDARD_WAVE_STAFF_WALK),
|
||||
ItemID.VERZIK_SPECIAL_WEAPON),
|
||||
WEAPON_HARM(Set.of(AnimationData.MAGIC_STANDARD_STRIKE_BOLT_BLAST_STAFF, AnimationData.MAGIC_STANDARD_WAVE_STAFF,
|
||||
AnimationData.MAGIC_STANDARD_SURGE_STAFF, AnimationData.MAGIC_STANDARD_WAVE_STAFF_WALK,
|
||||
AnimationData.MAGIC_STANDARD_STRIKE_BOLT_BLAST_STAFF_WALK, AnimationData.MAGIC_STANDARD_SURGE_STAFF_WALK),
|
||||
ItemID.NIGHTMARE_STAFF_HARMONISED),
|
||||
WEAPON_SANG(Set.of(AnimationData.MAGIC_STANDARD_WAVE_STAFF, AnimationData.MAGIC_STANDARD_WAVE_STAFF_WALK),
|
||||
ItemID.SANGUINESTI_STAFF, ItemID.SANGUINESTI_STAFF_OR),
|
||||
WEAPON_STARTER_STAFF(ItemID.DEADMAN_STARTER_STAFF, ItemID.DEADMAN_APOCALYPSE_STAFF),
|
||||
WEAPON_SWAMP(Set.of(AnimationData.MAGIC_STANDARD_WAVE_STAFF, AnimationData.MAGIC_STANDARD_WAVE_STAFF_WALK),
|
||||
ItemID.TOXIC_TOTS_CHARGED, ItemID.TOXIC_TOTS_I_CHARGED, ItemID.TOXIC_TOTS_CHARGED_ORN,
|
||||
ItemID.TOXIC_TOTS_I_CHARGED_ORN),
|
||||
WEAPON_THAMMARON(Set.of(AnimationData.MAGIC_STANDARD_WAVE_STAFF, AnimationData.MAGIC_STANDARD_WAVE_STAFF_WALK),
|
||||
ItemID.WILD_CAVE_SCEPTRE_CHARGED),
|
||||
WEAPON_TRIDENT(Set.of(AnimationData.MAGIC_STANDARD_WAVE_STAFF, AnimationData.MAGIC_STANDARD_WAVE_STAFF_WALK),
|
||||
ItemID.TOTS, ItemID.TOTS_CHARGED, ItemID.TOTS_I_CHARGED, ItemID.TOTS_CHARGED_ORN, ItemID.TOTS_ORN,
|
||||
ItemID.TOTS_I_CHARGED_ORN),
|
||||
WEAPON_WARPED_SCEPTRE(AnimationData.MAGIC_WARPED_SCEPTRE, ItemID.WARPED_SCEPTRE);
|
||||
|
||||
@Getter
|
||||
private final Set<Integer> ids;
|
||||
@Getter
|
||||
private final Set<AnimationData> animations;
|
||||
|
||||
private static Set<Integer> ListToSet(int... id)
|
||||
{
|
||||
ImmutableSet.Builder<Integer> builder = new ImmutableSet.Builder<>();
|
||||
if (id.length == 0)
|
||||
{
|
||||
return builder.build();
|
||||
}
|
||||
for (int i : id)
|
||||
{
|
||||
builder.add(i);
|
||||
}
|
||||
return builder.build();
|
||||
}
|
||||
|
||||
PoweredStaves(int... id)
|
||||
{
|
||||
this.ids = ListToSet(id);
|
||||
this.animations = null;
|
||||
}
|
||||
|
||||
PoweredStaves(AnimationData spell, int... id)
|
||||
{
|
||||
this.ids = ListToSet(id);
|
||||
this.animations = Set.of(spell);
|
||||
}
|
||||
|
||||
PoweredStaves(Set<AnimationData> spell, int... id)
|
||||
{
|
||||
this.ids = ListToSet(id);
|
||||
this.animations = spell;
|
||||
}
|
||||
|
||||
protected static final int UNKNOWN_SPELL = 0xDEADBEEF;
|
||||
protected static final ImmutableMap<Integer, ImmutableMap<Integer, PoweredStaves>> POWERED_STAVES;
|
||||
|
||||
static
|
||||
{
|
||||
ImmutableMap.Builder<Integer, ImmutableMap<Integer, PoweredStaves>> builder = new ImmutableMap.Builder<>();
|
||||
|
||||
for (PoweredStaves p : values())
|
||||
{
|
||||
for (int id : p.ids)
|
||||
{
|
||||
ImmutableMap.Builder<Integer, PoweredStaves> spellMap = new ImmutableMap.Builder<>();
|
||||
if (p.animations == null)
|
||||
{
|
||||
spellMap.put(UNKNOWN_SPELL, p);
|
||||
}
|
||||
else
|
||||
{
|
||||
for (AnimationData spell : p.animations)
|
||||
{
|
||||
spellMap.put(spell.animationId, p);
|
||||
}
|
||||
}
|
||||
builder.put(id, spellMap.build());
|
||||
}
|
||||
}
|
||||
|
||||
POWERED_STAVES = builder.build();
|
||||
}
|
||||
|
||||
public static PoweredStaves getPoweredStaves(int weaponId, AnimationData animation)
|
||||
{
|
||||
ImmutableMap<Integer, PoweredStaves> weaponMap = POWERED_STAVES.get(weaponId);
|
||||
if (weaponMap == null || animation == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
if (weaponMap.containsKey(UNKNOWN_SPELL))
|
||||
{
|
||||
return weaponMap.get(UNKNOWN_SPELL);
|
||||
}
|
||||
return weaponMap.get(animation.animationId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString()
|
||||
{
|
||||
String[] words = super.toString().toLowerCase().split("_");
|
||||
Arrays.stream(words).map(StringUtils::capitalize).collect(Collectors.toList()).toArray(words);
|
||||
return String.join(" ", words);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package com.example.essentialsplus.helpers.combat;
|
||||
|
||||
import com.google.common.collect.ImmutableMap;
|
||||
|
||||
public enum Spellbook
|
||||
{
|
||||
STANDARD(0),
|
||||
ANCIENT(1),
|
||||
LUNAR(2),
|
||||
ARCEUUS(3);
|
||||
|
||||
private final int id;
|
||||
|
||||
Spellbook(int id)
|
||||
{
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
private static final ImmutableMap<Integer, Spellbook> BOOKS;
|
||||
|
||||
static
|
||||
{
|
||||
ImmutableMap.Builder<Integer, Spellbook> builder = new ImmutableMap.Builder<>();
|
||||
for (Spellbook data : values())
|
||||
{
|
||||
builder.put(data.id, data);
|
||||
}
|
||||
BOOKS = builder.build();
|
||||
}
|
||||
|
||||
public static Spellbook fromVarbit(int varbit)
|
||||
{
|
||||
return BOOKS.get(varbit);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package com.example.essentialsplus.helpers.combat;
|
||||
|
||||
import static com.example.essentialsplus.helpers.combat.AttackStyle.ACCURATE;
|
||||
import static com.example.essentialsplus.helpers.combat.AttackStyle.AGGRESSIVE;
|
||||
import static com.example.essentialsplus.helpers.combat.AttackStyle.CASTING;
|
||||
import static com.example.essentialsplus.helpers.combat.AttackStyle.DEFENSIVE;
|
||||
import static com.example.essentialsplus.helpers.combat.AttackStyle.DEFENSIVE_CASTING;
|
||||
import static com.example.essentialsplus.helpers.combat.AttackStyle.OTHER;
|
||||
import static com.example.essentialsplus.helpers.combat.AttackType.CRUSH;
|
||||
import static com.example.essentialsplus.helpers.combat.AttackType.MAGIC;
|
||||
import static com.example.essentialsplus.helpers.combat.AttackType.NONE;
|
||||
import static com.example.essentialsplus.helpers.combat.AttackType.RANGED;
|
||||
import static com.example.essentialsplus.helpers.combat.AttackType.SLASH;
|
||||
import static com.example.essentialsplus.helpers.combat.AttackType.STAB;
|
||||
|
||||
import com.google.common.collect.ImmutableMap;
|
||||
import java.util.Map;
|
||||
import lombok.Getter;
|
||||
import net.runelite.api.Client;
|
||||
import net.runelite.api.EnumID;
|
||||
import net.runelite.api.ParamID;
|
||||
import net.runelite.api.StructComposition;
|
||||
|
||||
public enum WeaponType
|
||||
{
|
||||
UNARMED(CRUSH, CRUSH, null, CRUSH),
|
||||
AXE(SLASH, SLASH, CRUSH, SLASH),
|
||||
BLUNT(CRUSH, CRUSH, null, CRUSH),
|
||||
BOW(RANGED, RANGED, null, RANGED),
|
||||
CLAW(SLASH, SLASH, STAB, SLASH),
|
||||
HEAVY_RANGED(RANGED, RANGED, null, RANGED),
|
||||
SALAMANDER(SLASH, RANGED, MAGIC, null),
|
||||
CHINCHOMPA(RANGED, RANGED, null, RANGED),
|
||||
GUN(NONE, CRUSH, null, null),
|
||||
SLASH_SWORD(SLASH, SLASH, STAB, SLASH),
|
||||
TWO_HANDED_SWORD(SLASH, SLASH, CRUSH, SLASH),
|
||||
PICKAXE(STAB, STAB, CRUSH, STAB),
|
||||
POLEARM(STAB, SLASH, null, STAB),
|
||||
POLESTAFF(CRUSH, CRUSH, null, CRUSH),
|
||||
SCYTHE(SLASH, SLASH, CRUSH, SLASH),
|
||||
SPEAR(STAB, SLASH, CRUSH, STAB),
|
||||
SPIKED(CRUSH, CRUSH, STAB, CRUSH),
|
||||
STAB_SWORD(STAB, STAB, SLASH, STAB),
|
||||
STAFF(CRUSH, CRUSH, null, CRUSH, MAGIC, MAGIC),
|
||||
THROWN(RANGED, RANGED, null, RANGED),
|
||||
WHIP(SLASH, SLASH, null, SLASH),
|
||||
BLADED_STAFF(STAB, SLASH, null, CRUSH, MAGIC, MAGIC),
|
||||
TWO_HANDED_SWORD_GODSWORD(STAB, SLASH, null, CRUSH, MAGIC, MAGIC),
|
||||
POWERED_STAFF(SLASH, SLASH, CRUSH, SLASH),
|
||||
BANNER(MAGIC, MAGIC, null, MAGIC),
|
||||
POLEARM_CRYSTAL_HALBERD(STAB, SLASH, CRUSH, STAB),
|
||||
BLUDGEON(STAB, SLASH, null, STAB),
|
||||
BULWARK(CRUSH, CRUSH, null, CRUSH),
|
||||
POWERED_WAND(CRUSH, null, null, NONE),
|
||||
PARTISAN(MAGIC, MAGIC, null, MAGIC),
|
||||
PARTISAN_2(STAB, STAB, CRUSH, STAB),
|
||||
PLACE_HOLDER_0(null, null, null),
|
||||
PLACE_HOLDER_1(null, null, null),
|
||||
PLACE_HOLDER_2(null, null, null),
|
||||
PLACE_HOLDER_3(null, null, null),
|
||||
FLAIL(SLASH, SLASH, null, SLASH);
|
||||
|
||||
private static final Map<Integer, WeaponType> WEAPON_TYPES;
|
||||
|
||||
@Getter
|
||||
private final AttackType[] attackTypes;
|
||||
|
||||
static
|
||||
{
|
||||
ImmutableMap.Builder<Integer, WeaponType> builder = new ImmutableMap.Builder<>();
|
||||
for (WeaponType weaponType : values())
|
||||
{
|
||||
builder.put(weaponType.ordinal(), weaponType);
|
||||
}
|
||||
WEAPON_TYPES = builder.build();
|
||||
}
|
||||
|
||||
WeaponType(AttackType... attackTypes)
|
||||
{
|
||||
this.attackTypes = attackTypes;
|
||||
}
|
||||
|
||||
public AttackStyle[] getAttackStyles(Client client)
|
||||
{
|
||||
int weaponType = this.ordinal();
|
||||
int weaponStyleEnum = client.getEnum(EnumID.WEAPON_STYLES).getIntValue(weaponType);
|
||||
if (weaponStyleEnum == -1)
|
||||
{
|
||||
if (weaponType == 22)
|
||||
{
|
||||
return new AttackStyle[]{
|
||||
ACCURATE, AGGRESSIVE, null, DEFENSIVE, CASTING, DEFENSIVE_CASTING,
|
||||
};
|
||||
}
|
||||
|
||||
if (weaponType == 30)
|
||||
{
|
||||
return new AttackStyle[]{
|
||||
ACCURATE, AGGRESSIVE, AGGRESSIVE, DEFENSIVE,
|
||||
};
|
||||
}
|
||||
return new AttackStyle[0];
|
||||
}
|
||||
int[] weaponStyleStructs = client.getEnum(weaponStyleEnum).getIntVals();
|
||||
|
||||
AttackStyle[] styles = new AttackStyle[weaponStyleStructs.length];
|
||||
int i = 0;
|
||||
for (int style : weaponStyleStructs)
|
||||
{
|
||||
StructComposition attackStyleStruct = client.getStructComposition(style);
|
||||
String attackStyleName = attackStyleStruct.getStringValue(ParamID.ATTACK_STYLE_NAME);
|
||||
AttackStyle attackStyle = AttackStyle.valueOf(attackStyleName.toUpperCase());
|
||||
if (attackStyle == OTHER)
|
||||
{
|
||||
++i;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (i == 5 && attackStyle == DEFENSIVE)
|
||||
{
|
||||
attackStyle = DEFENSIVE_CASTING;
|
||||
}
|
||||
|
||||
styles[i++] = attackStyle;
|
||||
}
|
||||
return styles;
|
||||
}
|
||||
|
||||
public static WeaponType getWeaponType(int id)
|
||||
{
|
||||
return WEAPON_TYPES.get(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.example.essentialsplus.helpers.combat.variablespeed;
|
||||
|
||||
import com.example.essentialsplus.helpers.combat.AnimationData;
|
||||
import com.example.essentialsplus.helpers.combat.AttackProcedure;
|
||||
import com.example.essentialsplus.helpers.combat.AttackType;
|
||||
import com.example.essentialsplus.helpers.combat.CombatUtils;
|
||||
import com.example.essentialsplus.helpers.combat.Spellbook;
|
||||
import net.runelite.api.Client;
|
||||
import net.runelite.api.coords.WorldPoint;
|
||||
import net.runelite.api.gameval.NpcID;
|
||||
|
||||
public class Amoxliatl implements IVariableSpeed
|
||||
{
|
||||
private static final int AMOXLIATL_REGION_ID = 5446;
|
||||
|
||||
@Override
|
||||
public int apply(final Client client, final AnimationData curAnimation, final AttackProcedure atkType,
|
||||
final Spellbook spellbook, final int damageDealt, final int lastSpecDelta, final int baseSpeed,
|
||||
final int curSpeed)
|
||||
{
|
||||
final WorldPoint location = CombatUtils.getLocalLocation(client);
|
||||
final int targetId = CombatUtils.getTargetId(client);
|
||||
final AttackType attkType = CombatUtils.getAttackType(client);
|
||||
if (location.getRegionID() == AMOXLIATL_REGION_ID && targetId == NpcID.AMOXLIATL_ICE_BLOCK && attkType != null && attkType.IsMelee())
|
||||
{
|
||||
return 1;
|
||||
}
|
||||
return curSpeed;
|
||||
}
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
package com.example.essentialsplus.helpers.combat.variablespeed;
|
||||
|
||||
import com.example.essentialsplus.helpers.combat.AnimationData;
|
||||
import com.example.essentialsplus.helpers.combat.AttackProcedure;
|
||||
import com.example.essentialsplus.helpers.combat.Spellbook;
|
||||
import net.runelite.api.Client;
|
||||
import net.runelite.api.gameval.SpotanimID;
|
||||
|
||||
public class BloodMoonSet implements IVariableSpeed
|
||||
{
|
||||
@Override
|
||||
public int apply(final Client client, final AnimationData curAnimation, final AttackProcedure atkType,
|
||||
final Spellbook spellbook, final int damageDealt, final int lastSpecDelta, final int baseSpeed,
|
||||
final int curSpeed)
|
||||
{
|
||||
if (client.getLocalPlayer() != null && client.getLocalPlayer().hasSpotAnim(SpotanimID.SPECIAL_DUAL_MACUAHUITL_SPOTANIM))
|
||||
{
|
||||
return curSpeed - 1;
|
||||
}
|
||||
return curSpeed;
|
||||
}
|
||||
}
|
||||
+127
@@ -0,0 +1,127 @@
|
||||
package com.example.essentialsplus.helpers.combat.variablespeed;
|
||||
|
||||
import com.example.essentialsplus.helpers.combat.AnimationData;
|
||||
import com.example.essentialsplus.helpers.combat.AttackProcedure;
|
||||
import com.example.essentialsplus.helpers.combat.Attacking;
|
||||
import com.example.essentialsplus.helpers.combat.CombatUtils;
|
||||
import com.example.essentialsplus.helpers.combat.Spellbook;
|
||||
import com.example.essentialsplus.helpers.combat.variablespeed.state.TickCount;
|
||||
import com.google.common.collect.ImmutableSet;
|
||||
import java.util.Set;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import net.runelite.api.Client;
|
||||
import net.runelite.api.NPC;
|
||||
import net.runelite.api.gameval.AnimationID;
|
||||
import net.runelite.api.gameval.ItemID;
|
||||
import net.runelite.api.gameval.NpcID;
|
||||
import net.runelite.client.game.ItemManager;
|
||||
|
||||
@Slf4j
|
||||
public class DoomOfMokhaiotl implements IVariableSpeed
|
||||
{
|
||||
private static final Set<Integer> DOOM_REGION_IDS = new ImmutableSet.Builder<Integer>()
|
||||
.add(5269)
|
||||
.add(13668)
|
||||
.add(14180)
|
||||
.build();
|
||||
|
||||
private static final Set<Integer> COOLDOWN_OVERWRITES_IDS = new ImmutableSet.Builder<Integer>()
|
||||
.add(NpcID.DOM_DEMONIC_ENERGY)
|
||||
.add(NpcID.DOM_DEMONIC_ENERGY_GIANT_MAGE)
|
||||
.add(NpcID.DOM_DEMONIC_ENERGY_GIANT_RANGE)
|
||||
.add(NpcID.DOM_DEMONIC_ENERGY_MAGE)
|
||||
.add(NpcID.DOM_DEMONIC_ENERGY_RANGE)
|
||||
.add(NpcID.DOM_DEMONIC_ENERGY_MELEE)
|
||||
.add(NpcID.DOM_SHOCKWAVE_PATH_NODE)
|
||||
.build();
|
||||
|
||||
private static final Set<Integer> NO_COOLDOWN_WEAPON = new ImmutableSet.Builder<Integer>()
|
||||
.add(ItemID.SILVERLIGHT)
|
||||
.add(ItemID.DARKLIGHT)
|
||||
.add(ItemID.ARCLIGHT)
|
||||
.add(ItemID.EMBERLIGHT)
|
||||
.add(ItemID.BONE_CLAWS)
|
||||
.add(ItemID.SCORCHING_BOW)
|
||||
.add(ItemID.HOLY_WATER)
|
||||
.add(ItemID.EYE_OF_AYAK)
|
||||
.build();
|
||||
|
||||
private final TickCount tickCount;
|
||||
private int larvaeConsumed = -1;
|
||||
private int shieldConsumed = -1;
|
||||
|
||||
public DoomOfMokhaiotl(final TickCount tc)
|
||||
{
|
||||
this.tickCount = tc;
|
||||
}
|
||||
|
||||
public boolean onRender(final Client client, final ItemManager itemManager, final Spellbook spellbook)
|
||||
{
|
||||
if (!CombatUtils.isInRegionId(client, DOOM_REGION_IDS))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
final var atk = Attacking.playerAttack(client);
|
||||
final AnimationData anim = AnimationData.fromId(atk.getAnimationId());
|
||||
if (anim == null || atk.getTarget() == null || !(atk.getTarget() instanceof NPC) || anim.isBlockListAnimation())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
final NPC npc = (NPC) atk.getTarget();
|
||||
final int npcId = npc.getId();
|
||||
if (COOLDOWN_OVERWRITES_IDS.contains(npcId))
|
||||
{
|
||||
if (tickCount.isWithinNTicks(larvaeConsumed, 1))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
final int weaponId = CombatUtils.getWeaponId(client);
|
||||
final boolean isDemonbaneSpell = spellbook == Spellbook.ARCEUUS && AnimationData.isManualCasting(anim) && anim == AnimationData.MAGIC_ARCEUUS_DEMONBANE;
|
||||
if (NO_COOLDOWN_WEAPON.contains(weaponId) || isDemonbaneSpell)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
larvaeConsumed = tickCount.get();
|
||||
return true;
|
||||
}
|
||||
else if (npcId == NpcID.DOM_BOSS)
|
||||
{
|
||||
if (tickCount.isWithinNTicks(shieldConsumed, 30))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
final int animId = npc.getAnimation();
|
||||
if (animId == AnimationID.DOM_BEAM_CHARGE_LOOP || animId == AnimationID.DOM_BEAM_CHARGE)
|
||||
{
|
||||
if (CombatUtils.getAttackType(client) != null && CombatUtils.getAttackType(client).IsMelee())
|
||||
{
|
||||
shieldConsumed = tickCount.get();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int apply(final Client client, final AnimationData curAnimation, final AttackProcedure atkType,
|
||||
final Spellbook spellbook, final int damageDealt, final int lastSpecDelta, final int baseSpeed,
|
||||
final int curSpeed)
|
||||
{
|
||||
final int targetId = CombatUtils.getTargetId(client);
|
||||
final boolean inDoom = CombatUtils.isInRegionId(client, DOOM_REGION_IDS);
|
||||
if (inDoom && COOLDOWN_OVERWRITES_IDS.contains(targetId))
|
||||
{
|
||||
final int weaponId = CombatUtils.getWeaponId(client);
|
||||
final boolean isDemonbaneSpell = spellbook == Spellbook.ARCEUUS && AnimationData.isManualCasting(curAnimation) && curAnimation == AnimationData.MAGIC_ARCEUUS_DEMONBANE;
|
||||
if (NO_COOLDOWN_WEAPON.contains(weaponId) || isDemonbaneSpell)
|
||||
{
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
return curSpeed;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.example.essentialsplus.helpers.combat.variablespeed;
|
||||
|
||||
import com.example.essentialsplus.helpers.combat.AnimationData;
|
||||
import com.example.essentialsplus.helpers.combat.AttackProcedure;
|
||||
import com.example.essentialsplus.helpers.combat.Spellbook;
|
||||
import net.runelite.api.Client;
|
||||
|
||||
public class EyeOfAyak implements IVariableSpeed
|
||||
{
|
||||
@Override
|
||||
public int apply(final Client client, final AnimationData curAnimation, final AttackProcedure atkType,
|
||||
final Spellbook spellbook, final int damageDealt, final int lastSpecDelta, final int baseSpeed,
|
||||
final int curSpeed)
|
||||
{
|
||||
if (curAnimation == AnimationData.MAGIC_EYE_OF_AYAK_SPEC)
|
||||
{
|
||||
return 5;
|
||||
}
|
||||
return curSpeed;
|
||||
}
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
package com.example.essentialsplus.helpers.combat.variablespeed;
|
||||
|
||||
import com.example.essentialsplus.helpers.combat.AnimationData;
|
||||
import com.example.essentialsplus.helpers.combat.AttackProcedure;
|
||||
import com.example.essentialsplus.helpers.combat.Spellbook;
|
||||
import com.example.essentialsplus.helpers.combat.variablespeed.state.IStateTracker;
|
||||
import net.runelite.api.Client;
|
||||
|
||||
public interface IVariableSpeed extends IStateTracker
|
||||
{
|
||||
int apply(
|
||||
final Client client,
|
||||
final AnimationData curAnimation,
|
||||
final AttackProcedure atkType,
|
||||
final Spellbook spellbook,
|
||||
final int damageDealt,
|
||||
final int lastSpecDelta,
|
||||
final int baseSpeed,
|
||||
final int curSpeed
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package com.example.essentialsplus.helpers.combat.variablespeed;
|
||||
|
||||
import com.example.essentialsplus.helpers.combat.AnimationData;
|
||||
import com.example.essentialsplus.helpers.combat.Attacking;
|
||||
import com.example.essentialsplus.helpers.combat.CombatUtils;
|
||||
import com.example.essentialsplus.helpers.combat.variablespeed.state.TickCount;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import net.runelite.api.Client;
|
||||
import net.runelite.api.NPC;
|
||||
import net.runelite.api.gameval.AnimationID;
|
||||
import net.runelite.api.gameval.NpcID;
|
||||
import net.runelite.client.game.ItemManager;
|
||||
|
||||
@Slf4j
|
||||
public class MaggotKing
|
||||
{
|
||||
private static final int MAGGOT_KING_REGION_ID = 11645;
|
||||
|
||||
private final TickCount tickCount;
|
||||
private int consumed = -1;
|
||||
|
||||
public MaggotKing(final TickCount tc)
|
||||
{
|
||||
this.tickCount = tc;
|
||||
}
|
||||
|
||||
public boolean onRender(final Client client, final ItemManager itemManager)
|
||||
{
|
||||
if (!CombatUtils.isInRegionId(client, MAGGOT_KING_REGION_ID) || tickCount.isWithinNTicks(consumed, 1))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
final var atk = Attacking.playerAttack(client);
|
||||
final AnimationData anim = AnimationData.fromId(atk.getAnimationId());
|
||||
if (anim == null || atk.getTarget() == null || !(atk.getTarget() instanceof NPC))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
final NPC npc = (NPC) atk.getTarget();
|
||||
if (npc.getId() != NpcID.UR_MAGGOT_LARVAE || !anim.isStandardBowAttack()
|
||||
|| npc.getAnimation() != AnimationID.UR_MAGGOT_LARVAE_FLY)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
consumed = tickCount.get();
|
||||
return true;
|
||||
}
|
||||
|
||||
public int onChatMessage(final Client client, final String message)
|
||||
{
|
||||
if (message == null)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
if (message.contains("The sticky acid hampers your ability to attack!"))
|
||||
{
|
||||
return 1;
|
||||
}
|
||||
else if (message.contains("The Maggot King's screech disrupts your concentration!"))
|
||||
{
|
||||
return 3;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
package com.example.essentialsplus.helpers.combat.variablespeed;
|
||||
|
||||
import com.example.essentialsplus.helpers.combat.AnimationData;
|
||||
import com.example.essentialsplus.helpers.combat.AttackProcedure;
|
||||
import com.example.essentialsplus.helpers.combat.CombatUtils;
|
||||
import com.example.essentialsplus.helpers.combat.Spellbook;
|
||||
import com.example.essentialsplus.helpers.combat.variablespeed.state.Yama;
|
||||
import net.runelite.api.Client;
|
||||
import net.runelite.api.NPC;
|
||||
import net.runelite.api.gameval.ItemID;
|
||||
|
||||
public class PurgingStaffSpec implements IVariableSpeed
|
||||
{
|
||||
private final Yama yama;
|
||||
public NPC lastTarget;
|
||||
|
||||
public PurgingStaffSpec(Yama yama)
|
||||
{
|
||||
this.yama = yama;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int apply(final Client client, final AnimationData curAnimation, final AttackProcedure atkType,
|
||||
final Spellbook spellbook, final int damageDealt, final int lastSpecDelta, final int baseSpeed,
|
||||
final int curSpeed)
|
||||
{
|
||||
if (this.yama == null || !this.yama.inYamaRegion)
|
||||
{
|
||||
return curSpeed;
|
||||
}
|
||||
final NPC target = CombatUtils.getTargetNPC(client);
|
||||
final NPC flare = Yama.isEitherVoidFlare(target, lastTarget);
|
||||
lastTarget = target;
|
||||
if (flare == null)
|
||||
{
|
||||
return curSpeed;
|
||||
}
|
||||
|
||||
yama.dealVoidFlareDamage(flare, damageDealt);
|
||||
if (lastSpecDelta != -250 || CombatUtils.getWeaponId(client) != ItemID.PURGING_STAFF || spellbook != Spellbook.ARCEUUS)
|
||||
{
|
||||
return curSpeed;
|
||||
}
|
||||
|
||||
if (yama.getVoidFlareDead(flare))
|
||||
{
|
||||
return curSpeed - 2;
|
||||
}
|
||||
return curSpeed;
|
||||
}
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
package com.example.essentialsplus.helpers.combat.variablespeed;
|
||||
|
||||
import com.example.essentialsplus.helpers.combat.AnimationData;
|
||||
import com.example.essentialsplus.helpers.combat.AttackProcedure;
|
||||
import com.example.essentialsplus.helpers.combat.AttackStyle;
|
||||
import com.example.essentialsplus.helpers.combat.CombatUtils;
|
||||
import com.example.essentialsplus.helpers.combat.Spellbook;
|
||||
import net.runelite.api.Client;
|
||||
import net.runelite.api.gameval.VarPlayerID;
|
||||
|
||||
public class RapidAttackStyle implements IVariableSpeed
|
||||
{
|
||||
@Override
|
||||
public int apply(final Client client, final AnimationData curAnimation, final AttackProcedure atkType,
|
||||
final Spellbook spellbook, final int damageDealt, final int lastSpecDelta, final int baseSpeed,
|
||||
final int curSpeed)
|
||||
{
|
||||
final boolean isRapid = client.getVarpValue(VarPlayerID.COM_MODE) == 1;
|
||||
if (atkType == AttackProcedure.MELEE_OR_RANGE && CombatUtils.getAttackStyle(client) == AttackStyle.RANGING && isRapid)
|
||||
{
|
||||
return curSpeed - 1;
|
||||
}
|
||||
return curSpeed;
|
||||
}
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
package com.example.essentialsplus.helpers.combat.variablespeed;
|
||||
|
||||
import com.example.essentialsplus.helpers.combat.AnimationData;
|
||||
import com.example.essentialsplus.helpers.combat.AttackProcedure;
|
||||
import com.example.essentialsplus.helpers.combat.Spellbook;
|
||||
import net.runelite.api.Client;
|
||||
|
||||
public class RedKerisSpec implements IVariableSpeed
|
||||
{
|
||||
@Override
|
||||
public int apply(final Client client, final AnimationData curAnimation, final AttackProcedure atkType,
|
||||
final Spellbook spellbook, final int damageDealt, final int lastSpecDelta, final int baseSpeed,
|
||||
final int curSpeed)
|
||||
{
|
||||
if (lastSpecDelta != -750 || curAnimation != AnimationData.MELEE_RED_KERIS_SPEC)
|
||||
{
|
||||
return curSpeed;
|
||||
}
|
||||
if (damageDealt > 0)
|
||||
{
|
||||
return curSpeed + 4;
|
||||
}
|
||||
return curSpeed;
|
||||
}
|
||||
}
|
||||
+174
@@ -0,0 +1,174 @@
|
||||
package com.example.essentialsplus.helpers.combat.variablespeed;
|
||||
|
||||
import com.example.essentialsplus.helpers.combat.AnimationData;
|
||||
import com.example.essentialsplus.helpers.combat.AttackProcedure;
|
||||
import com.example.essentialsplus.helpers.combat.CombatUtils;
|
||||
import com.example.essentialsplus.helpers.combat.Spellbook;
|
||||
import com.google.common.collect.ImmutableSet;
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
import net.runelite.api.Client;
|
||||
import net.runelite.api.NPC;
|
||||
import net.runelite.api.coords.WorldPoint;
|
||||
import net.runelite.api.events.GameTick;
|
||||
import net.runelite.api.events.NpcDespawned;
|
||||
import net.runelite.api.events.NpcSpawned;
|
||||
import net.runelite.api.gameval.ItemID;
|
||||
import net.runelite.api.gameval.NpcID;
|
||||
|
||||
public class RoyalTitans implements IVariableSpeed
|
||||
{
|
||||
private static final int ROYAL_TITANS_REGION_ID = 11669;
|
||||
private static final int HP_FUDGE = 1;
|
||||
private static final int ELEMENTAL_HP = 40 - HP_FUDGE;
|
||||
|
||||
private final Set<NPC> iceElementals = new HashSet<>();
|
||||
private final Set<NPC> fireElementals = new HashSet<>();
|
||||
private boolean removeDead = false;
|
||||
|
||||
private static final Set<AnimationData> ONE_SHOT_SPELLS = new ImmutableSet.Builder<AnimationData>()
|
||||
.add(AnimationData.MAGIC_STANDARD_STRIKE_BOLT_BLAST)
|
||||
.add(AnimationData.MAGIC_STANDARD_STRIKE_BOLT_BLAST_STAFF)
|
||||
.add(AnimationData.MAGIC_STANDARD_STRIKE_BOLT_BLAST_STAFF_WALK)
|
||||
.add(AnimationData.MAGIC_STANDARD_STRIKE_BOLT_BLAST_WALK)
|
||||
.add(AnimationData.MAGIC_STANDARD_SURGE)
|
||||
.add(AnimationData.MAGIC_STANDARD_SURGE_STAFF)
|
||||
.add(AnimationData.MAGIC_STANDARD_SURGE_STAFF_WALK)
|
||||
.add(AnimationData.MAGIC_STANDARD_WAVE)
|
||||
.add(AnimationData.MAGIC_STANDARD_WAVE_STAFF)
|
||||
.add(AnimationData.MAGIC_STANDARD_WAVE_STAFF_WALK)
|
||||
.add(AnimationData.MAGIC_STANDARD_WAVE_WALK)
|
||||
.build();
|
||||
|
||||
@Override
|
||||
public int apply(final Client client, final AnimationData curAnimation, final AttackProcedure atkType,
|
||||
final Spellbook spellbook, final int damageDealt, final int lastSpecDelta, final int baseSpeed,
|
||||
final int curSpeed)
|
||||
{
|
||||
if (earlyExit(client, atkType, damageDealt, spellbook))
|
||||
{
|
||||
return curSpeed;
|
||||
}
|
||||
final int targetId = CombatUtils.getTargetId(client);
|
||||
if (spellbook != Spellbook.STANDARD || !isElemental(targetId) || !ONE_SHOT_SPELLS.contains(curAnimation))
|
||||
{
|
||||
return curSpeed;
|
||||
}
|
||||
final NPC target = CombatUtils.getTargetNPC(client);
|
||||
if (target == null)
|
||||
{
|
||||
return curSpeed;
|
||||
}
|
||||
|
||||
final boolean wieldingTwinflame = CombatUtils.getWeaponId(client) == ItemID.TWINFLAME_STAFF;
|
||||
final int computedDamage = wieldingTwinflame ? damageDealt + ((damageDealt * 4) / 10) : damageDealt;
|
||||
|
||||
if (computedDamage < ELEMENTAL_HP)
|
||||
{
|
||||
return curSpeed;
|
||||
}
|
||||
|
||||
final var set = targetId == NpcID.RT_SUMMON_ELEMENTAL_FIRE ? fireElementals : iceElementals;
|
||||
int count = 1;
|
||||
final var reference = target.getWorldLocation();
|
||||
for (final NPC elemental : set)
|
||||
{
|
||||
if (elemental == target)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
final WorldPoint worldLocation = elemental.getWorldLocation();
|
||||
final int distanceTo2D = reference.distanceTo2D(worldLocation);
|
||||
if (distanceTo2D <= 1)
|
||||
{
|
||||
count++;
|
||||
}
|
||||
}
|
||||
|
||||
final boolean isTwinflameKillOnSecondProjectile = wieldingTwinflame &&
|
||||
curAnimation == AnimationData.MAGIC_STANDARD_STRIKE_BOLT_BLAST_STAFF;
|
||||
final int extraHitOffset = isTwinflameKillOnSecondProjectile ? 1 : 0;
|
||||
|
||||
final WorldPoint playerLoc = client.getLocalPlayer().getWorldLocation();
|
||||
final int distance = playerLoc.distanceTo2D(reference);
|
||||
final int hitDelay = 2 + (distance / 3) + extraHitOffset;
|
||||
|
||||
final int remainingAtImpact = Math.max(0, curSpeed - hitDelay);
|
||||
final int remainingAfterReduction = Math.max(0, remainingAtImpact - count);
|
||||
final int finalSpeed = Math.min(curSpeed, hitDelay + remainingAfterReduction);
|
||||
|
||||
removeDead = true;
|
||||
return finalSpeed;
|
||||
}
|
||||
|
||||
private static boolean earlyExit(final Client client, final AttackProcedure atkType, final int damageDealt,
|
||||
final Spellbook spellbook)
|
||||
{
|
||||
return notInRegion(client)
|
||||
|| atkType != AttackProcedure.MANUAL_AUTO_CAST
|
||||
|| damageDealt <= 0
|
||||
|| spellbook != Spellbook.STANDARD;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onGameTick(Client client, GameTick tick)
|
||||
{
|
||||
if (removeDead)
|
||||
{
|
||||
boolean removed = iceElementals.removeIf(NPC::isDead);
|
||||
removed |= fireElementals.removeIf(NPC::isDead);
|
||||
if (removed)
|
||||
{
|
||||
removeDead = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onNpcSpawned(final Client client, final NpcSpawned npcSpawned)
|
||||
{
|
||||
if (notInRegion(client))
|
||||
{
|
||||
return;
|
||||
}
|
||||
final NPC npc = npcSpawned.getNpc();
|
||||
final int id = npc.getId();
|
||||
if (id == NpcID.RT_SUMMON_ELEMENTAL_ICE)
|
||||
{
|
||||
iceElementals.add(npc);
|
||||
}
|
||||
else if (id == NpcID.RT_SUMMON_ELEMENTAL_FIRE)
|
||||
{
|
||||
fireElementals.add(npc);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onNpcDespawned(final Client client, final NpcDespawned npcDespawned)
|
||||
{
|
||||
if (notInRegion(client))
|
||||
{
|
||||
return;
|
||||
}
|
||||
final NPC npc = npcDespawned.getNpc();
|
||||
final int id = npc.getId();
|
||||
if (id == NpcID.RT_SUMMON_ELEMENTAL_ICE)
|
||||
{
|
||||
iceElementals.remove(npc);
|
||||
}
|
||||
else if (id == NpcID.RT_SUMMON_ELEMENTAL_FIRE)
|
||||
{
|
||||
fireElementals.remove(npc);
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean isElemental(int id)
|
||||
{
|
||||
return id == NpcID.RT_SUMMON_ELEMENTAL_FIRE || id == NpcID.RT_SUMMON_ELEMENTAL_ICE;
|
||||
}
|
||||
|
||||
private static boolean notInRegion(final Client client)
|
||||
{
|
||||
return CombatUtils.getLocalLocation(client).getRegionID() != ROYAL_TITANS_REGION_ID;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package com.example.essentialsplus.helpers.combat.variablespeed;
|
||||
|
||||
import com.example.essentialsplus.helpers.combat.AnimationData;
|
||||
import com.example.essentialsplus.helpers.combat.AttackProcedure;
|
||||
import com.example.essentialsplus.helpers.combat.CombatUtils;
|
||||
import com.example.essentialsplus.helpers.combat.Spellbook;
|
||||
import net.runelite.api.Client;
|
||||
import net.runelite.api.coords.WorldPoint;
|
||||
import net.runelite.api.gameval.ItemID;
|
||||
import net.runelite.api.gameval.NpcID;
|
||||
|
||||
public class Scurrius implements IVariableSpeed
|
||||
{
|
||||
private static final int SCURRIUS_REGION_ID = 13210;
|
||||
private static final int SCURRIUS_MIN_X = 3290;
|
||||
private static final int SCURRIUS_MAX_X = 3307;
|
||||
private static final int SCURRIUS_MIN_Y = 9859;
|
||||
private static final int SCURRIUS_MAX_Y = 9876;
|
||||
|
||||
private static boolean attackingGiantRatWithBoneWeapon(final int equipped, final int regionId, final int x,
|
||||
final int y, final int target)
|
||||
{
|
||||
final boolean correctWeapon = equipped == ItemID.RAT_BONE_STAFF || equipped == ItemID.RAT_BONE_MACE || equipped == ItemID.RAT_BONE_BOW;
|
||||
final boolean correctCoords = x >= SCURRIUS_MIN_X && x <= SCURRIUS_MAX_X && y >= SCURRIUS_MIN_Y && y <= SCURRIUS_MAX_Y;
|
||||
final boolean correctRegion = regionId == SCURRIUS_REGION_ID;
|
||||
final boolean correctEnemy = target == NpcID.RAT_BOSS_GIANT_RAT;
|
||||
return correctWeapon && correctCoords && correctRegion && correctEnemy;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int apply(final Client client, final AnimationData curAnimation, final AttackProcedure atkType,
|
||||
final Spellbook spellbook, final int damageDealt, final int lastSpecDelta, final int baseSpeed,
|
||||
final int curSpeed)
|
||||
{
|
||||
final WorldPoint location = CombatUtils.getLocalLocation(client);
|
||||
final int weaponId = CombatUtils.getWeaponId(client);
|
||||
final int targetId = CombatUtils.getTargetId(client);
|
||||
if (attackingGiantRatWithBoneWeapon(weaponId, location.getRegionID(), location.getX(), location.getY(), targetId))
|
||||
{
|
||||
return 1;
|
||||
}
|
||||
return curSpeed;
|
||||
}
|
||||
}
|
||||
+257
@@ -0,0 +1,257 @@
|
||||
package com.example.essentialsplus.helpers.combat.variablespeed;
|
||||
|
||||
import com.example.essentialsplus.helpers.combat.variablespeed.state.MarkOfDarkness;
|
||||
import com.example.essentialsplus.helpers.combat.variablespeed.state.TickCount;
|
||||
import com.example.essentialsplus.helpers.combat.variablespeed.state.Yama;
|
||||
import com.example.essentialsplus.helpers.combat.variablespeed.state.YamaPhase;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import net.runelite.api.Client;
|
||||
import net.runelite.api.GraphicsObject;
|
||||
import net.runelite.api.Player;
|
||||
import net.runelite.api.WorldView;
|
||||
import net.runelite.api.coords.LocalPoint;
|
||||
import net.runelite.api.coords.WorldPoint;
|
||||
|
||||
@Slf4j
|
||||
public class ShadowCrash
|
||||
{
|
||||
private static final int FIREBALL_ID = 3262;
|
||||
private static final int TILE = 128;
|
||||
|
||||
private static final int HV_GAP_1 = 3;
|
||||
private static final int HV_GAP_2 = 6;
|
||||
private static final int HV_DELTA_1 = HV_GAP_1 * TILE;
|
||||
private static final int HV_DELTA_2 = HV_GAP_2 * TILE;
|
||||
private static final int D_GAP_1 = 2;
|
||||
private static final int D_GAP_2 = 4;
|
||||
private static final int D_DELTA_1 = D_GAP_1 * TILE;
|
||||
private static final int D_DELTA_2 = D_GAP_2 * TILE;
|
||||
|
||||
private final TickCount tickCount;
|
||||
private final Yama yama;
|
||||
private final MarkOfDarkness mod;
|
||||
|
||||
private int consumed = -1;
|
||||
private final Map<LocalPoint, GraphicsObject> fireballs;
|
||||
private ArrayList<FireballLine> lines;
|
||||
private WorldPoint player;
|
||||
|
||||
public ShadowCrash(Yama yama, MarkOfDarkness mod, TickCount tc)
|
||||
{
|
||||
this.yama = yama;
|
||||
this.mod = mod;
|
||||
this.tickCount = tc;
|
||||
this.fireballs = new HashMap<>();
|
||||
this.lines = new ArrayList<>();
|
||||
}
|
||||
|
||||
public int onRender(final Client client, final int attackDelayHoldoffTicks, final boolean isUsingMagic)
|
||||
{
|
||||
if (yama == null || !yama.inYamaRegion || yama.phase() != YamaPhase.P3 || !mod.isActive())
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
if (!tickCount.isWithinNTicks(consumed, 2) && shadowCrashSweetSpot(client))
|
||||
{
|
||||
consumed = tickCount.get();
|
||||
if (attackDelayHoldoffTicks <= 1 && isUsingMagic)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
private boolean shadowCrashSweetSpot(final Client client)
|
||||
{
|
||||
getState(client);
|
||||
if (lines.isEmpty())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
for (var line : lines)
|
||||
{
|
||||
if (line.isActive())
|
||||
{
|
||||
return line.inSweetSpot(client, player);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private void getState(final Client client)
|
||||
{
|
||||
fireballs.clear();
|
||||
lines.clear();
|
||||
final WorldView tlwv = client.getTopLevelWorldView();
|
||||
final Player localPlayer = client.getLocalPlayer();
|
||||
if (localPlayer == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
final WorldView playerWv = localPlayer.getWorldView();
|
||||
if (tlwv != null)
|
||||
{
|
||||
getFireballs(tlwv);
|
||||
}
|
||||
if (playerWv != null && playerWv != tlwv)
|
||||
{
|
||||
getFireballs(playerWv);
|
||||
}
|
||||
if (fireballs.size() < 3)
|
||||
{
|
||||
return;
|
||||
}
|
||||
player = localPlayer.getWorldLocation();
|
||||
lines = getLines();
|
||||
}
|
||||
|
||||
private void getFireballs(final WorldView wv)
|
||||
{
|
||||
for (GraphicsObject graphicsObject : wv.getGraphicsObjects())
|
||||
{
|
||||
if (graphicsObject.getId() == FIREBALL_ID)
|
||||
{
|
||||
fireballs.put(graphicsObject.getLocation(), graphicsObject);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private ArrayList<FireballLine> getLines()
|
||||
{
|
||||
final var result = new ArrayList<FireballLine>();
|
||||
|
||||
while (fireballs.size() >= 3)
|
||||
{
|
||||
LocalPoint first = null;
|
||||
final Set<LocalPoint> fireballsLocations = fireballs.keySet();
|
||||
for (var location : fireballsLocations)
|
||||
{
|
||||
if (first == null)
|
||||
{
|
||||
first = location;
|
||||
}
|
||||
else
|
||||
{
|
||||
if (first.getX() > location.getX())
|
||||
{
|
||||
first = location;
|
||||
}
|
||||
else if (first.getX() == location.getX())
|
||||
{
|
||||
if (first.getY() > location.getY())
|
||||
{
|
||||
first = location;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (first == null)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
var centre = first.plus(D_DELTA_1, D_DELTA_1);
|
||||
var end = first.plus(D_DELTA_2, D_DELTA_2);
|
||||
if (fireballsLocations.contains(centre) && fireballsLocations.contains(end))
|
||||
{
|
||||
result.add(new FireballLine(LineType.D2, centre, fireballs.get(centre)));
|
||||
removeLine(first, centre, end);
|
||||
continue;
|
||||
}
|
||||
|
||||
centre = first.plus(HV_DELTA_1, 0);
|
||||
end = first.plus(HV_DELTA_2, 0);
|
||||
if (fireballsLocations.contains(centre) && fireballsLocations.contains(end))
|
||||
{
|
||||
result.add(new FireballLine(LineType.H, centre, fireballs.get(centre)));
|
||||
removeLine(first, centre, end);
|
||||
continue;
|
||||
}
|
||||
|
||||
centre = first.plus(0, HV_DELTA_1);
|
||||
end = first.plus(0, HV_DELTA_2);
|
||||
if (fireballsLocations.contains(centre) && fireballsLocations.contains(end))
|
||||
{
|
||||
result.add(new FireballLine(LineType.V, centre, fireballs.get(centre)));
|
||||
removeLine(first, centre, end);
|
||||
continue;
|
||||
}
|
||||
|
||||
centre = first.plus(D_DELTA_1, -D_DELTA_1);
|
||||
end = first.plus(D_DELTA_2, -D_DELTA_2);
|
||||
if (fireballsLocations.contains(centre) && fireballsLocations.contains(end))
|
||||
{
|
||||
result.add(new FireballLine(LineType.D1, centre, fireballs.get(centre)));
|
||||
removeLine(first, centre, end);
|
||||
continue;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private void removeLine(final LocalPoint a, final LocalPoint b, final LocalPoint c)
|
||||
{
|
||||
fireballs.remove(a);
|
||||
fireballs.remove(b);
|
||||
fireballs.remove(c);
|
||||
}
|
||||
|
||||
public static boolean eq(final WorldPoint t, final int dx, final int dy, final WorldPoint other)
|
||||
{
|
||||
if (t == null || other == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
return t.getX() + dx == other.getX() && t.getY() + dy == other.getY();
|
||||
}
|
||||
|
||||
static class FireballLine
|
||||
{
|
||||
private final LineType type;
|
||||
private final LocalPoint centre;
|
||||
private final GraphicsObject cObject;
|
||||
|
||||
FireballLine(final LineType type, final LocalPoint centre, final GraphicsObject cObject)
|
||||
{
|
||||
this.centre = centre;
|
||||
this.cObject = cObject;
|
||||
this.type = type;
|
||||
}
|
||||
|
||||
public boolean isActive()
|
||||
{
|
||||
final int animationFrame = cObject.getAnimationFrame();
|
||||
return animationFrame >= 30 && animationFrame <= 40;
|
||||
}
|
||||
|
||||
public boolean inSweetSpot(final Client client, final WorldPoint lp)
|
||||
{
|
||||
final WorldPoint wp = WorldPoint.fromLocal(client, centre);
|
||||
switch (this.type)
|
||||
{
|
||||
case V:
|
||||
return eq(wp, 1, 0, lp) || eq(wp, -1, 0, lp);
|
||||
case H:
|
||||
return eq(wp, 0, 1, lp) || eq(wp, 0, -1, lp);
|
||||
case D1:
|
||||
return eq(wp, 1, 1, lp) || eq(wp, -1, -1, lp);
|
||||
case D2:
|
||||
return eq(wp, -1, 1, lp) || eq(wp, 1, -1, lp);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
enum LineType
|
||||
{
|
||||
V, H, D1, D2
|
||||
}
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
package com.example.essentialsplus.helpers.combat.variablespeed;
|
||||
|
||||
import com.example.essentialsplus.helpers.combat.AnimationData;
|
||||
import com.example.essentialsplus.helpers.combat.AttackProcedure;
|
||||
import com.example.essentialsplus.helpers.combat.AttackType;
|
||||
import com.example.essentialsplus.helpers.combat.CombatUtils;
|
||||
import com.example.essentialsplus.helpers.combat.Spellbook;
|
||||
import net.runelite.api.Client;
|
||||
import net.runelite.api.gameval.NpcID;
|
||||
|
||||
public class TombsOfAmascut implements IVariableSpeed
|
||||
{
|
||||
@Override
|
||||
public int apply(final Client client, final AnimationData curAnimation, final AttackProcedure atkType,
|
||||
final Spellbook spellbook, final int damageDealt, final int lastSpecDelta, final int baseSpeed,
|
||||
final int curSpeed)
|
||||
{
|
||||
final int targetId = CombatUtils.getTargetId(client);
|
||||
final AttackType attkType = CombatUtils.getAttackType(client);
|
||||
if (targetId == NpcID.WARDENS_P3_ORB_BLUE && attkType != null && attkType.IsMelee())
|
||||
{
|
||||
return 1;
|
||||
}
|
||||
return curSpeed;
|
||||
}
|
||||
}
|
||||
+184
@@ -0,0 +1,184 @@
|
||||
package com.example.essentialsplus.helpers.combat.variablespeed;
|
||||
|
||||
import com.example.essentialsplus.helpers.combat.AnimationData;
|
||||
import com.example.essentialsplus.helpers.combat.AttackProcedure;
|
||||
import com.example.essentialsplus.helpers.combat.AttackType;
|
||||
import com.example.essentialsplus.helpers.combat.CombatUtils;
|
||||
import com.example.essentialsplus.helpers.combat.Spellbook;
|
||||
import com.example.essentialsplus.helpers.combat.WeaponType;
|
||||
import com.example.essentialsplus.helpers.combat.variablespeed.state.TickCount;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import net.runelite.api.Client;
|
||||
import net.runelite.api.NPC;
|
||||
import net.runelite.api.events.GameTick;
|
||||
import net.runelite.api.events.NpcDespawned;
|
||||
import net.runelite.api.events.NpcSpawned;
|
||||
import net.runelite.api.gameval.NpcID;
|
||||
import net.runelite.api.gameval.SpotanimID;
|
||||
|
||||
public class TormentedDemons implements IVariableSpeed
|
||||
{
|
||||
private final TickCount tickCount;
|
||||
private final Map<NPC, DemonData> tormentedDemons = new HashMap<>();
|
||||
|
||||
public TormentedDemons(TickCount tc)
|
||||
{
|
||||
this.tickCount = tc;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int apply(final Client client, final AnimationData curAnimation, final AttackProcedure atkType,
|
||||
final Spellbook spellbook, final int damageDealt, final int lastSpecDelta, final int baseSpeed,
|
||||
final int curSpeed)
|
||||
{
|
||||
final int targetId = CombatUtils.getTargetId(client);
|
||||
if (!isTormentedDemon(targetId))
|
||||
{
|
||||
return curSpeed;
|
||||
}
|
||||
final NPC target = CombatUtils.getTargetNPC(client);
|
||||
if (!tormentedDemons.containsKey(target))
|
||||
{
|
||||
return curSpeed;
|
||||
}
|
||||
final DemonData targetDemon = tormentedDemons.get(target);
|
||||
if (!targetDemon.isVulnerable(tickCount.get()))
|
||||
{
|
||||
return curSpeed;
|
||||
}
|
||||
if (targetDemon.vulnConsumed(tickCount.get()))
|
||||
{
|
||||
return curSpeed;
|
||||
}
|
||||
switch (atkType)
|
||||
{
|
||||
case POWERED_STAVE:
|
||||
return curSpeed;
|
||||
case MANUAL_AUTO_CAST:
|
||||
break;
|
||||
case MELEE_OR_RANGE:
|
||||
final WeaponType wep = CombatUtils.getWeaponType(client);
|
||||
final AttackType attkType = CombatUtils.getAttackType(client);
|
||||
if (wep != WeaponType.HEAVY_RANGED && (attkType == null || attkType != AttackType.CRUSH))
|
||||
{
|
||||
return curSpeed;
|
||||
}
|
||||
break;
|
||||
}
|
||||
if (curSpeed > 4)
|
||||
{
|
||||
return 4;
|
||||
}
|
||||
return curSpeed;
|
||||
}
|
||||
|
||||
private static boolean isTormentedDemon(int targetId)
|
||||
{
|
||||
return targetId == NpcID.TORMENTED_DEMON_1 || targetId == NpcID.TORMENTED_DEMON_2;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onGameTick(Client client, GameTick tick)
|
||||
{
|
||||
for (final Map.Entry<NPC, DemonData> td : tormentedDemons.entrySet())
|
||||
{
|
||||
final boolean isVulnerable = td.getKey().hasSpotAnim(SpotanimID.LUC2_UNDEAD_DEMON_EXPLOSION_FIRE_SPOT);
|
||||
td.getValue().update(tickCount.get(), isVulnerable);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onNpcSpawned(final Client client, final NpcSpawned npcSpawned)
|
||||
{
|
||||
final NPC npc = npcSpawned.getNpc();
|
||||
maybeInsertDemon(npc);
|
||||
}
|
||||
|
||||
private void maybeInsertDemon(final NPC npc)
|
||||
{
|
||||
if (isTormentedDemon(npc.getId()))
|
||||
{
|
||||
final boolean isVulnerable = npc.hasSpotAnim(SpotanimID.LUC2_UNDEAD_DEMON_EXPLOSION_FIRE_SPOT);
|
||||
tormentedDemons.put(npc, new DemonData(tickCount.get(), isVulnerable));
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onNpcDespawned(final Client client, final NpcDespawned npcDespawned)
|
||||
{
|
||||
final NPC npc = npcDespawned.getNpc();
|
||||
if (isTormentedDemon(npc.getId()))
|
||||
{
|
||||
tormentedDemons.remove(npc);
|
||||
}
|
||||
}
|
||||
|
||||
static class DemonData
|
||||
{
|
||||
private static final int VULN_TICKS_AFTER_END = 10;
|
||||
private Integer vulnerableStart;
|
||||
private Integer vulnerableFinish;
|
||||
private int attacked;
|
||||
|
||||
DemonData(int tick, boolean vuln)
|
||||
{
|
||||
this.update(tick, vuln);
|
||||
this.attacked = -1;
|
||||
}
|
||||
|
||||
void update(int tick, boolean vuln)
|
||||
{
|
||||
if (vuln && this.vulnerableStart == null)
|
||||
{
|
||||
this.vulnerableStart = tick;
|
||||
this.vulnerableFinish = tick;
|
||||
}
|
||||
else if (vuln && this.vulnerableStart != null)
|
||||
{
|
||||
this.vulnerableFinish = tick;
|
||||
}
|
||||
|
||||
if (!vuln)
|
||||
{
|
||||
this.vulnerableStart = null;
|
||||
}
|
||||
if (this.attacked != -1 && this.attacked + 1 >= tick)
|
||||
{
|
||||
this.attacked = -1;
|
||||
}
|
||||
}
|
||||
|
||||
boolean isVulnerable(int tick)
|
||||
{
|
||||
if (this.vulnerableFinish == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
return (this.vulnerableFinish + VULN_TICKS_AFTER_END) > tick;
|
||||
}
|
||||
|
||||
boolean vulnConsumed(int tick)
|
||||
{
|
||||
if (this.attacked == tick || this.attacked == -1)
|
||||
{
|
||||
this.consumeVuln(tick);
|
||||
return false;
|
||||
}
|
||||
else if (this.attacked > tick + VULN_TICKS_AFTER_END)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
else
|
||||
{
|
||||
this.consumeVuln(tick);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
void consumeVuln(int tick)
|
||||
{
|
||||
this.attacked = tick;
|
||||
}
|
||||
}
|
||||
}
|
||||
+117
@@ -0,0 +1,117 @@
|
||||
package com.example.essentialsplus.helpers.combat.variablespeed;
|
||||
|
||||
import com.example.essentialsplus.helpers.combat.AnimationData;
|
||||
import com.example.essentialsplus.helpers.combat.AttackProcedure;
|
||||
import com.example.essentialsplus.helpers.combat.Spellbook;
|
||||
import com.example.essentialsplus.helpers.combat.variablespeed.state.IStateTracker;
|
||||
import com.example.essentialsplus.helpers.combat.variablespeed.state.MarkOfDarkness;
|
||||
import com.example.essentialsplus.helpers.combat.variablespeed.state.TickCount;
|
||||
import com.example.essentialsplus.helpers.combat.variablespeed.state.Yama;
|
||||
import net.runelite.api.Client;
|
||||
import net.runelite.api.events.ChatMessage;
|
||||
import net.runelite.api.events.GameTick;
|
||||
import net.runelite.api.events.NpcDespawned;
|
||||
import net.runelite.api.events.NpcSpawned;
|
||||
|
||||
public class VariableSpeed
|
||||
{
|
||||
private final TickCount tickCount;
|
||||
private final Yama yama;
|
||||
private final MarkOfDarkness markOfDarkness;
|
||||
|
||||
public final ShadowCrash shadowCrash;
|
||||
public final MaggotKing maggotKing;
|
||||
public final DoomOfMokhaiotl doomOfMokhaiotl;
|
||||
|
||||
private final IStateTracker[] toTrack;
|
||||
private final IVariableSpeed[] toApply;
|
||||
|
||||
public VariableSpeed(TickCount tc)
|
||||
{
|
||||
this.tickCount = tc;
|
||||
this.yama = new Yama();
|
||||
this.markOfDarkness = new MarkOfDarkness();
|
||||
this.shadowCrash = new ShadowCrash(yama, markOfDarkness, tc);
|
||||
this.maggotKing = new MaggotKing(tc);
|
||||
this.doomOfMokhaiotl = new DoomOfMokhaiotl(tc);
|
||||
|
||||
this.toTrack = new IStateTracker[]{
|
||||
tc,
|
||||
yama,
|
||||
markOfDarkness
|
||||
};
|
||||
|
||||
this.toApply = new IVariableSpeed[]{
|
||||
new BloodMoonSet(),
|
||||
new RapidAttackStyle(),
|
||||
new RedKerisSpec(),
|
||||
new PurgingStaffSpec(yama),
|
||||
new EyeOfAyak(),
|
||||
new TormentedDemons(tc),
|
||||
new RoyalTitans(),
|
||||
new Amoxliatl(),
|
||||
new Scurrius(),
|
||||
new TombsOfAmascut(),
|
||||
doomOfMokhaiotl
|
||||
};
|
||||
}
|
||||
|
||||
public int compute(final Client client, final AnimationData curAnimation, final AttackProcedure atkType,
|
||||
final Spellbook spellbook, final int damageDealt, final int lastSpecDelta, final int baseSpeed)
|
||||
{
|
||||
int newSpeed = baseSpeed;
|
||||
for (final IVariableSpeed i : toApply)
|
||||
{
|
||||
newSpeed = i.apply(client, curAnimation, atkType, spellbook, damageDealt, lastSpecDelta, baseSpeed, newSpeed);
|
||||
}
|
||||
return newSpeed;
|
||||
}
|
||||
|
||||
public void onGameTick(final Client client, final GameTick tick)
|
||||
{
|
||||
for (final IStateTracker i : toTrack)
|
||||
{
|
||||
i.onGameTick(client, tick);
|
||||
}
|
||||
for (final IStateTracker i : toApply)
|
||||
{
|
||||
i.onGameTick(client, tick);
|
||||
}
|
||||
}
|
||||
|
||||
public void onChatMessage(final Client client, final ChatMessage event)
|
||||
{
|
||||
for (final IStateTracker i : toTrack)
|
||||
{
|
||||
i.onChatMessage(client, event);
|
||||
}
|
||||
for (final IStateTracker i : toApply)
|
||||
{
|
||||
i.onChatMessage(client, event);
|
||||
}
|
||||
}
|
||||
|
||||
public void onNpcSpawned(final Client client, final NpcSpawned npcSpawned)
|
||||
{
|
||||
for (final IStateTracker i : toTrack)
|
||||
{
|
||||
i.onNpcSpawned(client, npcSpawned);
|
||||
}
|
||||
for (final IStateTracker i : toApply)
|
||||
{
|
||||
i.onNpcSpawned(client, npcSpawned);
|
||||
}
|
||||
}
|
||||
|
||||
public void onNpcDespawned(final Client client, final NpcDespawned npcDespawned)
|
||||
{
|
||||
for (final IStateTracker i : toTrack)
|
||||
{
|
||||
i.onNpcDespawned(client, npcDespawned);
|
||||
}
|
||||
for (final IStateTracker i : toApply)
|
||||
{
|
||||
i.onNpcDespawned(client, npcDespawned);
|
||||
}
|
||||
}
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
package com.example.essentialsplus.helpers.combat.variablespeed.state;
|
||||
|
||||
import net.runelite.api.Client;
|
||||
import net.runelite.api.events.ChatMessage;
|
||||
import net.runelite.api.events.GameTick;
|
||||
import net.runelite.api.events.NpcDespawned;
|
||||
import net.runelite.api.events.NpcSpawned;
|
||||
|
||||
public interface IStateTracker
|
||||
{
|
||||
default void onGameTick(final Client client, final GameTick tick)
|
||||
{
|
||||
}
|
||||
|
||||
default void onChatMessage(final Client client, final ChatMessage event)
|
||||
{
|
||||
}
|
||||
|
||||
default void onNpcSpawned(final Client client, final NpcSpawned npcSpawned)
|
||||
{
|
||||
}
|
||||
|
||||
default void onNpcDespawned(final Client client, final NpcDespawned npcDespawned)
|
||||
{
|
||||
}
|
||||
}
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
package com.example.essentialsplus.helpers.combat.variablespeed.state;
|
||||
|
||||
import com.example.essentialsplus.helpers.combat.CombatUtils;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import net.runelite.api.Client;
|
||||
import net.runelite.api.Skill;
|
||||
import net.runelite.api.events.ChatMessage;
|
||||
import net.runelite.api.gameval.ItemID;
|
||||
import net.runelite.client.util.RSTimeUnit;
|
||||
|
||||
public class MarkOfDarkness implements IStateTracker
|
||||
{
|
||||
private static final String MARK_OF_DARKNESS_MESSAGE = "You have placed a Mark of Darkness upon yourself.</col>";
|
||||
|
||||
private Instant modStartTime = Instant.now();
|
||||
private Instant modEndTime = Instant.now();
|
||||
|
||||
@Override
|
||||
public void onChatMessage(Client client, ChatMessage event)
|
||||
{
|
||||
final String message = event.getMessage();
|
||||
if (message != null && message.endsWith(MARK_OF_DARKNESS_MESSAGE))
|
||||
{
|
||||
final Duration duration = getMarkOfDarknessDuration(client);
|
||||
this.modStartTime = Instant.now();
|
||||
this.modEndTime = modStartTime.plus(duration);
|
||||
}
|
||||
}
|
||||
|
||||
public boolean isActive()
|
||||
{
|
||||
final Instant n = Instant.now();
|
||||
return this.modStartTime.isBefore(n) && this.modEndTime.isAfter(n);
|
||||
}
|
||||
|
||||
private static Duration getMarkOfDarknessDuration(Client client)
|
||||
{
|
||||
final int magicLevel = client.getRealSkillLevel(Skill.MAGIC);
|
||||
final Duration markOfDarknessDuration = Duration.of((long) magicLevel * 3, RSTimeUnit.GAME_TICKS);
|
||||
if (CombatUtils.getWeaponId(client) == ItemID.PURGING_STAFF)
|
||||
{
|
||||
return markOfDarknessDuration.multipliedBy(5);
|
||||
}
|
||||
return markOfDarknessDuration;
|
||||
}
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
package com.example.essentialsplus.helpers.combat.variablespeed.state;
|
||||
|
||||
import net.runelite.api.Client;
|
||||
import net.runelite.api.events.GameTick;
|
||||
|
||||
public class TickCount implements IStateTracker
|
||||
{
|
||||
private int tickCount = 0;
|
||||
|
||||
public int get()
|
||||
{
|
||||
return tickCount;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onGameTick(Client client, GameTick tick)
|
||||
{
|
||||
tickCount++;
|
||||
}
|
||||
|
||||
public boolean isWithinNTicks(int toCheckAgainst, int n)
|
||||
{
|
||||
return this.tickCount <= toCheckAgainst + n && this.tickCount >= toCheckAgainst;
|
||||
}
|
||||
|
||||
public void reset()
|
||||
{
|
||||
tickCount = 0;
|
||||
}
|
||||
}
|
||||
+177
@@ -0,0 +1,177 @@
|
||||
package com.example.essentialsplus.helpers.combat.variablespeed.state;
|
||||
|
||||
import com.example.essentialsplus.helpers.combat.CombatUtils;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import lombok.NonNull;
|
||||
import net.runelite.api.Client;
|
||||
import net.runelite.api.NPC;
|
||||
import net.runelite.api.events.ChatMessage;
|
||||
import net.runelite.api.events.GameTick;
|
||||
import net.runelite.api.events.NpcSpawned;
|
||||
import net.runelite.api.gameval.NpcID;
|
||||
|
||||
public class Yama implements IStateTracker
|
||||
{
|
||||
private static final int HP_FUDGE = 2;
|
||||
private static final int VOID_FLARE_HP_P1_P2 = 140 - HP_FUDGE;
|
||||
private static final int VOID_FLARE_HP_P3 = 71 - HP_FUDGE;
|
||||
private static final int YAMA_REGION_ID = 6045;
|
||||
private static final int YAMA_PHASE_TRANSITION_ANIMATION_ID = 12147;
|
||||
|
||||
public YamaData yama;
|
||||
public boolean inYamaRegion;
|
||||
public NPC lastTarget;
|
||||
|
||||
@Override
|
||||
public void onGameTick(Client client, GameTick tick)
|
||||
{
|
||||
inYamaRegion = CombatUtils.isInRegionId(client, YAMA_REGION_ID);
|
||||
if (!inYamaRegion)
|
||||
{
|
||||
yama = null;
|
||||
return;
|
||||
}
|
||||
if (yama != null)
|
||||
{
|
||||
yama.determineYamaPhase();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onChatMessage(Client client, ChatMessage event)
|
||||
{
|
||||
if (!inYamaRegion || yama == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (event.getMessage().startsWith("Your Yama success count is:"))
|
||||
{
|
||||
yama.killed();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onNpcSpawned(final Client client, final NpcSpawned npcSpawned)
|
||||
{
|
||||
if (!inYamaRegion || yama != null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
final NPC npc = npcSpawned.getNpc();
|
||||
if (npc.getId() == NpcID.YAMA)
|
||||
{
|
||||
yama = new YamaData(npc);
|
||||
}
|
||||
}
|
||||
|
||||
public static NPC isEitherVoidFlare(NPC a, NPC b)
|
||||
{
|
||||
if (a != null && a.getId() == NpcID.YAMA_VOIDFLARE)
|
||||
{
|
||||
return a;
|
||||
}
|
||||
if (b != null && b.getId() == NpcID.YAMA_VOIDFLARE)
|
||||
{
|
||||
return b;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public void dealVoidFlareDamage(NPC target, int damageDealt)
|
||||
{
|
||||
if (yama == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
this.yama.dealVoidFlareDamage(target, damageDealt);
|
||||
}
|
||||
|
||||
public boolean getVoidFlareDead(NPC target)
|
||||
{
|
||||
if (yama == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
return this.yama.getVoidFlareDead(target);
|
||||
}
|
||||
|
||||
public YamaPhase phase()
|
||||
{
|
||||
if (yama == null)
|
||||
{
|
||||
return YamaPhase.P1;
|
||||
}
|
||||
return this.yama.phase;
|
||||
}
|
||||
|
||||
public static class YamaData
|
||||
{
|
||||
@NonNull
|
||||
private final NPC yama;
|
||||
private YamaPhase phase;
|
||||
private int phaseChangeCooldown;
|
||||
private final Map<NPC, Integer> voidFlares;
|
||||
|
||||
YamaData(@NonNull NPC yama)
|
||||
{
|
||||
this.yama = yama;
|
||||
this.phase = YamaPhase.P1;
|
||||
this.phaseChangeCooldown = 0;
|
||||
this.voidFlares = new HashMap<>();
|
||||
}
|
||||
|
||||
public boolean getVoidFlareDead(NPC target)
|
||||
{
|
||||
if (!this.voidFlares.containsKey(target))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
return this.voidFlares.get(target) <= 0;
|
||||
}
|
||||
|
||||
public void dealVoidFlareDamage(NPC target, int damageDealt)
|
||||
{
|
||||
if (this.voidFlares.containsKey(target))
|
||||
{
|
||||
this.voidFlares.put(target, this.voidFlares.get(target) - damageDealt);
|
||||
}
|
||||
else
|
||||
{
|
||||
final int hp = this.getVoidFlareHp() - damageDealt;
|
||||
this.voidFlares.put(target, hp);
|
||||
}
|
||||
}
|
||||
|
||||
private void determineYamaPhase()
|
||||
{
|
||||
if (phaseChangeCooldown == 0 && this.yama.getAnimation() == YAMA_PHASE_TRANSITION_ANIMATION_ID)
|
||||
{
|
||||
this.phaseChangeCooldown = 20;
|
||||
this.phase = this.phase.nextPhase();
|
||||
}
|
||||
if (this.phaseChangeCooldown > 0)
|
||||
{
|
||||
this.phaseChangeCooldown--;
|
||||
}
|
||||
}
|
||||
|
||||
private int getVoidFlareHp()
|
||||
{
|
||||
switch (this.phase)
|
||||
{
|
||||
case P1:
|
||||
case P2:
|
||||
return VOID_FLARE_HP_P1_P2;
|
||||
default:
|
||||
return VOID_FLARE_HP_P3;
|
||||
}
|
||||
}
|
||||
|
||||
private void killed()
|
||||
{
|
||||
this.phase = YamaPhase.P1;
|
||||
this.voidFlares.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
package com.example.essentialsplus.helpers.combat.variablespeed.state;
|
||||
|
||||
public enum YamaPhase
|
||||
{
|
||||
P1, P2, P3;
|
||||
|
||||
public YamaPhase nextPhase()
|
||||
{
|
||||
switch (this)
|
||||
{
|
||||
case P1:
|
||||
return P2;
|
||||
case P2:
|
||||
return P3;
|
||||
case P3:
|
||||
return P3;
|
||||
}
|
||||
return P1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.example.essentialsplus.loot;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Kills and drops of one source within a small time bucket. Recent events are kept per minute,
|
||||
* older ones are merged into hourly and daily buckets (see {@link LootRecord#compactEvents}).
|
||||
*/
|
||||
public class LootEvent
|
||||
{
|
||||
/** bucket start (epoch millis) */
|
||||
public long t;
|
||||
/** kills in this bucket */
|
||||
public int k;
|
||||
/** item id -> quantity */
|
||||
public Map<Integer, Long> i = new LinkedHashMap<>();
|
||||
|
||||
public LootEvent()
|
||||
{
|
||||
}
|
||||
|
||||
public LootEvent(long t)
|
||||
{
|
||||
this.t = t;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
package com.example.essentialsplus.loot;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
import net.runelite.client.game.ItemManager;
|
||||
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
public class LootRecord
|
||||
{
|
||||
private String name;
|
||||
private String type; // "NPC", "CHEST", "CLUE", "EVENT", "PLAYER"
|
||||
private int kills;
|
||||
private long firstSeen;
|
||||
private long lastSeen;
|
||||
private Map<Integer, TrackedLootItem> items = new LinkedHashMap<>();
|
||||
private com.example.essentialsplus.loot.combat.CombatStats combatStats;
|
||||
/** Time-bucketed kills/drops, used for time range filtering. */
|
||||
private List<LootEvent> events = new ArrayList<>();
|
||||
|
||||
private static final long MINUTE = 60_000L;
|
||||
private static final long HOUR = 60 * MINUTE;
|
||||
private static final long DAY = 24 * HOUR;
|
||||
|
||||
public boolean hasCombatStats()
|
||||
{
|
||||
return combatStats != null && combatStats.getFightsCount() > 0;
|
||||
}
|
||||
|
||||
public synchronized void addCombatFight(com.example.essentialsplus.loot.combat.FightSession session)
|
||||
{
|
||||
addCombatFight(session, 0);
|
||||
}
|
||||
|
||||
public synchronized void addCombatFight(com.example.essentialsplus.loot.combat.FightSession session, long lootValue)
|
||||
{
|
||||
if (combatStats == null)
|
||||
{
|
||||
combatStats = new com.example.essentialsplus.loot.combat.CombatStats();
|
||||
}
|
||||
combatStats.addFight(session, lootValue);
|
||||
}
|
||||
|
||||
public LootRecord(String name, String type)
|
||||
{
|
||||
this.name = name;
|
||||
this.type = type != null ? type : "NPC";
|
||||
this.kills = 0;
|
||||
this.firstSeen = System.currentTimeMillis();
|
||||
this.lastSeen = this.firstSeen;
|
||||
}
|
||||
|
||||
public synchronized void addDrop(int itemId, String itemName, int quantity, long 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;
|
||||
long 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
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user