feat: player tracker

This commit is contained in:
Hikyu
2026-10-05 21:04:27 +02:00
parent ddcbd2303c
commit c0868c2703
52 changed files with 9411 additions and 965 deletions
+322
View File
@@ -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
-136
View File
@@ -1,136 +0,0 @@
# Item charges – Taskbeschreibung
Implementiere einen **„Item charges"**-Helper in **Essentials Plus** (`com.example.essentialsplus.helpers`): Auf dem **Icon jedes Items** mit bekanntem Charge/Füllstand wird eine **Zahl** gerendert. Manche Items zeigen **mehrere Zahlen** (z. B. Toxic Blowpipe: Scales + Darts). Togglebar per Checkbox, anzeige in Inventar, Ausrüstung und (optional) Bank.
**Beispiele:**
- Toxic Blowpipe (mit Scales) → `scales`-Zahl; mit geladenen Darts → zweiter Wert (Darts-Marker, s. Hinweise).
- Dizan's Quiver → zwei Zahlen: Ammo (`CHARGES_DIZANAS_QUIVER_QUANTITY`) + Blessing (`CHARGES_DIZANAS_QUIVER_BLESSING`).
- Trident/Shadow/Tome/Bowfa etc. → eine Zahl.
- **Lootingsack**: Geldwert (Coins) als Zahl auf dem Sack-Icon.
- **Coal Bag**: Füllstand via Chat-Tracking + Persistenz (kein Varbit!).
**Format:** Ganzzahlen / Stack-Format (Geld: `QuantityFormatter.quantityToStackSize`, z. B. `1.2M`). Unbekannter Wert → `?`.
## Datenquellen & Mechanik
### 1. `CHARGES_*`-Varbits (Hauptquelle, charakterweit)
Werte via `client.getVarbitValue(id)` (`net.runelite.api.gameval.VarbitID`). Mapping Item-Gruppe → Varbit (verifiziert, Release 1.12.39). Varbit aus der **ID-Variante** des Items ableiten (s. Quelle 2), nicht aus einem Katalog — zwei identische Waffen haben nur EINEN Varbit-Wert (OSRS-Grenze!).
| Item-Gruppe (ItemID-Konstanten) | VarbitID-Konstante |
|---|---|
| `TOXIC_BLOWPIPE`, `TOXIC_BLOWPIPE_LOADED`, `TOXIC_BLOWPIPE_ORNAMENT`, `TOXIC_BLOWPIPE_LOADED_ORNAMENT` | `CHARGES_TOXIC_BLOWPIPE_QUANTITY` |
| `SERPENTINE_HELM`, `SERPENTINE_HELM_CHARGED`, `_CYAN`, `_CYAN_CHARGED`, `_RED`, `_RED_CHARGED` | `CHARGES_SERPENTINE_HELM_QUANTITY` |
| `WARPED_SCEPTRE`, `WARPED_SCEPTRE_UNCHARGED` | `CHARGES_WARPED_SCEPTRE_QUANTITY` |
| `TUMEKENS_SHADOW`, `TUMEKENS_SHADOW_UNCHARGED`, `TUMEKENS_HEKA`, `TUMEKENS_HEKA_UNCHARGED` | `CHARGES_TUMEKENS_SHADOW_QUANTITY` |
| `SANGUINESTI_STAFF`, `SANGUINESTI_STAFF_UNCHARGED`, `_OR`, `_UNCHARGED_OR` | `CHARGES_SANGUINESTI_STAFF_QUANTITY` |
| `TRIDENT_OF_THE_SEAS` (+ `_E`), jew. gecharge/un-gecharge-Varianten | `CHARGES_TRIDENT_OF_THE_SEAS_QUANTITY` bzw. `..._E_QUANTITY` |
| `TOXIC_STAFF_OF_THE_DEAD` (+ `_UNCHARGED`, `_OR`-Varianten) | `CHARGES_TOXIC_STAFF_OF_THE_DEAD_QUANTITY` |
| `BOW_OF_FAERDHINEN`, `BOW_OF_FAERDHINEN_INACTIVE`, `_INFINITE*` | `CHARGES_BOW_OF_FAERDHINEN_QUANTITY` (+ `CHARGES_BOW_OF_FAERDHINEN_CORRUPTION` für korrupte Variante) |
| `BLADE_OF_SAELDOR`, `BLADE_OF_SAELDOR_INACTIVE`, `_INFINITE` | `CHARGES_BLADE_OF_SAELDOR_QUANTITY` (+ `_CORRUPTION`) |
| `VENATOR_BOW`, `VENATOR_BOW_UNCHARGED`, `VENATOR_BOW_ORNAMENT(_UNCHARGED)` | `CHARGES_VENATOR_BOW_QUANTITY` |
| `TONALZTICS_OF_RALOS_CHARGED`, `_UNCHARGED` | `CHARGES_TONALZTICS_OF_RALOS_QUANTITY` |
| `ARCLIGHT` | `CHARGES_ARCLIGHT_QUANTITY` |
| `DIZANAS_QUIVER_CHARGED`, `_UNCHARGED`, `_INFINITE`, `_BROKEN`, Trouver-Varianten | `CHARGES_DIZANAS_QUIVER_QUANTITY` (Ammo) + `CHARGES_DIZANAS_QUIVER_BLESSING` (Blessing) — zwei Zahlen |
| `TOME_OF_FIRE`, `TOME_OF_FIRE_UNCHARGED` | `CHARGES_TOME_OF_FIRE_QUANTITY` |
| `TOME_OF_WATER`, `TOME_OF_WATER_UNCHARGED` | `CHARGES_TOME_OF_WATER_QUANTITY` |
| `TOME_OF_EARTH`, `TOME_OF_EARTH_UNCHARGED` | `CHARGES_TOME_OF_EARTH_QUANTITY` |
| `ASH_SANCTIFIER` | `CHARGES_ASH_SANCTIFIER_QUANTITY` |
| `BONECRUSHER` | `CHARGES_BONECRUSHER_QUANTITY` |
| `BRYOPHYTAS_STAFF` | `CHARGES_BRYOPHYTAS_STAFF_QUANTITY` |
| `CIRCLET_OF_WATER` | `CHARGES_CIRCLET_OF_WATER_QUANTITY` |
| `SOUL_BEARER` | `CHARGES_SOUL_BEARER_QUANTITY` |
| `CELESTIAL_RING`, `CELESTIAL_RING_CHARGED` | `CHARGES_CELESTIAL_RING_QUANTITY` |
| `WILD_CAVE_SCEPTRE_CHARGED`, `WILD_CAVE_SCEPTRE_UNCHARGED` | `CHARGES_WILDERNESS_WEAPON_QUANTITY` |
| `BRACELET_OF_ETHEREUM` | `CHARGES_BRACELET_OF_ETHEREUM_QUANTITY` |
| `RING_OF_SUFFERING` | `CHARGES_RING_OF_SUFFERING_QUANTITY` |
| `XERIC_TALISMAN`, `XERIC_TALISMAN_EMPTY` | `CHARGES_XERICS_TALISMAN_QUANTITY` |
| `RING_OF_SHADOWS`, `RING_OF_SHADOWS_UNCHARGED` | `CHARGES_RING_OF_SHADOWS_QUANTITY` |
| `ECHO_BOOTS` | `CHARGES_ECHO_BOOTS_QUANTITY` |
| `AMULET_OF_BLOOD_FURY` | `CHARGES_AMULET_OF_BLOOD_FURY_QUANTITY` |
| `GIANTSOUL_AMULET_CHARGED`, `_UNCHARGED` | `CHARGES_GIANTSOUL_AMULET_QUANTITY` |
| `EYE_OF_AYAK`, `EYE_OF_AYAK_UNCHARGED` | `CHARGES_EYE_OF_AYAK_QUANTITY` |
| `HORN_OF_PLENTY`, `_UNCHARGED`, `SAILORS_AMULET`, `_EMPTY`, `COWBELL_AMULET`, `_EMPTY`, `PENDANT_OF_ATES`, `_EMPTY` | jew. `CHARGES_*_QUANTITY`-Varbit |
| Crystal Armour/Tools (Crystal, `CRYSTAL_*` piece IDs) | `CHARGES_CRYSTAL_ARMOUR_QUANTITY`, `CHARGES_CRYSTAL_TOOLS_QUANTITY`, `CHARGES_CRYSTAL_WEAK_QUANTITY` |
| `ALCHEMIST_RING` | `CHARGES_ALCHEMIST_AMULET_QUANTITY` |
Vollständige Liste ergänzen per `javap -constants net.runelite.api.gameval.VarbitID | grep 'CHARGES_.*_QUANTITY'` gegen das runelite-api-Jar (release 1.12.39; Pfad s. **Referenzen**). Das sind ~55 Konstanten; es kommen auch Solo-/Sailing-Charges vor (`CHARGES_PLANK_SACK*`, `CHARGES_BOWSTRING_SPOOL*`), nur relevante übernehmen. Stichproben-Hinweis: `DBTableID$Charges$Row` (in runelite-api `net.runelite.api.gameval.DBTableID`) listet dieselben Charge-Typen als DB-Rows (Referenz/Querschnitt, nicht zwingend zur Laufzeit nötig).
Varianten-Ableitung für die Tabelle: Einträge in `net.runelite.api.gameval.ItemID` mit typischen Suffixen (`_CHARGED`, `_UNCHARGED`, `_INACTIVE`, `_EMPTY`, `_LOADED`, `_OR`, `_CYAN/_RED`, `_TROUVER`, `_INFINITE`, `_BROKEN`) — per `javap -constants ... ItemID | grep` verifizieren.
### 2. Item-ID-Statusvarianten (Ladungs-Status)
Viele Items wechseln die **Item-ID** bei leer/laden — mehrere exakte IDs auf dieselbe Charge abbilden bzw. „0"-Semantik:
- `TOXIC_BLOWPIPE` (12924) vs `TOXIC_BLOWPIPE_LOADED` (12926): **Darts geladen: ja/nein** (zweiter Wert, Marker; die exakte **Dart-Anzahl ist NICHT in der API** — kein Varbit/Container). `DRYGORE_BLOWPIPE` vs `DRYGORE_BLOWPIPE_LOADED` analog.
- `BOW_OF_FAERDHINEN_INACTIVE` → 0; `BLADE_OF_SAELDOR_INACTIVE` → 0; `*_INFINITE` → „∞".
- Alle `*_UNCHARGED`/`*_EMPTY` → 0 bzw. kein Varbit abfragen.
### 3. Container (Echtzeit; per GameTick gelesen)
- **Lootingsack**: `client.getItemContainer(InventoryID.LOOTING_BAG)` (auch `LOOTING_BAG_OPEN`). Geldwert = Σ qty von `ItemID.COINS` im Container. (Plugin hat bereits `readContainer()`/WealthStore für Loot — hier eigenes Lesen im Overlay-Tick, da das Overlay nicht an den Container-Event gebunden ist.)
- **Dizan's Quiver Ammo**: `InventoryID.DIZANAS_QUIVER_AMMO` als Crosscheck/Geld-Anzeige zum Varbit-Wert möglich.
- **Coal/Gem/Herb sack**: kein Container, kein Varbit (verifiziert) → Quelle 4.
### 4. Coal Bag — Chat-Tracking + Persistenz
- Kein Varbit/Container in `VarbitID`/`InventoryID` (verifiziert; nur `ItemID.COAL_BAG`/`COAL_BAG_OPEN`).
- Füllstand (max **27**) ableiten aus **Chat-Nachrichten** + **letzten bekannten Wert pro Account** in `configManager` persistieren (key `coalBag.<accountKey>`, Wert int 0–27; `accountKey`-Konvention wie bestehende Persistenz im Plugin).
- Zu verarbeitende Muster (ChatMessage-Filter `Game`): Check-/Fill-Feedback „[…]`X` coal in your coal bag]" o. Ä. *exakt in-game verifizieren* (2024 wurde Feedback von Popups auf Chat umgestellt); zusätzlich Empty → 0; bei unbekannter Nachricht/Login ohne Check → **`?`** anzeigen statt falscher Zahl.
- `ChatMessage`-Events: Fehler-tolerant parsen (Regex), bei Update sofort speichern. Beim `LOGOUT` aktuellen Wert speichern. Optional Rechtsklick „Coal bag vergessen" → Wert nicht aus Chat-Nachrichten ableiten.
## Neue Dateien
### `helpers/ItemCharges.java` (`@Singleton`, Logik)
- **Datenmodell**: `ChargeValue { String text; Color color; }` (+ optional 2. Wert). Lookup-API `List<ChargeValue> chargesFor(int itemId)`:
1. exakte `itemId` in Tabelle (Load/Empty/Inactive-Varianten); dann
2. `itemManager.canonicalize(itemId)` (Noten/Placeholder) erneut prüfen.
- **`CHARGE_MAP`**: `Map<Integer itemId, ChargeSource>` statisch aufgebaut aus den ItemID-Konstanten (Quelle 1+2); `ChargeSource` = Varbit-Konstante bzw. `COAL_BAG`-Typ bzw. `LOOTING_BAG`-Container-Typ.
- **State**: Coal-Bag-Count pro Account + `Map<Integer, ...>`-Cache nur so weit nötig (Varbit-Lesen ist billig; kein harter Cache zwingend).
- **Events** (im Plugin registriert, Muster `TreeCooldowns`):
- `GameTick` → die zwei Bag-Varbits/Container aktualisieren.
- `VarbitChanged` → relevante Varbit-Id → Value merken.
- `ChatMessage` → Coal-Bag-Messages parsen (s. o.), speichern.
- `GameStateChanged` → `!= LOGGED_IN` → `reset()`, Coal-Bag-Count aus `configManager` laden bzw. auf „?" zurücksetzen.
- **Reset**: Coal-Bag-Cache auf `?` beim Logout (Persistenz bleibt).
### `helpers/ItemChargesOverlay.java` (`WidgetItemOverlay`)
- `showOnInventory(); showOnEquipment(); showOnBank();` — Bank über Config-Sub-Key steuerbar (s. unten).
- `renderItemOverlay(g, itemId, WidgetItem)`:
- `if (!config.itemCharges()) return;`
- `List<ChargeValue>` aus `chargesFor(itemId)`; leer → nichts rendern.
- Zahl unten-links am Icon (offizielles RuneLite-Muster): `bounds.x - 1, bounds.y + 15`, mit Schatten (`Fonts.RUNESCAPE_SMALL` bzw. `TimerTextSize`), Farbe aus `itemChargesColor`; **2. Wert** (Blowpipe-Darts, Quiver-Blessing) darüber (`bounds.y - 3`) in `itemChargesSecondaryColor`.
- Geld (Lootingsack): Stack-Size-Format; Low-Charge-Färbung ab `itemChargesLowAt` → `itemChargesLowColor`.
- Coins/Loot-Bag-Wert: aus `chargesFor(LOOTING_BAG_OPEN/LOOTING_BAG)`-Container-Lesung im Overlay-Tick (Falls Container null → kein Wert).
## Zu ändernde Dateien
- **`EssentialsPlusConfig.java`**: `@ConfigItem itemCharges` (bool, default `false`, position ~37). Optionen als `configManager`-Keys via `setHelperOption` (Muster wie treeCooldowns):
- `itemChargesWeapons` (bool, true) — Varbit-Charges an/aus
- `itemChargesBags` (bool, true) — Loot-Bag-Geld + Coal-Bag-Füllstand an/aus
- `itemChargesBank` (bool, false) — Overlay auch in Bank (Achtung Hinweise!)
- `itemChargesSize` (`TimerTextSize`, SMALL), `itemChargesColor` (Color, weiß), `itemChargesSecondaryColor` (Color, hellgrau `0xC0C0C0`), `itemChargesLowColor` (Color, rot `0xFF3333`), `itemChargesLowAt` (int, 0 = aus)
- **`EssentialsPlusPlugin.java`**: `@Inject ItemCharges` + `ItemChargesOverlay`; `startUp()`: `eventBus.register(itemCharges)`, `overlayManager.add(itemChargesOverlay)`; `shutDown()`: spiegeln + `itemCharges.reset()`. `isHelperOption()`: `case "itemCharges"` → `config.itemCharges()`. `setHelperOption`/`onConfigChanged`: Bestände für `itemcharges*`-Keys refresh (Muster solcher Bestands-Abschnitte).
- **`HelpersCard.java`**: neues `JCheckBox "Item charges"` (Tooltip: „Charge-Zahl auf Item-Icons (Blowpipe, Trident, Tome, …) + Loot-Bag-Geld & Coal-Bag-Füllstand. Rechtsklick für Optionen"). Rechtsklick-Menü analog `styleMenu`/`treeMenu`: Toggles `itemChargesWeapons`/`itemChargesBags`/`itemChargesBank`, Größe, Farben, Schwellenwert, Reset. `refresh()` erweitern.
## Verifikation
- `./gradlew compileJava` (bzw. `./gradlew deploy` für Bolt).
- Manuell: Blowpipe mit Scales (nur Scales-Zahl) → mit Darts laden (2. Zahl erscheint); Trident/Shadow/Sang/Tome (Zahl); Lootingsack mit Coins betreten (Geld auf Sack-Icon); Coal Bag füllen → Zahl steigt, Empty → 0, Relog → Wert bleibt; Bank öffnen mit `itemChargesBank` → Zahlen auch dort; beide Blowpipes (leer + geladen) im Inventar → korrekte per-ID-Darstellung.
## Hinweise / Einschränkungen
- `CHARGES_*`-Varbits sind **charakterweit, nicht pro Item-Exemplar**: zwei unterschiedlich geladene Tridents zeigen denselben Wert (OSRS-Systemgrenze, deckungsgleich mit dem In-Game-Charge-Overlay).
- **Bank-Anzeige** dementsprechend unzuverlässig für „unbenutzte" Kopien; default daher `itemChargesBank = false`.
- Coal Bag: nur Heuristik (Chat + Persistenz), kein API-Wert; nach Login unbekannt → `?`.
- Blowpipe: Dart-**Anzahl** nicht in der API → 2. Wert ist Darts-geladen-Marker, keine Zahl.
- Keine Chat-Popup-abhängigkeit (Popup = ist abgeschaltet hat), ausschließlich ChatMessage-Events verwenden.
## Referenzen
- `tree-cooldown.md` (gleiche Struktur), `reflect.txt`/Cachierte Jars für Konstanten:
- runelite-api: `/home/hikyu/.gradle/caches/modules-2/files-2.1/net.runelite/runelite-api/1.12.39/<hash>/runelite-api-1.12.39.jar` → `net/runelite/api/gameval/{VarbitID,ItemID,InventoryID,DBTableID$Charges$Row}`
- client: `/home/hikyu/.gradle/caches/modules-2/files-2.1/net.runelite/client/1.12.39/<hash>/client-1.12.39.jar` → `net.runelite.client.ui.overlay.WidgetItemOverlay`, `components.TextComponent`, `ui.FontManager`
- Konventionen: `helpers/TreeCooldowns.java` (Logik+Events), `helpers/TreeCooldownOverlay.java`/`ItemPriceOverlay.java` (Overlay-Zeichnung), `HelpersCard.java` (Checkbox+Menü), `EssentialsPlusPlugin.isHelperOption/setHelperOption/onConfigChanged`.
-94
View File
@@ -1,94 +0,0 @@
# Skill "Reset Hourly" – Taskbeschreibung
Implementiere ein **Rechtsklick-Feature „Reset Hourly"** in **Essentials Plus**. Es erscheint an zwei Stellen:
1. **In-Game-XP-HUD** (`skills/SkillsHudOverlay.java`): Rechtsklick auf das Overlay zeigt den Menüeintrag „Reset Hourly".
2. **Skills-Karte** im Seitenpanel: auf der **Overview-Karte** als Eintrag im bestehenden Karten-Rechtsklick-Menü und im **Skills-Tab** als eigenes Rechtsklick-Popup auf der Karte.
Klick setzt die **aktuell angezeigte XP-Zählung** zurück. Es wirkt **nur**, wenn der global aktive Zeitraum `Session` oder `Now` ist. Alle anderen Zeiträume (`1H`, `1D`, `2W`, `3M`, `All`) bleiben unberührt – insbesondere bleibt der **Gesamt-XP-Wert (`All`) erhalten**, weil er aus der ersten Sample-Spur abgeleitet wird.
**Display-Sprache:** Deutsch (Menütext trotzdem exakt `Reset Hourly`).
## Hintergrund / Datenmodell
- `skills/SkillHistory.java` speichert **absolute** XP-Samples pro Skill mit Zeitstempel (`XpSample`, `List<XpSample> samples`), ein File pro Charakter (JSON in `essentials-plus/skills`).
- `ui/TimeFilter.java` + `ui/TimeRange.java` liefern den global geteilten Zeitraum: `CURRENT("Now")`, `SESSION("Sess")`, `HOUR("1H")`, `DAY`, `TWO_WEEKS`, `QUARTER`, `ALL`.
- Gewinne werden als `latest - at(start)` berechnet; der Start kommt aus `SkillHistory.start(filter)`, `starts(filter)` und `gained(filter)`.
- Es gibt **noch kein** Reset-/Baseline-Feld. Der Reset wird als **Zeitmarker** umgesetzt, nicht durch Löschen von Samples.
## Neue Dateien
### `skills/SkillHistory.java` (erweitern, keine neue Datei)
- Zwei persistente Marker ergänzen (0 = nie zurückgesetzt):
- `public long resetSessionAt;`
- `public long resetNowAt;`
- `public synchronized void reset(TimeRange range)`:
- `SESSION` → `resetSessionAt = System.currentTimeMillis()`
- `CURRENT` → `resetNowAt = System.currentTimeMillis()`
- sonst → no-op
- `dirty = true` (Persistenz-Flag, existiert bereits)
- Startlogik **nur** für die betroffenen Ranges clampen:
- `starts(TimeFilter)`: bei `SESSION` für jeden Skill `Math.max(filter.start(), resetSessionAt)`; bei `CURRENT` pro Skill `Math.max(currentStart(i, now), resetNowAt)`, idle (`Long.MAX_VALUE`) bleibt idle.
- `start(TimeFilter)`: bei `SESSION` `Math.max(filter.start(), resetSessionAt)`; bei `CURRENT` nach `currentStart(-1, now)` clampen (Fallback `now` wenn idle).
- `gained(TimeFilter)`: Nicht-Current-Zweig von `gained(filter.start())` auf `gained(start(filter))` umstellen, damit der Session-Clamp greift. Current-Zweig nutzt bereits `starts(filter)`.
- Da `range(long)`, `levelUps(long)` und `activeMillis(long, int)` ihren Start aus `start(filter)` bekommen, ziehen Charts, Stats/„XP per hour" und Level-Ups automatisch mit.
- `normalize()` prüft nur `skills`/`samples`; die neuen Felder brauchen keine Migration (fehlend = 0 = nie zurückgesetzt).
### `skills/SkillStore.java` (erweitern)
- `public void resetHourly(String key, TimeRange range)`: History per `histories.get(key)` holen; wenn vorhanden `h.reset(range)` und `save(h)` aufrufen.
- Wird vom Plugin auf dem Executor aufgerufen (File-I/O), nicht auf dem Swing-/Client-Thread.
## Zu ändernde Dateien
### `skills/SkillsHudOverlay.java`
- Im Konstruktor: `addMenuEntry(net.runelite.api.MenuAction.RUNELITE_OVERLAY, "Reset Hourly", "Skills")`.
- Die Render-Logik nutzt bereits `history.starts(filter)`/`gained(filter)` → greift den Reset automatisch, **kein** Render-Umbau nötig.
- Verifikation der API (RuneLite 1.12.39): `Overlay.addMenuEntry(MenuAction, option, target)` und die 4-arg-Variante mit `Consumer<MenuEntry>` existieren; `OverlayRenderer` injiziert den Callback in `MenuEntry.onClick(...)` **und** feuert `OverlayMenuClicked`.
### `EssentialsPlusPlugin.java`
- Import `net.runelite.client.events.OverlayMenuClicked` (+ `net.runelite.client.ui.overlay.Overlay` falls nötig).
- `public void resetSkillsHourly(String key)`:
- `if (key == null) return;`
- `executor.execute(() -> skillStore.resetHourly(key, timeFilter.getRange()));`
- danach Panel-Refresh: `EssentialsPlusPanel p = panel; if (p != null) p.refreshSkillsLater();`
- `@Subscribe public void onOverlayMenuClicked(OverlayMenuClicked event)`:
- wenn `event.getOverlay() == skillsHudOverlay` und `"Reset Hourly".equals(event.getEntry().getOption())` → `resetSkillsHourly(skillKey)`.
- `getSkillStore()`, `getSkillKey()`, `getTimeFilter()` sind vorhandene Lombok-Getter; `skillsHudOverlay` ist injiziert (`EssentialsPlusPlugin.java:232`).
- Alternativ den `Consumer<MenuEntry>` der 4-arg-`addMenuEntry` nutzen; dann ist kein `OverlayMenuClicked`-Subscriber nötig (beides funktioniert). Empfehlung: Subscriber, damit die Aktion im Plugin kapselt bleibt.
### `ui/EssentialsPlusPanel.java` (Overview-Karte)
- In `menu(String id)` im `case SKILLS` (ca. Zeile 560) einen Eintrag ergänzen:
- `JMenuItem reset = new JMenuItem("Reset Hourly");`
- Action: Character über `SkillsPanel.resolve(plugin.getSkillStore(), null, plugin.getSkillKey())` auflösen; wenn nicht null → `plugin.resetSkillsHourly(h.key);` und danach `refreshSkills();`.
- **Kein** zweites Popup auf der Overview-Karte installieren: `CardDrag` (`ui/CardDrag.java`) konsumiert dort bereits Rechtsklick und öffnet `menu(id)` – sonst gäbe es zwei Menüs.
### `ui/SkillsPanel.java` (Skills-Tab)
- Nach `private final SkillsCard card = new SkillsCard(null);` (Zeile 61) im Konstruktor einen `MouseAdapter` auf `card` installieren:
- bei `SwingUtilities.isRightMouseButton(e)` (oder `e.isPopupTrigger()`) ein `JPopupMenu` mit `JMenuItem "Reset Hourly"` bauen und `menu.show(card, e.getX(), e.getY())` zeigen.
- Action: `plugin.resetSkillsHourly(shownKey);` danach `refresh();` (bzw. `refreshLater()`).
- Im Skills-Tab ist **keine** `CardDrag` installiert, daher kollidiert das Popup nicht.
## Verifikation
- `./gradlew test` (bestehende Tests + neuer Test unten) und `./gradlew build`.
- Neuer Test in `src/test/java/com/example/essentialsplus/SkillHistoryTest.java` (Muster: `record` per Reflection, package-private):
- Samples über ca. eine Stunde anlegen, dann `h.reset(TimeRange.CURRENT)` bzw. `h.reset(TimeRange.SESSION)`.
- Erwartung: `gained(filter)` für den betroffenen Range startet neu (Baseline = Sample bei `resetAt`); `gained(TimeRange.ALL)` bleibt unverändert; `1H`/`1D` bleiben unverändert.
- Manuell:
- Filter auf **Sess** oder **Now** stellen → Rechtsklick aufs XP-HUD → „Reset Hourly" → `XP gained` zählt ab 0 neu; Gesamt-XP (`All`) unverändert.
- Filter auf `1H`/`1D`/`All` stellen → „Reset Hourly" ändert die Anzeige nicht.
- Overview-Skills-Karte rechtsklicken → „Reset Hourly"; Skills-Tab-Karte rechtsklicken → „Reset Hourly".
- Nach Reset Datei `essentials-plus/skills/<key>.json` prüfen (`resetSessionAt`/`resetNowAt` gesetzt); Relog → Session wird ohnehin vom neuen Login übersteuert, `Now`-Marker bleibt gespeichert.
## Hinweise / Einschränkungen
- „Reset Hourly" wirkt bewusst **nur** auf `Session` und `Now`; bei `1H`/`1D`/`2W`/`3M`/`All` passiert nichts (`TimeRange`-Vergleich im `reset(...)`).
- Der Gesamtwert (`All`) bleibt in jedem Fall erhalten, da er aus der ersten Sample-Spur (`samples.get(0)`) berechnet wird und der Reset nur einen Start-Clamp setzt.
- Marker sind pro Charakter im `SkillHistory`-JSON persistiert; ein fehlendes Feld nach älterem Stand bedeutet 0 (nie zurückgesetzt) und ist damit abwärtskompatibel.
- `record(...)` bleibt unverändert: Es fügt weiterhin Samples hinzu; der Reset löscht keine Historie.
+1 -1
View File
@@ -10,7 +10,7 @@ public interface ExampleConfig extends Config
{
@ConfigSection(
name = "General Settings",
description = "Allgemeine Einstellungen für das Plugin",
description = "General plugin settings.",
position = 0
)
String generalSection = "generalSection";
+1 -1
View File
@@ -35,7 +35,7 @@ import net.runelite.client.util.ImageUtil;
@Slf4j
@PluginDescriptor(
name = "Bank Highlighter / Auto-Profile",
description = "Aktiviert automatisch Profile, Item-Highlights, 3D-Objekte, Boden-Tiles und Pfade",
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
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -13,6 +13,7 @@ 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;
/**
@@ -36,17 +37,21 @@ final class GrandExchangeTracker
/** 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;
}
/**
@@ -106,7 +111,14 @@ final class GrandExchangeTracker
}
boolean seeded = migrate;
boolean collecting = isOpen(client, InterfaceID.GE_OFFERS) || isOpen(client, InterfaceID.GE_COLLECT) || isOpen(client, InterfaceID.BANKMAIN);
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++)
{
@@ -155,6 +167,10 @@ final class GrandExchangeTracker
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
@@ -185,6 +201,52 @@ final class GrandExchangeTracker
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]);
@@ -312,7 +374,21 @@ final class GrandExchangeTracker
private static boolean isOpen(Client client, int group)
{
Widget w = client.getWidget(group, 0);
return w != null && !w.isHidden();
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;
}
}
@@ -130,7 +130,7 @@ public class CurrencyHistory
{
return out;
}
long start = filter.getRange() == TimeRange.CURRENT
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--)
@@ -49,16 +49,16 @@ public class AccountData
public static class GeBuyLimitSlot
{
public int itemId;
public int price;
public long price;
public int total;
public int quantity;
public int spent;
public long spent;
public GeBuyLimitSlot()
{
}
public GeBuyLimitSlot(int itemId, int price, int total, int quantity, int spent)
public GeBuyLimitSlot(int itemId, long price, int total, int quantity, long spent)
{
this.itemId = itemId;
this.price = price;
@@ -302,6 +302,82 @@ public class AccountData
|| 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).
@@ -371,10 +447,88 @@ public class AccountData
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;
/**
@@ -459,4 +613,3 @@ public class AccountData
}
}
}
@@ -8,15 +8,15 @@ public class GeRow
/** null = empty slot */
public final GeSlot offer;
public final String name;
public final int marketPrice;
public final long marketPrice;
public final GeLimitInfo limitInfo;
public GeRow(String character, int slot, GeSlot offer, String name, int marketPrice)
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, int marketPrice, GeLimitInfo limitInfo)
public GeRow(String character, int slot, GeSlot offer, String name, long marketPrice, GeLimitInfo limitInfo)
{
this.character = character;
this.slot = slot;
@@ -27,4 +27,9 @@ public class GeSlot
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;
}
@@ -11,7 +11,7 @@ public class ItemSnapshot
public long t;
public int[] ids;
public long[] q;
public int[] p;
public long[] p;
/** pinned (session start) - kept by compaction */
public Boolean k;
@@ -19,7 +19,7 @@ public class ItemSnapshot
{
}
public ItemSnapshot(long t, int[] ids, long[] q, int[] p)
public ItemSnapshot(long t, int[] ids, long[] q, long[] p)
{
this.t = t;
this.ids = ids;
@@ -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);
}
}
}
@@ -6,6 +6,7 @@ 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;
@@ -17,15 +18,18 @@ 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.ComponentID;
import net.runelite.api.widgets.Widget;
import net.runelite.api.widgets.WidgetUtil;
import net.runelite.client.callback.ClientThread;
@@ -183,10 +187,17 @@ public class ChatHelper
if (removedAny)
{
client.runScript(83);
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)
@@ -204,13 +215,22 @@ public class ChatHelper
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:
@@ -222,6 +242,8 @@ public class ChatHelper
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();
@@ -349,7 +371,16 @@ public class ChatHelper
}
}
// 3. Highlight own name
// 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);
@@ -360,6 +391,13 @@ public class ChatHelper
}
}
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)
@@ -495,18 +533,17 @@ public class ChatHelper
if (config.chatCopyToClipboard() && "Report".equals(event.getOption()))
{
Widget lineWidget = client.getWidget(interfaceId, childId);
if (lineWidget != null && lineWidget.getParent() != null && lineWidget.getParent().getId() == ComponentID.CHATBOX_MESSAGE_LINES)
if (lineWidget != null && lineWidget.getParent() != null && lineWidget.getParent().getId() == InterfaceID.Chatbox.SCROLLAREA)
{
int lineIndex = (childId - WidgetUtil.componentToId(ComponentID.CHATBOX_FRAME)) * 4 + 1;
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)
{
String fullText = textWidget.getText();
client.createMenuEntry(1)
.setOption("Copy message")
.setTarget(event.getTarget())
.setType(MenuAction.RUNELITE)
.onClick(e -> copyToClipboard(fullText));
addCopyEntry(textWidget.getText(), event.getTarget());
}
}
}
@@ -522,7 +559,73 @@ public class ChatHelper
}
}
private void copyToClipboard(String text)
/** 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
{
@@ -572,7 +675,8 @@ public class ChatHelper
}
}
client.runScript(83);
reset();
refreshChat();
});
}
}
@@ -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
@@ -41,17 +41,23 @@ public class ItemChargesOverlay extends WidgetItemOverlay
@Override
public void renderItemOverlay(Graphics2D g, int itemId, WidgetItem widgetItem)
{
if (!config.itemCharges())
if (!itemCharges.isEnabled())
{
return;
}
// Check if on bank and if bank display is allowed
boolean showBank = plugin.getHelperValue("itemChargesBank", Boolean.class, true);
if (!showBank && widgetItem.getWidget() != null)
// 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 (group == net.runelite.api.gameval.InterfaceID.BANKMAIN || group == 12) // 12 = InterfaceID.BANK
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;
}
@@ -65,7 +71,8 @@ public class ItemChargesOverlay extends WidgetItemOverlay
g.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON);
TimerTextSize size = plugin.getHelperValue("itemChargesSize", TimerTextSize.class, TimerTextSize.SMALL);
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);
@@ -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";
}
}
@@ -1,114 +1,24 @@
package com.example.essentialsplus.helpers;
import com.example.essentialsplus.EssentialsPlusConfig;
import java.awt.Color;
import java.awt.Dimension;
import java.awt.Graphics2D;
import com.example.essentialsplus.EssentialsPlusPlugin;
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.ItemID;
import net.runelite.api.widgets.Widget;
import net.runelite.api.widgets.WidgetUtil;
import net.runelite.client.game.ItemManager;
import net.runelite.client.ui.overlay.Overlay;
import net.runelite.client.ui.overlay.OverlayLayer;
import net.runelite.client.ui.overlay.OverlayPosition;
import net.runelite.client.ui.overlay.tooltip.Tooltip;
import net.runelite.client.ui.overlay.tooltip.TooltipManager;
import net.runelite.client.util.ColorUtil;
import net.runelite.client.util.QuantityFormatter;
/** Tooltip line with the GE and/or high alch value of the inventory item under the mouse. */
public class ItemPriceOverlay extends Overlay
/**
* Legacy wrapper for {@link ItemInformationOverlay}.
*/
@Deprecated
@Singleton
public class ItemPriceOverlay extends ItemInformationOverlay
{
private static final int INVENTORY = 149;
private static final int BANK_INVENTORY = 15;
private static final Color GE = new Color(0xFFDC33);
private static final Color HA = new Color(0xFF9040);
private final Client client;
private final EssentialsPlusConfig config;
private final ItemManager itemManager;
private final TooltipManager tooltipManager;
@Inject
ItemPriceOverlay(Client client, EssentialsPlusConfig config, ItemManager itemManager, TooltipManager tooltipManager)
ItemPriceOverlay(Client client, EssentialsPlusConfig config, EssentialsPlusPlugin plugin,
ItemManager itemManager, TooltipManager tooltipManager)
{
this.client = client;
this.config = config;
this.itemManager = itemManager;
this.tooltipManager = tooltipManager;
setPosition(OverlayPosition.DYNAMIC);
setLayer(OverlayLayer.ABOVE_WIDGETS);
}
@Override
public Dimension render(Graphics2D g)
{
if (!config.itemPrices() || (!config.itemPricesGe() && !config.itemPricesHa()) || client.isMenuOpen())
{
return null;
}
MenuEntry[] entries = client.getMenuEntries();
if (entries.length == 0)
{
return null;
}
Widget w = entries[entries.length - 1].getWidget();
if (w == null)
{
return null;
}
int group = WidgetUtil.componentToInterface(w.getId());
if (group != INVENTORY && group != BANK_INVENTORY)
{
return null;
}
int id = w.getItemId();
if (id <= 0 || id == ItemID.COINS || id == ItemID.PLATINUM)
{
return null;
}
long qty = Math.max(1, w.getItemQuantity());
String text = text(itemManager.canonicalize(id), qty);
if (!text.isEmpty())
{
tooltipManager.add(new Tooltip(text));
}
return null;
}
private String text(int id, long qty)
{
StringBuilder sb = new StringBuilder();
if (config.itemPricesGe())
{
long ge = (long) itemManager.getItemPrice(id) * qty;
if (ge > 0)
{
sb.append("GE: ").append(ColorUtil.wrapWithColorTag(gp(ge), GE));
}
}
if (config.itemPricesHa())
{
ItemComposition comp = itemManager.getItemComposition(id);
long ha = comp == null ? 0 : (long) comp.getHaPrice() * qty;
if (ha > 0)
{
if (sb.length() > 0)
{
sb.append(" ");
}
sb.append("HA: ").append(ColorUtil.wrapWithColorTag(gp(ha), HA));
}
}
return sb.toString();
}
private static String gp(long v)
{
return QuantityFormatter.quantityToStackSize(v) + " gp";
super(client, config, plugin, itemManager, tooltipManager);
}
}
@@ -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.
}
}
}
}
}
@@ -54,7 +54,7 @@ public class LootRecord
this.lastSeen = this.firstSeen;
}
public synchronized void addDrop(int itemId, String itemName, int quantity, int gePrice)
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);
@@ -175,7 +175,7 @@ public class LootTrackerStore
int qty = item.getQuantity();
String name = "Item " + id;
int gePrice = 0;
long gePrice = 0;
if (itemManager != null)
{
ItemComposition comp = itemManager.getItemComposition(id);
@@ -870,7 +870,7 @@ public class CombatTracker
String name = comp.getName();
String lower = name.toLowerCase(Locale.ENGLISH);
int price = Math.max(0, itemManager.getItemPrice(itemId));
long price = Math.max(0, itemManager.getItemPrice(itemId));
// Check inventory actions for "Eat" or "Drink"
String[] actions = comp.getInventoryActions();
@@ -0,0 +1,505 @@
package com.example.essentialsplus.player;
import java.io.IOException;
import java.text.NumberFormat;
import java.awt.image.BufferedImage;
import java.time.Instant;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import javax.inject.Inject;
import javax.inject.Singleton;
import net.runelite.api.ItemComposition;
import net.runelite.api.Player;
import net.runelite.api.PlayerComposition;
import net.runelite.api.kit.KitType;
import net.runelite.client.game.ItemManager;
import net.runelite.client.game.SkillIconManager;
import net.runelite.client.hiscore.HiscoreEndpoint;
import net.runelite.client.hiscore.HiscoreManager;
import net.runelite.client.hiscore.HiscoreResult;
import net.runelite.client.hiscore.HiscoreSkill;
import net.runelite.client.hiscore.HiscoreSkillType;
import net.runelite.client.hiscore.Skill;
/** Captures visible player equipment and formats it with public hiscore data. */
@Singleton
public class PlayerEquipmentFeature
{
private static final HiscoreEndpoint[] HISCORE_ENDPOINTS = {
HiscoreEndpoint.NORMAL,
HiscoreEndpoint.IRONMAN,
HiscoreEndpoint.HARDCORE_IRONMAN,
HiscoreEndpoint.ULTIMATE_IRONMAN,
HiscoreEndpoint.DEADMAN,
HiscoreEndpoint.SEASONAL,
HiscoreEndpoint.TOURNAMENT,
HiscoreEndpoint.FRESH_START_WORLD,
HiscoreEndpoint.PURE,
HiscoreEndpoint.LEVEL_3_SKILLER
};
private static final HiscoreSkill[] SKILLS = {
HiscoreSkill.ATTACK, HiscoreSkill.STRENGTH, HiscoreSkill.DEFENCE, HiscoreSkill.RANGED,
HiscoreSkill.PRAYER, HiscoreSkill.MAGIC, HiscoreSkill.RUNECRAFT, HiscoreSkill.CONSTRUCTION,
HiscoreSkill.HITPOINTS, HiscoreSkill.AGILITY, HiscoreSkill.HERBLORE, HiscoreSkill.THIEVING,
HiscoreSkill.CRAFTING, HiscoreSkill.FLETCHING, HiscoreSkill.SLAYER, HiscoreSkill.HUNTER,
HiscoreSkill.MINING, HiscoreSkill.SMITHING, HiscoreSkill.FISHING, HiscoreSkill.COOKING,
HiscoreSkill.FIREMAKING, HiscoreSkill.WOODCUTTING, HiscoreSkill.FARMING, HiscoreSkill.SAILING
};
private static final String[] SLOT_NAMES = {
"Head", "Cape", "Amulet", "Weapon", "Body", "Shield", "Arms", "Legs", "Hair", "Hands", "Boots", "Jaw"
};
private static final DateTimeFormatter FETCHED_AT = DateTimeFormatter.ofPattern("dd.MM.yyyy HH:mm:ss", Locale.ROOT)
.withZone(ZoneId.systemDefault());
private static final String LOCAL_IMAGE_BASE = "https://essentials-plus.invalid/images/";
private final ItemManager itemManager;
private final HiscoreManager hiscoreManager;
private final SkillIconManager skillIconManager;
@Inject
public PlayerEquipmentFeature(ItemManager itemManager, HiscoreManager hiscoreManager, SkillIconManager skillIconManager)
{
this.itemManager = itemManager;
this.hiscoreManager = hiscoreManager;
this.skillIconManager = skillIconManager;
}
public Snapshot capture(Player player)
{
String name = cleanName(player.getName());
List<EquipmentItem> equipment = new ArrayList<>();
Map<String, BufferedImage> images = skillImages();
PlayerComposition composition = player.getPlayerComposition();
int[] appearance = composition == null ? null : composition.getEquipmentIds();
if (appearance != null)
{
for (KitType slot : KitType.values())
{
int index = slot.getIndex();
if (index < 0 || index >= appearance.length)
{
continue;
}
int encodedId = appearance[index];
if (encodedId < PlayerComposition.ITEM_OFFSET)
{
continue;
}
int itemId = encodedId - PlayerComposition.ITEM_OFFSET;
ItemComposition item = itemManager.getItemComposition(itemId);
if (item == null || item.getName() == null || item.getName().trim().isEmpty())
{
continue;
}
int priceId = itemManager.canonicalize(itemId);
ItemComposition pricedItem = itemManager.getItemComposition(priceId);
long ge = Math.max(0, itemManager.getItemPrice(priceId));
long ha = pricedItem == null ? 0 : Math.max(0, pricedItem.getHaPrice());
String icon = itemIcon(itemId);
images.put(icon, itemManager.getImage(itemId));
equipment.add(new EquipmentItem(slotName(slot), item.getName(), ge, ha, icon));
}
}
return new Snapshot(name, player.getCombatLevel(), player.getHealthRatio(), player.getHealthScale(), equipment, images, false);
}
/** Snapshot for a name search: public hiscores are available, but no remote equipment data is exposed. */
public Snapshot captureHiscoreTarget(String playerName)
{
return new Snapshot(cleanName(playerName), 0, -1, -1, Collections.emptyList(), skillImages(), true);
}
private Map<String, BufferedImage> skillImages()
{
Map<String, BufferedImage> images = new ConcurrentHashMap<>();
for (HiscoreSkill skill : SKILLS)
{
BufferedImage image = skillIconManager.getSkillImage(net.runelite.api.Skill.valueOf(skill.name()), true);
if (image != null)
{
images.put(skillIcon(skill), image);
}
}
return images;
}
private static String cleanName(String name)
{
String cleaned = name == null ? "" : net.runelite.client.util.Text.removeTags(name).trim();
return cleaned.isEmpty() ? "Unknown player" : cleaned;
}
public HiscoreResult lookupHiscores(String playerName) throws IOException
{
for (HiscoreEndpoint endpoint : HISCORE_ENDPOINTS)
{
HiscoreResult result = hiscoreManager.lookup(playerName, endpoint);
if (result != null && !result.getSkills().isEmpty())
{
return result;
}
}
return null;
}
public String render(Snapshot snapshot, HiscoreResult hiscores, boolean loading, boolean failed, long fetchedAtMillis,
PlayerHiscoreHistory.Comparison comparison, PlayerHiscoreHistory.Range range)
{
StringBuilder html = new StringBuilder(7000);
appendEquipment(html, snapshot);
int combatLevel = snapshot.combatLevel > 0 ? snapshot.combatLevel : combatLevelFrom(hiscores);
html.append("<h2>Player</h2><table><tr><th>Combat level</th><td>")
.append(combatLevel > 0 ? combatLevel : "—")
.append(deltaMarkup(combatLevelDelta(hiscores, comparison)))
.append("</td></tr>");
Skill hitpoints = hiscores == null ? null : hiscores.getSkill(HiscoreSkill.HITPOINTS);
int hpLevel = hitpoints == null ? 0 : hitpoints.getLevel();
html.append("<tr><th>Hitpoints level</th><td>").append(hpLevel > 0 ? hpLevel : "—")
.append(deltaMarkup(comparison == null ? null : comparison.levelDelta(HiscoreSkill.HITPOINTS))).append("</td></tr>");
String liveHealth = liveHealth(snapshot, hpLevel);
html.append("<tr><th>Current health</th><td>").append(escape(liveHealth)).append("</td></tr>");
long totalXp = 0;
int totalLevel = 0;
if (hiscores != null)
{
for (HiscoreSkill skill : SKILLS)
{
Skill value = hiscores.getSkill(skill);
if (value != null)
{
if (value.getExperience() > 0)
{
totalXp += value.getExperience();
}
if (value.getLevel() > 0)
{
totalLevel += value.getLevel();
}
}
}
Skill overall = hiscores.getSkill(HiscoreSkill.OVERALL);
if (overall != null)
{
if (overall.getExperience() >= 0)
{
totalXp = overall.getExperience();
}
if (overall.getLevel() > 0)
{
totalLevel = overall.getLevel();
}
}
}
html.append("<tr><th>Last fetched</th><td>").append(fetchedAtMillis <= 0 ? (loading ? "Loading…" : "—") : FETCHED_AT.format(Instant.ofEpochMilli(fetchedAtMillis))).append("</td></tr>")
.append("<tr><th>Changes</th><td>").append(escape(comparisonLabel(comparison, range))).append("</td></tr>")
.append("<tr><th>Total level</th><td>").append(hiscores == null ? "—" : number(totalLevel))
.append(deltaMarkup(comparison == null ? null : comparison.levelDelta(HiscoreSkill.OVERALL))).append("</td></tr>")
.append("<tr><th>Total XP</th><td>").append(hiscores == null ? "—" : number(totalXp))
.append(deltaMarkup(comparison == null ? null : comparison.experienceDelta(HiscoreSkill.OVERALL))).append("</td></tr></table>");
if (loading)
{
html.append("<p>Loading public hiscores…</p>");
}
else if (hiscores == null)
{
html.append("<p>").append(failed ? "Could not load public hiscores." : "No public hiscores found.").append("</p>");
}
else
{
html.append("<p>Skill levels and XP come from public hiscores and may be out of date.</p>")
.append("<h2>Skills</h2><table><tr><th colspan=\"2\">Skill</th><th>Level</th><th>XP</th></tr>");
for (HiscoreSkill skill : SKILLS)
{
Skill value = hiscores.getSkill(skill);
html.append("<tr><td><img src=\"").append(skillIcon(skill)).append("\" width=\"20\" height=\"20\"></td><td>")
.append(escape(skill.getName())).append("</td><td>")
.append(value == null || value.getLevel() < 0 ? "—" : number(value.getLevel()))
.append(deltaMarkup(comparison == null ? null : comparison.levelDelta(skill))).append("</td><td>")
.append(value == null || value.getExperience() < 0 ? "—" : number(value.getExperience()))
.append(deltaMarkup(comparison == null ? null : comparison.experienceDelta(skill))).append("</td></tr>");
}
html.append("</table>");
appendBossKills(html, hiscores, comparison);
}
return html.toString();
}
private static void appendEquipment(StringBuilder html, Snapshot snapshot)
{
html.append("<h2>Visible equipment</h2>");
if (snapshot.equipment.isEmpty())
{
html.append(snapshot.publicSearchOnly
? "<p>Public hiscores don't include equipment; gear is only available for players visible nearby.</p>"
: "<p>No equipment is visible in this player's model.</p>");
}
else
{
html.append("<table><tr><th>Slot</th><th colspan=\"2\">Item</th><th>GE</th><th>HA</th></tr>");
for (EquipmentItem item : snapshot.equipment)
{
html.append("<tr><td>").append(escape(item.slot)).append("</td><td><img src=\"").append(item.icon)
.append("\" width=\"24\" height=\"24\"></td><td>").append(escape(item.name))
.append("</td><td>").append(price(item.gePrice)).append("</td><td>").append(price(item.haPrice)).append("</td></tr>");
}
html.append("<tr><th colspan=\"3\">Known total</th><th>").append(price(snapshot.geTotal))
.append("</th><th>").append(price(snapshot.haTotal)).append("</th></tr></table>");
}
html.append("<p>Only visible items can be identified; rings and ammunition are not shown on the player model.</p>");
}
private static int combatLevelFrom(HiscoreResult hiscores)
{
if (hiscores == null)
{
return 0;
}
int attack = level(hiscores, HiscoreSkill.ATTACK);
int strength = level(hiscores, HiscoreSkill.STRENGTH);
int defence = level(hiscores, HiscoreSkill.DEFENCE);
int hitpoints = level(hiscores, HiscoreSkill.HITPOINTS);
int prayer = level(hiscores, HiscoreSkill.PRAYER);
int ranged = level(hiscores, HiscoreSkill.RANGED);
int magic = level(hiscores, HiscoreSkill.MAGIC);
return combatLevel(attack, strength, defence, hitpoints, prayer, ranged, magic);
}
private static int combatLevel(int attack, int strength, int defence, int hitpoints, int prayer, int ranged, int magic)
{
if (attack <= 0 || strength <= 0 || defence <= 0 || hitpoints <= 0 || prayer <= 0 || ranged <= 0 || magic <= 0)
{
return 0;
}
double base = 0.25 * (defence + hitpoints + Math.floor(prayer / 2.0));
double melee = 0.325 * (attack + strength);
double rangedCombat = 0.325 * Math.floor(ranged * 1.5);
double magicCombat = 0.325 * Math.floor(magic * 1.5);
return (int) Math.floor(base + Math.max(melee, Math.max(rangedCombat, magicCombat)));
}
private static int level(HiscoreResult hiscores, HiscoreSkill skill)
{
Skill value = hiscores.getSkill(skill);
return value == null || value.getLevel() < 0 ? 0 : value.getLevel();
}
private static Long combatLevelDelta(HiscoreResult hiscores, PlayerHiscoreHistory.Comparison comparison)
{
if (hiscores == null || comparison == null || !comparison.hasBaseline())
{
return null;
}
int attack = baselineLevel(hiscores, comparison, HiscoreSkill.ATTACK);
int strength = baselineLevel(hiscores, comparison, HiscoreSkill.STRENGTH);
int defence = baselineLevel(hiscores, comparison, HiscoreSkill.DEFENCE);
int hitpoints = baselineLevel(hiscores, comparison, HiscoreSkill.HITPOINTS);
int prayer = baselineLevel(hiscores, comparison, HiscoreSkill.PRAYER);
int ranged = baselineLevel(hiscores, comparison, HiscoreSkill.RANGED);
int magic = baselineLevel(hiscores, comparison, HiscoreSkill.MAGIC);
int baseline = combatLevel(attack, strength, defence, hitpoints, prayer, ranged, magic);
int current = combatLevelFrom(hiscores);
return baseline == 0 || current == 0 ? null : (long) current - baseline;
}
private static int baselineLevel(HiscoreResult hiscores, PlayerHiscoreHistory.Comparison comparison, HiscoreSkill skill)
{
Long delta = comparison.levelDelta(skill);
return delta == null ? 0 : level(hiscores, skill) - delta.intValue();
}
private static void appendBossKills(StringBuilder html, HiscoreResult hiscores, PlayerHiscoreHistory.Comparison comparison)
{
List<BossKill> kills = bossKills(hiscores);
html.append("<h2>Boss kills</h2>");
if (kills.isEmpty())
{
html.append("<p>No boss kills listed in public hiscores.</p>");
return;
}
html.append("<table><tr><th colspan=\"2\">Boss</th><th>Kills</th></tr>");
for (BossKill kill : kills)
{
html.append("<tr><td><img src=\"").append(bossIcon(kill.skill)).append("\" width=\"20\" height=\"20\"></td><td>")
.append(escape(kill.skill.getName())).append("</td><td>").append(number(kill.kills))
.append(deltaMarkup(comparison == null ? null : comparison.levelDelta(kill.skill))).append("</td></tr>");
}
html.append("</table>");
}
private static String comparisonLabel(PlayerHiscoreHistory.Comparison comparison, PlayerHiscoreHistory.Range range)
{
PlayerHiscoreHistory.Range selected = comparison == null ? range : comparison.range;
String label = selected == null ? PlayerHiscoreHistory.Range.LAST_VISIT.label : selected.label;
return comparison != null && comparison.hasBaseline()
? label + " since " + FETCHED_AT.format(Instant.ofEpochMilli(comparison.fromTime))
: label + (selected == PlayerHiscoreHistory.Range.LAST_VISIT
? " (no earlier snapshot yet)"
: " (no snapshot in this range)");
}
private static String deltaMarkup(Long delta)
{
if (delta == null || delta == 0)
{
return "";
}
String color = delta > 0 ? "#55D16C" : "#FF7070";
return " <font color=\"" + color + "\">(" + (delta > 0 ? "+" : "") + number(delta) + ")</font>";
}
public List<BossKill> getBossKills(HiscoreResult hiscores)
{
return bossKills(hiscores);
}
private static List<BossKill> bossKills(HiscoreResult hiscores)
{
if (hiscores == null)
{
return Collections.emptyList();
}
List<BossKill> kills = new ArrayList<>();
for (HiscoreSkill skill : HiscoreSkill.values())
{
if (skill.getType() != HiscoreSkillType.BOSS)
{
continue;
}
Skill value = hiscores.getSkill(skill);
if (value != null && value.getLevel() > 0)
{
kills.add(new BossKill(skill, value.getLevel()));
}
}
kills.sort(Comparator.comparingInt((BossKill kill) -> kill.kills).reversed()
.thenComparing(kill -> kill.skill.getName(), String.CASE_INSENSITIVE_ORDER));
return kills;
}
public static String bossIcon(HiscoreSkill skill)
{
return LOCAL_IMAGE_BASE + "boss/" + skill.name().toLowerCase(Locale.ROOT) + ".png";
}
private static String itemIcon(int itemId)
{
return LOCAL_IMAGE_BASE + "item/" + itemId + ".png";
}
private static String skillIcon(HiscoreSkill skill)
{
return LOCAL_IMAGE_BASE + "skill/" + skill.name().toLowerCase(Locale.ROOT) + ".png";
}
private static String liveHealth(Snapshot snapshot, int hitpointsLevel)
{
if (snapshot.healthRatio < 0 || snapshot.healthScale <= 0)
{
return "Unavailable";
}
if (hitpointsLevel <= 0)
{
return number(snapshot.healthRatio) + " / " + number(snapshot.healthScale) + " health-bar ratio";
}
long estimate = Math.round((double) hitpointsLevel * snapshot.healthRatio / snapshot.healthScale);
estimate = Math.max(0, Math.min(hitpointsLevel, estimate));
return "~" + number(estimate) + " / " + number(hitpointsLevel) + " HP (health-bar estimate)";
}
private static String slotName(KitType slot)
{
int index = slot.getIndex();
return index >= 0 && index < SLOT_NAMES.length ? SLOT_NAMES[index] : slot.name();
}
private static String price(long value)
{
return value <= 0 ? "—" : number(value) + " gp";
}
private static String number(long value)
{
return NumberFormat.getIntegerInstance(Locale.US).format(value);
}
private static String escape(String value)
{
return value.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace("\"", "&quot;").replace("'", "&#39;");
}
public static final class Snapshot
{
public final String name;
public final int combatLevel;
public final int healthRatio;
public final int healthScale;
public final List<EquipmentItem> equipment;
public final long geTotal;
public final long haTotal;
public final Map<String, BufferedImage> images;
public final boolean publicSearchOnly;
private Snapshot(String name, int combatLevel, int healthRatio, int healthScale, List<EquipmentItem> equipment,
Map<String, BufferedImage> images, boolean publicSearchOnly)
{
this.name = name;
this.combatLevel = combatLevel;
this.healthRatio = healthRatio;
this.healthScale = healthScale;
this.equipment = Collections.unmodifiableList(new ArrayList<>(equipment));
this.geTotal = equipment.stream().mapToLong(item -> item.gePrice).sum();
this.haTotal = equipment.stream().mapToLong(item -> item.haPrice).sum();
this.images = images;
this.publicSearchOnly = publicSearchOnly;
}
}
public static final class EquipmentItem
{
public final String slot;
public final String name;
public final long gePrice;
public final long haPrice;
public final String icon;
private EquipmentItem(String slot, String name, long gePrice, long haPrice, String icon)
{
this.slot = slot;
this.name = name;
this.gePrice = gePrice;
this.haPrice = haPrice;
this.icon = icon;
}
}
public static final class BossKill
{
public final HiscoreSkill skill;
public final int kills;
private BossKill(HiscoreSkill skill, int kills)
{
this.skill = skill;
this.kills = kills;
}
}
}
@@ -0,0 +1,362 @@
package com.example.essentialsplus.player;
import com.google.gson.Gson;
import com.google.gson.reflect.TypeToken;
import java.io.IOException;
import java.lang.reflect.Type;
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.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import javax.inject.Inject;
import javax.inject.Singleton;
import lombok.extern.slf4j.Slf4j;
import net.runelite.client.RuneLite;
import net.runelite.client.hiscore.HiscoreResult;
import net.runelite.client.hiscore.HiscoreSkill;
import net.runelite.client.hiscore.Skill;
/** Stores recent public hiscore snapshots, expiring profiles one week after their last view. */
@Slf4j
@Singleton
public class PlayerHiscoreHistory
{
private static final long DAY_MILLIS = 24L * 60L * 60L * 1000L;
private static final long RETENTION_MILLIS = 7L * DAY_MILLIS;
private static final int MAX_SAMPLES_PER_PLAYER = 256;
private static final Path FILE = RuneLite.RUNELITE_DIR.toPath()
.resolve("plugin-data")
.resolve("essentialsplus")
.resolve("player-hiscores.json");
private static final Type STORE_TYPE = new TypeToken<Map<String, PlayerRecord>>()
{
}.getType();
public enum Range
{
LAST_VISIT("Since last", 0),
DAILY("Daily", DAY_MILLIS),
WEEKLY("Weekly", RETENTION_MILLIS);
public final String label;
private final long duration;
Range(String label, long duration)
{
this.label = label;
this.duration = duration;
}
}
public static final class Comparison
{
public final Range range;
public final long fromTime;
public final long toTime;
public final Map<String, Long> levelDeltas;
public final Map<String, Long> experienceDeltas;
private Comparison(Range range, long fromTime, long toTime, Map<String, Long> levelDeltas,
Map<String, Long> experienceDeltas)
{
this.range = range;
this.fromTime = fromTime;
this.toTime = toTime;
this.levelDeltas = Collections.unmodifiableMap(levelDeltas);
this.experienceDeltas = Collections.unmodifiableMap(experienceDeltas);
}
public boolean hasBaseline()
{
return fromTime > 0;
}
public Long levelDelta(HiscoreSkill skill)
{
return levelDeltas.get(skill.name());
}
public Long experienceDelta(HiscoreSkill skill)
{
return experienceDeltas.get(skill.name());
}
}
private final Gson gson;
private final Map<String, PlayerRecord> players = new HashMap<>();
private boolean loaded;
@Inject
public PlayerHiscoreHistory(Gson gson)
{
this.gson = gson;
}
public synchronized void load()
{
if (loaded)
{
return;
}
loaded = true;
if (Files.exists(FILE))
{
try
{
Map<String, PlayerRecord> stored = gson.fromJson(Files.readString(FILE, StandardCharsets.UTF_8), STORE_TYPE);
if (stored != null)
{
for (Map.Entry<String, PlayerRecord> entry : stored.entrySet())
{
if (entry.getKey() != null && entry.getValue() != null)
{
entry.getValue().normalize();
players.put(entry.getKey(), entry.getValue());
}
}
}
}
catch (Exception e)
{
log.warn("Player hiscores: could not read history", e);
}
}
if (prune(System.currentTimeMillis()))
{
save();
}
}
/** Marks the profile active; it will expire one week after its most recent view. */
public synchronized void viewed(String playerName, long now)
{
load();
prune(now);
String key = key(playerName);
if (key.isEmpty())
{
return;
}
PlayerRecord player = players.computeIfAbsent(key, ignored -> new PlayerRecord());
player.name = playerName;
player.lastViewedAt = now;
pruneSamples(player, now);
save();
}
/** Removes profiles and snapshots that have passed their seven-day retention window. */
public synchronized void pruneExpired(long now)
{
load();
if (prune(now))
{
save();
}
}
/** Saves one fetched result and returns a snapshot delta for the selected period. */
public synchronized Comparison record(String playerName, HiscoreResult result, long fetchedAt, Range range)
{
load();
if (result == null)
{
return empty(range);
}
prune(fetchedAt);
String key = key(playerName);
if (key.isEmpty())
{
return empty(range);
}
PlayerRecord player = players.computeIfAbsent(key, ignored -> new PlayerRecord());
player.name = playerName;
player.lastViewedAt = fetchedAt;
pruneSamples(player, fetchedAt);
Sample sample = new Sample();
sample.fetchedAt = fetchedAt;
for (Map.Entry<HiscoreSkill, Skill> entry : result.getSkills().entrySet())
{
Skill value = entry.getValue();
if (value == null)
{
continue;
}
if (value.getLevel() >= 0)
{
sample.levels.put(entry.getKey().name(), (long) value.getLevel());
}
if (value.getExperience() >= 0)
{
sample.experience.put(entry.getKey().name(), value.getExperience());
}
}
player.samples.add(sample);
player.samples.sort(Comparator.comparingLong(s -> s.fetchedAt));
while (player.samples.size() > MAX_SAMPLES_PER_PLAYER)
{
player.samples.remove(0);
}
Comparison comparison = compare(player, range == null ? Range.LAST_VISIT : range);
save();
return comparison;
}
/** Compares the latest stored sample with the closest available baseline for the requested period. */
public synchronized Comparison comparison(String playerName, Range range, long now)
{
load();
if (prune(now))
{
save();
}
PlayerRecord player = players.get(key(playerName));
return player == null ? empty(range) : compare(player, range == null ? Range.LAST_VISIT : range);
}
private Comparison compare(PlayerRecord player, Range range)
{
List<Sample> samples = player.samples;
if (samples.size() < 2)
{
return empty(range);
}
Sample current = samples.get(samples.size() - 1);
int baselineIndex = -1;
if (range == Range.LAST_VISIT)
{
baselineIndex = samples.size() - 2;
}
else
{
long threshold = current.fetchedAt - range.duration;
for (int i = 0; i < samples.size() - 1; i++)
{
if (samples.get(i).fetchedAt >= threshold)
{
baselineIndex = i;
break;
}
}
if (baselineIndex < 0)
{
return empty(range);
}
}
Sample baseline = samples.get(baselineIndex);
return new Comparison(range, baseline.fetchedAt, current.fetchedAt,
differences(baseline.levels, current.levels), differences(baseline.experience, current.experience));
}
private static Map<String, Long> differences(Map<String, Long> baseline, Map<String, Long> current)
{
Map<String, Long> deltas = new LinkedHashMap<>();
for (Map.Entry<String, Long> entry : current.entrySet())
{
Long previous = baseline.get(entry.getKey());
if (previous != null)
{
deltas.put(entry.getKey(), entry.getValue() - previous);
}
}
return deltas;
}
private static Comparison empty(Range range)
{
return new Comparison(range == null ? Range.LAST_VISIT : range, 0, 0, new LinkedHashMap<>(), new LinkedHashMap<>());
}
private boolean prune(long now)
{
long cutoff = now - RETENTION_MILLIS;
boolean changed = players.entrySet().removeIf(e -> e.getValue().lastViewedAt < cutoff);
for (PlayerRecord player : players.values())
{
changed |= pruneSamples(player, now);
}
return changed;
}
private static boolean pruneSamples(PlayerRecord player, long now)
{
return player.samples.removeIf(sample -> sample.fetchedAt < now - RETENTION_MILLIS);
}
private static String key(String playerName)
{
return playerName == null ? "" : net.runelite.client.util.Text.removeTags(playerName).trim().toLowerCase(Locale.ROOT);
}
private void save()
{
try
{
Files.createDirectories(FILE.getParent());
Path temp = FILE.resolveSibling(FILE.getFileName() + ".tmp");
Files.writeString(temp, gson.toJson(players), StandardCharsets.UTF_8);
try
{
Files.move(temp, FILE, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
}
catch (AtomicMoveNotSupportedException e)
{
Files.move(temp, FILE, StandardCopyOption.REPLACE_EXISTING);
}
}
catch (IOException e)
{
log.warn("Player hiscores: could not save history", e);
}
}
private static final class PlayerRecord
{
String name;
long lastViewedAt;
List<Sample> samples = new ArrayList<>();
void normalize()
{
if (samples == null)
{
samples = new ArrayList<>();
}
samples.removeIf(s -> s == null);
for (Sample sample : samples)
{
sample.normalize();
}
samples.sort(Comparator.comparingLong(s -> s.fetchedAt));
}
}
private static final class Sample
{
long fetchedAt;
Map<String, Long> levels = new HashMap<>();
Map<String, Long> experience = new HashMap<>();
void normalize()
{
if (levels == null)
{
levels = new HashMap<>();
}
if (experience == null)
{
experience = new HashMap<>();
}
}
}
}
@@ -164,12 +164,12 @@ public class SkillHistory
public synchronized void reset(TimeRange range)
{
if (range == TimeRange.SESSION)
if (range != null && range.isSession())
{
resetSessionAt = System.currentTimeMillis();
dirty = true;
}
else if (range == TimeRange.CURRENT)
else if (range != null && range.isCurrent())
{
resetNowAt = System.currentTimeMillis();
dirty = true;
@@ -220,7 +220,7 @@ public class SkillHistory
long streak = currentStart(i, now);
out[i] = streak == Long.MAX_VALUE ? Long.MAX_VALUE : Math.max(streak, resetNowAt);
}
else if (filter.getRange() == TimeRange.SESSION)
else if (filter.getRange() != null && filter.getRange().isSession())
{
out[i] = Math.max(filter.start(), resetSessionAt);
}
@@ -242,7 +242,7 @@ public class SkillHistory
long base = start == Long.MAX_VALUE ? now : start;
return Math.max(base, resetNowAt);
}
if (filter.getRange() == TimeRange.SESSION)
if (filter.getRange() != null && filter.getRange().isSession())
{
return Math.max(filter.start(), resetSessionAt);
}
@@ -12,19 +12,29 @@ import com.example.essentialsplus.wiki.ui.WikiCard;
import com.example.essentialsplus.wiki.ui.WikiPanel;
import java.awt.BorderLayout;
import java.awt.CardLayout;
import java.awt.Component;
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.GridLayout;
import javax.swing.BorderFactory;
import javax.swing.Box;
import javax.swing.BoxLayout;
import javax.swing.JButton;
import javax.swing.JComboBox;
import javax.swing.JCheckBoxMenuItem;
import javax.swing.JComponent;
import javax.swing.JMenu;
import javax.swing.JMenuItem;
import javax.swing.JOptionPane;
import javax.swing.JPopupMenu;
import javax.swing.JRadioButtonMenuItem;
import javax.swing.JScrollPane;
import javax.swing.ScrollPaneConstants;
import javax.swing.JTextArea;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
@@ -55,11 +65,13 @@ public class EssentialsPlusPanel extends PluginPanel
private static final String VIEW_WIKI = "WIKI";
private static final String VIEW_PROGRESS = "PROGRESS";
private static final String VIEW_CURRENCY = "CURRENCY";
private static final String VIEW_EQUIPMENT = "EQUIPMENT";
private final CardLayout cardLayout = new CardLayout();
private final JPanel container = new JPanel(cardLayout);
private final EssentialsPlusPlugin plugin;
private Runnable refreshEquipmentProfiles;
private final OverviewPanel overviewPanel;
private final WealthPanel wealthPanel;
private final LootTrackerPanel lootTrackerPanel;
@@ -109,15 +121,16 @@ public class EssentialsPlusPanel extends PluginPanel
container.add(wikiPanel, VIEW_WIKI);
container.add(progressPanel, VIEW_PROGRESS);
container.add(currencyPanel, VIEW_CURRENCY);
container.add(createEquipmentPanel(), VIEW_EQUIPMENT);
add(container, BorderLayout.CENTER);
rangeBar = Segmented.ranges(plugin.getTimeFilter().getRange(), plugin.getTimeFilter()::set);
rangeBar = Segmented.ranges(plugin.getTimeFilter(), plugin.getTimeFilter()::set);
add(buildFooter(), BorderLayout.SOUTH);
TimeFilter filter = plugin.getTimeFilter();
rangeListener = () -> SwingUtilities.invokeLater(() ->
{
rangeBar.setSelected(filter.getRange().ordinal());
rangeBar.setSelected(filter.getSlotIndex(filter.getRange()));
overviewPanel.refreshSkills();
currencyPanel.refresh();
});
@@ -209,6 +222,282 @@ public class EssentialsPlusPanel extends PluginPanel
currencyPanel.refresh();
}
public void showEquipment()
{
cardLayout.show(container, VIEW_EQUIPMENT);
if (refreshEquipmentProfiles != null)
{
refreshEquipmentProfiles.run();
}
}
public void refreshEquipmentProfilesLater()
{
SwingUtilities.invokeLater(() ->
{
if (refreshEquipmentProfiles != null)
{
refreshEquipmentProfiles.run();
}
});
}
private JPanel createEquipmentPanel()
{
JPanel panel = new JPanel(new BorderLayout());
panel.setBackground(Ui.BG);
JPanel header = new JPanel(new BorderLayout(8, 0));
header.setOpaque(false);
header.setBorder(BorderFactory.createEmptyBorder(12, 12, 10, 12));
JPanel titleBlock = new JPanel();
titleBlock.setOpaque(false);
titleBlock.setLayout(new BoxLayout(titleBlock, BoxLayout.Y_AXIS));
JLabel label = new JLabel("Equipment");
label.setFont(FontManager.getRunescapeBoldFont().deriveFont(16f));
label.setForeground(Ui.TEXT);
JLabel subtitle = new JLabel("Saved bank loadouts");
subtitle.setFont(FontManager.getRunescapeSmallFont());
subtitle.setForeground(Ui.MUTED);
titleBlock.add(label);
titleBlock.add(Box.createVerticalStrut(2));
titleBlock.add(subtitle);
header.add(titleBlock, BorderLayout.CENTER);
header.add(createSmallButton("Back", this::showOverview), BorderLayout.EAST);
panel.add(header, BorderLayout.NORTH);
JPanel controls = new JPanel();
controls.setLayout(new BoxLayout(controls, BoxLayout.Y_AXIS));
controls.setOpaque(false);
controls.setBorder(BorderFactory.createEmptyBorder(2, 12, 12, 12));
JPanel loadouts = new JPanel();
loadouts.setLayout(new BoxLayout(loadouts, BoxLayout.Y_AXIS));
loadouts.setBackground(Ui.CARD);
loadouts.setBorder(BorderFactory.createCompoundBorder(
BorderFactory.createMatteBorder(0, 3, 0, 0, Ui.ACCENT),
BorderFactory.createEmptyBorder(10, 10, 10, 10)));
loadouts.setAlignmentX(Component.LEFT_ALIGNMENT);
JPanel savedTitleRow = new JPanel(new BorderLayout(6, 0));
savedTitleRow.setOpaque(false);
JLabel savedLabel = new JLabel("Saved loadouts");
savedLabel.setFont(FontManager.getRunescapeBoldFont().deriveFont(12f));
savedLabel.setForeground(Ui.TEXT);
JLabel layoutCount = new JLabel("0 saved");
layoutCount.setFont(FontManager.getRunescapeSmallFont());
layoutCount.setForeground(Ui.MUTED);
savedTitleRow.add(savedLabel, BorderLayout.WEST);
savedTitleRow.add(layoutCount, BorderLayout.EAST);
savedTitleRow.setAlignmentX(Component.LEFT_ALIGNMENT);
savedTitleRow.setMaximumSize(new Dimension(Integer.MAX_VALUE, savedTitleRow.getPreferredSize().height));
loadouts.add(savedTitleRow);
loadouts.add(Box.createVerticalStrut(4));
JLabel savedDescription = new JLabel("Equipment and inventory snapshots");
savedDescription.setFont(FontManager.getRunescapeSmallFont());
savedDescription.setForeground(Ui.MUTED);
savedDescription.setAlignmentX(Component.LEFT_ALIGNMENT);
loadouts.add(savedDescription);
loadouts.add(Box.createVerticalStrut(10));
JComboBox<String> profiles = new JComboBox<>();
profiles.setFont(FontManager.getRunescapeSmallFont());
profiles.setForeground(Ui.TEXT);
profiles.setBackground(Ui.CARD_HOVER);
profiles.setBorder(BorderFactory.createCompoundBorder(
BorderFactory.createLineBorder(Ui.FAINT),
BorderFactory.createEmptyBorder(2, 4, 2, 4)));
profiles.setMaximumRowCount(10);
profiles.setAlignmentX(Component.LEFT_ALIGNMENT);
profiles.setMaximumSize(new Dimension(Integer.MAX_VALUE, 30));
loadouts.add(profiles);
loadouts.add(Box.createVerticalStrut(7));
JLabel emptyHint = new JLabel("<html>No loadouts yet.<br>Save your current setup to start.</html>");
emptyHint.setFont(FontManager.getRunescapeSmallFont());
emptyHint.setForeground(Ui.MUTED);
emptyHint.setAlignmentX(Component.LEFT_ALIGNMENT);
loadouts.add(emptyHint);
loadouts.add(Box.createVerticalStrut(7));
JTextArea status = new JTextArea("Bank contents stay unchanged.");
status.setFont(FontManager.getRunescapeSmallFont());
status.setForeground(Ui.MUTED);
status.setOpaque(false);
status.setEditable(false);
status.setFocusable(false);
status.setLineWrap(true);
status.setWrapStyleWord(true);
status.setRows(2);
status.setBorder(BorderFactory.createEmptyBorder(2, 0, 0, 0));
status.setAlignmentX(Component.LEFT_ALIGNMENT);
status.setMaximumSize(new Dimension(Integer.MAX_VALUE, status.getPreferredSize().height));
Runnable refreshProfiles = () ->
{
String selected = (String) profiles.getSelectedItem();
List<String> names = plugin.getSavedEquipmentLayoutNames();
profiles.removeAllItems();
for (String savedName : names)
{
profiles.addItem(savedName);
}
if (selected != null && names.contains(selected))
{
profiles.setSelectedItem(selected);
}
else if (profiles.getItemCount() > 0)
{
profiles.setSelectedIndex(0);
}
layoutCount.setText(names.size() == 1 ? "1 saved" : names.size() + " saved");
emptyHint.setVisible(names.isEmpty());
profiles.setEnabled(profiles.getItemCount() > 0);
Dimension loadoutsSize = loadouts.getPreferredSize();
loadouts.setMaximumSize(new Dimension(Integer.MAX_VALUE, loadoutsSize.height));
controls.revalidate();
controls.repaint();
};
refreshEquipmentProfiles = refreshProfiles;
JButton saveLayout = createEquipmentActionButton("Save equipment + inventory", () ->
{
String name = JOptionPane.showInputDialog(panel, "Loadout name:", "Save loadout", JOptionPane.PLAIN_MESSAGE);
if (name == null || name.trim().isEmpty())
{
return;
}
if (plugin.hasSavedEquipmentLayout(name)
&& JOptionPane.showConfirmDialog(panel, "Replace the saved layout '" + name.trim() + "'?", "Replace loadout", JOptionPane.YES_NO_OPTION) != JOptionPane.YES_OPTION)
{
return;
}
plugin.saveEquipmentAndInventory(name, message ->
{
status.setText(message);
refreshProfiles.run();
profiles.setSelectedItem(name.trim());
});
}, false, false);
loadouts.add(saveLayout);
loadouts.add(Box.createVerticalStrut(10));
JButton showLayout = createEquipmentActionButton("Show in bank", () ->
{
String selected = (String) profiles.getSelectedItem();
if (selected != null)
{
plugin.showSavedEquipmentLayout(selected, status::setText);
}
}, true, false);
loadouts.add(showLayout);
loadouts.add(Box.createVerticalStrut(6));
JButton renameLayout = createEquipmentActionButton("Rename", () ->
{
String selected = (String) profiles.getSelectedItem();
if (selected == null)
{
return;
}
String newName = JOptionPane.showInputDialog(panel, "New layout name:", selected);
if (newName == null || newName.trim().isEmpty())
{
return;
}
plugin.renameSavedEquipmentLayout(selected, newName, message ->
{
status.setText(message);
refreshProfiles.run();
if (message.startsWith("Renamed "))
{
profiles.setSelectedItem(newName.trim());
}
});
}, false, false);
JButton deleteLayout = createEquipmentActionButton("Delete", () ->
{
String selected = (String) profiles.getSelectedItem();
if (selected == null || JOptionPane.showConfirmDialog(panel,
"Delete the saved layout '" + selected + "'?", "Delete layout", JOptionPane.YES_NO_OPTION) != JOptionPane.YES_OPTION)
{
return;
}
plugin.deleteSavedEquipmentLayout(selected, message ->
{
status.setText(message);
refreshProfiles.run();
});
}, false, true);
JPanel savedActions = new JPanel(new GridLayout(1, 2, 6, 0));
savedActions.setOpaque(false);
savedActions.add(renameLayout);
savedActions.add(deleteLayout);
savedActions.setAlignmentX(Component.LEFT_ALIGNMENT);
savedActions.setMaximumSize(new Dimension(Integer.MAX_VALUE, 30));
loadouts.add(savedActions);
loadouts.add(Box.createVerticalStrut(6));
loadouts.add(status);
controls.add(loadouts);
controls.add(Box.createVerticalStrut(12));
JPanel bankDisplay = new JPanel();
bankDisplay.setLayout(new BoxLayout(bankDisplay, BoxLayout.Y_AXIS));
bankDisplay.setOpaque(false);
bankDisplay.setBorder(BorderFactory.createCompoundBorder(
BorderFactory.createMatteBorder(1, 0, 0, 0, Ui.GRID),
BorderFactory.createEmptyBorder(10, 0, 0, 0)));
bankDisplay.setAlignmentX(Component.LEFT_ALIGNMENT);
JLabel bankTitle = new JLabel("Bank display");
bankTitle.setFont(FontManager.getRunescapeBoldFont().deriveFont(12f));
bankTitle.setForeground(Ui.TEXT);
JLabel bankDescription = new JLabel("Restore every bank item.");
bankDescription.setFont(FontManager.getRunescapeSmallFont());
bankDescription.setForeground(Ui.MUTED);
bankDescription.setAlignmentX(Component.LEFT_ALIGNMENT);
bankDisplay.add(bankTitle);
bankDisplay.add(Box.createVerticalStrut(3));
bankDisplay.add(bankDescription);
bankDisplay.add(Box.createVerticalStrut(7));
bankDisplay.add(createEquipmentActionButton("Reset bank display", plugin::restoreBankItems, false, false));
Dimension bankDisplaySize = bankDisplay.getPreferredSize();
bankDisplay.setMaximumSize(new Dimension(Integer.MAX_VALUE, bankDisplaySize.height));
controls.add(bankDisplay);
profiles.addActionListener(e ->
{
boolean selected = profiles.getSelectedItem() != null;
showLayout.setEnabled(selected);
renameLayout.setEnabled(selected);
deleteLayout.setEnabled(selected);
});
refreshProfiles.run();
panel.add(controls, BorderLayout.CENTER);
return panel;
}
private static JButton createEquipmentActionButton(String text, Runnable action, boolean primary, boolean danger)
{
JButton button = new JButton(text);
button.setFont(primary ? FontManager.getRunescapeBoldFont() : FontManager.getRunescapeSmallFont());
button.setForeground(primary ? Ui.BG : danger ? Ui.DOWN : Ui.TEXT);
button.setBackground(primary ? Ui.ACCENT : Ui.CARD_HOVER);
button.setOpaque(true);
button.setContentAreaFilled(true);
button.setFocusPainted(true);
button.setBorder(BorderFactory.createCompoundBorder(
BorderFactory.createLineBorder(primary ? Ui.ACCENT : Ui.FAINT),
BorderFactory.createEmptyBorder(4, 6, 4, 6)));
button.setCursor(Cursor.getPredefinedCursor(Cursor.HAND_CURSOR));
button.addActionListener(e -> action.run());
button.setPreferredSize(new Dimension(button.getPreferredSize().width, 30));
button.setMaximumSize(new Dimension(Integer.MAX_VALUE, 30));
button.setAlignmentX(Component.LEFT_ALIGNMENT);
return button;
}
public void showWiki()
{
cardLayout.show(container, VIEW_WIKI);
@@ -293,8 +582,9 @@ public class EssentialsPlusPanel extends PluginPanel
private static final String SKILLS = "skills";
private static final String PROGRESS = "progress";
private static final String WIKI = "wiki";
private static final String EQUIPMENT = "equipment";
private static final String HELPERS = "helpers";
private static final List<String> DEFAULT_ORDER = Arrays.asList(CURRENCY, WEALTH, LOOT, SKILLS, PROGRESS, WIKI, HELPERS);
private static final List<String> DEFAULT_ORDER = Arrays.asList(CURRENCY, WEALTH, LOOT, SKILLS, PROGRESS, WIKI, EQUIPMENT, HELPERS);
private static final Map<String, String> NAMES = new HashMap<>();
static
@@ -305,6 +595,7 @@ public class EssentialsPlusPanel extends PluginPanel
NAMES.put(SKILLS, "Skills");
NAMES.put(PROGRESS, "Achievements");
NAMES.put(WIKI, "Wiki");
NAMES.put(EQUIPMENT, "Equipment");
NAMES.put(HELPERS, "Helpers");
}
@@ -314,6 +605,7 @@ public class EssentialsPlusPanel extends PluginPanel
private final LootSummaryCard lootSummaryCard;
private final SkillsCard skillsCard;
private final WikiCard wikiCard;
private final EmptyCategoryCard equipmentCard;
private final ProgressCard progressCard;
private final HelpersCard helpersCard;
private final JPanel topContainer;
@@ -370,6 +662,7 @@ public class EssentialsPlusPanel extends PluginPanel
skillsCard = new SkillsCard(none);
progressCard = new ProgressCard(none);
wikiCard = new WikiCard(none);
equipmentCard = new EmptyCategoryCard("Equipment");
helpersCard = new HelpersCard(plugin);
refreshSkills();
refreshWiki();
@@ -402,6 +695,7 @@ public class EssentialsPlusPanel extends PluginPanel
register(SKILLS, skillsCard, parent::showSkills);
register(PROGRESS, progressCard, parent::showProgress);
register(WIKI, wikiCard, parent::showWiki);
register(EQUIPMENT, equipmentCard, parent::showEquipment);
register(HELPERS, helpersCard, none());
applyOptions();
layoutCards();
@@ -648,6 +942,9 @@ public class EssentialsPlusPanel extends PluginPanel
case PROGRESS:
parent.showProgress();
break;
case EQUIPMENT:
parent.showEquipment();
break;
default:
parent.showWiki();
break;
@@ -701,7 +998,7 @@ public class EssentialsPlusPanel extends PluginPanel
private void wealthItems(JPopupMenu menu)
{
boolean isHourly = wealthPanel != null ? wealthPanel.isHourly() : (plugin.getTimeFilter().isCurrent() || plugin.getTimeFilter().getRange() == TimeRange.SESSION);
boolean isHourly = wealthPanel != null ? wealthPanel.isHourly() : (plugin.getTimeFilter().isCurrent() || (plugin.getTimeFilter().getRange() != null && plugin.getTimeFilter().getRange().isSession()));
if (isHourly)
{
JMenuItem reset = new JMenuItem("Reset Hourly");
@@ -802,5 +1099,60 @@ public class EssentialsPlusPanel extends PluginPanel
SkillsPanel.resolve(plugin.getSkillStore(), null, plugin.getSkillKey()),
plugin.getTimeFilter(), "Skills");
}
private static final class EmptyCategoryCard extends JComponent
{
private boolean hover;
EmptyCategoryCard(String title)
{
setCursor(Cursor.getPredefinedCursor(Cursor.HAND_CURSOR));
setToolTipText("Open " + title);
addMouseListener(new java.awt.event.MouseAdapter()
{
@Override
public void mouseEntered(java.awt.event.MouseEvent e)
{
hover = true;
repaint();
}
@Override
public void mouseExited(java.awt.event.MouseEvent e)
{
hover = false;
repaint();
}
});
this.title = title;
}
private final String title;
@Override
public Dimension getPreferredSize()
{
return new Dimension(PluginPanel.PANEL_WIDTH - 16, 58);
}
@Override
protected void paintComponent(Graphics g0)
{
Graphics2D g = Ui.prepare(g0);
g.setColor(hover ? Ui.CARD_HOVER : Ui.CARD);
g.fillRoundRect(0, 0, getWidth(), getHeight(), 8, 8);
Font bold = FontManager.getRunescapeBoldFont();
g.setFont(bold);
FontMetrics fm = g.getFontMetrics();
int baseline = (getHeight() - fm.getHeight()) / 2 + fm.getAscent();
g.setColor(Ui.ACCENT);
g.drawString(title, 10, baseline);
g.setFont(bold.deriveFont(14f));
g.setColor(hover ? Ui.ACCENT : Ui.FAINT);
Ui.drawRight(g, "›", getWidth() - 10, baseline);
Ui.paintDragOutline(g, this);
g.dispose();
}
}
}
}
@@ -4,20 +4,29 @@ import com.example.essentialsplus.EssentialsPlusPlugin;
import com.example.essentialsplus.helpers.BlastFurnaceOverlay;
import com.example.essentialsplus.helpers.TimerTextSize;
import java.awt.Color;
import java.awt.Dialog;
import java.awt.Dimension;
import java.awt.Graphics;
import java.awt.Graphics2D;
import java.awt.event.KeyAdapter;
import java.awt.event.KeyEvent;
import javax.swing.ButtonGroup;
import javax.swing.BorderFactory;
import javax.swing.BoxLayout;
import javax.swing.JButton;
import javax.swing.JCheckBox;
import javax.swing.JCheckBoxMenuItem;
import javax.swing.JDialog;
import javax.swing.JMenu;
import javax.swing.JMenuItem;
import javax.swing.JPopupMenu;
import javax.swing.JRadioButtonMenuItem;
import javax.swing.SwingUtilities;
import javax.swing.JLabel;
import javax.swing.JOptionPane;
import javax.swing.JPanel;
import javax.swing.SwingUtilities;
import net.runelite.api.SoundEffectID;
import net.runelite.client.config.Keybind;
import net.runelite.client.ui.FontManager;
import net.runelite.client.ui.PluginPanel;
import net.runelite.client.ui.overlay.OverlayPosition;
@@ -26,14 +35,16 @@ import net.runelite.client.ui.overlay.OverlayPosition;
public class HelpersCard extends JPanel
{
private final EssentialsPlusPlugin plugin;
private final JCheckBox attackDistance = box("Attack Distance", "Outlines bow and crossbow range based on the active style. Right-click for options.");
private final JCheckBox attackTimer = box("Attack timer", "Ticks until your next attack, above your head. Right-click for options");
private final JCheckBox enemyAttackStyle = box("Enemy attack style", "Melee, range or mage icon above NPCs attacking you. Right-click for options");
private final JCheckBox itemPrices = box("Item prices", "GE and high alch price when hovering inventory items. Right-click for options");
private final JCheckBox enemyAttackTimer = box("Enemy attack timer", "Ticks until attacking NPCs hit next, above their heads. Right-click for options");
private final JCheckBox treeCooldowns = box("Tree cooldowns", "Respawn-Countdown auf gefällten Bäumen + Rest-Zeit auf langlebigen Bäumen. Rechtsklick für Optionen");
private final JCheckBox itemCharges = box("Item charges", "Charge-Zahl auf Item-Icons (Blowpipe, Trident, Tome, …) + Loot-Bag-Geld & Coal-Bag-Füllstand. Rechtsklick für Optionen");
private final JCheckBox itemInformation = box("Item information", "Shows item prices, banked quantities and charge information. Right-click for options.");
private final JCheckBox treeCooldowns = box("Tree cooldowns", "Respawn countdown on felled trees and remaining chopping time. Right-click for options");
private final JCheckBox blastFurnace = box("Blast Furnace", "Highlights conveyor belt, bar dispenser & bank chest, shows coal in furnace, coffer time, and bars craftable from bank ores. Right-click for options");
private final JCheckBox chatHelper = box("Chat helper", "Auto-Clear nach 1 Min, Duplikate (x2, x3), Spam-Filter, Namen-Highlight & Kopieren. Rechtsklick für Optionen");
private final JCheckBox chatHelper = box("Chat helper", "Auto-clear after one minute, duplicate collapsing, spam filtering, name highlighting, and copying. Right-click for options");
private final JCheckBox worldHopTracker = box("World hop tracker", "Show world-hop attempts per hour and today in chat. Right-click for hop hotkeys and filters.");
private final JCheckBox idleNotifier = box("Idle Notifier", "Blink and play a sound after a sustained activity ends. Right-click for timing, sound, and repeat options.");
public HelpersCard(EssentialsPlusPlugin plugin)
{
@@ -48,6 +59,19 @@ public class HelpersCard extends JPanel
title.setBorder(BorderFactory.createEmptyBorder(0, 0, 4, 0));
add(title);
attackDistance.addActionListener(e -> plugin.setHelperOption("attackDistance", attackDistance.isSelected()));
attackDistance.addMouseListener(new java.awt.event.MouseAdapter()
{
@Override
public void mousePressed(java.awt.event.MouseEvent e)
{
if (e.isPopupTrigger() || SwingUtilities.isRightMouseButton(e))
{
attackDistanceMenu().show(attackDistance, e.getX(), e.getY());
}
}
});
add(attackDistance);
attackTimer.addActionListener(e -> plugin.setHelperOption("attackTimer", attackTimer.isSelected()));
enemyAttackTimer.addActionListener(e -> plugin.setHelperOption("enemyAttackTimer", enemyAttackTimer.isSelected()));
optionsMenu(attackTimer, "attackTimer", "Attack timer", Color.WHITE);
@@ -67,19 +91,19 @@ public class HelpersCard extends JPanel
}
});
add(enemyAttackStyle);
itemPrices.addActionListener(e -> plugin.setHelperOption("itemPrices", itemPrices.isSelected()));
itemPrices.addMouseListener(new java.awt.event.MouseAdapter()
itemInformation.addActionListener(e -> plugin.setHelperOption("itemInformation", itemInformation.isSelected()));
itemInformation.addMouseListener(new java.awt.event.MouseAdapter()
{
@Override
public void mousePressed(java.awt.event.MouseEvent e)
{
if (e.isPopupTrigger() || SwingUtilities.isRightMouseButton(e))
{
priceMenu().show(itemPrices, e.getX(), e.getY());
itemInformationMenu().show(itemInformation, e.getX(), e.getY());
}
}
});
add(itemPrices);
add(itemInformation);
treeCooldowns.addActionListener(e -> plugin.setHelperOption("treeCooldowns", treeCooldowns.isSelected()));
treeCooldowns.addMouseListener(new java.awt.event.MouseAdapter()
{
@@ -93,19 +117,6 @@ public class HelpersCard extends JPanel
}
});
add(treeCooldowns);
itemCharges.addActionListener(e -> plugin.setHelperOption("itemCharges", itemCharges.isSelected()));
itemCharges.addMouseListener(new java.awt.event.MouseAdapter()
{
@Override
public void mousePressed(java.awt.event.MouseEvent e)
{
if (e.isPopupTrigger() || SwingUtilities.isRightMouseButton(e))
{
chargesMenu().show(itemCharges, e.getX(), e.getY());
}
}
});
add(itemCharges);
blastFurnace.addActionListener(e -> plugin.setHelperOption("blastFurnace", blastFurnace.isSelected()));
blastFurnace.addMouseListener(new java.awt.event.MouseAdapter()
{
@@ -132,20 +143,48 @@ public class HelpersCard extends JPanel
}
});
add(chatHelper);
worldHopTracker.addActionListener(e -> plugin.setHelperOption("worldHopTracker", worldHopTracker.isSelected()));
worldHopTracker.addMouseListener(new java.awt.event.MouseAdapter()
{
@Override
public void mousePressed(java.awt.event.MouseEvent e)
{
if (e.isPopupTrigger() || SwingUtilities.isRightMouseButton(e))
{
worldHopMenu().show(worldHopTracker, e.getX(), e.getY());
}
}
});
add(worldHopTracker);
idleNotifier.addActionListener(e -> plugin.setHelperOption("idleNotifier", idleNotifier.isSelected()));
idleNotifier.addMouseListener(new java.awt.event.MouseAdapter()
{
@Override
public void mousePressed(java.awt.event.MouseEvent e)
{
if (e.isPopupTrigger() || SwingUtilities.isRightMouseButton(e))
{
idleNotifierMenu().show(idleNotifier, e.getX(), e.getY());
}
}
});
add(idleNotifier);
refresh();
}
/** Re-reads the switches from the config (also changed in the RuneLite settings). */
public void refresh()
{
attackDistance.setSelected(plugin.isHelperOption("attackDistance"));
attackTimer.setSelected(plugin.isHelperOption("attackTimer"));
enemyAttackTimer.setSelected(plugin.isHelperOption("enemyAttackTimer"));
enemyAttackStyle.setSelected(plugin.isHelperOption("enemyAttackStyle"));
itemPrices.setSelected(plugin.isHelperOption("itemPrices"));
itemInformation.setSelected(plugin.isHelperOption("itemInformation"));
treeCooldowns.setSelected(plugin.isHelperOption("treeCooldowns"));
itemCharges.setSelected(plugin.isHelperOption("itemCharges"));
blastFurnace.setSelected(plugin.isHelperOption("blastFurnace"));
chatHelper.setSelected(plugin.isHelperOption("chatHelper"));
worldHopTracker.setSelected(plugin.isHelperOption("worldHopTracker"));
idleNotifier.setSelected(plugin.isHelperOption("idleNotifier"));
}
@@ -155,6 +194,109 @@ public class HelpersCard extends JPanel
private static final int[] STYLE_HEIGHTS = {10, 30, 60, 100, 150};
private static final String[] STYLE_HEIGHT_LABELS = {"Low", "Normal", "High", "Very high", "Highest"};
private JPopupMenu attackDistanceMenu()
{
JPopupMenu menu = new JPopupMenu();
boolean hideOutOfCombat = plugin.getHelperValue("attackDistanceHideOutOfCombat", Boolean.class, false);
JCheckBoxMenuItem hideItem = new JCheckBoxMenuItem("Hide out of combat", hideOutOfCombat);
hideItem.addActionListener(e -> plugin.setHelperOption("attackDistanceHideOutOfCombat", hideItem.isSelected()));
menu.add(hideItem);
return menu;
}
private JPopupMenu worldHopMenu()
{
JPopupMenu menu = new JPopupMenu();
Keybind nextKey = plugin.getHelperValue("worldHopNextKey", Keybind.class, Keybind.NOT_SET);
JMenuItem next = new JMenuItem("Next world: " + nextKey);
next.addActionListener(e -> captureWorldHopKey("worldHopNextKey", "Next world"));
menu.add(next);
Keybind randomKey = plugin.getHelperValue("worldHopRandomKey", Keybind.class, Keybind.NOT_SET);
JMenuItem random = new JMenuItem("Random world: " + randomKey);
random.addActionListener(e -> captureWorldHopKey("worldHopRandomKey", "Random world"));
menu.add(random);
menu.addSeparator();
boolean avoidDangerous = plugin.getHelperValue("worldHopAvoidDangerous", Boolean.class, true);
JCheckBoxMenuItem avoid = new JCheckBoxMenuItem("Avoid PvP and high-risk worlds", avoidDangerous);
avoid.addActionListener(e -> plugin.setHelperOption("worldHopAvoidDangerous", avoid.isSelected()));
menu.add(avoid);
JMenuItem bounty = new JMenuItem("Bounty worlds are always skipped");
bounty.setEnabled(false);
menu.add(bounty);
return menu;
}
private void captureWorldHopKey(String key, String action)
{
JDialog dialog = new JDialog(SwingUtilities.getWindowAncestor(worldHopTracker), "Set " + action + " hotkey", Dialog.ModalityType.APPLICATION_MODAL);
JPanel panel = new JPanel(new java.awt.BorderLayout(8, 8));
panel.setBorder(BorderFactory.createEmptyBorder(12, 12, 8, 12));
panel.setFocusable(true);
JLabel prompt = new JLabel("Press a key or key combination. Esc cancels.");
panel.add(prompt, java.awt.BorderLayout.CENTER);
JPanel buttons = new JPanel(new java.awt.FlowLayout(java.awt.FlowLayout.RIGHT, 6, 0));
JButton clear = new JButton("Clear");
clear.setFocusable(false);
clear.addActionListener(e ->
{
plugin.setHelperOption(key, Keybind.NOT_SET);
dialog.dispose();
});
JButton cancel = new JButton("Cancel");
cancel.setFocusable(false);
cancel.addActionListener(e -> dialog.dispose());
buttons.add(clear);
buttons.add(cancel);
panel.add(buttons, java.awt.BorderLayout.SOUTH);
panel.addKeyListener(new KeyAdapter()
{
@Override
public void keyPressed(KeyEvent e)
{
if (e.getKeyCode() == KeyEvent.VK_ESCAPE)
{
dialog.dispose();
e.consume();
return;
}
if (Keybind.getModifierForKeyCode(e.getExtendedKeyCode()) != null)
{
prompt.setText("Now press the key while holding the modifier.");
e.consume();
return;
}
if (e.getExtendedKeyCode() == KeyEvent.VK_UNDEFINED)
{
return;
}
Keybind selected = new Keybind(e);
String otherKeyName = "worldHopNextKey".equals(key) ? "worldHopRandomKey" : "worldHopNextKey";
Keybind otherKey = plugin.getHelperValue(otherKeyName, Keybind.class, Keybind.NOT_SET);
if (!Keybind.NOT_SET.equals(otherKey) && otherKey.equals(selected))
{
JOptionPane.showMessageDialog(dialog, "That hotkey is already assigned to the other hop action.", "Hotkey already used", JOptionPane.WARNING_MESSAGE);
e.consume();
return;
}
plugin.setHelperOption(key, selected);
dialog.dispose();
e.consume();
}
});
panel.setFocusTraversalKeysEnabled(false);
dialog.setContentPane(panel);
dialog.setDefaultCloseOperation(JDialog.DISPOSE_ON_CLOSE);
dialog.pack();
dialog.setLocationRelativeTo(worldHopTracker);
SwingUtilities.invokeLater(panel::requestFocusInWindow);
dialog.setVisible(true);
}
/** Right-click on a switch: look of that timer (bar, size, colors, height). */
private void optionsMenu(JCheckBox box, String prefix, String name, Color defColor)
{
@@ -218,18 +360,6 @@ public class HelpersCard extends JPanel
return menu;
}
private JPopupMenu priceMenu()
{
JPopupMenu menu = new JPopupMenu();
for (String[] opt : new String[][]{{"itemPricesGe", "Show GE price"}, {"itemPricesHa", "Show HA price"}})
{
boolean on = plugin.getHelperValue(opt[0], Boolean.class, true);
JCheckBoxMenuItem item = new JCheckBoxMenuItem(opt[1], on);
item.addActionListener(e -> plugin.setHelperOption(opt[0], on ? false : null));
menu.add(item);
}
return menu;
}
private JPopupMenu styleMenu()
{
@@ -267,15 +397,15 @@ public class HelpersCard extends JPanel
}
private static final int[] TREE_HEIGHTS = {20, 50, 80, 120};
private static final String[] TREE_HEIGHT_LABELS = {"Niedrig", "Normal", "Hoch", "Sehr hoch"};
private static final String[] TREE_HEIGHT_LABELS = {"Low", "Normal", "High", "Very high"};
private JPopupMenu treeMenu()
{
JPopupMenu menu = new JPopupMenu();
for (String[] opt : new String[][]{
{"treeCooldownRespawn", "Respawn-Countdown anzeigen"},
{"treeCooldownChop", "Hack-Restzeit anzeigen"},
{"treeCooldownBar", "Fortschrittsbalken anzeigen"}
{"treeCooldownRespawn", "Show respawn countdown"},
{"treeCooldownChop", "Show remaining chopping time"},
{"treeCooldownBar", "Show progress bar"}
})
{
boolean on = plugin.getHelperValue(opt[0], Boolean.class, !opt[0].equals("treeCooldownBar"));
@@ -285,7 +415,7 @@ public class HelpersCard extends JPanel
}
menu.addSeparator();
JMenu size = new JMenu("Textgröße");
JMenu size = new JMenu("Text size");
TimerTextSize currentSize = plugin.getHelperValue("treeCooldownSize", TimerTextSize.class, TimerTextSize.NORMAL);
for (TimerTextSize s : TimerTextSize.values())
{
@@ -293,7 +423,7 @@ public class HelpersCard extends JPanel
}
menu.add(size);
JMenu height = new JMenu("Höhe");
JMenu height = new JMenu("Height");
int currentHeight = plugin.getHelperValue("treeCooldownOffset", Integer.class, 50);
for (int i = 0; i < TREE_HEIGHTS.length; i++)
{
@@ -303,11 +433,11 @@ public class HelpersCard extends JPanel
menu.add(height);
menu.addSeparator();
menu.add(colorItem("Farbe...", "treeCooldownColor", "Tree Cooldown Farbe", new Color(0x8FBF3F)));
menu.add(colorItem("Farbe bei Bereit...", "treeCooldownReadyColor", "Tree Cooldown Farbe bei Bereit", READY));
menu.add(colorItem("Color...", "treeCooldownColor", "Tree Cooldown color", new Color(0x8FBF3F)));
menu.add(colorItem("Ready color...", "treeCooldownReadyColor", "Tree Cooldown ready color", READY));
menu.addSeparator();
JMenuItem reset = new JMenuItem("Aussehen zurücksetzen");
JMenuItem reset = new JMenuItem("Reset look");
reset.addActionListener(e ->
{
for (String key : new String[]{"treeCooldownRespawn", "treeCooldownChop", "treeCooldownBar", "treeCooldownSize", "treeCooldownOffset", "treeCooldownColor", "treeCooldownReadyColor"})
@@ -319,51 +449,96 @@ public class HelpersCard extends JPanel
return menu;
}
private static final int[] CHARGES_LOW_VALUES = {0, 10, 50, 100, 500, 1000};
private static final String[] CHARGES_LOW_LABELS = {"Aus (0)", "≤ 10", "≤ 50", "≤ 100", "≤ 500", "≤ 1000"};
private static final int[] INFO_LOW_VALUES = {0, 10, 50, 100, 500, 1000};
private static final String[] INFO_LOW_LABELS = {"Off", "≤ 10", "≤ 50", "≤ 100", "≤ 500", "≤ 1000"};
private JPopupMenu chargesMenu()
private JPopupMenu itemInformationMenu()
{
JPopupMenu menu = new JPopupMenu();
for (String[] opt : new String[][]{
{"itemChargesWeapons", "Waffen & Rüstung anzeigen"},
{"itemChargesBags", "Looting- & Coal-Bag anzeigen"},
{"itemChargesBank", "In der Bank anzeigen"}
})
{
boolean on = plugin.getHelperValue(opt[0], Boolean.class, true);
JCheckBoxMenuItem item = new JCheckBoxMenuItem(opt[1], on);
item.addActionListener(e -> plugin.setHelperOption(opt[0], on ? false : true));
menu.add(item);
}
// Information types
boolean geOn = plugin.getHelperValue("itemInformationGe", Boolean.class, true);
JCheckBoxMenuItem geItem = new JCheckBoxMenuItem("Show GE price", geOn);
geItem.addActionListener(e -> plugin.setHelperOption("itemInformationGe", !geOn));
menu.add(geItem);
boolean haOn = plugin.getHelperValue("itemInformationHa", Boolean.class, true);
JCheckBoxMenuItem haItem = new JCheckBoxMenuItem("Show High Alchemy value", haOn);
haItem.addActionListener(e -> plugin.setHelperOption("itemInformationHa", !haOn));
menu.add(haItem);
boolean bankedOn = plugin.getHelperValue("itemInformationBanked", Boolean.class, true);
JCheckBoxMenuItem bankedItem = new JCheckBoxMenuItem("Show banked quantity", bankedOn);
bankedItem.addActionListener(e -> plugin.setHelperOption("itemInformationBanked", !bankedOn));
menu.add(bankedItem);
boolean chargesOn = plugin.getHelperValue("itemInformationCharges", Boolean.class, true);
JCheckBoxMenuItem chargesItem = new JCheckBoxMenuItem("Show item charges", chargesOn);
chargesItem.addActionListener(e -> plugin.setHelperOption("itemInformationCharges", !chargesOn));
menu.add(chargesItem);
menu.addSeparator();
JMenu size = new JMenu("Textgröße");
TimerTextSize currentSize = plugin.getHelperValue("itemChargesSize", TimerTextSize.class, TimerTextSize.SMALL);
// Charge categories (relevant when charges are enabled)
boolean weaponsOn = plugin.getHelperValue("itemInformationChargesWeapons", Boolean.class, true);
JCheckBoxMenuItem weaponsItem = new JCheckBoxMenuItem("Weapons and armor", weaponsOn);
weaponsItem.setEnabled(chargesOn);
weaponsItem.addActionListener(e -> plugin.setHelperOption("itemInformationChargesWeapons", !weaponsOn));
menu.add(weaponsItem);
boolean bagsOn = plugin.getHelperValue("itemInformationChargesBags", Boolean.class, true);
JCheckBoxMenuItem bagsItem = new JCheckBoxMenuItem("Bags and containers", bagsOn);
bagsItem.setEnabled(chargesOn);
bagsItem.addActionListener(e -> plugin.setHelperOption("itemInformationChargesBags", !bagsOn));
menu.add(bagsItem);
menu.addSeparator();
// Display locations
boolean bankHover = plugin.getHelperValue("itemInformationBank", Boolean.class, true);
JCheckBoxMenuItem bankHoverItem = new JCheckBoxMenuItem("Show in bank", bankHover);
bankHoverItem.addActionListener(e -> plugin.setHelperOption("itemInformationBank", !bankHover));
menu.add(bankHoverItem);
boolean equipOn = plugin.getHelperValue("itemInformationEquipment", Boolean.class, true);
JCheckBoxMenuItem equipItem = new JCheckBoxMenuItem("Show charges in equipment", equipOn);
equipItem.setEnabled(chargesOn);
equipItem.addActionListener(e -> plugin.setHelperOption("itemInformationEquipment", !equipOn));
menu.add(equipItem);
boolean bankCharges = plugin.getHelperValue("itemInformationChargesBank", Boolean.class, true);
JCheckBoxMenuItem bankChargesItem = new JCheckBoxMenuItem("Show charges in bank", bankCharges);
bankChargesItem.setEnabled(chargesOn);
bankChargesItem.addActionListener(e -> plugin.setHelperOption("itemInformationChargesBank", !bankCharges));
menu.add(bankChargesItem);
menu.addSeparator();
// Appearance & Warnings
JMenu size = new JMenu("Text size");
TimerTextSize currentSize = plugin.getHelperValue("itemInformationSize", TimerTextSize.class, TimerTextSize.SMALL);
for (TimerTextSize s : TimerTextSize.values())
{
size.add(radio(s.toString(), s == currentSize, () -> plugin.setHelperOption("itemChargesSize", s)));
size.add(radio(s.toString(), s == currentSize, () -> plugin.setHelperOption("itemInformationSize", s)));
}
menu.add(size);
JMenu lowThreshold = new JMenu("Warnung bei wenig Ladung");
int currentLowAt = plugin.getHelperValue("itemChargesLowAt", Integer.class, 0);
for (int i = 0; i < CHARGES_LOW_VALUES.length; i++)
JMenu lowThreshold = new JMenu("Low-charge warning");
int currentLowAt = plugin.getHelperValue("itemInformationLowAt", Integer.class, 0);
for (int i = 0; i < INFO_LOW_VALUES.length; i++)
{
int value = CHARGES_LOW_VALUES[i];
lowThreshold.add(radio(CHARGES_LOW_LABELS[i], value == currentLowAt, () -> plugin.setHelperOption("itemChargesLowAt", value)));
int value = INFO_LOW_VALUES[i];
lowThreshold.add(radio(INFO_LOW_LABELS[i], value == currentLowAt, () -> plugin.setHelperOption("itemInformationLowAt", value)));
}
menu.add(lowThreshold);
menu.addSeparator();
menu.add(colorItem("Farbe...", "itemChargesColor", "Item Charges Primärfarbe", Color.WHITE));
menu.add(colorItem("Sekundärfarbe...", "itemChargesSecondaryColor", "Item Charges Sekundärfarbe", new Color(0xC0C0C0)));
menu.add(colorItem("Warnfarbe...", "itemChargesLowColor", "Item Charges Warnfarbe", new Color(0xFF3333)));
menu.add(colorItem("Primary color...", "itemInformationColor", "Item info primary color", Color.WHITE));
menu.add(colorItem("Secondary color...", "itemInformationSecondaryColor", "Item info secondary color", new Color(0xC0C0C0)));
menu.add(colorItem("Warning color...", "itemInformationLowColor", "Item info warning color", new Color(0xFF3333)));
menu.addSeparator();
JMenuItem forgetCharges = new JMenuItem("Gespeicherte Ladungen zurücksetzen");
JMenuItem forgetCharges = new JMenuItem("Reset saved charges");
forgetCharges.addActionListener(e ->
{
if (plugin.getItemCharges() != null)
@@ -373,7 +548,7 @@ public class HelpersCard extends JPanel
});
menu.add(forgetCharges);
JMenuItem forgetCoal = new JMenuItem("Coal bag vergessen");
JMenuItem forgetCoal = new JMenuItem("Forget coal bag");
forgetCoal.addActionListener(e ->
{
if (plugin.getItemCharges() != null)
@@ -383,10 +558,16 @@ public class HelpersCard extends JPanel
});
menu.add(forgetCoal);
JMenuItem reset = new JMenuItem("Aussehen zurücksetzen");
menu.addSeparator();
JMenuItem reset = new JMenuItem("Reset appearance");
reset.addActionListener(e ->
{
for (String key : new String[]{"itemChargesWeapons", "itemChargesBags", "itemChargesBank", "itemChargesSize", "itemChargesLowAt", "itemChargesColor", "itemChargesSecondaryColor", "itemChargesLowColor"})
for (String key : new String[]{
"itemInformationSize", "itemInformationLowAt", "itemInformationColor",
"itemInformationSecondaryColor", "itemInformationLowColor",
"itemChargesSize", "itemChargesLowAt", "itemChargesColor",
"itemChargesSecondaryColor", "itemChargesLowColor"
})
{
plugin.setHelperOption(key, null);
}
@@ -491,18 +672,18 @@ public class HelpersCard extends JPanel
}
private static final int[] CHAT_CLEAR_TIMES = {0, 30, 60, 120, 300};
private static final String[] CHAT_CLEAR_TIME_LABELS = {"Aus (0s)", "30 Sekunden", "1 Minute (Standard)", "2 Minuten", "5 Minuten"};
private static final String[] CHAT_CLEAR_TIME_LABELS = {"Off (0s)", "30 seconds", "1 minute (default)", "2 minutes", "5 minutes"};
private JPopupMenu chatMenu()
{
JPopupMenu menu = new JPopupMenu();
boolean autoClear = plugin.getHelperValue("chatAutoClear", Boolean.class, true);
JCheckBoxMenuItem autoClearItem = new JCheckBoxMenuItem("Automatisch aufräumen", autoClear);
JCheckBoxMenuItem autoClearItem = new JCheckBoxMenuItem("Auto-clear messages", autoClear);
autoClearItem.addActionListener(e -> plugin.setHelperOption("chatAutoClear", !autoClear));
menu.add(autoClearItem);
JMenu clearTimeMenu = new JMenu("Auto-Clear Zeit");
JMenu clearTimeMenu = new JMenu("Auto-clear delay");
int currentTime = plugin.getHelperValue("chatAutoClearTime", Integer.class, 60);
for (int i = 0; i < CHAT_CLEAR_TIMES.length; i++)
{
@@ -511,12 +692,12 @@ public class HelpersCard extends JPanel
}
menu.add(clearTimeMenu);
JMenu typesMenu = new JMenu("Was wird aufgeräumt?");
JMenu typesMenu = new JMenu("Message types to clear");
for (String[] opt : new String[][]{
{"chatClearGame", "Spiel- & System-Meldungen", "true"},
{"chatClearPublic", "Öffentlicher Chat (Public)", "true"},
{"chatClearPrivate", "Private Nachrichten (PM)", "false"},
{"chatClearClan", "Clan- & Freunde-Chat", "false"}
{"chatClearGame", "Game and system messages", "true"},
{"chatClearPublic", "Public chat", "true"},
{"chatClearPrivate", "Private messages (PMs)", "false"},
{"chatClearClan", "Clan and friends chat", "false"}
})
{
boolean def = Boolean.parseBoolean(opt[2]);
@@ -530,10 +711,10 @@ public class HelpersCard extends JPanel
menu.addSeparator();
for (String[] opt : new String[][]{
{"chatCollapseDuplicates", "Duplikate zusammenfassen (x2, x3...)"},
{"chatFilterSpam", "Spam & Systemlärm filtern"},
{"chatHighlightName", "Eigenen Namen hervorheben"},
{"chatCopyToClipboard", "Rechtsklick 'Kopieren' aktivieren"}
{"chatCollapseDuplicates", "Collapse duplicates (x2, x3...)"},
{"chatFilterSpam", "Filter spam and game noise"},
{"chatHighlightName", "Highlight your name"},
{"chatCopyToClipboard", "Enable right-click copying"}
})
{
boolean on = plugin.getHelperValue(opt[0], Boolean.class, true);
@@ -542,11 +723,12 @@ public class HelpersCard extends JPanel
menu.add(item);
}
menu.add(colorItem("Highlight-Farbe...", "chatHighlightColor", "Chat Highlight-Farbe", new Color(0xFFD700)));
menu.add(colorItem("Highlight color...", "chatHighlightColor", "Chat highlight color", new Color(0xFFD700)));
menu.add(colorItem("Clan message color...", "chatClanMessageColor", "Clan message color", new Color(0x66FFFF)));
menu.addSeparator();
JMenuItem clearNow = new JMenuItem("Chat jetzt leeren");
JMenuItem clearNow = new JMenuItem("Clear chat now");
clearNow.addActionListener(e ->
{
if (plugin.getChatHelper() != null)
@@ -556,13 +738,13 @@ public class HelpersCard extends JPanel
});
menu.add(clearNow);
JMenuItem reset = new JMenuItem("Einstellungen zurücksetzen");
JMenuItem reset = new JMenuItem("Reset settings");
reset.addActionListener(e ->
{
for (String key : new String[]{
"chatAutoClear", "chatAutoClearTime", "chatClearGame", "chatClearPublic",
"chatClearPrivate", "chatClearClan", "chatCollapseDuplicates",
"chatFilterSpam", "chatHighlightName", "chatHighlightColor", "chatCopyToClipboard"
"chatFilterSpam", "chatHighlightName", "chatHighlightColor", "chatClanMessageColor", "chatCopyToClipboard"
})
{
plugin.setHelperOption(key, null);
@@ -573,6 +755,115 @@ public class HelpersCard extends JPanel
return menu;
}
private JPopupMenu idleNotifierMenu()
{
JPopupMenu menu = new JPopupMenu();
menu.add(secondsMenu("Minimum action", "idleNotifierMinActionSeconds", 5, new int[]{5, 10, 15, 30, 60, 120}));
menu.add(secondsMenu("Idle delay", "idleNotifierIdleSeconds", 5, new int[]{1, 2, 3, 5, 10, 15, 30, 60}));
JMenu blinkDuration = new JMenu("Blink duration");
int currentDuration = plugin.getHelperValue("idleNotifierFlashDurationMs", Integer.class, 1500);
int[] durations = {750, 1000, 1500, 2000, 3000, 5000};
ButtonGroup durationGroup = new ButtonGroup();
for (int duration : durations)
{
String label = duration % 1000 == 0 ? (duration / 1000) + " seconds" : (duration / 1000.0) + " seconds";
JRadioButtonMenuItem item = radio(label, duration == currentDuration, () -> plugin.setHelperOption("idleNotifierFlashDurationMs", duration));
durationGroup.add(item);
blinkDuration.add(item);
}
menu.add(blinkDuration);
JMenu blinkInterval = new JMenu("Blink speed");
int currentInterval = plugin.getHelperValue("idleNotifierBlinkIntervalMs", Integer.class, 250);
int[] intervals = {100, 150, 250, 400, 600};
ButtonGroup intervalGroup = new ButtonGroup();
for (int interval : intervals)
{
JRadioButtonMenuItem item = radio(interval + " ms", interval == currentInterval, () -> plugin.setHelperOption("idleNotifierBlinkIntervalMs", interval));
intervalGroup.add(item);
blinkInterval.add(item);
}
menu.add(blinkInterval);
menu.addSeparator();
boolean soundOn = plugin.getHelperValue("idleNotifierSound", Boolean.class, true);
JCheckBoxMenuItem sound = new JCheckBoxMenuItem("Play sound", soundOn);
sound.addActionListener(e -> plugin.setHelperOption("idleNotifierSound", sound.isSelected()));
menu.add(sound);
boolean repeatOn = plugin.getHelperValue("idleNotifierRepeat", Boolean.class, false);
JCheckBoxMenuItem repeat = new JCheckBoxMenuItem("Repeat while idle", repeatOn);
repeat.addActionListener(e -> plugin.setHelperOption("idleNotifierRepeat", repeat.isSelected()));
menu.add(repeat);
menu.add(secondsMenu("Repeat every", "idleNotifierRepeatIntervalSeconds", 5, new int[]{1, 2, 3, 5, 10, 15, 30, 60}));
menu.add(secondsMenu("Repeat for", "idleNotifierRepeatDurationSeconds", 15, new int[]{5, 10, 15, 30, 60, 120, 300}));
JMenu soundChoice = new JMenu("Sound effect");
int currentSoundId = plugin.getHelperValue("idleNotifierSoundId", Integer.class, SoundEffectID.GE_ADD_OFFER_DINGALING);
int[] soundIds = {SoundEffectID.GE_ADD_OFFER_DINGALING, SoundEffectID.UI_BOOP, SoundEffectID.GE_COIN_TINKLE, SoundEffectID.TOWN_CRIER_BELL_DING};
String[] soundLabels = {"GE ding", "Soft boop", "Coin tinkle", "Bell"};
ButtonGroup soundGroup = new ButtonGroup();
for (int i = 0; i < soundIds.length; i++)
{
int soundId = soundIds[i];
JRadioButtonMenuItem item = radio(soundLabels[i], soundId == currentSoundId, () -> plugin.setHelperOption("idleNotifierSoundId", soundId));
soundGroup.add(item);
soundChoice.add(item);
}
menu.add(soundChoice);
JMenu volume = new JMenu("Sound volume");
int currentVolume = plugin.getHelperValue("idleNotifierSoundVolume", Integer.class, 64);
int[] volumes = {32, 64, 96, 127};
String[] volumeLabels = {"Quiet", "Normal", "Loud", "Full"};
ButtonGroup volumeGroup = new ButtonGroup();
for (int i = 0; i < volumes.length; i++)
{
int value = volumes[i];
JRadioButtonMenuItem item = radio(volumeLabels[i], value == currentVolume, () -> plugin.setHelperOption("idleNotifierSoundVolume", value));
volumeGroup.add(item);
volume.add(item);
}
menu.add(volume);
JMenuItem testSound = new JMenuItem("Test sound");
testSound.addActionListener(e -> plugin.previewIdleNotifierSound());
menu.add(testSound);
menu.addSeparator();
JMenuItem reset = new JMenuItem("Reset options");
reset.addActionListener(e ->
{
for (String key : new String[]{
"idleNotifierMinActionSeconds", "idleNotifierIdleSeconds", "idleNotifierFlashDurationMs",
"idleNotifierBlinkIntervalMs", "idleNotifierSound", "idleNotifierSoundId", "idleNotifierSoundVolume",
"idleNotifierRepeat", "idleNotifierRepeatIntervalSeconds", "idleNotifierRepeatDurationSeconds"
})
{
plugin.setHelperOption(key, null);
}
});
menu.add(reset);
return menu;
}
private JMenu secondsMenu(String title, String key, int defaultValue, int[] values)
{
JMenu menu = new JMenu(title);
int current = plugin.getHelperValue(key, Integer.class, defaultValue);
ButtonGroup group = new ButtonGroup();
for (int value : values)
{
JRadioButtonMenuItem item = radio(value + (value == 1 ? " second" : " seconds"), value == current,
() -> plugin.setHelperOption(key, value));
group.add(item);
menu.add(item);
}
return menu;
}
/** Checkbox for a switch that is on by default (turning it on stores the default explicitly). */
private JCheckBoxMenuItem toggle(String key, String label)
@@ -8,21 +8,26 @@ import java.awt.Graphics;
import java.awt.Graphics2D;
import java.awt.event.MouseAdapter;
import java.awt.event.MouseEvent;
import java.util.List;
import java.util.function.BiConsumer;
import java.util.function.Consumer;
import java.util.function.IntConsumer;
import javax.swing.JComponent;
import javax.swing.SwingUtilities;
import net.runelite.client.ui.FontManager;
/** Segmented control (time range, chart style). */
public class Segmented extends JComponent
{
private final String[] labels;
private String[] labels;
private final IntConsumer onSelect;
private BiConsumer<Integer, MouseEvent> onRightClick;
private int selected;
private int hover = -1;
public Segmented(String[] labels, int selected, IntConsumer onSelect)
{
this.labels = labels;
this.labels = labels != null ? labels.clone() : new String[0];
this.selected = selected;
this.onSelect = onSelect;
setCursor(Cursor.getPredefinedCursor(Cursor.HAND_CURSOR));
@@ -49,11 +54,36 @@ public class Segmented extends JComponent
@Override
public void mousePressed(MouseEvent e)
{
int i = indexAt(e.getX());
if (i >= 0)
if (e.isPopupTrigger() || SwingUtilities.isRightMouseButton(e))
{
setSelected(i);
onSelect.accept(i);
int i = indexAt(e.getX());
if (i >= 0 && onRightClick != null)
{
onRightClick.accept(i, e);
}
return;
}
if (SwingUtilities.isLeftMouseButton(e))
{
int i = indexAt(e.getX());
if (i >= 0)
{
setSelected(i);
onSelect.accept(i);
}
}
}
@Override
public void mouseReleased(MouseEvent e)
{
if (e.isPopupTrigger())
{
int i = indexAt(e.getX());
if (i >= 0 && onRightClick != null)
{
onRightClick.accept(i, e);
}
}
}
};
@@ -61,7 +91,69 @@ public class Segmented extends JComponent
addMouseMotionListener(mouse);
}
static Segmented ranges(TimeRange initial, java.util.function.Consumer<TimeRange> onSelect)
public void setOnRightClick(BiConsumer<Integer, MouseEvent> onRightClick)
{
this.onRightClick = onRightClick;
}
public void setLabels(String[] labels)
{
this.labels = labels != null ? labels.clone() : new String[0];
repaint();
revalidate();
}
public void setLabel(int index, String label)
{
if (index >= 0 && index < labels.length)
{
labels[index] = label;
repaint();
revalidate();
}
}
public String[] getLabels()
{
return labels.clone();
}
public static Segmented ranges(TimeFilter filter, Consumer<TimeRange> onSelect)
{
List<TimeRange> slots = filter.getSlots();
String[] labels = new String[slots.size()];
for (int i = 0; i < slots.size(); i++)
{
labels[i] = slots.get(i).getLabel();
}
Segmented bar = new Segmented(labels, filter.getSlotIndex(filter.getRange()), i -> {
List<TimeRange> currentSlots = filter.getSlots();
if (i >= 0 && i < currentSlots.size())
{
onSelect.accept(currentSlots.get(i));
}
});
bar.setOnRightClick((slotIndex, mouseEvent) -> {
TimeRangeMenu.show(bar, filter, slotIndex, mouseEvent, onSelect);
});
filter.addListener(() -> SwingUtilities.invokeLater(() -> {
List<TimeRange> currentSlots = filter.getSlots();
String[] currentLabels = new String[currentSlots.size()];
for (int i = 0; i < currentSlots.size(); i++)
{
currentLabels[i] = currentSlots.get(i).getLabel();
}
bar.setLabels(currentLabels);
bar.setSelected(filter.getSlotIndex(filter.getRange()));
}));
return bar;
}
public static Segmented ranges(TimeRange initial, Consumer<TimeRange> onSelect)
{
TimeRange[] values = TimeRange.values();
String[] labels = new String[values.length];
@@ -69,7 +161,7 @@ public class Segmented extends JComponent
{
labels[i] = values[i].getLabel();
}
return new Segmented(labels, initial.ordinal(), i -> onSelect.accept(values[i]));
return new Segmented(labels, initial != null ? initial.ordinal() : 0, i -> onSelect.accept(values[i]));
}
public void setSelected(int index)
@@ -78,12 +170,32 @@ public class Segmented extends JComponent
repaint();
}
public int getSelected()
{
return selected;
}
private int indexAt(int x)
{
if (labels == null || labels.length == 0)
{
return -1;
}
int i = x * labels.length / Math.max(1, getWidth());
return i >= 0 && i < labels.length ? i : -1;
}
@Override
public String getToolTipText(MouseEvent e)
{
int i = indexAt(e.getX());
if (i >= 0 && labels != null && i < labels.length)
{
return labels[i] + " (Right-click to customize)";
}
return super.getToolTipText(e);
}
@Override
public Dimension getPreferredSize()
{
@@ -93,11 +205,15 @@ public class Segmented extends JComponent
}
FontMetrics fm = getFontMetrics(FontManager.getRunescapeSmallFont());
int w = 0;
for (String l : labels)
if (labels != null)
{
w = Math.max(w, fm.stringWidth(l));
for (String l : labels)
{
w = Math.max(w, fm.stringWidth(l));
}
return new Dimension((w + 16) * labels.length, 24);
}
return new Dimension((w + 16) * labels.length, 24);
return new Dimension(100, 24);
}
@Override
@@ -111,23 +227,26 @@ public class Segmented extends JComponent
g.setFont(h >= 28 ? FontManager.getRunescapeBoldFont() : FontManager.getRunescapeSmallFont());
FontMetrics fm = g.getFontMetrics();
for (int i = 0; i < labels.length; i++)
if (labels != null)
{
int x0 = w * i / labels.length;
int x1 = w * (i + 1) / labels.length;
boolean sel = i == selected;
if (sel)
for (int i = 0; i < labels.length; i++)
{
g.setColor(Ui.ACCENT);
g.fillRoundRect(x0 + 2, 2, x1 - x0 - 4, h - 4, 6, 6);
int x0 = w * i / labels.length;
int x1 = w * (i + 1) / labels.length;
boolean sel = i == selected;
if (sel)
{
g.setColor(Ui.ACCENT);
g.fillRoundRect(x0 + 2, 2, x1 - x0 - 4, h - 4, 6, 6);
}
else if (i == hover)
{
g.setColor(Ui.CARD_HOVER.brighter());
g.fillRoundRect(x0 + 2, 2, x1 - x0 - 4, h - 4, 6, 6);
}
g.setColor(sel ? Color.WHITE : i == hover ? Ui.TEXT : Ui.MUTED);
g.drawString(labels[i], x0 + (x1 - x0 - fm.stringWidth(labels[i])) / 2, (h + fm.getAscent()) / 2 - 1);
}
else if (i == hover)
{
g.setColor(Ui.CARD_HOVER.brighter());
g.fillRoundRect(x0 + 2, 2, x1 - x0 - 4, h - 4, 6, 6);
}
g.setColor(sel ? Color.WHITE : i == hover ? Ui.TEXT : Ui.MUTED);
g.drawString(labels[i], x0 + (x1 - x0 - fm.stringWidth(labels[i])) / 2, (h + fm.getAscent()) / 2 - 1);
}
g.dispose();
}
@@ -73,7 +73,25 @@ final class Series
out.add(s);
}
}
if (before != null && (out.isEmpty() || out.get(0).t > start))
if (!out.isEmpty() && out.get(0).t > start)
{
// If the point before start was before a significant offline gap (> 10 mins),
// do not carry that old pre-logout value across the gap into this active session/range.
// Instead, the baseline at the start of this active period is its first active value.
if (before == null || (start - before.t > 10 * 60 * 1000L))
{
Snapshot first = new Snapshot(start, out.get(0).v);
first.m = out.get(0).m;
out.add(0, first);
}
else
{
Snapshot first = new Snapshot(start, before.v);
first.m = before.m;
out.add(0, first);
}
}
else if (before != null && out.isEmpty())
{
Snapshot first = new Snapshot(start, before.v);
first.m = before.m;
@@ -132,15 +150,15 @@ final class Series
for (int i = 0; i < all.size(); i++)
{
List<Snapshot> s = all.get(i);
if (s.isEmpty())
{
continue;
}
while (idx[i] + 1 < s.size() && s.get(idx[i] + 1).t <= t)
{
idx[i]++;
}
if (s.isEmpty() || s.get(idx[i]).t > t)
{
continue;
}
Snapshot p = s.get(idx[i]);
Snapshot p = s.get(idx[i]).t > t ? s.get(0) : s.get(idx[i]);
for (int c = 0; c < v.length; c++)
{
v[c] += p.get(WealthCategory.values()[c]);
@@ -345,7 +345,7 @@ public class SkillsPanel extends PluginPanel
long[] rateStarts = h.rateStarts(timeFilter);
List<XpSample> samples = h.range(start);
long chartStart = samples.isEmpty() ? now - Ui.HOUR : Math.max(start, samples.get(0).t);
if (timeFilter.getRange() != TimeRange.ALL)
if (timeFilter.getRange() != null && !timeFilter.getRange().isAll())
{
chartStart = start;
}
@@ -1,5 +1,7 @@
package com.example.essentialsplus.ui;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.function.Consumer;
@@ -14,29 +16,144 @@ public final class TimeFilter
/** login time of the current game session (hopping keeps it) */
private volatile long sessionStart = System.currentTimeMillis();
public static final TimeRange[] DEFAULT_SLOTS = {
TimeRange.CURRENT,
TimeRange.SESSION,
TimeRange.HOUR,
TimeRange.DAY,
TimeRange.WEEK,
TimeRange.MONTH,
TimeRange.ALL
};
private final List<TimeRange> slots = new CopyOnWriteArrayList<>();
private final Consumer<String> onPersistSlots;
private volatile TimeRange range;
private final Consumer<TimeRange> onPersist;
private final List<Runnable> listeners = new CopyOnWriteArrayList<>();
public TimeFilter(TimeRange initial, Consumer<TimeRange> onPersist)
{
this.range = initial == null ? TimeRange.DAY : initial;
this(initial, onPersist, null, null);
}
public TimeFilter(TimeRange initial, Consumer<TimeRange> onPersist, String savedSlots, Consumer<String> onPersistSlots)
{
this.onPersist = onPersist;
this.onPersistSlots = onPersistSlots;
initSlots(savedSlots);
this.range = initial == null ? TimeRange.DAY : initial;
}
private void initSlots(String saved)
{
slots.clear();
if (saved != null && !saved.trim().isEmpty())
{
String[] parts = saved.split(",");
for (String part : parts)
{
TimeRange r = parse(part.trim());
if (r != null)
{
slots.add(r);
}
}
}
if (slots.size() != DEFAULT_SLOTS.length)
{
slots.clear();
for (TimeRange def : DEFAULT_SLOTS)
{
slots.add(def);
}
}
}
public List<TimeRange> getSlots()
{
return Collections.unmodifiableList(new ArrayList<>(slots));
}
public int getSlotIndex(TimeRange r)
{
if (r == null)
{
return -1;
}
for (int i = 0; i < slots.size(); i++)
{
if (r.equals(slots.get(i)))
{
return i;
}
}
return -1;
}
public void setSlot(int index, TimeRange newRange)
{
if (index < 0 || index >= slots.size() || newRange == null)
{
return;
}
slots.set(index, newRange);
persistSlots();
for (Runnable l : listeners)
{
l.run();
}
}
public void resetSlot(int index)
{
if (index < 0 || index >= slots.size())
{
return;
}
slots.set(index, DEFAULT_SLOTS[index]);
persistSlots();
for (Runnable l : listeners)
{
l.run();
}
}
public void resetSlots()
{
slots.clear();
for (TimeRange def : DEFAULT_SLOTS)
{
slots.add(def);
}
persistSlots();
for (Runnable l : listeners)
{
l.run();
}
}
private void persistSlots()
{
if (onPersistSlots != null)
{
StringBuilder sb = new StringBuilder();
for (int i = 0; i < slots.size(); i++)
{
if (i > 0)
{
sb.append(",");
}
sb.append(slots.get(i).name());
}
onPersistSlots.accept(sb.toString());
}
}
public static TimeRange parse(String name)
{
if (name != null)
{
for (TimeRange r : TimeRange.values())
{
if (r.name().equals(name))
{
return r;
}
}
}
return TimeRange.DAY;
return TimeRange.parse(name);
}
public TimeRange getRange()
@@ -46,23 +163,25 @@ public final class TimeFilter
public String getLabel()
{
return range.getLabel();
return range != null ? range.getLabel() : "";
}
/** Label for "nothing in ..." texts. */
public String inLabel()
{
switch (range)
if (range == null || range.isAll())
{
case CURRENT:
return "right now";
case SESSION:
return "this session";
case ALL:
return "total";
default:
return "in " + range.getLabel();
return "total";
}
if (range.isCurrent())
{
return "right now";
}
if (range.isSession())
{
return "this session";
}
return "in " + range.getLabel();
}
public long getSessionStart()
@@ -82,7 +201,7 @@ public final class TimeFilter
public boolean isCurrent()
{
return range == TimeRange.CURRENT;
return range != null && range.isCurrent();
}
/**
@@ -92,17 +211,19 @@ public final class TimeFilter
public long start()
{
long now = System.currentTimeMillis();
switch (range)
if (range == null || range.isAll())
{
case ALL:
return 0;
case SESSION:
return sessionStart;
case CURRENT:
return now - ACTIVE_GAP;
default:
return now - range.getMillis();
return 0;
}
if (range.isSession())
{
return sessionStart;
}
if (range.isCurrent())
{
return now - ACTIVE_GAP;
}
return now - range.getMillis();
}
/**
@@ -144,7 +265,7 @@ public final class TimeFilter
{
return;
}
boolean changed = r != range;
boolean changed = !r.equals(range);
range = r;
if (changed && onPersist != null)
{
@@ -1,22 +1,288 @@
package com.example.essentialsplus.ui;
import java.util.Locale;
import java.util.Objects;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
@Getter
@RequiredArgsConstructor
public enum TimeRange
public final class TimeRange
{
/** only what is being gained right now: resets after {@link TimeFilter#ACTIVE_GAP} without progress */
CURRENT("Now", -1),
public static final TimeRange CURRENT = new TimeRange("CURRENT", "Now", -1);
/** since the client was started */
SESSION("Sess", -1),
HOUR("1H", Ui.HOUR),
DAY("1D", Ui.DAY),
TWO_WEEKS("2W", 14 * Ui.DAY),
QUARTER("3M", 91 * Ui.DAY),
ALL("All", -1);
public static final TimeRange SESSION = new TimeRange("SESSION", "Sess", -1);
public static final TimeRange HOUR = new TimeRange("HOUR", "1H", Ui.HOUR);
public static final TimeRange DAY = new TimeRange("DAY", "1D", Ui.DAY);
public static final TimeRange WEEK = new TimeRange("WEEK", "1W", 7 * Ui.DAY);
public static final TimeRange MONTH = new TimeRange("MONTH", "1M", 30 * Ui.DAY);
public static final TimeRange ALL = new TimeRange("ALL", "All", -1);
public static final TimeRange[] DEFAULT_VALUES = {
CURRENT, SESSION, HOUR, DAY, WEEK, MONTH, ALL
};
private static final Pattern PATTERN = Pattern.compile("^([0-9]+(?:\\.[0-9]+)?)\\s*([a-zA-Z]+)$");
private final String id;
private final String label;
private final long millis;
public TimeRange(String id, String label, long millis)
{
this.id = id;
this.label = label;
this.millis = millis;
}
public static TimeRange[] values()
{
return DEFAULT_VALUES.clone();
}
public static TimeRange valueOf(String name)
{
if (name == null)
{
throw new NullPointerException("Name is null");
}
for (TimeRange r : DEFAULT_VALUES)
{
if (r.name().equals(name))
{
return r;
}
}
throw new IllegalArgumentException("No constant " + name);
}
public String name()
{
return id != null ? id : (millis > 0 ? "CUSTOM:" + label + ":" + millis : label);
}
public int ordinal()
{
for (int i = 0; i < DEFAULT_VALUES.length; i++)
{
if (this.equals(DEFAULT_VALUES[i]))
{
return i;
}
}
return -1;
}
public boolean isCurrent()
{
return this == CURRENT || "CURRENT".equals(id) || "Now".equals(label);
}
public boolean isSession()
{
return this == SESSION || "SESSION".equals(id) || "Sess".equals(label);
}
public boolean isAll()
{
return this == ALL || "ALL".equals(id) || "All".equals(label);
}
public static TimeRange custom(String label, long millis)
{
for (TimeRange def : DEFAULT_VALUES)
{
if (def.millis == millis && def.label.equalsIgnoreCase(label))
{
return def;
}
}
return new TimeRange("CUSTOM:" + label + ":" + millis, label, millis);
}
public static TimeRange parseCustom(String input)
{
if (input == null)
{
return null;
}
String s = input.trim();
if (s.isEmpty())
{
return null;
}
if (s.equalsIgnoreCase("now"))
{
return CURRENT;
}
if (s.equalsIgnoreCase("sess") || s.equalsIgnoreCase("session"))
{
return SESSION;
}
if (s.equalsIgnoreCase("all") || s.equalsIgnoreCase("all time"))
{
return ALL;
}
Matcher m = PATTERN.matcher(s);
if (!m.matches())
{
return null;
}
try
{
double val = Double.parseDouble(m.group(1));
if (val <= 0 || Double.isNaN(val) || Double.isInfinite(val))
{
return null;
}
String rawUnit = m.group(2);
String unit = rawUnit.toLowerCase(Locale.ROOT);
long millis;
String label;
String numStr = (val == (long) val) ? String.valueOf((long) val) : String.format(Locale.ROOT, "%.1f", val);
if (unit.equals("s") || unit.startsWith("sec"))
{
millis = (long) (val * 1000L);
label = numStr + "s";
}
else if (unit.equals("min") || unit.startsWith("minute"))
{
millis = (long) (val * 60 * 1000L);
label = numStr + "m";
}
else if (unit.equals("h") || unit.startsWith("hour") || unit.startsWith("hr"))
{
millis = (long) (val * Ui.HOUR);
label = numStr + "H";
}
else if (unit.equals("d") || unit.startsWith("day"))
{
millis = (long) (val * Ui.DAY);
label = numStr + "D";
}
else if (unit.equals("w") || unit.startsWith("week") || unit.startsWith("wk"))
{
millis = (long) (val * 7 * Ui.DAY);
label = numStr + "W";
}
else if (unit.equals("mo") || unit.startsWith("mon") || unit.startsWith("month") || rawUnit.equals("M"))
{
millis = (long) (val * 30 * Ui.DAY);
label = numStr + "M";
}
else if (unit.equals("y") || unit.startsWith("year") || unit.startsWith("yr"))
{
millis = (long) (val * 365 * Ui.DAY);
label = numStr + "Y";
}
else if (unit.equals("m"))
{
if (val >= 15)
{
millis = (long) (val * 60 * 1000L);
label = numStr + "m";
}
else
{
millis = (long) (val * 30 * Ui.DAY);
label = numStr + "M";
}
}
else
{
return null;
}
if (millis <= 0)
{
return null;
}
return custom(label, millis);
}
catch (Exception e)
{
return null;
}
}
public static TimeRange parse(String name)
{
if (name == null || name.trim().isEmpty())
{
return TimeRange.DAY;
}
name = name.trim();
if ("TWO_WEEKS".equals(name))
{
return TimeRange.WEEK;
}
if ("QUARTER".equals(name))
{
return TimeRange.MONTH;
}
for (TimeRange r : DEFAULT_VALUES)
{
if (r.name().equalsIgnoreCase(name) || r.getLabel().equalsIgnoreCase(name))
{
return r;
}
}
if (name.startsWith("CUSTOM:"))
{
String[] p = name.split(":", 3);
if (p.length == 3)
{
try
{
return new TimeRange(name, p[1], Long.parseLong(p[2]));
}
catch (NumberFormatException ignored)
{
}
}
}
TimeRange custom = parseCustom(name);
if (custom != null)
{
return custom;
}
return TimeRange.DAY;
}
@Override
public boolean equals(Object o)
{
if (this == o)
{
return true;
}
if (o == null || getClass() != o.getClass())
{
return false;
}
TimeRange that = (TimeRange) o;
return millis == that.millis && Objects.equals(label, that.label);
}
@Override
public int hashCode()
{
return Objects.hash(label, millis);
}
@Override
public String toString()
{
return label;
}
}
@@ -0,0 +1,190 @@
package com.example.essentialsplus.ui;
import java.awt.Component;
import java.awt.event.MouseEvent;
import java.util.List;
import java.util.function.Consumer;
import javax.swing.JMenu;
import javax.swing.JMenuItem;
import javax.swing.JOptionPane;
import javax.swing.JPopupMenu;
import net.runelite.client.ui.FontManager;
public final class TimeRangeMenu
{
private TimeRangeMenu()
{
}
public static void show(Component parent, TimeFilter filter, int slotIndex, MouseEvent e, Consumer<TimeRange> onSelect)
{
List<TimeRange> slots = filter.getSlots();
if (slotIndex < 0 || slotIndex >= slots.size())
{
return;
}
TimeRange current = slots.get(slotIndex);
TimeRange defaultRange = TimeFilter.DEFAULT_SLOTS[slotIndex];
JPopupMenu menu = new JPopupMenu();
JMenuItem title = new JMenuItem("Slot " + (slotIndex + 1) + " (" + current.getLabel() + ")");
title.setFont(FontManager.getRunescapeBoldFont());
title.setEnabled(false);
menu.add(title);
menu.addSeparator();
// 1. Minutes
JMenu minutesMenu = new JMenu("Minutes");
addPreset(minutesMenu, filter, slotIndex, onSelect, "5m", 5 * 60 * 1000L);
addPreset(minutesMenu, filter, slotIndex, onSelect, "10m", 10 * 60 * 1000L);
addPreset(minutesMenu, filter, slotIndex, onSelect, "15m", 15 * 60 * 1000L);
addPreset(minutesMenu, filter, slotIndex, onSelect, "30m", 30 * 60 * 1000L);
addPreset(minutesMenu, filter, slotIndex, onSelect, "45m", 45 * 60 * 1000L);
menu.add(minutesMenu);
// 2. Hours
JMenu hoursMenu = new JMenu("Hours");
addPreset(hoursMenu, filter, slotIndex, onSelect, "1H", Ui.HOUR);
addPreset(hoursMenu, filter, slotIndex, onSelect, "2H", 2 * Ui.HOUR);
addPreset(hoursMenu, filter, slotIndex, onSelect, "3H", 3 * Ui.HOUR);
addPreset(hoursMenu, filter, slotIndex, onSelect, "4H", 4 * Ui.HOUR);
addPreset(hoursMenu, filter, slotIndex, onSelect, "6H", 6 * Ui.HOUR);
addPreset(hoursMenu, filter, slotIndex, onSelect, "8H", 8 * Ui.HOUR);
addPreset(hoursMenu, filter, slotIndex, onSelect, "12H", 12 * Ui.HOUR);
menu.add(hoursMenu);
// 3. Days
JMenu daysMenu = new JMenu("Days");
addPreset(daysMenu, filter, slotIndex, onSelect, "1D", Ui.DAY);
addPreset(daysMenu, filter, slotIndex, onSelect, "2D", 2 * Ui.DAY);
addPreset(daysMenu, filter, slotIndex, onSelect, "3D", 3 * Ui.DAY);
addPreset(daysMenu, filter, slotIndex, onSelect, "4D", 4 * Ui.DAY);
addPreset(daysMenu, filter, slotIndex, onSelect, "5D", 5 * Ui.DAY);
addPreset(daysMenu, filter, slotIndex, onSelect, "6D", 6 * Ui.DAY);
addPreset(daysMenu, filter, slotIndex, onSelect, "7D", 7 * Ui.DAY);
menu.add(daysMenu);
// 4. Weeks
JMenu weeksMenu = new JMenu("Weeks");
addPreset(weeksMenu, filter, slotIndex, onSelect, "1W", 7 * Ui.DAY);
addPreset(weeksMenu, filter, slotIndex, onSelect, "2W", 14 * Ui.DAY);
addPreset(weeksMenu, filter, slotIndex, onSelect, "3W", 21 * Ui.DAY);
addPreset(weeksMenu, filter, slotIndex, onSelect, "4W", 28 * Ui.DAY);
addPreset(weeksMenu, filter, slotIndex, onSelect, "5W", 35 * Ui.DAY);
addPreset(weeksMenu, filter, slotIndex, onSelect, "6W", 42 * Ui.DAY);
addPreset(weeksMenu, filter, slotIndex, onSelect, "8W", 56 * Ui.DAY);
addPreset(weeksMenu, filter, slotIndex, onSelect, "12W", 84 * Ui.DAY);
menu.add(weeksMenu);
// 5. Months
JMenu monthsMenu = new JMenu("Months");
addPreset(monthsMenu, filter, slotIndex, onSelect, "1M", 30 * Ui.DAY);
addPreset(monthsMenu, filter, slotIndex, onSelect, "2M", 60 * Ui.DAY);
addPreset(monthsMenu, filter, slotIndex, onSelect, "3M", 90 * Ui.DAY);
addPreset(monthsMenu, filter, slotIndex, onSelect, "4M", 120 * Ui.DAY);
addPreset(monthsMenu, filter, slotIndex, onSelect, "6M", 180 * Ui.DAY);
addPreset(monthsMenu, filter, slotIndex, onSelect, "9M", 270 * Ui.DAY);
addPreset(monthsMenu, filter, slotIndex, onSelect, "12M", 360 * Ui.DAY);
menu.add(monthsMenu);
// 6. Years
JMenu yearsMenu = new JMenu("Years");
addPreset(yearsMenu, filter, slotIndex, onSelect, "1Y", 365 * Ui.DAY);
addPreset(yearsMenu, filter, slotIndex, onSelect, "2Y", 2 * 365 * Ui.DAY);
addPreset(yearsMenu, filter, slotIndex, onSelect, "3Y", 3 * 365 * Ui.DAY);
addPreset(yearsMenu, filter, slotIndex, onSelect, "5Y", 5 * 365 * Ui.DAY);
menu.add(yearsMenu);
// 7. Special
JMenu specialMenu = new JMenu("Special");
addSpecial(specialMenu, filter, slotIndex, onSelect, TimeRange.CURRENT, "Now (Live streak)");
addSpecial(specialMenu, filter, slotIndex, onSelect, TimeRange.SESSION, "Sess (Session)");
addSpecial(specialMenu, filter, slotIndex, onSelect, TimeRange.ALL, "All (All time)");
menu.add(specialMenu);
menu.addSeparator();
// 8. Custom...
JMenuItem customItem = new JMenuItem("Custom...");
customItem.addActionListener(a -> {
String input = JOptionPane.showInputDialog(
parent,
"Enter custom time range:\n(e.g., 30m, 2h, 3d, 2w, 5w, 6mo, 1y)",
"Custom Time Range for Slot " + (slotIndex + 1),
JOptionPane.QUESTION_MESSAGE
);
if (input != null && !input.trim().isEmpty())
{
TimeRange parsed = TimeRange.parseCustom(input.trim());
if (parsed != null)
{
applyRange(filter, slotIndex, onSelect, parsed);
}
else
{
JOptionPane.showMessageDialog(
parent,
"Could not parse \"" + input.trim() + "\".\n\nValid examples:\n• Minutes: 15m, 30min\n• Hours: 2h, 36h\n• Days: 3d, 10d\n• Weeks: 2w, 5w\n• Months: 2mo, 6m\n• Years: 1y, 2y\n• Special: now, sess, all",
"Invalid Format",
JOptionPane.ERROR_MESSAGE
);
}
}
});
menu.add(customItem);
menu.addSeparator();
// 9. Reset this slot
JMenuItem resetSlot = new JMenuItem("Reset Slot " + (slotIndex + 1) + " (Default: " + defaultRange.getLabel() + ")");
resetSlot.addActionListener(a -> {
filter.resetSlot(slotIndex);
if (onSelect != null)
{
onSelect.accept(defaultRange);
}
});
menu.add(resetSlot);
// 10. Reset all slots
JMenuItem resetAll = new JMenuItem("Reset All Slots to Default");
resetAll.addActionListener(a -> {
filter.resetSlots();
if (onSelect != null)
{
onSelect.accept(defaultRange);
}
});
menu.add(resetAll);
menu.show(parent, e.getX(), e.getY());
}
private static void addPreset(JMenu parent, TimeFilter filter, int slotIndex, Consumer<TimeRange> onSelect, String label, long millis)
{
JMenuItem item = new JMenuItem(label);
item.addActionListener(a -> {
TimeRange r = TimeRange.custom(label, millis);
applyRange(filter, slotIndex, onSelect, r);
});
parent.add(item);
}
private static void addSpecial(JMenu parent, TimeFilter filter, int slotIndex, Consumer<TimeRange> onSelect, TimeRange range, String desc)
{
JMenuItem item = new JMenuItem(desc);
item.addActionListener(a -> applyRange(filter, slotIndex, onSelect, range));
parent.add(item);
}
private static void applyRange(TimeFilter filter, int slotIndex, Consumer<TimeRange> onSelect, TimeRange range)
{
filter.setSlot(slotIndex, range);
if (onSelect != null)
{
onSelect.accept(range);
}
}
}
@@ -220,7 +220,7 @@ public class WealthPanel extends PluginPanel
sessionView = false;
if (popout != null)
{
popout.setRange(timeFilter.getRange().ordinal());
popout.setRange(timeFilter.getSlotIndex(timeFilter.getRange()));
}
refreshLater();
}
@@ -316,7 +316,7 @@ public class WealthPanel extends PluginPanel
public boolean isSessionView()
{
return sessionView || (timeFilter != null && timeFilter.getRange() == TimeRange.SESSION);
return sessionView || (timeFilter != null && timeFilter.getRange() != null && timeFilter.getRange().isSession());
}
public boolean isHourly()
@@ -329,6 +329,11 @@ public class WealthPanel extends PluginPanel
plugin.resetWealthHourly(key);
}
public TimeFilter getTimeFilter()
{
return timeFilter;
}
TimeRange getRange()
{
return timeFilter.getRange();
@@ -482,8 +487,8 @@ public class WealthPanel extends PluginPanel
}
else
{
start = range == TimeRange.ALL ? Series.earliest(accounts) : timeFilter.start();
rangeLabel = range.getLabel();
start = (range != null && range.isAll()) ? Series.earliest(accounts) : timeFilter.start();
rangeLabel = range != null ? range.getLabel() : "total";
}
sessionLabel.setText(sessionStart <= 0 ? "No session started"
: "Session since " + Instant.ofEpochMilli(sessionStart).atZone(ZoneId.systemDefault()).format(DateTimeFormatter.ofPattern("d MMM HH:mm", Locale.ENGLISH))
@@ -513,28 +518,43 @@ public class WealthPanel extends PluginPanel
List<List<Snapshot>> allSeries = new ArrayList<>();
long currentStart = now;
// price movements of held items only count as profit/loss over all time
boolean ignorePrices = isSession || range != TimeRange.ALL;
boolean ignorePrices = isSession || (range != null && !range.isAll());
boolean isHourly = isHourly();
int needBank = 0;
long totalNow = 0;
Map<String, String> charGphMap = new HashMap<>();
if (timeFilter.isCurrent() && !isSession)
{
long activeStart = Long.MAX_VALUE;
for (AccountData a : accounts)
{
boolean live = a.key.equals(active);
if (live || now - a.lastUpdated <= TimeFilter.ACTIVE_GAP)
{
activeStart = Math.min(activeStart, computeActiveStreakStart(a.history, a.lastUpdated, now, live));
}
}
currentStart = activeStart == Long.MAX_VALUE ? now - TimeFilter.ACTIVE_GAP : activeStart;
start = currentStart;
}
for (AccountData a : accounts)
{
for (TopItem t : a.topItems)
{
icons.putName(t.id, t.name);
}
boolean live = a.key.equals(active);
long accountStart = start;
if (timeFilter.isCurrent() && !isSession)
{
List<Snapshot> h = a.history;
accountStart = computeActiveStreakStart(h, a.lastUpdated, now);
accountStart = computeActiveStreakStart(a.history, a.lastUpdated, now, live);
}
currentStart = Math.min(currentStart, accountStart);
List<Snapshot> s = Series.forAccount(a, accountStart, now, ignorePrices);
allSeries.add(s);
List<Snapshot> sCombined = Series.forAccount(a, start, now, ignorePrices);
allSeries.add(sCombined);
List<Snapshot> s = (accountStart == start) ? sCombined : Series.forAccount(a, accountStart, now, ignorePrices);
long delta = s.size() > 1 ? s.get(s.size() - 1).total() - s.get(0).total() : 0;
boolean live = a.key.equals(active);
String status;
java.awt.Color statusColor;
@@ -690,7 +710,7 @@ public class WealthPanel extends PluginPanel
Model m = new Model();
m.hourlyRate = hourlyRate;
m.start = start;
m.ignorePrices = isSessionView() || timeFilter.getRange() != TimeRange.ALL;
m.ignorePrices = isSessionView() || (timeFilter.getRange() != null && !timeFilter.getRange().isAll());
m.end = now;
m.ledgers = new ArrayList<>();
m.selection = selection;
@@ -1029,7 +1049,7 @@ public class WealthPanel extends PluginPanel
}
}
private static long computeActiveStreakStart(List<Snapshot> h, long lastUpdated, long now)
private static long computeActiveStreakStart(List<Snapshot> h, long lastUpdated, long now, boolean isActive)
{
if (h == null || h.isEmpty())
{
@@ -1037,7 +1057,7 @@ public class WealthPanel extends PluginPanel
}
long latest = Math.max(lastUpdated, h.get(h.size() - 1).t);
if (now - latest > TimeFilter.ACTIVE_GAP)
if (now - latest > TimeFilter.ACTIVE_GAP || (!isActive && now - lastUpdated > TimeFilter.ACTIVE_GAP))
{
// Character has been idle / logged out for more than 5 minutes
return now - TimeFilter.ACTIVE_GAP;
@@ -1060,11 +1080,11 @@ public class WealthPanel extends PluginPanel
}
}
// Baseline is right before the first active point of this session
if (firstIdx > 0)
// Baseline is right before the first active point of this session, only if part of the continuous streak
if (firstIdx > 0 && streakStart - h.get(firstIdx - 1).t <= 10 * Ui.MINUTE)
{
return Math.max(h.get(firstIdx - 1).t, streakStart - Ui.MINUTE);
}
return streakStart - Ui.MINUTE;
return streakStart;
}
}
@@ -80,8 +80,8 @@ class WealthPopout extends JFrame
view = new WealthPanel.DetailView(panel.getIcons(), true);
view.main.setMode(panel.getMainMode());
view.breakdown.setOnOpen((v, label) -> panel.openItems(v, label, resolved));
rangeBar = Segmented.ranges(range, panel::setRange);
rangeBar.setSelected(panel.isCustomSessionView() ? -1 : range.ordinal());
rangeBar = Segmented.ranges(panel.getTimeFilter(), panel::setRange);
rangeBar.setSelected(panel.isCustomSessionView() ? -1 : panel.getTimeFilter().getSlotIndex(range));
styleBar = new Segmented(new String[]{"Line", "Candles"}, panel.getMainMode() == WealthChart.Mode.CANDLES ? 1 : 0,
i -> panel.setMainMode(i == 1 ? WealthChart.Mode.CANDLES : WealthChart.Mode.LINE));
@@ -35,7 +35,7 @@ final class WikiHtml
/** Kept as they are (with filtered attributes). */
private static final Set<String> KEEP = Set.of("a", "b", "strong", "i", "em", "u", "s", "strike", "p", "br", "hr",
"h1", "h2", "h3", "h4", "h5", "h6", "ul", "ol", "li", "dl", "dt", "dd", "table", "tr", "td", "th", "caption", "img",
"div", "span", "pre", "code", "sup", "sub", "center", "blockquote", "small", "big", "tt", "kbd", "var", "cite");
"div", "span", "pre", "code", "sup", "sub", "center", "blockquote", "small", "big", "tt", "kbd", "var", "cite", "font");
/** Unknown to Swing, rendered as another tag. */
private static final Map<String, String> RENAME = Map.of("figure", "div", "figcaption", "div", "section", "div",
"article", "div", "aside", "div", "header", "div", "footer", "div", "abbr", "span", "q", "i", "del", "s");
@@ -167,6 +167,13 @@ final class WikiHtml
case "th":
copy(attrs, open, "colspan", "rowspan", "align", "valign");
break;
case "font":
String color = attrs.get("color");
if (color != null && color.matches("#[0-9A-Fa-f]{6}"))
{
open.append(" color=\"").append(color).append('"');
}
break;
case "div":
case "p":
String style = attrs.getOrDefault("style", "").toLowerCase(Locale.ENGLISH);
@@ -1,5 +1,6 @@
package com.example.essentialsplus.wiki;
import com.example.essentialsplus.player.PlayerHiscoreHistory;
import java.awt.Color;
import java.awt.Dimension;
import java.awt.Font;
@@ -86,7 +87,7 @@ public class WikiOverlay extends Overlay
Font small = FontManager.getRunescapeSmallFont();
g.setFont(small);
window.externalWidth = g.getFontMetrics().stringWidth("Open in browser") + 14;
window.externalWidth = window.contentKey == null ? g.getFontMetrics().stringWidth("Open in browser") + 14 : 0;
if (border[0] != null && border[4] != null && border[5] != null)
{
window.border = Math.max(4, Math.min(24, Math.max(border[4].getWidth(), border[5].getHeight())));
@@ -136,7 +137,10 @@ public class WikiOverlay extends Overlay
{
button(g, l.back, "Back", hover == WikiWindow.Part.BACK, pressed == WikiWindow.Part.BACK);
}
button(g, l.external, "Open in browser", hover == WikiWindow.Part.EXTERNAL, pressed == WikiWindow.Part.EXTERNAL);
if (window.contentKey == null)
{
button(g, l.external, "Open in browser", hover == WikiWindow.Part.EXTERNAL, pressed == WikiWindow.Part.EXTERNAL);
}
BufferedImage closeImg = hover == WikiWindow.Part.CLOSE && closeHover != null ? closeHover : close;
if (closeImg != null)
{
@@ -146,6 +150,25 @@ public class WikiOverlay extends Overlay
{
button(g, l.close, "X", hover == WikiWindow.Part.CLOSE, pressed == WikiWindow.Part.CLOSE);
}
if (l.playerSearch != null)
{
String label = window.playerSearchName.isEmpty() ? "Search any player" : "Search player: " + window.playerSearchName;
label = ellipsize(g.getFontMetrics(), label, l.playerSearch.width - 12);
button(g, l.playerSearch, label, hover == WikiWindow.Part.PLAYER_SEARCH, pressed == WikiWindow.Part.PLAYER_SEARCH);
}
if (l.playerRanges != null)
{
PlayerHiscoreHistory.Range[] ranges = PlayerHiscoreHistory.Range.values();
for (int i = 0; i < ranges.length; i++)
{
int x = l.playerRanges.x + l.playerRanges.width * i / ranges.length;
int right = l.playerRanges.x + l.playerRanges.width * (i + 1) / ranges.length;
Rectangle segment = new Rectangle(x, l.playerRanges.y, right - x, l.playerRanges.height);
boolean over = hover == WikiWindow.Part.PLAYER_RANGES && segment.contains(window.mouse);
boolean selected = window.playerStatsRange == ranges[i];
button(g, segment, ranges[i].label, over, selected);
}
}
// content
g.setColor(new Color(0, 0, 0, 40));
@@ -1,5 +1,6 @@
package com.example.essentialsplus.wiki;
import com.example.essentialsplus.player.PlayerHiscoreHistory;
import java.awt.FontMetrics;
import java.awt.Point;
import java.awt.Rectangle;
@@ -46,12 +47,17 @@ public class WikiWindow
static final int CHIP_LABEL = -1;
static final int CHIP_MORE = -2;
static final int CHIP_LESS = -3;
private static final String PLAYER_EQUIPMENT_PREFIX = "player-equipment:";
/** Callbacks into the window manager. */
interface Host
{
void openInNewWindow(String title, String anchor, WikiWindow from);
void searchPlayer(WikiWindow window, String currentName);
void selectPlayerRange(WikiWindow window, PlayerHiscoreHistory.Range range);
void closed(WikiWindow window);
void saveBounds(Rectangle bounds);
@@ -61,7 +67,7 @@ public class WikiWindow
enum Part
{
NONE, TITLE, CLOSE, EXTERNAL, BACK, TOC, UP, DOWN, TRACK, THUMB, LEFT, RIGHT, HTRACK, HTHUMB, GRIP, CONTENT, FRAME
NONE, TITLE, CLOSE, EXTERNAL, BACK, PLAYER_SEARCH, PLAYER_RANGES, TOC, UP, DOWN, TRACK, THUMB, LEFT, RIGHT, HTRACK, HTHUMB, GRIP, CONTENT, FRAME
}
/** Positions of all window parts for one set of bounds. */
@@ -72,13 +78,15 @@ public class WikiWindow
final Rectangle close;
final Rectangle external;
final Rectangle back;
final Rectangle playerSearch;
final Rectangle playerRanges;
final Rectangle toc;
final Rectangle content;
final Rectangle vbar;
final Rectangle hbar;
final Rectangle grip;
Layout(Rectangle b, int border, int corner, int externalWidth, boolean horizontal, int tocHeight)
Layout(Rectangle b, int border, int corner, int externalWidth, boolean horizontal, int tocHeight, boolean showPlayerSearch)
{
bounds = b;
int side = Math.max(border + 4, corner + 2);
@@ -89,6 +97,16 @@ public class WikiWindow
int left = b.x + border + PAD;
int right = b.x + b.width - border - PAD;
int top = title.y + title.height + 2;
playerSearch = showPlayerSearch ? new Rectangle(left, top, right - left, 24) : null;
if (playerSearch != null)
{
top = playerSearch.y + playerSearch.height + 4;
}
playerRanges = showPlayerSearch ? new Rectangle(left, top, right - left, 20) : null;
if (playerRanges != null)
{
top = playerRanges.y + playerRanges.height + 4;
}
toc = new Rectangle(left, top + 2, right - left, tocHeight);
if (tocHeight > 0)
{
@@ -194,6 +212,9 @@ public class WikiWindow
volatile int corner = 6;
volatile int externalWidth = 96;
volatile String title = "";
volatile String contentKey;
volatile String playerSearchName = "";
volatile PlayerHiscoreHistory.Range playerStatsRange = PlayerHiscoreHistory.Range.LAST_VISIT;
volatile String status;
volatile BufferedImage frame;
volatile int scrollX;
@@ -259,12 +280,28 @@ public class WikiWindow
Layout layout(Rectangle b)
{
return new Layout(b, border, corner, externalWidth, contentWidth > 0 && viewportWidth(b) < contentWidth, toc.height);
return new Layout(b, border, corner, externalWidth, contentWidth > 0 && viewportWidth(b) < contentWidth, toc.height,
isPlayerEquipmentContent());
}
private int viewportWidth(Rectangle b)
{
return new Layout(b, border, corner, externalWidth, false, 0).content.width;
return new Layout(b, border, corner, externalWidth, false, 0, isPlayerEquipmentContent()).content.width;
}
private boolean isPlayerEquipmentContent()
{
return contentKey != null && contentKey.startsWith(PLAYER_EQUIPMENT_PREFIX);
}
public String playerSearchName()
{
return playerSearchName;
}
public PlayerHiscoreHistory.Range playerStatsRange()
{
return playerStatsRange;
}
// ---------------------------------------------------------------- pages
@@ -290,6 +327,9 @@ public class WikiWindow
}
canGoBack = !history.isEmpty();
int seq = ++loadSeq;
contentKey = null;
playerSearchName = "";
playerStatsRange = PlayerHiscoreHistory.Range.LAST_VISIT;
currentTitle = pageTitle;
title = pageTitle;
status = "Loading...";
@@ -360,6 +400,72 @@ public class WikiWindow
});
}
/** Opens local, generated content in the same in-game window chrome as wiki pages (Swing thread). */
void openContent(String key, String pageTitle, String html)
{
openContent(key, pageTitle, html, Collections.emptyMap());
}
/** Opens local, generated content with cached images in the same wiki window. */
void openContent(String key, String pageTitle, String html, Map<String, BufferedImage> images)
{
if (!open)
{
open = true;
clampBounds();
}
loadSeq++;
boolean sameContent = key.equals(contentKey);
contentKey = key;
if (!sameContent)
{
playerStatsRange = PlayerHiscoreHistory.Range.LAST_VISIT;
}
playerSearchName = isPlayerEquipmentContent() && pageTitle.startsWith("Equipment: ")
? pageTitle.substring("Equipment: ".length()) : "";
currentTitle = null;
title = pageTitle;
history.clear();
canGoBack = false;
status = "Loading...";
frame = null;
view = null;
hoverLink = null;
sections = Collections.emptyList();
toc = Toc.EMPTY;
try
{
Map<String, BufferedImage> cachedImages = images == null ? Collections.emptyMap() : images;
WikiHtml.Result safe = WikiHtml.sanitize(html, Math.max(120, viewportWidth() - 24), cachedImages);
HTMLDocument doc = WikiDocumentView.buildDocument(safe.html, WikiApi.pageUrl(pageTitle), cachedImages);
WikiDocumentView v = new WikiDocumentView();
v.setDocument(doc, viewportWidth());
view = v;
status = null;
contentWidth = v.contentWidth();
contentHeight = v.height();
tocExpanded = false;
sections = v.sections();
updateToc();
scrollX = 0;
scrollY = 0;
repaint();
}
catch (Exception e)
{
log.debug("local wiki window content failed", e);
status = "Could not render content";
}
}
void refreshContent()
{
if (contentKey != null && view != null)
{
repaint();
}
}
private void back()
{
Entry e = history.poll();
@@ -510,7 +616,7 @@ public class WikiWindow
toc = Toc.EMPTY;
return;
}
Layout base = new Layout(bounds, border, corner, externalWidth, false, 0);
Layout base = new Layout(bounds, border, corner, externalWidth, false, 0, isPlayerEquipmentContent());
int width = base.vbar.x + base.vbar.width - base.content.x;
FontMetrics fm = metrics();
int rowStep = CHIP_H + CHIP_GAP;
@@ -797,6 +903,14 @@ public class WikiWindow
{
return Part.CLOSE;
}
if (l.playerSearch != null && l.playerSearch.contains(p))
{
return Part.PLAYER_SEARCH;
}
if (l.playerRanges != null && l.playerRanges.contains(p))
{
return Part.PLAYER_RANGES;
}
if (l.external.contains(p))
{
return Part.EXTERNAL;
@@ -857,6 +971,15 @@ public class WikiWindow
return v.linkAt(p.x - l.content.x + scrollX, p.y - l.content.y + scrollY);
}
private PlayerHiscoreHistory.Range rangeAt(Point p)
{
Layout l = layout();
PlayerHiscoreHistory.Range[] ranges = PlayerHiscoreHistory.Range.values();
int index = Math.max(0, Math.min(ranges.length - 1,
(p.x - l.playerRanges.x) * ranges.length / Math.max(1, l.playerRanges.width)));
return ranges[index];
}
void mousePressed(MouseEvent e)
{
Point p = e.getPoint();
@@ -992,6 +1115,13 @@ public class WikiWindow
case BACK:
back();
break;
case PLAYER_SEARCH:
host.searchPlayer(this, playerSearchName);
break;
case PLAYER_RANGES:
playerStatsRange = rangeAt(e.getPoint());
host.selectPlayerRange(this, playerStatsRange);
break;
default:
break;
}
@@ -1,12 +1,17 @@
package com.example.essentialsplus.wiki;
import com.example.essentialsplus.player.PlayerHiscoreHistory;
import java.awt.Point;
import java.awt.Rectangle;
import java.awt.image.BufferedImage;
import java.awt.event.MouseEvent;
import java.awt.event.MouseWheelEvent;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.ExecutorService;
import java.util.function.BiConsumer;
import java.util.function.Consumer;
import java.util.function.IntSupplier;
import javax.swing.SwingUtilities;
@@ -27,6 +32,8 @@ public class WikiWindows implements MouseListener, MouseWheelListener, WikiWindo
private final IntSupplier canvasHeight;
private final Consumer<Rectangle> saveBounds;
private final Consumer<String> onPageOpened;
private final BiConsumer<WikiWindow, String> onPlayerSearch;
private final BiConsumer<WikiWindow, PlayerHiscoreHistory.Range> onPlayerRange;
private final List<WikiWindow> windows = new CopyOnWriteArrayList<>();
private Rectangle lastBounds;
@@ -34,7 +41,8 @@ public class WikiWindows implements MouseListener, MouseWheelListener, WikiWindo
private WikiWindow captured;
public WikiWindows(WikiApi api, ExecutorService executor, IntSupplier canvasWidth, IntSupplier canvasHeight,
Rectangle savedBounds, Consumer<Rectangle> saveBounds, Consumer<String> onPageOpened)
Rectangle savedBounds, Consumer<Rectangle> saveBounds, Consumer<String> onPageOpened,
BiConsumer<WikiWindow, String> onPlayerSearch, BiConsumer<WikiWindow, PlayerHiscoreHistory.Range> onPlayerRange)
{
this.api = api;
this.executor = executor;
@@ -43,6 +51,8 @@ public class WikiWindows implements MouseListener, MouseWheelListener, WikiWindo
this.lastBounds = savedBounds;
this.saveBounds = saveBounds;
this.onPageOpened = onPageOpened;
this.onPlayerSearch = onPlayerSearch;
this.onPlayerRange = onPlayerRange;
}
/** Windows from back to front (read by the overlay). */
@@ -75,6 +85,91 @@ public class WikiWindows implements MouseListener, MouseWheelListener, WikiWindo
});
}
/** Opens or refreshes generated local content in an in-game window (any thread). */
public void openContent(String key, String title, String html)
{
openContent(key, title, html, Collections.emptyMap());
}
/** Opens or refreshes generated local content with local images in an in-game window (any thread). */
public void openContent(String key, String title, String html, Map<String, BufferedImage> images)
{
SwingUtilities.invokeLater(() ->
{
WikiWindow target = null;
for (WikiWindow window : windows)
{
if (window.isOpen() && key.equals(window.contentKey))
{
target = window;
break;
}
}
if (target == null)
{
WikiWindow front = windows.isEmpty() ? null : windows.get(windows.size() - 1);
target = front == null ? create(null) : create(front);
}
target.openContent(key, title, html, images);
});
}
/** Updates generated content only while its window remains open (any thread). */
public void updateContent(String key, String title, String html, Map<String, BufferedImage> images)
{
SwingUtilities.invokeLater(() ->
{
for (WikiWindow window : windows)
{
if (window.isOpen() && key.equals(window.contentKey))
{
window.openContent(key, title, html, images);
return;
}
}
});
}
/** Replaces content in a specific open window without creating another one (any thread). */
public void updateContent(WikiWindow target, String key, String title, String html, Map<String, BufferedImage> images)
{
SwingUtilities.invokeLater(() ->
{
if (target != null && target.isOpen() && windows.contains(target))
{
target.openContent(key, title, html, images);
}
});
}
public PlayerHiscoreHistory.Range playerStatsRange(String key)
{
for (WikiWindow window : windows)
{
if (window.isOpen() && key.equals(window.contentKey))
{
return window.playerStatsRange();
}
}
return PlayerHiscoreHistory.Range.LAST_VISIT;
}
/** Repaints generated content after one of its cached images finishes loading (any thread). */
public void refreshContent(String key)
{
SwingUtilities.invokeLater(() ->
{
for (WikiWindow window : windows)
{
if (window.isOpen() && key.equals(window.contentKey))
{
window.refreshContent();
return;
}
}
});
}
public void closeAll()
{
SwingUtilities.invokeLater(() ->
@@ -117,6 +212,18 @@ public class WikiWindows implements MouseListener, MouseWheelListener, WikiWindo
create(from).open(title, anchor);
}
@Override
public void searchPlayer(WikiWindow window, String currentName)
{
onPlayerSearch.accept(window, currentName);
}
@Override
public void selectPlayerRange(WikiWindow window, PlayerHiscoreHistory.Range range)
{
onPlayerRange.accept(window, range);
}
@Override
public void closed(WikiWindow window)
{
@@ -12,7 +12,7 @@ public interface LiveDashboardConfig extends Config
@ConfigItem(
keyName = "port",
name = "Server Port (0 = auto)",
description = "Port fuer den integrierten Webserver. Bei 0 wird automatisch ein freier Port gewaehlt.",
description = "Port for the built-in web server. When set to 0, an available port is chosen automatically.",
position = 1
)
default int port()
@@ -22,8 +22,8 @@ public interface LiveDashboardConfig extends Config
@ConfigItem(
keyName = "autoOpenBrowser",
name = "Browser beim Start oeffnen",
description = "Oeffnet automatisch das Dashboard im Standardbrowser.",
name = "Open browser on startup",
description = "Automatically open the dashboard in your default browser.",
position = 2
)
default boolean autoOpenBrowser()
@@ -46,7 +46,7 @@ import net.runelite.client.util.ImageUtil;
@Slf4j
@PluginDescriptor(
name = "Live Web Dashboard",
description = "Zeigt Echtzeit-Koordinaten, Vitals, Map, Hindernis-GPS-Navigation, Ausruestung, Inventar und Bank an",
description = "Shows real-time coordinates, stats, map, GPS navigation around obstacles, equipment, inventory, and bank contents",
tags = {"telemetry", "web", "dashboard", "live", "coordinates", "coords", "map", "bank", "inventory", "equipment", "ge", "gps"}
)
public class LiveDashboardPlugin extends Plugin
@@ -434,7 +434,7 @@ public class LiveDashboardPlugin extends Plugin
itemMap.put("name", "Item #" + item.getId());
}
int gePrice = itemManager.getItemPrice(item.getId());
long gePrice = itemManager.getItemPrice(item.getId());
itemMap.put("price", gePrice);
list.add(itemMap);
@@ -0,0 +1,215 @@
package com.example.essentialsplus;
import com.example.essentialsplus.data.AccountData;
import com.example.essentialsplus.data.GeSlot;
import java.lang.reflect.Proxy;
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.gameval.InterfaceID;
import net.runelite.api.gameval.ItemID;
import net.runelite.api.widgets.ComponentID;
import net.runelite.api.widgets.Widget;
import org.junit.Assert;
import org.junit.Before;
import org.junit.Test;
public class GrandExchangeTrackerTest
{
private static final int RUNITE_ORE = 451;
private static final int RUNITE_ORE_NOTED = 452;
private final IntUnaryOperator canonicalize = id -> id == RUNITE_ORE_NOTED ? RUNITE_ORE : id;
private Client client;
private GrandExchangeTracker tracker;
private AccountData acc;
private GrandExchangeOffer[] currentOffers;
private boolean geOpen;
private boolean bankOpen;
@Before
public void setUp()
{
tracker = new GrandExchangeTracker();
acc = new AccountData();
acc.containers = new HashMap<>();
acc.containers.put("inventory", new HashMap<>());
acc.containers.put("bank", new HashMap<>());
currentOffers = new GrandExchangeOffer[8];
geOpen = true;
bankOpen = true;
client = (Client) Proxy.newProxyInstance(
Client.class.getClassLoader(),
new Class<?>[]{Client.class},
(proxy, method, args) -> {
String name = method.getName();
if (name.equals("getGrandExchangeOffers"))
{
return currentOffers;
}
if (name.equals("getWidget") && args != null)
{
if (args.length == 2 && args[0].equals(InterfaceID.GE_OFFERS) && args[1].equals(0))
{
return geOpen ? mockWidget() : null;
}
if (args.length == 1 && args[0].equals(ComponentID.BANK_CONTAINER))
{
return bankOpen ? mockWidget() : null;
}
}
return null;
}
);
}
private Widget mockWidget()
{
return (Widget) Proxy.newProxyInstance(
Widget.class.getClassLoader(),
new Class<?>[]{Widget.class},
(proxy, method, args) -> {
if (method.getName().equals("isHidden"))
{
return false;
}
return null;
}
);
}
private GrandExchangeOffer mockOffer(int itemId, int total, int done, long price, long spent, GrandExchangeOfferState state)
{
return (GrandExchangeOffer) Proxy.newProxyInstance(
GrandExchangeOffer.class.getClassLoader(),
new Class<?>[]{GrandExchangeOffer.class},
(proxy, method, args) -> {
switch (method.getName())
{
case "getItemId": return itemId;
case "getTotalQuantity": return total;
case "getQuantitySold": return done;
case "getPrice": return (Long) price;
case "getSpent": return (Long) spent;
case "getState": return state;
default: return null;
}
}
);
}
@Test
public void testPartialCollectionToInventoryDoesNotDoubleCount()
{
// Offer: Buying 4000 Runite ore @ 10,000 gp
currentOffers[0] = mockOffer(RUNITE_ORE, 4000, 0, 10_000, 0, GrandExchangeOfferState.BUYING);
// Tick 1: establish baseline holdings
tracker.update(acc, client, canonicalize, true);
// Tick 2: slot created
Map<Integer, Long> ge = tracker.update(acc, client, canonicalize, true);
GeSlot slot = acc.geSlots[0];
Assert.assertNotNull(slot);
Assert.assertEquals(0L, slot.done);
Assert.assertEquals(40_000_000L, slot.escrowCoins);
Assert.assertEquals(0L, slot.collectItems);
// Now 1000 ores buy:
currentOffers[0] = mockOffer(RUNITE_ORE, 4000, 1000, 10_000, 10_000_000, GrandExchangeOfferState.BUYING);
ge = tracker.update(acc, client, canonicalize, true);
Assert.assertEquals(Long.valueOf(1000L), ge.get(RUNITE_ORE));
Assert.assertEquals(1000L, slot.collectItems);
Assert.assertEquals(0L, slot.collectedItems);
// User collects 600 ores to inventory:
acc.containers.get("inventory").put(RUNITE_ORE_NOTED, 600L);
ge = tracker.update(acc, client, canonicalize, true);
// GE should only hold the remaining 400 uncollected ores, NOT all 1000!
Assert.assertEquals(600L, slot.collectedItems);
Assert.assertEquals(400L, slot.collectItems);
Assert.assertEquals(Long.valueOf(400L), ge.get(RUNITE_ORE));
// Total Runite ores accounted across player + GE = 600 (inventory) + 400 (GE) = 1000!
long totalRunite = acc.containers.get("inventory").get(RUNITE_ORE_NOTED) + ge.get(RUNITE_ORE);
Assert.assertEquals(1000L, totalRunite);
}
@Test
public void testPartialCollectionWithInterfaceClosedPreservesExactCount()
{
// Baseline
currentOffers[0] = mockOffer(RUNITE_ORE, 4000, 1000, 10_000, 10_000_000, GrandExchangeOfferState.BUYING);
tracker.update(acc, client, canonicalize, true);
tracker.update(acc, client, canonicalize, true);
// User closes GE window before compute runs
geOpen = false;
bankOpen = false;
// User collected 500 ores to inventory
acc.containers.get("inventory").put(RUNITE_ORE_NOTED, 500L);
Map<Integer, Long> ge = tracker.update(acc, client, canonicalize, true);
GeSlot slot = acc.geSlots[0];
// Collecting window and baseline floor both protect against double counting
Assert.assertEquals(500L, slot.collectedItems);
Assert.assertEquals(500L, slot.collectItems);
Assert.assertEquals(Long.valueOf(500L), ge.get(RUNITE_ORE));
long totalRunite = acc.containers.get("inventory").get(RUNITE_ORE_NOTED) + ge.get(RUNITE_ORE);
Assert.assertEquals(1000L, totalRunite);
}
@Test
public void testUnmigratedOldSlotAutoReconcilesWithoutDoubleCounting()
{
// Simulate slot loaded from old json:
// 3515 ores bought, s.collectedItems = 0, user already has 2000 in bank from earlier.
acc.geSlots = new GeSlot[8];
GeSlot old = new GeSlot();
old.item = RUNITE_ORE;
old.buy = true;
old.total = 4000;
old.price = 10_000;
old.done = 3515;
old.spent = 35_150_000;
old.collectedItems = 0;
old.collectItems = 3515;
old.escrowCoins = 4_850_000;
acc.geSlots[0] = old;
acc.containers.get("bank").put(RUNITE_ORE, 2000L);
currentOffers[0] = mockOffer(RUNITE_ORE, 4000, 3515, 10_000, 35_150_000, GrandExchangeOfferState.BUYING);
// Tick 1: establish previousHoldings
tracker.update(acc, client, canonicalize, true);
// Tick 2: processes offers and establishes baselineHoldings
tracker.update(acc, client, canonicalize, true);
GeSlot slot = acc.geSlots[0];
Assert.assertTrue(slot.baselineInitialized);
Assert.assertEquals(2000L, slot.baselineHoldings);
Assert.assertEquals(3515L, slot.collectItems);
// Now user collects 1000 ores into bank
acc.containers.get("bank").put(RUNITE_ORE, 3000L);
Map<Integer, Long> ge = tracker.update(acc, client, canonicalize, true);
Assert.assertEquals(1000L, slot.collectedItems);
Assert.assertEquals(2515L, slot.collectItems);
Assert.assertEquals(Long.valueOf(2515L), ge.get(RUNITE_ORE));
// Total Runite ores = 3000 (bank) + 2515 (GE) = 5515 (2000 baseline + 3515 from offer)
long totalRunite = acc.containers.get("bank").get(RUNITE_ORE) + ge.get(RUNITE_ORE);
Assert.assertEquals(5515L, totalRunite);
}
}
@@ -5,7 +5,9 @@ import java.lang.reflect.Proxy;
import java.util.List;
import net.runelite.api.ChatMessageType;
import net.runelite.api.Client;
import net.runelite.api.MenuEntry;
import net.runelite.api.events.ChatMessage;
import net.runelite.api.events.MenuOptionClicked;
import net.runelite.api.gameval.ItemID;
import org.junit.Assert;
import org.junit.Before;
@@ -24,13 +26,42 @@ public class ItemChargesFullTest
return (T) Proxy.newProxyInstance(iface.getClassLoader(), new Class<?>[]{iface}, handler);
}
private final java.util.Map<Integer, Integer> equipped = new java.util.HashMap<>();
private int playerAnim = -1;
@Before
public void setup()
{
equipped.clear();
playerAnim = -1;
net.runelite.api.Player mockPlayer = mockInterface(net.runelite.api.Player.class, (p2, m2, a2) -> {
if ("getAnimation".equals(m2.getName())) return playerAnim;
return null;
});
client = mockInterface(Client.class, (proxy, method, args) -> {
if ("getAccountHash".equals(method.getName())) return 123456789L;
if ("getTickCount".equals(method.getName())) return 100;
if ("getVarbitValue".equals(method.getName())) return 0;
if ("getItemContainer".equals(method.getName())) return null;
if ("getLocalPlayer".equals(method.getName())) {
return mockPlayer;
}
if ("getItemContainer".equals(method.getName())) {
Object arg0 = args[0];
int id = (arg0 instanceof Integer) ? (Integer) arg0 : (arg0 instanceof net.runelite.api.InventoryID ? ((net.runelite.api.InventoryID) arg0).getId() : -1);
if (id == net.runelite.api.gameval.InventoryID.WORN) {
return mockInterface(net.runelite.api.ItemContainer.class, (p2, m2, a2) -> {
if ("getItem".equals(m2.getName())) {
int slotIdx = (Integer) a2[0];
Integer itemId = equipped.get(slotIdx);
if (itemId != null) {
return new net.runelite.api.Item(itemId, 1);
}
}
return null;
});
}
return null;
}
if ("getItemDefinition".equals(method.getName())) {
int id = (Integer) args[0];
return mockInterface(net.runelite.api.ItemComposition.class, (p2, m2, a2) -> {
@@ -260,5 +291,281 @@ public class ItemChargesFullTest
List<ItemCharges.ChargeValue> rod = itemCharges.chargesFor(net.runelite.api.ItemID.RING_OF_DUELING8);
Assert.assertEquals("8", rod.get(0).getText());
}
private void clickMenu(int itemId, String option, String target, int param1)
{
MenuEntry entry = mockInterface(MenuEntry.class, (proxy, method, args) -> {
if ("getItemId".equals(method.getName())) return itemId;
if ("getOption".equals(method.getName())) return option;
if ("getTarget".equals(method.getName())) return target;
if ("getParam1".equals(method.getName())) return param1;
return null;
});
itemCharges.onMenuOptionClicked(new MenuOptionClicked(entry));
}
@Test
public void testPendantOfAtesFlow()
{
// 1. Initial state
List<ItemCharges.ChargeValue> empty = itemCharges.chargesFor(ItemID.PENDANT_OF_ATES_EMPTY);
Assert.assertEquals(1, empty.size());
Assert.assertEquals("0", empty.get(0).getText());
List<ItemCharges.ChargeValue> initial = itemCharges.chargesFor(ItemID.PENDANT_OF_ATES);
Assert.assertEquals(1, initial.size());
Assert.assertEquals("?", initial.get(0).getText());
// 2. Check message
sendChat("The pendant has 450 charges.");
List<ItemCharges.ChargeValue> afterCheck = itemCharges.chargesFor(ItemID.PENDANT_OF_ATES);
Assert.assertEquals("450", afterCheck.get(0).getText());
// 3. Add charges
sendChat("Your add 50 frozen tears to your pendant. It now has 500 charges.");
List<ItemCharges.ChargeValue> afterAdd = itemCharges.chargesFor(ItemID.PENDANT_OF_ATES);
Assert.assertEquals("500", afterAdd.get(0).getText());
// 4. Direct teleport menu action (e.g. worn right-click "The Darkfrost")
clickMenu(ItemID.PENDANT_OF_ATES, "The Darkfrost", "<col=ff9040>Pendant of Ates</col>", 0);
List<ItemCharges.ChargeValue> afterTele = itemCharges.chargesFor(ItemID.PENDANT_OF_ATES);
Assert.assertEquals("499", afterTele.get(0).getText());
// 5. Interface 879 teleport click
clickMenu(-1, "Twilight Temple", "", (879 << 16) | 1);
List<ItemCharges.ChargeValue> afterInterfaceTele = itemCharges.chargesFor(ItemID.PENDANT_OF_ATES);
Assert.assertEquals("498", afterInterfaceTele.get(0).getText());
// 6. Already at location (does not use charge)
sendChat("The pendant doesn't respond as you appear to already be at that location.");
List<ItemCharges.ChargeValue> afterAlreadyThere = itemCharges.chargesFor(ItemID.PENDANT_OF_ATES);
Assert.assertEquals("499", afterAlreadyThere.get(0).getText());
// 7. Fully charged message
sendChat("The pendant is already fully charged.");
List<ItemCharges.ChargeValue> afterFull = itemCharges.chargesFor(ItemID.PENDANT_OF_ATES);
Assert.assertEquals("1K", afterFull.get(0).getText());
// 8. Uncharge message
sendChat("You uncharge your pendant by removing 1,000 frozen tears from it.");
List<ItemCharges.ChargeValue> afterUncharge = itemCharges.chargesFor(ItemID.PENDANT_OF_ATES);
Assert.assertEquals("0", afterUncharge.get(0).getText());
}
@Test
public void testTridentFlow()
{
// 1. Initial state
Assert.assertEquals("?", itemCharges.chargesFor(ItemID.TOXIC_TOTS_CHARGED).get(0).getText());
Assert.assertEquals("0", itemCharges.chargesFor(ItemID.TOXIC_TOTS_UNCHARGED).get(0).getText());
Assert.assertEquals("?", itemCharges.chargesFor(ItemID.TOTS_CHARGED).get(0).getText());
Assert.assertEquals("0", itemCharges.chargesFor(ItemID.TOTS_UNCHARGED).get(0).getText());
// 2. Check messages
sendChat("Your Trident of the swamp has 2,450 charges.");
Assert.assertEquals("2.5K", itemCharges.chargesFor(ItemID.TOXIC_TOTS_CHARGED).get(0).getText());
sendChat("Your Trident of the swamp (e) has 8,500 charges.");
Assert.assertEquals("8.5K", itemCharges.chargesFor(ItemID.TOXIC_TOTS_I_CHARGED).get(0).getText());
sendChat("Your Trident of the seas has 1,500 charges.");
Assert.assertEquals("1.5K", itemCharges.chargesFor(ItemID.TOTS_CHARGED).get(0).getText());
sendChat("Your Trident of the seas (e) has 6,000 charges.");
Assert.assertEquals("6K", itemCharges.chargesFor(ItemID.TOTS_I_CHARGED).get(0).getText());
// 3. Decrement
itemCharges.decrementCount("trident_of_the_swamp", 50);
Assert.assertEquals("2.4K", itemCharges.chargesFor(ItemID.TOXIC_TOTS_CHARGED).get(0).getText());
// 4. Low warning
sendChat("Your trident only has 100 charges left!");
Assert.assertEquals("100", itemCharges.chargesFor(ItemID.TOXIC_TOTS_CHARGED).get(0).getText());
// 5. Uncharge menu option
clickMenu(ItemID.TOXIC_TOTS_CHARGED, "Uncharge", "<col=ff9040>Trident of the swamp</col>", 0);
Assert.assertEquals("0", itemCharges.chargesFor(ItemID.TOXIC_TOTS_CHARGED).get(0).getText());
// 6. Zero / one / empty messages
sendChat("Your Trident of the swamp has one charge.");
Assert.assertEquals("1", itemCharges.chargesFor(ItemID.TOXIC_TOTS_CHARGED).get(0).getText());
sendChat("Your Trident of the swamp has no charges.");
Assert.assertEquals("0", itemCharges.chargesFor(ItemID.TOXIC_TOTS_CHARGED).get(0).getText());
sendChat("Your trident has run out of charges.");
Assert.assertEquals("0", itemCharges.chargesFor(ItemID.TOXIC_TOTS_CHARGED).get(0).getText());
}
@Test
public void testSanguinestiAndTumekensAndWarped()
{
// Sanguinesti Staff
Assert.assertEquals("?", itemCharges.chargesFor(ItemID.SANGUINESTI_STAFF).get(0).getText());
Assert.assertEquals("0", itemCharges.chargesFor(ItemID.SANGUINESTI_STAFF_UNCHARGED).get(0).getText());
sendChat("Your Sanguinesti staff has 1,500 charges remaining.");
Assert.assertEquals("1.5K", itemCharges.chargesFor(ItemID.SANGUINESTI_STAFF).get(0).getText());
sendChat("You apply an additional 500 charges to your Sanguinesti staff. It now has 2,000 charges in total.");
Assert.assertEquals("2K", itemCharges.chargesFor(ItemID.SANGUINESTI_STAFF).get(0).getText());
sendChat("Your Sanguinesti staff is already fully charged.");
Assert.assertEquals("20K", itemCharges.chargesFor(ItemID.SANGUINESTI_STAFF).get(0).getText());
sendChat("Your Sanguinesti staff has run out of charges.");
Assert.assertEquals("0", itemCharges.chargesFor(ItemID.SANGUINESTI_STAFF).get(0).getText());
// Tumeken's Shadow
Assert.assertEquals("?", itemCharges.chargesFor(ItemID.TUMEKENS_SHADOW).get(0).getText());
Assert.assertEquals("0", itemCharges.chargesFor(ItemID.TUMEKENS_SHADOW_UNCHARGED).get(0).getText());
sendChat("Tumeken's shadow has 450 charges remaining.");
Assert.assertEquals("450", itemCharges.chargesFor(ItemID.TUMEKENS_SHADOW).get(0).getText());
sendChat("You apply an additional 150 charges to your Tumeken's shadow. It now has 600 charges in total.");
Assert.assertEquals("600", itemCharges.chargesFor(ItemID.TUMEKENS_SHADOW).get(0).getText());
sendChat("Tumeken's shadow has run out of charges.");
Assert.assertEquals("0", itemCharges.chargesFor(ItemID.TUMEKENS_SHADOW).get(0).getText());
// Warped Sceptre
Assert.assertEquals("?", itemCharges.chargesFor(ItemID.WARPED_SCEPTRE).get(0).getText());
Assert.assertEquals("0", itemCharges.chargesFor(ItemID.WARPED_SCEPTRE_UNCHARGED).get(0).getText());
sendChat("Your warped sceptre has 5,000 charges remaining.");
Assert.assertEquals("5K", itemCharges.chargesFor(ItemID.WARPED_SCEPTRE).get(0).getText());
sendChat("Your warped sceptre has run out of charges!");
Assert.assertEquals("0", itemCharges.chargesFor(ItemID.WARPED_SCEPTRE).get(0).getText());
}
@Test
public void testSerpentineAndTomesAndBloodFury()
{
// Serpentine Helm
Assert.assertEquals("?", itemCharges.chargesFor(ItemID.SERPENTINE_HELM_CHARGED).get(0).getText());
Assert.assertEquals("0", itemCharges.chargesFor(ItemID.SERPENTINE_HELM).get(0).getText());
sendChat("Scales: 11,000 (100.0%)");
Assert.assertEquals("11K", itemCharges.chargesFor(ItemID.SERPENTINE_HELM_CHARGED).get(0).getText());
sendChat("Your serpentine helm has run out of Zulrah's scales.");
Assert.assertEquals("0", itemCharges.chargesFor(ItemID.SERPENTINE_HELM_CHARGED).get(0).getText());
// Tomes
Assert.assertEquals("?", itemCharges.chargesFor(ItemID.TOME_OF_FIRE).get(0).getText());
Assert.assertEquals("0", itemCharges.chargesFor(ItemID.TOME_OF_FIRE_UNCHARGED).get(0).getText());
sendChat("Your tome has been charged with Burnt Pages. It currently holds 200 charges.");
Assert.assertEquals("200", itemCharges.chargesFor(ItemID.TOME_OF_FIRE).get(0).getText());
sendChat("Your tome has been charged with Water Pages. It currently holds 500 charges.");
Assert.assertEquals("500", itemCharges.chargesFor(ItemID.TOME_OF_WATER).get(0).getText());
sendChat("Your tome has been charged with Earth Pages. It currently holds 300 charges.");
Assert.assertEquals("300", itemCharges.chargesFor(ItemID.TOME_OF_EARTH).get(0).getText());
sendChat("Your Tome of Fire is now empty.");
Assert.assertEquals("0", itemCharges.chargesFor(ItemID.TOME_OF_FIRE).get(0).getText());
// Blood Fury
sendChat("You have successfully created an Amulet of blood fury.");
Assert.assertEquals("10K", itemCharges.chargesFor(ItemID.BLOOD_AMULET).get(0).getText());
sendChat("Your Amulet of blood fury will work for 9,016 more hits.");
Assert.assertEquals("9.0K", itemCharges.chargesFor(ItemID.BLOOD_AMULET).get(0).getText());
}
@Test
public void testWildernessAndSufferingAndUtilityWeapons()
{
// Ether weapons
sendChat("Your bow has 666 charges left powering it.");
Assert.assertEquals("666", itemCharges.chargesFor(ItemID.WILD_CAVE_BOW_CHARGED).get(0).getText());
sendChat("You add a further 1,000 revenant ether to your weapon, giving it a total of 1,666 charges.");
Assert.assertEquals("1.7K", itemCharges.chargesFor(ItemID.WILD_CAVE_BOW_CHARGED).get(0).getText());
// Ring of Suffering
Assert.assertEquals("0", itemCharges.chargesFor(ItemID.ZENYTE_RING_ENCHANTED).get(0).getText());
sendChat("Your Ring of suffering (i) has 50,000 charges remaining.");
Assert.assertEquals("50K", itemCharges.chargesFor(ItemID.ZENYTE_RING_ENCHANTED_RECOIL).get(0).getText());
sendChat("You load 25 rings of recoil into your Ring of suffering. It now has 51,000 charges.");
Assert.assertEquals("51K", itemCharges.chargesFor(ItemID.ZENYTE_RING_ENCHANTED_RECOIL).get(0).getText());
sendChat("Your Ring of suffering is already fully charged.");
Assert.assertEquals("100K", itemCharges.chargesFor(ItemID.ZENYTE_RING_ENCHANTED_RECOIL).get(0).getText());
// Bonecrusher & Ash Sanctifier
sendChat("Your bonecrusher has 2,500 charges left.");
Assert.assertEquals("2.5K", itemCharges.chargesFor(ItemID.BONECRUSHER).get(0).getText());
sendChat("Your ash sanctifier has 1,200 charges left.");
Assert.assertEquals("1.2K", itemCharges.chargesFor(ItemID.ASH_SANCTIFIER).get(0).getText());
// Arclight & Crystal Halberd & Crystal Bow
sendChat("Your arclight has 6,397 charges left.");
Assert.assertEquals("6.4K", itemCharges.chargesFor(ItemID.ARCLIGHT).get(0).getText());
sendChat("Your crystal halberd has 278 charges remaining.");
Assert.assertEquals("278", itemCharges.chargesFor(ItemID.CRYSTAL_HALBERD).get(0).getText());
sendChat("Your crystal bow has 299 charges remaining.");
Assert.assertEquals("299", itemCharges.chargesFor(ItemID.CRYSTAL_BOW).get(0).getText());
// Venator Bow
sendChat("Your Venator bow has 500 charges remaining.");
Assert.assertEquals("500", itemCharges.chargesFor(ItemID.VENATOR_BOW).get(0).getText());
sendChat("You use 100 ancient essence to charge your venator bow. It now has 600 charges.");
Assert.assertEquals("600", itemCharges.chargesFor(ItemID.VENATOR_BOW).get(0).getText());
}
private void triggerAnimation(int anim)
{
playerAnim = anim;
net.runelite.api.Player me = client.getLocalPlayer();
net.runelite.api.events.AnimationChanged event = new net.runelite.api.events.AnimationChanged();
event.setActor(me);
itemCharges.onAnimationChanged(event);
}
@Test
public void testCombatAnimationDecrements()
{
// 1. Trident attack animation (11430)
sendChat("Your Trident of the swamp has 2,500 charges.");
Assert.assertEquals("2.5K", itemCharges.chargesFor(ItemID.TOXIC_TOTS_CHARGED).get(0).getText());
equipped.put(net.runelite.api.EquipmentInventorySlot.WEAPON.getSlotIdx(), ItemID.TOXIC_TOTS_CHARGED);
triggerAnimation(11430);
Assert.assertEquals("2.5K", itemCharges.chargesFor(ItemID.TOXIC_TOTS_CHARGED).get(0).getText());
Assert.assertEquals(2499, itemCharges.getTrackedCount("trident_of_the_swamp"));
// 2. Tumeken's shadow attack animation (9493)
sendChat("Tumeken's shadow has 500 charges remaining.");
equipped.put(net.runelite.api.EquipmentInventorySlot.WEAPON.getSlotIdx(), ItemID.TUMEKENS_SHADOW);
triggerAnimation(9493);
Assert.assertEquals(499, itemCharges.getTrackedCount("tumekens_shadow"));
// 3. Warped sceptre attack animation (10501)
sendChat("Your warped sceptre has 300 charges remaining.");
equipped.put(net.runelite.api.EquipmentInventorySlot.WEAPON.getSlotIdx(), ItemID.WARPED_SCEPTRE);
triggerAnimation(10501);
Assert.assertEquals(299, itemCharges.getTrackedCount("warped_sceptre"));
// 4. Tome of fire in shield slot (anim 711)
sendChat("Your tome has been charged with Burnt Pages. It currently holds 100 charges.");
equipped.put(net.runelite.api.EquipmentInventorySlot.SHIELD.getSlotIdx(), ItemID.TOME_OF_FIRE);
triggerAnimation(711);
Assert.assertEquals(99, itemCharges.getTrackedCount("tome_of_fire"));
// 5. Blood fury in amulet slot (anim 1658 whip attack)
sendChat("Your Amulet of blood fury will work for 1,000 more hits.");
equipped.put(net.runelite.api.EquipmentInventorySlot.AMULET.getSlotIdx(), ItemID.BLOOD_AMULET);
triggerAnimation(1658);
Assert.assertEquals(999, itemCharges.getTrackedCount("amulet_of_blood_fury"));
}
}
@@ -0,0 +1,461 @@
package com.example.essentialsplus;
import com.example.essentialsplus.data.AccountData;
import com.example.essentialsplus.WealthStore;
import com.example.essentialsplus.helpers.ItemCharges;
import com.example.essentialsplus.helpers.ItemInformationOverlay;
import java.lang.reflect.Field;
import java.lang.reflect.Proxy;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import net.runelite.api.Client;
import net.runelite.api.Item;
import net.runelite.api.ItemContainer;
import net.runelite.api.MenuEntry;
import net.runelite.api.events.ItemContainerChanged;
import net.runelite.api.gameval.InventoryID;
import net.runelite.api.gameval.ItemID;
import net.runelite.api.widgets.Widget;
import net.runelite.client.ui.overlay.tooltip.Tooltip;
import net.runelite.client.ui.overlay.tooltip.TooltipManager;
import org.junit.Assert;
import org.junit.Before;
import org.junit.Test;
public class ItemInformationTest
{
private static final int SHARK_ID = 383;
private static final int SHARK_NOTED_ID = 384;
private static final int LOBSTER_ID = 379;
private Client client;
private EssentialsPlusConfig config;
private EssentialsPlusPlugin plugin;
private TooltipManager tooltipManager;
private ItemInformationOverlay overlay;
private ItemCharges itemCharges;
private final Map<String, Object> configStore = new HashMap<>();
private ItemContainer mockBankContainer;
private MenuEntry[] currentMenuEntries;
private AccountData testAccount;
@SuppressWarnings("unchecked")
private static <T> T mockInterface(Class<T> iface, java.lang.reflect.InvocationHandler handler)
{
return (T) Proxy.newProxyInstance(iface.getClassLoader(), new Class<?>[]{iface}, handler);
}
@Before
public void setup() throws Exception
{
configStore.clear();
mockBankContainer = null;
currentMenuEntries = new MenuEntry[0];
client = mockInterface(Client.class, (proxy, method, args) -> {
String name = method.getName();
if ("getAccountHash".equals(name)) return 123456789L;
if ("getVarbitValue".equals(name)) return 0;
if ("isMenuOpen".equals(name)) return false;
if ("getItemContainer".equals(name)) {
Object arg = args[0];
int id = arg instanceof Number ? ((Number) arg).intValue() : (arg != null ? ((net.runelite.api.InventoryID) arg).getId() : -1);
if (id == InventoryID.BANK) {
return mockBankContainer;
}
return null;
}
if ("getMenuEntries".equals(name)) return currentMenuEntries;
return null;
});
config = mockInterface(EssentialsPlusConfig.class, (proxy, method, args) -> {
String name = method.getName();
Object val = configStore.get(name);
if (val != null) return val;
if ("itemInformation".equals(name)) return false;
if ("itemInformationGe".equals(name)) return true;
if ("itemInformationHa".equals(name)) return true;
if ("itemInformationBanked".equals(name)) return true;
if ("itemInformationCharges".equals(name)) return true;
if ("itemInformationChargesWeapons".equals(name)) return true;
if ("itemInformationChargesBags".equals(name)) return true;
if ("itemInformationBank".equals(name)) return true;
if ("itemInformationEquipment".equals(name)) return true;
if ("itemInformationChargesBank".equals(name)) return true;
if ("itemPrices".equals(name)) return false;
if ("itemCharges".equals(name)) return false;
return null;
});
tooltipManager = new TooltipManager();
plugin = new EssentialsPlusPlugin()
{
@Override
public boolean isItemInformationEnabled()
{
Boolean val = (Boolean) configStore.get("itemInformation");
if (val != null)
{
return val;
}
Boolean legacyPrices = (Boolean) configStore.get("itemPrices");
Boolean legacyCharges = (Boolean) configStore.get("itemCharges");
if (Boolean.TRUE.equals(legacyPrices) || Boolean.TRUE.equals(legacyCharges))
{
return true;
}
return false;
}
@Override
@SuppressWarnings("unchecked")
public <T> T getHelperValue(String key, Class<T> type, T def)
{
Object val = configStore.get(key);
if (val != null)
{
return (T) val;
}
Map<String, String> fallbacks = Map.ofEntries(
Map.entry("itemInformationGe", "itemPricesGe"),
Map.entry("itemInformationHa", "itemPricesHa"),
Map.entry("itemInformationCharges", "itemCharges"),
Map.entry("itemInformationChargesWeapons", "itemChargesWeapons"),
Map.entry("itemInformationChargesBags", "itemChargesBags"),
Map.entry("itemInformationChargesBank", "itemChargesBank"),
Map.entry("itemInformationBank", "itemChargesBank"),
Map.entry("itemInformationSize", "itemChargesSize"),
Map.entry("itemInformationColor", "itemChargesColor"),
Map.entry("itemInformationSecondaryColor", "itemChargesSecondaryColor"),
Map.entry("itemInformationLowColor", "itemChargesLowColor"),
Map.entry("itemInformationLowAt", "itemChargesLowAt")
);
String fb = fallbacks.get(key);
if (fb != null)
{
Object fbVal = configStore.get(fb);
if (fbVal != null)
{
return (T) fbVal;
}
}
return def;
}
@Override
public void setHelperOption(String key, Object value)
{
if (value == null)
{
configStore.remove(key);
}
else
{
configStore.put(key, value);
}
}
@Override
public int canonicalize(int id)
{
if (id == SHARK_NOTED_ID)
{
return SHARK_ID;
}
return id;
}
};
setField(plugin, "client", client);
setField(plugin, "config", config);
WealthStore store = new WealthStore(new com.google.gson.Gson());
setField(plugin, "store", store);
setField(plugin, "activeKey", "acc_123");
testAccount = new AccountData();
testAccount.key = "acc_123";
testAccount.name = "TestPlayer";
store.put(testAccount);
itemCharges = new ItemCharges(client, config, plugin, null, null);
setField(plugin, "itemCharges", itemCharges);
overlay = new ItemInformationOverlay(client, config, plugin, null, tooltipManager)
{
@Override
public int canonicalize(int id)
{
if (id == SHARK_NOTED_ID)
{
return SHARK_ID;
}
return id;
}
@Override
public long getGePrice(int id)
{
if (id == SHARK_ID) return 1000;
if (id == LOBSTER_ID) return 200;
return 0;
}
@Override
public long getHaPrice(int id)
{
if (id == SHARK_ID) return 600;
if (id == LOBSTER_ID) return 120;
return 0;
}
};
}
private static void setField(Object target, String fieldName, Object val) throws Exception
{
Class<?> clazz = target.getClass();
while (clazz != null)
{
try
{
Field f = clazz.getDeclaredField(fieldName);
f.setAccessible(true);
f.set(target, val);
return;
}
catch (NoSuchFieldException e)
{
clazz = clazz.getSuperclass();
}
}
throw new NoSuchFieldException(fieldName);
}
private Tooltip lastTooltip()
{
List<Tooltip> list = tooltipManager.getTooltips();
return (list == null || list.isEmpty()) ? null : list.get(list.size() - 1);
}
// 1. GE-Preis allein
@Test
public void test1_GePriceAlone()
{
String text = overlay.buildTooltipText(SHARK_ID, 2, true, false, false);
Assert.assertTrue(text.contains("GE:"));
Assert.assertTrue(text.contains("2,000 gp") || text.contains("2.0k gp") || text.contains("2k gp"));
Assert.assertFalse(text.contains("HA:"));
Assert.assertFalse(text.contains("Banked:"));
}
// 2. HA-Wert allein
@Test
public void test2_HaPriceAlone()
{
String text = overlay.buildTooltipText(SHARK_ID, 2, false, true, false);
Assert.assertFalse(text.contains("GE:"));
Assert.assertTrue(text.contains("HA:"));
Assert.assertTrue(text.contains("1,200 gp") || text.contains("1.2k gp") || text.contains("1k gp"));
Assert.assertFalse(text.contains("Banked:"));
}
// 3. GE und HA zusammen
@Test
public void test3_GeAndHaTogether()
{
String text = overlay.buildTooltipText(SHARK_ID, 1, true, true, false);
Assert.assertTrue(text.contains("GE:"));
Assert.assertTrue(text.contains("HA:"));
Assert.assertFalse(text.contains("</br>"));
Assert.assertFalse(text.contains("Banked:"));
}
// 4. Bankmenge bei normalem Item
@Test
public void test4_BankedQuantityNormalItem()
{
testAccount.bankKnown = true;
testAccount.containers.put("bank", new HashMap<>(Map.of(SHARK_ID, 300L)));
String text = overlay.buildTooltipText(SHARK_ID, 1, true, true, true);
Assert.assertTrue(text.contains("GE:"));
Assert.assertTrue(text.contains("HA:"));
Assert.assertTrue(text.contains("</br>Banked:"));
Assert.assertTrue(text.contains("300"));
}
// 5. Bankmenge bei noted/unnoted Kombination
@Test
public void test5_BankedQuantityNotedUnnotedCombination()
{
testAccount.bankKnown = true;
testAccount.containers.put("bank", new HashMap<>(Map.of(SHARK_ID, 500L)));
long qtyForNoted = plugin.getBankedQuantity(SHARK_NOTED_ID);
Assert.assertEquals(500L, qtyForNoted);
String text = overlay.buildTooltipText(overlay.canonicalize(SHARK_NOTED_ID), 1, false, false, true);
Assert.assertTrue(text.contains("Banked:"));
Assert.assertTrue(text.contains("500"));
}
// 6. unbekannter Bankbestand wird nicht angezeigt
@Test
public void test6_UnknownBankQuantityNotDisplayed()
{
testAccount.bankKnown = false;
mockBankContainer = null;
long banked = plugin.getBankedQuantity(SHARK_ID);
Assert.assertEquals(-1L, banked);
String text = overlay.buildTooltipText(SHARK_ID, 1, true, true, true);
Assert.assertFalse(text.contains("Banked:"));
}
// 7. bekannter Bankbestand von 0
@Test
public void test7_KnownBankQuantityZero()
{
testAccount.bankKnown = true;
testAccount.containers.put("bank", new HashMap<>());
long banked = plugin.getBankedQuantity(SHARK_ID);
Assert.assertEquals(0L, banked);
String text = overlay.buildTooltipText(SHARK_ID, 1, false, false, true);
Assert.assertTrue(text.contains("Banked:"));
Assert.assertTrue(text.contains("0"));
}
// 8. Bankbestand aktualisiert sich nach Containeränderung
@Test
public void test8_BankQuantityUpdatesOnContainerChange()
{
testAccount.bankKnown = true;
testAccount.containers.put("bank", new HashMap<>(Map.of(SHARK_ID, 100L)));
Assert.assertEquals(100L, plugin.getBankedQuantity(SHARK_ID));
Item sharkItem = new Item(SHARK_ID, 250);
ItemContainer newBank = mockInterface(ItemContainer.class, (p, m, a) -> {
if ("getItems".equals(m.getName())) {
return new Item[]{sharkItem};
}
return null;
});
ItemContainerChanged event = new ItemContainerChanged(InventoryID.BANK, newBank);
plugin.onItemContainerChanged(event);
Assert.assertEquals(250L, plugin.getBankedQuantity(SHARK_ID));
}
// 9. gemeinsamer Hauptschalter deaktiviert beide Overlay-Arten
@Test
public void test9_MasterSwitchDisablesBothOverlays()
{
configStore.put("itemInformation", false);
configStore.put("itemPrices", false);
configStore.put("itemCharges", false);
Assert.assertFalse(plugin.isItemInformationEnabled());
Assert.assertFalse(itemCharges.isEnabled());
Widget slot = mockInterface(Widget.class, (p, m, a) -> {
if ("getId".equals(m.getName())) return 149 << 16;
if ("getItemId".equals(m.getName())) return SHARK_ID;
if ("getItemQuantity".equals(m.getName())) return 1;
return null;
});
MenuEntry entry = mockInterface(MenuEntry.class, (p, m, a) -> {
if ("getWidget".equals(m.getName())) return slot;
return null;
});
currentMenuEntries = new MenuEntry[]{entry};
overlay.render(null);
Assert.assertNull(lastTooltip());
List<ItemCharges.ChargeValue> charges = itemCharges.chargesFor(ItemID.TOXIC_BLOWPIPE_LOADED);
Assert.assertTrue(charges.isEmpty());
}
// 10. Charge-Unteroptionen bleiben funktionsfähig
@Test
public void test10_ChargeSubOptionsRemainFunctional()
{
configStore.put("itemInformation", true);
configStore.put("itemInformationCharges", true);
configStore.put("itemInformationChargesWeapons", false);
configStore.put("itemInformationChargesBags", true);
Assert.assertTrue(itemCharges.isEnabled());
List<ItemCharges.ChargeValue> weaponCharges = itemCharges.chargesFor(ItemID.ABYSSAL_TENTACLE);
Assert.assertTrue(weaponCharges.isEmpty());
List<ItemCharges.ChargeValue> bagCharges = itemCharges.chargesFor(ItemID.GEM_BAG);
Assert.assertFalse(bagCharges.isEmpty());
}
// 11. alte Konfigurationswerte werden korrekt übernommen
@Test
public void test11_LegacyConfigFallback()
{
configStore.clear();
configStore.put("itemPrices", true);
configStore.put("itemPricesGe", false);
Assert.assertTrue(plugin.isItemInformationEnabled());
boolean geVal = plugin.getHelperValue("itemInformationGe", Boolean.class, true);
Assert.assertFalse(geVal);
configStore.clear();
configStore.put("itemCharges", true);
configStore.put("itemChargesWeapons", false);
Assert.assertTrue(plugin.isItemInformationEnabled());
Assert.assertTrue(itemCharges.isEnabled());
boolean weaponsVal = plugin.getHelperValue("itemInformationChargesWeapons", Boolean.class, true);
Assert.assertFalse(weaponsVal);
}
// 12. Tooltip erscheint nicht bei Coins/Platinum
@Test
public void test12_CoinsAndPlatinumIgnored()
{
configStore.put("itemInformation", true);
Widget coinSlot = mockInterface(Widget.class, (p, m, a) -> {
if ("getId".equals(m.getName())) return 149 << 16;
if ("getItemId".equals(m.getName())) return 995;
if ("getItemQuantity".equals(m.getName())) return 10000;
return null;
});
MenuEntry coinEntry = mockInterface(MenuEntry.class, (p, m, a) -> {
if ("getWidget".equals(m.getName())) return coinSlot;
return null;
});
currentMenuEntries = new MenuEntry[]{coinEntry};
overlay.render(null);
Assert.assertNull(lastTooltip());
Widget platSlot = mockInterface(Widget.class, (p, m, a) -> {
if ("getId".equals(m.getName())) return 149 << 16;
if ("getItemId".equals(m.getName())) return ItemID.PLATINUM;
if ("getItemQuantity".equals(m.getName())) return 50;
return null;
});
MenuEntry platEntry = mockInterface(MenuEntry.class, (p, m, a) -> {
if ("getWidget".equals(m.getName())) return platSlot;
return null;
});
currentMenuEntries = new MenuEntry[]{platEntry};
overlay.render(null);
Assert.assertNull(lastTooltip());
}
}
@@ -0,0 +1,187 @@
package com.example.essentialsplus;
import com.example.essentialsplus.ui.TimeFilter;
import com.example.essentialsplus.ui.TimeRange;
import com.example.essentialsplus.ui.Ui;
import java.util.concurrent.atomic.AtomicReference;
import org.junit.Test;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertTrue;
public class TimeRangeTest
{
@Test
public void testDefaults()
{
assertEquals("Now", TimeRange.CURRENT.getLabel());
assertEquals("Sess", TimeRange.SESSION.getLabel());
assertEquals("1H", TimeRange.HOUR.getLabel());
assertEquals("1D", TimeRange.DAY.getLabel());
assertEquals("1W", TimeRange.WEEK.getLabel());
assertEquals("1M", TimeRange.MONTH.getLabel());
assertEquals("All", TimeRange.ALL.getLabel());
assertEquals(7, TimeRange.values().length);
assertTrue(TimeRange.CURRENT.isCurrent());
assertTrue(TimeRange.SESSION.isSession());
assertTrue(TimeRange.ALL.isAll());
}
@Test
public void testParseCustomWeeks()
{
TimeRange r1w = TimeRange.parseCustom("1w");
assertNotNull(r1w);
assertEquals("1W", r1w.getLabel());
assertEquals(7 * Ui.DAY, r1w.getMillis());
TimeRange r2w = TimeRange.parseCustom("2w");
assertNotNull(r2w);
assertEquals("2W", r2w.getLabel());
assertEquals(14 * Ui.DAY, r2w.getMillis());
TimeRange r3w = TimeRange.parseCustom("3w");
assertNotNull(r3w);
assertEquals("3W", r3w.getLabel());
assertEquals(21 * Ui.DAY, r3w.getMillis());
TimeRange r5w = TimeRange.parseCustom("5w");
assertNotNull(r5w);
assertEquals("5W", r5w.getLabel());
assertEquals(35 * Ui.DAY, r5w.getMillis());
TimeRange rWeeks = TimeRange.parseCustom("4 weeks");
assertNotNull(rWeeks);
assertEquals("4W", rWeeks.getLabel());
assertEquals(28 * Ui.DAY, rWeeks.getMillis());
}
@Test
public void testParseCustomDaysHoursMinutesYears()
{
TimeRange d3 = TimeRange.parseCustom("3d");
assertNotNull(d3);
assertEquals("3D", d3.getLabel());
assertEquals(3 * Ui.DAY, d3.getMillis());
TimeRange h12 = TimeRange.parseCustom("12h");
assertNotNull(h12);
assertEquals("12H", h12.getLabel());
assertEquals(12 * Ui.HOUR, h12.getMillis());
TimeRange y2 = TimeRange.parseCustom("2y");
assertNotNull(y2);
assertEquals("2Y", y2.getLabel());
assertEquals(2 * 365 * Ui.DAY, y2.getMillis());
TimeRange m30 = TimeRange.parseCustom("30m");
assertNotNull(m30);
assertEquals("30m", m30.getLabel());
assertEquals(30 * 60 * 1000L, m30.getMillis());
TimeRange mo2 = TimeRange.parseCustom("2m");
assertNotNull(mo2);
assertEquals("2M", mo2.getLabel());
assertEquals(2 * 30 * Ui.DAY, mo2.getMillis());
TimeRange mo6 = TimeRange.parseCustom("6mo");
assertNotNull(mo6);
assertEquals("6M", mo6.getLabel());
assertEquals(6 * 30 * Ui.DAY, mo6.getMillis());
}
@Test
public void testSpecialKeywords()
{
assertEquals(TimeRange.CURRENT, TimeRange.parseCustom("now"));
assertEquals(TimeRange.SESSION, TimeRange.parseCustom("sess"));
assertEquals(TimeRange.SESSION, TimeRange.parseCustom("session"));
assertEquals(TimeRange.ALL, TimeRange.parseCustom("all"));
}
@Test
public void testInvalidInputs()
{
assertNull(TimeRange.parseCustom(null));
assertNull(TimeRange.parseCustom(""));
assertNull(TimeRange.parseCustom(" "));
assertNull(TimeRange.parseCustom("abc"));
assertNull(TimeRange.parseCustom("-5d"));
assertNull(TimeRange.parseCustom("0h"));
}
@Test
public void testParseSerialization()
{
TimeRange r2w = TimeRange.custom("2W", 14 * Ui.DAY);
String serialized = r2w.name();
TimeRange restored = TimeRange.parse(serialized);
assertEquals(r2w, restored);
assertEquals("2W", restored.getLabel());
assertEquals(14 * Ui.DAY, restored.getMillis());
assertEquals(TimeRange.WEEK, TimeRange.parse("TWO_WEEKS"));
assertEquals(TimeRange.MONTH, TimeRange.parse("QUARTER"));
assertEquals(TimeRange.HOUR, TimeRange.parse("HOUR"));
}
@Test
public void testTimeFilterCustomSlots()
{
AtomicReference<String> savedSlots = new AtomicReference<>();
TimeFilter filter = new TimeFilter(TimeRange.DAY, null, null, savedSlots::set);
assertEquals(7, filter.getSlots().size());
assertEquals(TimeRange.CURRENT, filter.getSlots().get(0));
assertEquals(TimeRange.SESSION, filter.getSlots().get(1));
assertEquals(TimeRange.HOUR, filter.getSlots().get(2));
assertEquals(TimeRange.DAY, filter.getSlots().get(3));
assertEquals(TimeRange.WEEK, filter.getSlots().get(4));
assertEquals(TimeRange.MONTH, filter.getSlots().get(5));
assertEquals(TimeRange.ALL, filter.getSlots().get(6));
assertEquals(3, filter.getSlotIndex(TimeRange.DAY));
// Customize slot 2 (1H) to 2W
TimeRange r2w = TimeRange.custom("2W", 14 * Ui.DAY);
filter.setSlot(2, r2w);
assertEquals(r2w, filter.getSlots().get(2));
assertEquals(2, filter.getSlotIndex(r2w));
assertNotNull(savedSlots.get());
assertTrue(savedSlots.get().contains("2W"));
// Filter calculation using 2W
filter.set(r2w);
long now = System.currentTimeMillis();
long start = filter.start();
assertTrue(start <= now - 14 * Ui.DAY && start >= now - 14 * Ui.DAY - 5000);
assertEquals("in 2W", filter.inLabel());
// Reset slot 2
filter.resetSlot(2);
assertEquals(TimeRange.HOUR, filter.getSlots().get(2));
// Reset all slots
filter.setSlot(0, r2w);
filter.resetSlots();
assertEquals(TimeRange.CURRENT, filter.getSlots().get(0));
}
@Test
public void testRestoreSlotsFromConfig()
{
String configStr = "CURRENT,SESSION,CUSTOM:5W:3024000000,DAY,WEEK,CUSTOM:2Y:63072000000,ALL";
TimeFilter filter = new TimeFilter(TimeRange.DAY, null, configStr, null);
assertEquals(7, filter.getSlots().size());
assertEquals("5W", filter.getSlots().get(2).getLabel());
assertEquals(3024000000L, filter.getSlots().get(2).getMillis());
assertEquals("2Y", filter.getSlots().get(5).getLabel());
assertEquals(63072000000L, filter.getSlots().get(5).getMillis());
}
}
@@ -235,4 +235,48 @@ public class WealthSpikeFilterTest
Assert.assertEquals(100_000_000L, clean.get(0).total());
Assert.assertEquals(102_000_000L, clean.get(1).total());
}
@Test
public void testPlateauSpikeIsRemoved()
{
// 154.7M baseline -> 18 points @ 161.2M (~18 mins) -> 154.9M baseline
List<Snapshot> raw = new ArrayList<>();
long t = 1000;
raw.add(snap(t, 154_700_000L));
for (int i = 0; i < 18; i++)
{
t += 60_000;
raw.add(snap(t, 161_200_000L + (i * 10_000L)));
}
t += 60_000;
raw.add(snap(t, 154_900_000L));
Assert.assertEquals(20, raw.size());
List<Snapshot> clean = AccountData.sanitizeHistory(raw);
Assert.assertEquals(2, clean.size());
Assert.assertEquals(154_700_000L, clean.get(0).total());
Assert.assertEquals(154_900_000L, clean.get(1).total());
}
@Test
public void testLoginStepGlitchIsRemoved()
{
// 161.8M yesterday -> 10.7h gap -> 162.2M on login (102s unverified cache) -> 155.7M on bank sync -> 155.8M sustained
List<Snapshot> raw = new ArrayList<>();
raw.add(snap(1000, 161_856_503L));
long loginTime = 1000 + 10 * 3600 * 1000L;
raw.add(snap(loginTime, 162_198_011L)); // unverified bank cache artifact
raw.add(snap(loginTime + 102_000L, 155_767_415L)); // verified bank sync
raw.add(snap(loginTime + 163_000L, 155_807_432L)); // sustained session
raw.add(snap(loginTime + 223_000L, 155_806_232L));
List<Snapshot> clean = AccountData.sanitizeHistory(raw);
Assert.assertEquals(4, clean.size());
Assert.assertEquals(161_856_503L, clean.get(0).total());
Assert.assertEquals(155_767_415L, clean.get(1).total());
Assert.assertEquals(155_807_432L, clean.get(2).total());
Assert.assertEquals(155_806_232L, clean.get(3).total());
}
}
@@ -2,6 +2,7 @@ package com.example.essentialsplus.helpers;
import com.example.essentialsplus.EssentialsPlusConfig;
import java.awt.Color;
import java.awt.Rectangle;
import java.lang.reflect.Proxy;
import java.time.Instant;
import java.util.ArrayList;
@@ -13,10 +14,19 @@ import net.runelite.api.ChatMessageType;
import net.runelite.api.Client;
import net.runelite.api.IterableHashTable;
import net.runelite.api.MessageNode;
import net.runelite.api.Menu;
import net.runelite.api.MenuAction;
import net.runelite.api.MenuEntry;
import net.runelite.api.Point;
import net.runelite.api.ScriptID;
import net.runelite.api.Player;
import net.runelite.api.events.ChatMessage;
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.client.callback.ClientThread;
import org.junit.Assert;
import org.junit.Before;
@@ -36,6 +46,71 @@ public class ChatHelperTest
private int[] intStack;
private Object[] objStack;
private Player localPlayer;
private final List<MenuEntry> menuEntries = new ArrayList<>();
private final Map<Integer, Widget> widgets = new HashMap<>();
private Point mouse = new Point(50, 420);
private String copiedText;
private int chatRefreshes;
private MenuEntry entry(String option, MenuAction type, int param1)
{
Map<String, Object> values = new HashMap<>();
values.put("Option", option);
values.put("Type", type);
values.put("Param1", param1);
return mockInterface(MenuEntry.class, (proxy, method, args) ->
{
String name = method.getName();
if (name.startsWith("set"))
{
values.put(name.substring(3), args[0]);
return proxy;
}
if (name.equals("onClick"))
{
if (args == null || args.length == 0) return values.get("OnClick");
values.put("OnClick", args[0]);
return proxy;
}
if (name.equals("getWidget"))
{
return widgets.get(values.get("Param1"));
}
return name.startsWith("get") ? values.get(name.substring(3)) : null;
});
}
private Widget widget(int id, String text, Rectangle bounds, boolean hidden, Widget parent, Widget[] children)
{
Widget widget = mockInterface(Widget.class, (proxy, method, args) ->
{
switch (method.getName())
{
case "getId": return id;
case "getText": return text;
case "getBounds": return bounds;
case "isHidden": return hidden;
case "getParent": return parent;
case "getChildren": return children;
case "getChild":
int index = (Integer) args[0];
return index >= 0 && index < children.length ? children[index] : null;
default: return null;
}
});
widgets.put(id, widget);
return widget;
}
private Widget[] chatWidgets(boolean hidden)
{
Widget[] children = new Widget[8];
Widget scroll = widget(InterfaceID.Chatbox.SCROLLAREA, "", new Rectangle(0, 400, 500, 100), hidden, null, children);
children[1] = widget(-1, "First message", new Rectangle(20, 440, 300, 15), false, scroll, null);
children[5] = widget(-2, "<col=ffffff>You smelt a bar.</col>", new Rectangle(20, 410, 300, 20), false, scroll, null);
widget(InterfaceID.Chatbox.LINE0 + 1, "Player", new Rectangle(), false, scroll, null);
return children;
}
@SuppressWarnings("unchecked")
private static <T> T mockInterface(Class<T> iface, java.lang.reflect.InvocationHandler handler)
@@ -105,6 +180,10 @@ public class ChatHelperTest
scriptsRun.clear();
configValues.clear();
messagesMap.clear();
menuEntries.clear();
widgets.clear();
chatRefreshes = 0;
copiedText = null;
// Default config values
configValues.put("chatHelper", true);
@@ -157,10 +236,32 @@ public class ChatHelperTest
{
switch (method.getName())
{
case "refreshChat":
chatRefreshes++;
return null;
case "getMouseCanvasPosition": return mouse;
case "getWidget":
return widgets.get(args.length == 1 ? (Integer) args[0] : ((Integer) args[0] << 16) | (Integer) args[1]);
case "getMenu":
return mockInterface(Menu.class, (p, m, a) ->
{
if (m.getName().equals("getMenuEntries")) return menuEntries.toArray(new MenuEntry[0]);
if (m.getName().equals("setMenuEntries"))
{
menuEntries.clear();
java.util.Collections.addAll(menuEntries, (MenuEntry[]) a[0]);
}
return null;
});
case "createMenuEntry":
MenuEntry entry = entry("", MenuAction.RUNELITE, 0);
int index = (Integer) args[0];
menuEntries.add(index < 0 ? menuEntries.size() + index + 1 : index, entry);
return entry;
case "getChatLineMap":
return chatLineMap;
case "runScript":
scriptsRun.add((Object[]) args);
scriptsRun.add((Object[]) args[0]);
return null;
case "getLocalPlayer":
return localPlayer;
@@ -186,7 +287,14 @@ public class ChatHelperTest
}
});
chatHelper = new ChatHelper(client, config, clientThread);
chatHelper = new ChatHelper(client, config, clientThread)
{
@Override
void copyToClipboard(String text)
{
copiedText = text;
}
};
}
@Test
@@ -207,6 +315,100 @@ public class ChatHelperTest
Assert.assertEquals(1, buf.getLines().length);
Assert.assertEquals("You cut an oak log.", buf.getLines()[0].getValue());
Assert.assertFalse(scriptsRun.isEmpty());
Assert.assertEquals(1, chatRefreshes);
Assert.assertEquals(ScriptID.SPLITPM_CHANGED, scriptsRun.get(0)[0]);
}
@Test
public void clearsWelcomeAndSystemMessagesButProtectsClanMessages()
{
int old = (int) Instant.now().getEpochSecond() - 120;
for (ChatMessageType type : new ChatMessageType[]{ChatMessageType.WELCOME, ChatMessageType.PLAYERRELATED,
ChatMessageType.CLAN_GUEST_MESSAGE, ChatMessageType.FRIENDSCHATNOTIFICATION})
{
chatLineMap.put(type.getType(), createChatLineBuffer(List.of(createMessageNode(type.getType(), type, "", "message", old))));
}
chatHelper.onGameTick(new GameTick());
Assert.assertEquals(0, chatLineMap.get(ChatMessageType.WELCOME.getType()).getLength());
Assert.assertEquals(0, chatLineMap.get(ChatMessageType.PLAYERRELATED.getType()).getLength());
Assert.assertEquals(1, chatLineMap.get(ChatMessageType.CLAN_GUEST_MESSAGE.getType()).getLength());
Assert.assertEquals(1, chatLineMap.get(ChatMessageType.FRIENDSCHATNOTIFICATION.getType()).getLength());
}
@Test
public void autoClearRespectsDisabledSettings()
{
int old = (int) Instant.now().getEpochSecond() - 120;
chatLineMap.put(0, createChatLineBuffer(List.of(createMessageNode(1, ChatMessageType.GAMEMESSAGE, "", "old", old))));
configValues.put("chatAutoClearTime", 0);
chatHelper.onGameTick(new GameTick());
configValues.put("chatAutoClearTime", 60);
configValues.put("chatAutoClear", false);
chatHelper.onGameTick(new GameTick());
configValues.put("chatAutoClear", true);
configValues.put("chatHelper", false);
chatHelper.onGameTick(new GameTick());
Assert.assertEquals(1, chatLineMap.get(0).getLength());
Assert.assertEquals(0, chatRefreshes);
}
@Test
public void copyUsesMessageLineOffsetAndCapturesText()
{
Widget[] children = chatWidgets(false);
MenuEntry report = entry("Report", MenuAction.CC_OP, InterfaceID.Chatbox.LINE0 + 1);
menuEntries.add(report);
chatHelper.onMenuEntryAdded(new MenuEntryAdded(report));
Assert.assertEquals(2, menuEntries.size());
MenuEntry copy = menuEntries.get(1);
Assert.assertEquals("Copy message", copy.getOption());
children[5] = null;
copy.onClick().accept(copy);
Assert.assertEquals("<col=ffffff>You smelt a bar.</col>", copiedText);
}
@Test
public void copyWorksOnClickThroughGameMessageWithoutReport()
{
chatWidgets(false);
menuEntries.add(entry("Cancel", MenuAction.CANCEL, 0));
menuEntries.add(entry("Walk here", MenuAction.WALK, 0));
chatHelper.onMenuOpened(new MenuOpened());
Assert.assertEquals(2, menuEntries.size());
MenuEntry copy = menuEntries.get(1);
Assert.assertEquals("Copy message", copy.getOption());
copy.onClick().accept(copy);
Assert.assertEquals("<col=ffffff>You smelt a bar.</col>", copiedText);
chatHelper.onMenuOpened(new MenuOpened());
Assert.assertEquals(2, menuEntries.size());
}
@Test
public void copyIgnoresHiddenClippedAndUnrelatedWidgets()
{
menuEntries.add(entry("Walk here", MenuAction.WALK, 0));
chatWidgets(true);
chatHelper.onMenuOpened(new MenuOpened());
Assert.assertEquals(1, menuEntries.size());
chatWidgets(false);
mouse = new Point(50, 390);
chatHelper.onMenuOpened(new MenuOpened());
Assert.assertEquals(1, menuEntries.size());
mouse = new Point(50, 420);
widget(12 << 16, "Bank", new Rectangle(), false, null, null);
menuEntries.add(entry("Withdraw", MenuAction.CC_OP, 12 << 16));
chatHelper.onMenuOpened(new MenuOpened());
Assert.assertEquals(2, menuEntries.size());
}
@Test
public void copyRespectsDisabledSetting()
{
chatWidgets(false);
configValues.put("chatCopyToClipboard", false);
chatHelper.onMenuOpened(new MenuOpened());
chatHelper.onMenuEntryAdded(new MenuEntryAdded(entry("Report", MenuAction.CC_OP, InterfaceID.Chatbox.LINE0 + 1)));
Assert.assertTrue(menuEntries.isEmpty());
}
@Test
@@ -0,0 +1,63 @@
package com.example.essentialsplus.ui;
import com.example.essentialsplus.WealthCategory;
import com.example.essentialsplus.data.AccountData;
import com.example.essentialsplus.data.Snapshot;
import java.util.ArrayList;
import java.util.List;
import org.junit.Assert;
import org.junit.Test;
public class SeriesTest
{
private static Snapshot snap(long t, long total)
{
long[] v = new long[WealthCategory.COUNT];
v[WealthCategory.BANK.ordinal()] = total;
return new Snapshot(t, v);
}
@Test
public void testCombineCarriesEarliestKnownPointBackwardsWithoutSpike()
{
// Series 1 starts at t=100 with 7.8M
List<Snapshot> s1 = new ArrayList<>();
s1.add(snap(100, 7_800_000L));
s1.add(snap(200, 7_800_000L));
// Series 2 starts at t=110 with 162M (e.g. slight timestamp offset on login)
List<Snapshot> s2 = new ArrayList<>();
s2.add(snap(110, 162_000_000L));
s2.add(snap(200, 162_000_000L));
List<Snapshot> combined = Series.combine(List.of(s1, s2));
// Point at t=100 must include s2's initial value (162M), not 0!
Assert.assertEquals(3, combined.size());
Assert.assertEquals(100L, combined.get(0).t);
Assert.assertEquals(169_800_000L, combined.get(0).total());
Assert.assertEquals(110L, combined.get(1).t);
Assert.assertEquals(169_800_000L, combined.get(1).total());
Assert.assertEquals(200L, combined.get(2).t);
Assert.assertEquals(169_800_000L, combined.get(2).total());
}
@Test
public void testForAccountDoesNotCarryOldLogoutValuesAcrossInactivityGaps()
{
AccountData acc = new AccountData();
acc.bankKnown = true;
acc.history.add(snap(1000, 161_800_000L)); // yesterday before logout
long sessionStart = 1000 + 10 * 3600 * 1000L; // 10 hours later
long firstVerifiedBank = sessionStart + 100_000L;
acc.history.add(snap(firstVerifiedBank, 155_800_000L));
List<Snapshot> series = Series.forAccount(acc, sessionStart, firstVerifiedBank + 100_000L);
// The baseline at sessionStart must be the first verified value in this session (155.8M),
// NOT yesterday's pre-logout value (161.8M).
Assert.assertEquals(sessionStart, series.get(0).t);
Assert.assertEquals(155_800_000L, series.get(0).total());
}
}
-74
View File
@@ -1,74 +0,0 @@
# Tree Cooldowns – Taskbeschreibung
Implementiere ein **„Tree Cooldowns"**-Helper in **Essentials Plus** (`com.example.essentialsplus.helpers`). Es gibt zwei Anzeigen:
1. **Respawn-Countdown** über Baumstümpfen: Sekunden bis der Baum wieder wächst (nur Stümpfe, deren Fällung man in der Nähe *miterlebt* hat — zugehörige Zeiten sind sonst nicht messbar).
2. **Verbleibende Chop-Zeit** auf langlebigen Bäumen (Maple+, z. B. Yew/Magic/Redwood) über dem Baum, solange man ihn hackt.
**Format:** reine Sekunden (Ganzzahl, z. B. `8`). Deutsch als Display-Sprache.
## Mechanik (OSRS-Wiki, Depletion-System)
Despawn = wie lange aktiv gehäckt werden kann (beginnt beim ersten Hieb, **regeneriert** bei Pause). Respawn = Zeit bis der Baum wieder da ist.
| Baum | Despawn | Respawn |
|---|---|---|
| Normal tree | — | ~10 s |
| Oak | 27 s | 8,4 s |
| Willow | 30 s | 8,4 s |
| Teak | 30 s | 9 s |
| Maple | 60 s | 35,4 s |
| Hollow | 36 s | 25,8 s |
| Mahogany | 60 s | 8,4 s |
| Arctic pine | 84 s | 8,4 s |
| Yew | 114 s | 59,4 s |
| Camphor | 120 s | 59,4 s |
| Magic | 234 s | 119,4 s |
| Ironwood | 240 s | 119,4 s |
| Redwood | 264 s | 119,4 s |
| Rosewood | 275 s | 123 s |
Chop-Countdown nur wenn `despawnSec > 30` (`CHOP_MIN_DESPAWN_SEC`).
## Neue Dateien
### `helpers/TreeCooldowns.java` (`@Singleton`, Logik)
- **`TreeType`-Enum** mit `despawnSec`, `respawnSec` und Objekt-ID-Sets aus `net.runelite.api.gameval.ObjectID` (z. B. `OAKTREE`, `OAKTREE_STUMP`, `WILLOWTREE`, `YEWTREE`, `YEWTREE_STUMP`, `MAPLETREE`, `MAGICTREE`, `TEAKTREE`, `MAHOGANYTREE`, `ACHEY_TREE`, `REDWOODTREE_L/_R(+_CUT)`, diverse Varianten wie `TREE/TREE2/LIGHTTREE/DEADTREE/JUNGLETREE/EVERGREEN` plus jeweilige `*_STUMP`-Ids). IDs zusätzlich über `ObjectComposition.getName()` verifizieren; unbekannte Regio-Varianten ergänzen.
- **`WOODCUT_ANIMS`**: alle `AnimationID.HUMAN_WOODCUTTING_*`.
- **State**: `Map<TileKey(sceneX, sceneY, plane), TreeState{type, choppedMs, chopStartTick, respawnDeadlineMs}>`.
- **Events** (im Plugin registriert):
- `GameTick`: Ist `client.getLocalPlayer().getAnimation()` ∈ `WOODCUT_ANIMS` → nächsten Baum in Reichweite suchen (Scan der Scene-Tiles wie `WorldObjectHighlighterOverlay.java:91`), als Ziel merken und `choppedMs += 600`. Kein Hacken (Animation weg / anderes Ziel) → `choppedMs` zurücksetzen (Depletion regeneriert).
- `GameObjectDespawned`: getracktes Ziel (Baum) verschwindet → felled: `respawnDeadlineMs = now + respawnSec(type)` am Tile setzen.
- `GameObjectSpawned`:
- Stump-Id → fremder Baum wurde in der Nähe gefällt → ebenfalls `respawnDeadlineMs = now + respawnSec(stumpType)` setzen (Fallback `TreeType.NORMAL` bei unbekannter Stump-Id). Nur wenn Tile in einem Radius von ~16 Tiles um den Spieler („in der Nähe gefällt").
- Baum-Id an einem Tile mit aktiver Respawn-Deadline → Eintrag löschen (wiedergewachsen).
- `GameStateChanged` (`!= LOGGED_IN`) und Region-Wechsel → `reset()`.
- **API für das Overlay**: Iteratoren `respawnTimers()`, `chopTimers()` (nur Records mit Rest > 0) samt Referenz auf das Tile (`LocalPoint`, `sceneX/Y`, plane).
### `helpers/TreeCooldownOverlay.java` (`Overlay`)
- `OverlayPosition.DYNAMIC`, `OverlayLayer.ABOVE_SCENE`.
- Text-Draw mit Schatten, identisch zu `AttackTimerOverlay.shadowed`; Font über `TimerTextSize`.
- **Respawn**: Sekunden-Zahl über dem Stumpf/Perspektivenpunkt der Tree-Basis (Anchor über `Perspective.getCanvasTextLocation`, s. `AttackTimerOverlay.java:130`), Höhe = `treeCooldownOffset`.
- **Chop**: Rest-Sekunden über dem Baum, Höhe = `treeCooldownOffset + 80`.
- Optional Fortschrittsbalken (`treeCooldownBar`), Muster aus `AttackTimerOverlay.drawTimer` (drainende Pips).
- Nichts rendern wenn `!config.treeCooldowns()`.
## Zu ändernde Dateien
- **`EssentialsPlusConfig.java`**: `@ConfigItem treeCooldowns` (bool, default `false`, position 34). Stil/Toggles als `configManager`-Keys via `setHelperOption` (eingesetztes Muster):
- `treeCooldownRespawn` (bool, true), `treeCooldownChop` (bool, true), `treeCooldownBar` (bool, false)
- `treeCooldownSize` (`TimerTextSize`, NORMAL), `treeCooldownOffset` (int, 50), `treeCooldownColor` (Color, weiß oder grün `0x8FBF3F`), `treeCooldownReadyColor` (Color, `0x66FF66`)
- **`EssentialsPlusPlugin.java`**: `@Inject TreeCooldowns` + `TreeCooldownOverlay`; `startUp()`: `eventBus.register(treeCooldowns)`, `overlayManager.add(treeCooldownOverlay)`; `shutDown()`: spiegeln + `treeCooldowns.reset()`. `isHelperOption()`: `case "treeCooldowns"`.
- **`HelpersCard.java`**: neues `JCheckBox "Tree cooldowns"` (Tooltip: „Respawn-Countdown auf gefällten Bäumen + Rest-Zeit auf langlebigen Bäumen. Rechtsklick für Optionen"). Rechtsklick-Menü analog `priceMenu`/`styleMenu`: Toggles `treeCooldownRespawn`/`treeCooldownChop`/`treeCooldownBar`, Größe, Höhe, Farben, Reset. `refresh()` erweitern.
## Verifikation
- `./gradlew compileJava` (bzw. `./gradlew deploy` für Bolt).
- Manuell: Baum fällen (Oak/Willow → Respawn-Zahl auf Stump), Yew/Magic/Redwood hacken (Chop-Rest über Baum, zählt runter, fällt → Respawn-Zahl), weggehen und zurück (fremder frisch gefällter Stump in der Nähe → Timer), ausloggen (alles reset).
## Hinweise / Einschränkungen
- Nur Bäume, die selbst beobachtet gefällt wurden (eigene oder fremde in der Nähe); vorab vorhandene Stümpfe unbekannten Alters → kein Timer.
- Times anderer Spieler stimmen nicht exakt (Depletion läuft global); selbst gehackte Bäume sind exakt.