diff --git a/Item Information.md b/Item Information.md new file mode 100644 index 0000000..aa77bda --- /dev/null +++ b/Item Information.md @@ -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 diff --git a/item-charges.md b/item-charges.md deleted file mode 100644 index 957ab6d..0000000 --- a/item-charges.md +++ /dev/null @@ -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.`, Wert int 0–27; `accountKey`-Konvention wie bestehende Persistenz im Plugin). -- Zu verarbeitende Muster (ChatMessage-Filter `Game`): Check-/Fill-Feedback „[…]`X` coal in your coal bag]" o. Ä. *exakt in-game verifizieren* (2024 wurde Feedback von Popups auf Chat umgestellt); zusätzlich Empty → 0; bei unbekannter Nachricht/Login ohne Check → **`?`** anzeigen statt falscher Zahl. -- `ChatMessage`-Events: Fehler-tolerant parsen (Regex), bei Update sofort speichern. Beim `LOGOUT` aktuellen Wert speichern. Optional Rechtsklick „Coal bag vergessen" → Wert nicht aus Chat-Nachrichten ableiten. - -## Neue Dateien - -### `helpers/ItemCharges.java` (`@Singleton`, Logik) - -- **Datenmodell**: `ChargeValue { String text; Color color; }` (+ optional 2. Wert). Lookup-API `List chargesFor(int itemId)`: - 1. exakte `itemId` in Tabelle (Load/Empty/Inactive-Varianten); dann - 2. `itemManager.canonicalize(itemId)` (Noten/Placeholder) erneut prüfen. -- **`CHARGE_MAP`**: `Map` statisch aufgebaut aus den ItemID-Konstanten (Quelle 1+2); `ChargeSource` = Varbit-Konstante bzw. `COAL_BAG`-Typ bzw. `LOOTING_BAG`-Container-Typ. -- **State**: Coal-Bag-Count pro Account + `Map`-Cache nur so weit nötig (Varbit-Lesen ist billig; kein harter Cache zwingend). -- **Events** (im Plugin registriert, Muster `TreeCooldowns`): - - `GameTick` → die zwei Bag-Varbits/Container aktualisieren. - - `VarbitChanged` → relevante Varbit-Id → Value merken. - - `ChatMessage` → Coal-Bag-Messages parsen (s. o.), speichern. - - `GameStateChanged` → `!= LOGGED_IN` → `reset()`, Coal-Bag-Count aus `configManager` laden bzw. auf „?" zurücksetzen. -- **Reset**: Coal-Bag-Cache auf `?` beim Logout (Persistenz bleibt). - -### `helpers/ItemChargesOverlay.java` (`WidgetItemOverlay`) - -- `showOnInventory(); showOnEquipment(); showOnBank();` — Bank über Config-Sub-Key steuerbar (s. unten). -- `renderItemOverlay(g, itemId, WidgetItem)`: - - `if (!config.itemCharges()) return;` - - `List` aus `chargesFor(itemId)`; leer → nichts rendern. - - Zahl unten-links am Icon (offizielles RuneLite-Muster): `bounds.x - 1, bounds.y + 15`, mit Schatten (`Fonts.RUNESCAPE_SMALL` bzw. `TimerTextSize`), Farbe aus `itemChargesColor`; **2. Wert** (Blowpipe-Darts, Quiver-Blessing) darüber (`bounds.y - 3`) in `itemChargesSecondaryColor`. - - Geld (Lootingsack): Stack-Size-Format; Low-Charge-Färbung ab `itemChargesLowAt` → `itemChargesLowColor`. -- Coins/Loot-Bag-Wert: aus `chargesFor(LOOTING_BAG_OPEN/LOOTING_BAG)`-Container-Lesung im Overlay-Tick (Falls Container null → kein Wert). - -## Zu ändernde Dateien - -- **`EssentialsPlusConfig.java`**: `@ConfigItem itemCharges` (bool, default `false`, position ~37). Optionen als `configManager`-Keys via `setHelperOption` (Muster wie treeCooldowns): - - `itemChargesWeapons` (bool, true) — Varbit-Charges an/aus - - `itemChargesBags` (bool, true) — Loot-Bag-Geld + Coal-Bag-Füllstand an/aus - - `itemChargesBank` (bool, false) — Overlay auch in Bank (Achtung Hinweise!) - - `itemChargesSize` (`TimerTextSize`, SMALL), `itemChargesColor` (Color, weiß), `itemChargesSecondaryColor` (Color, hellgrau `0xC0C0C0`), `itemChargesLowColor` (Color, rot `0xFF3333`), `itemChargesLowAt` (int, 0 = aus) -- **`EssentialsPlusPlugin.java`**: `@Inject ItemCharges` + `ItemChargesOverlay`; `startUp()`: `eventBus.register(itemCharges)`, `overlayManager.add(itemChargesOverlay)`; `shutDown()`: spiegeln + `itemCharges.reset()`. `isHelperOption()`: `case "itemCharges"` → `config.itemCharges()`. `setHelperOption`/`onConfigChanged`: Bestände für `itemcharges*`-Keys refresh (Muster solcher Bestands-Abschnitte). -- **`HelpersCard.java`**: neues `JCheckBox "Item charges"` (Tooltip: „Charge-Zahl auf Item-Icons (Blowpipe, Trident, Tome, …) + Loot-Bag-Geld & Coal-Bag-Füllstand. Rechtsklick für Optionen"). Rechtsklick-Menü analog `styleMenu`/`treeMenu`: Toggles `itemChargesWeapons`/`itemChargesBags`/`itemChargesBank`, Größe, Farben, Schwellenwert, Reset. `refresh()` erweitern. - -## Verifikation - -- `./gradlew compileJava` (bzw. `./gradlew deploy` für Bolt). -- Manuell: Blowpipe mit Scales (nur Scales-Zahl) → mit Darts laden (2. Zahl erscheint); Trident/Shadow/Sang/Tome (Zahl); Lootingsack mit Coins betreten (Geld auf Sack-Icon); Coal Bag füllen → Zahl steigt, Empty → 0, Relog → Wert bleibt; Bank öffnen mit `itemChargesBank` → Zahlen auch dort; beide Blowpipes (leer + geladen) im Inventar → korrekte per-ID-Darstellung. - -## Hinweise / Einschränkungen - -- `CHARGES_*`-Varbits sind **charakterweit, nicht pro Item-Exemplar**: zwei unterschiedlich geladene Tridents zeigen denselben Wert (OSRS-Systemgrenze, deckungsgleich mit dem In-Game-Charge-Overlay). -- **Bank-Anzeige** dementsprechend unzuverlässig für „unbenutzte" Kopien; default daher `itemChargesBank = false`. -- Coal Bag: nur Heuristik (Chat + Persistenz), kein API-Wert; nach Login unbekannt → `?`. -- Blowpipe: Dart-**Anzahl** nicht in der API → 2. Wert ist Darts-geladen-Marker, keine Zahl. -- Keine Chat-Popup-abhängigkeit (Popup = ist abgeschaltet hat), ausschließlich ChatMessage-Events verwenden. - -## Referenzen - -- `tree-cooldown.md` (gleiche Struktur), `reflect.txt`/Cachierte Jars für Konstanten: - - runelite-api: `/home/hikyu/.gradle/caches/modules-2/files-2.1/net.runelite/runelite-api/1.12.39//runelite-api-1.12.39.jar` → `net/runelite/api/gameval/{VarbitID,ItemID,InventoryID,DBTableID$Charges$Row}` - - client: `/home/hikyu/.gradle/caches/modules-2/files-2.1/net.runelite/client/1.12.39//client-1.12.39.jar` → `net.runelite.client.ui.overlay.WidgetItemOverlay`, `components.TextComponent`, `ui.FontManager` -- Konventionen: `helpers/TreeCooldowns.java` (Logik+Events), `helpers/TreeCooldownOverlay.java`/`ItemPriceOverlay.java` (Overlay-Zeichnung), `HelpersCard.java` (Checkbox+Menü), `EssentialsPlusPlugin.isHelperOption/setHelperOption/onConfigChanged`. \ No newline at end of file diff --git a/skill-reset-hourly.md b/skill-reset-hourly.md deleted file mode 100644 index f72fe26..0000000 --- a/skill-reset-hourly.md +++ /dev/null @@ -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 samples`), ein File pro Charakter (JSON in `essentials-plus/skills`). -- `ui/TimeFilter.java` + `ui/TimeRange.java` liefern den global geteilten Zeitraum: `CURRENT("Now")`, `SESSION("Sess")`, `HOUR("1H")`, `DAY`, `TWO_WEEKS`, `QUARTER`, `ALL`. -- Gewinne werden als `latest - at(start)` berechnet; der Start kommt aus `SkillHistory.start(filter)`, `starts(filter)` und `gained(filter)`. -- Es gibt **noch kein** Reset-/Baseline-Feld. Der Reset wird als **Zeitmarker** umgesetzt, nicht durch Löschen von Samples. - -## Neue Dateien - -### `skills/SkillHistory.java` (erweitern, keine neue Datei) - -- Zwei persistente Marker ergänzen (0 = nie zurückgesetzt): - - `public long resetSessionAt;` - - `public long resetNowAt;` -- `public synchronized void reset(TimeRange range)`: - - `SESSION` → `resetSessionAt = System.currentTimeMillis()` - - `CURRENT` → `resetNowAt = System.currentTimeMillis()` - - sonst → no-op - - `dirty = true` (Persistenz-Flag, existiert bereits) -- Startlogik **nur** für die betroffenen Ranges clampen: - - `starts(TimeFilter)`: bei `SESSION` für jeden Skill `Math.max(filter.start(), resetSessionAt)`; bei `CURRENT` pro Skill `Math.max(currentStart(i, now), resetNowAt)`, idle (`Long.MAX_VALUE`) bleibt idle. - - `start(TimeFilter)`: bei `SESSION` `Math.max(filter.start(), resetSessionAt)`; bei `CURRENT` nach `currentStart(-1, now)` clampen (Fallback `now` wenn idle). - - `gained(TimeFilter)`: Nicht-Current-Zweig von `gained(filter.start())` auf `gained(start(filter))` umstellen, damit der Session-Clamp greift. Current-Zweig nutzt bereits `starts(filter)`. -- Da `range(long)`, `levelUps(long)` und `activeMillis(long, int)` ihren Start aus `start(filter)` bekommen, ziehen Charts, Stats/„XP per hour" und Level-Ups automatisch mit. -- `normalize()` prüft nur `skills`/`samples`; die neuen Felder brauchen keine Migration (fehlend = 0 = nie zurückgesetzt). - -### `skills/SkillStore.java` (erweitern) - -- `public void resetHourly(String key, TimeRange range)`: History per `histories.get(key)` holen; wenn vorhanden `h.reset(range)` und `save(h)` aufrufen. -- Wird vom Plugin auf dem Executor aufgerufen (File-I/O), nicht auf dem Swing-/Client-Thread. - -## Zu ändernde Dateien - -### `skills/SkillsHudOverlay.java` - -- Im Konstruktor: `addMenuEntry(net.runelite.api.MenuAction.RUNELITE_OVERLAY, "Reset Hourly", "Skills")`. -- Die Render-Logik nutzt bereits `history.starts(filter)`/`gained(filter)` → greift den Reset automatisch, **kein** Render-Umbau nötig. -- Verifikation der API (RuneLite 1.12.39): `Overlay.addMenuEntry(MenuAction, option, target)` und die 4-arg-Variante mit `Consumer` existieren; `OverlayRenderer` injiziert den Callback in `MenuEntry.onClick(...)` **und** feuert `OverlayMenuClicked`. - -### `EssentialsPlusPlugin.java` - -- Import `net.runelite.client.events.OverlayMenuClicked` (+ `net.runelite.client.ui.overlay.Overlay` falls nötig). -- `public void resetSkillsHourly(String key)`: - - `if (key == null) return;` - - `executor.execute(() -> skillStore.resetHourly(key, timeFilter.getRange()));` - - danach Panel-Refresh: `EssentialsPlusPanel p = panel; if (p != null) p.refreshSkillsLater();` -- `@Subscribe public void onOverlayMenuClicked(OverlayMenuClicked event)`: - - wenn `event.getOverlay() == skillsHudOverlay` und `"Reset Hourly".equals(event.getEntry().getOption())` → `resetSkillsHourly(skillKey)`. -- `getSkillStore()`, `getSkillKey()`, `getTimeFilter()` sind vorhandene Lombok-Getter; `skillsHudOverlay` ist injiziert (`EssentialsPlusPlugin.java:232`). -- Alternativ den `Consumer` der 4-arg-`addMenuEntry` nutzen; dann ist kein `OverlayMenuClicked`-Subscriber nötig (beides funktioniert). Empfehlung: Subscriber, damit die Aktion im Plugin kapselt bleibt. - -### `ui/EssentialsPlusPanel.java` (Overview-Karte) - -- In `menu(String id)` im `case SKILLS` (ca. Zeile 560) einen Eintrag ergänzen: - - `JMenuItem reset = new JMenuItem("Reset Hourly");` - - Action: Character über `SkillsPanel.resolve(plugin.getSkillStore(), null, plugin.getSkillKey())` auflösen; wenn nicht null → `plugin.resetSkillsHourly(h.key);` und danach `refreshSkills();`. -- **Kein** zweites Popup auf der Overview-Karte installieren: `CardDrag` (`ui/CardDrag.java`) konsumiert dort bereits Rechtsklick und öffnet `menu(id)` – sonst gäbe es zwei Menüs. - -### `ui/SkillsPanel.java` (Skills-Tab) - -- Nach `private final SkillsCard card = new SkillsCard(null);` (Zeile 61) im Konstruktor einen `MouseAdapter` auf `card` installieren: - - bei `SwingUtilities.isRightMouseButton(e)` (oder `e.isPopupTrigger()`) ein `JPopupMenu` mit `JMenuItem "Reset Hourly"` bauen und `menu.show(card, e.getX(), e.getY())` zeigen. - - Action: `plugin.resetSkillsHourly(shownKey);` danach `refresh();` (bzw. `refreshLater()`). -- Im Skills-Tab ist **keine** `CardDrag` installiert, daher kollidiert das Popup nicht. - -## Verifikation - -- `./gradlew test` (bestehende Tests + neuer Test unten) und `./gradlew build`. -- Neuer Test in `src/test/java/com/example/essentialsplus/SkillHistoryTest.java` (Muster: `record` per Reflection, package-private): - - Samples über ca. eine Stunde anlegen, dann `h.reset(TimeRange.CURRENT)` bzw. `h.reset(TimeRange.SESSION)`. - - Erwartung: `gained(filter)` für den betroffenen Range startet neu (Baseline = Sample bei `resetAt`); `gained(TimeRange.ALL)` bleibt unverändert; `1H`/`1D` bleiben unverändert. -- Manuell: - - Filter auf **Sess** oder **Now** stellen → Rechtsklick aufs XP-HUD → „Reset Hourly" → `XP gained` zählt ab 0 neu; Gesamt-XP (`All`) unverändert. - - Filter auf `1H`/`1D`/`All` stellen → „Reset Hourly" ändert die Anzeige nicht. - - Overview-Skills-Karte rechtsklicken → „Reset Hourly"; Skills-Tab-Karte rechtsklicken → „Reset Hourly". - - Nach Reset Datei `essentials-plus/skills/.json` prüfen (`resetSessionAt`/`resetNowAt` gesetzt); Relog → Session wird ohnehin vom neuen Login übersteuert, `Now`-Marker bleibt gespeichert. - -## Hinweise / Einschränkungen - -- „Reset Hourly" wirkt bewusst **nur** auf `Session` und `Now`; bei `1H`/`1D`/`2W`/`3M`/`All` passiert nichts (`TimeRange`-Vergleich im `reset(...)`). -- Der Gesamtwert (`All`) bleibt in jedem Fall erhalten, da er aus der ersten Sample-Spur (`samples.get(0)`) berechnet wird und der Reset nur einen Start-Clamp setzt. -- Marker sind pro Charakter im `SkillHistory`-JSON persistiert; ein fehlendes Feld nach älterem Stand bedeutet 0 (nie zurückgesetzt) und ist damit abwärtskompatibel. -- `record(...)` bleibt unverändert: Es fügt weiterhin Samples hinzu; der Reset löscht keine Historie. diff --git a/src/main/java/com/example/ExampleConfig.java b/src/main/java/com/example/ExampleConfig.java index 146f1b9..e7591de 100644 --- a/src/main/java/com/example/ExampleConfig.java +++ b/src/main/java/com/example/ExampleConfig.java @@ -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"; diff --git a/src/main/java/com/example/ExamplePlugin.java b/src/main/java/com/example/ExamplePlugin.java index 0790b09..e6da9ff 100644 --- a/src/main/java/com/example/ExamplePlugin.java +++ b/src/main/java/com/example/ExamplePlugin.java @@ -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 diff --git a/src/main/java/com/example/essentialsplus/EssentialsPlusConfig.java b/src/main/java/com/example/essentialsplus/EssentialsPlusConfig.java index 947bdbf..cce64db 100644 --- a/src/main/java/com/example/essentialsplus/EssentialsPlusConfig.java +++ b/src/main/java/com/example/essentialsplus/EssentialsPlusConfig.java @@ -2,6 +2,8 @@ package com.example.essentialsplus; import com.example.essentialsplus.helpers.TimerTextSize; import java.awt.Color; +import net.runelite.client.config.Keybind; +import net.runelite.api.SoundEffectID; import net.runelite.client.config.Config; import net.runelite.client.config.ConfigGroup; import net.runelite.client.config.ConfigItem; @@ -15,8 +17,8 @@ public interface EssentialsPlusConfig extends Config @ConfigItem( keyName = "snapshotSeconds", - name = "Min. Abstand Datenpunkte", - description = "Mindestabstand zwischen zwei gespeicherten Datenpunkten. Unveraenderte Werte werden nie doppelt gespeichert.", + name = "Minimum snapshot interval", + description = "Minimum time between stored snapshots. Unchanged values are never stored twice.", position = 1 ) @Units(Units.SECONDS) @@ -28,8 +30,8 @@ public interface EssentialsPlusConfig extends Config @ConfigItem( keyName = "rawDays", - name = "Jede Aenderung behalten (Tage)", - description = "So lange wird jeder einzelne Datenpunkt gespeichert. Danach: stuendliche Werte (Open/Close/Min/Max/Durchschnitt).", + name = "Keep every change (days)", + description = "How long to retain individual snapshots before aggregating them hourly (open, close, minimum, maximum, average).", position = 2 ) @Units(" d") @@ -41,8 +43,8 @@ public interface EssentialsPlusConfig extends Config @ConfigItem( keyName = "hourlyDays", - name = "Stuendliche Werte (Tage)", - description = "So lange bleiben stuendliche Werte erhalten. Aeltere Daten werden zu Tageswerten (Open/Close/Min/Max/Durchschnitt).", + name = "Keep hourly values (days)", + description = "How long to retain hourly values before aggregating them daily (open, close, minimum, maximum, average).", position = 3 ) @Units(" d") @@ -54,8 +56,8 @@ public interface EssentialsPlusConfig extends Config @ConfigItem( keyName = "includeGe", - name = "GE-Angebote zaehlen", - description = "Gold und Items in laufenden Grand Exchange Angeboten mitzaehlen.", + name = "Include GE offers", + description = "Include coins and items in active Grand Exchange offers.", position = 4 ) default boolean includeGe() @@ -65,8 +67,8 @@ public interface EssentialsPlusConfig extends Config @ConfigItem( keyName = "revalueOffline", - name = "Offline-Charaktere neu bewerten", - description = "Bewertet ausgeloggte Charaktere stuendlich mit aktuellen GE-Preisen neu (Marktschwankungen im Verlauf).", + name = "Revalue offline characters", + description = "Revalue offline characters hourly using current GE prices to track market changes.", position = 5 ) default boolean revalueOffline() @@ -76,8 +78,8 @@ public interface EssentialsPlusConfig extends Config @ConfigItem( keyName = "showGeBuyLimits", - name = "GE-Kauflimit (4h) im Interface", - description = "Zeigt im Grand Exchange direkt das 4-Stunden-Kauflimit, bereits gekaufte Menge und verbleibendes Limit an.", + name = "GE buy limits (4h)", + description = "Show the four-hour buy limit, amount purchased, and remaining allowance in the Grand Exchange.", position = 7 ) default boolean showGeBuyLimits() @@ -87,8 +89,8 @@ public interface EssentialsPlusConfig extends Config @ConfigItem( keyName = "showSkillsHud", - name = "XP-HUD im Spiel", - description = "Zeigt ingame die gefarmte XP pro Skill im gewaehlten Zeitraum (1H, 1D, ...) an.", + name = "In-game XP HUD", + description = "Show XP gained per skill during the selected period (1H, 1D, etc.).", position = 8 ) default boolean showSkillsHud() @@ -98,8 +100,8 @@ public interface EssentialsPlusConfig extends Config @ConfigItem( keyName = "skillsHudMax", - name = "XP-HUD max. Skills", - description = "Maximale Anzahl Skills im XP-HUD (meiste XP zuerst).", + name = "XP HUD: Maximum skills", + description = "Maximum number of skills in the XP HUD, ordered by XP gained.", position = 9 ) @Range(min = 1, max = 24) @@ -110,8 +112,8 @@ public interface EssentialsPlusConfig extends Config @ConfigItem( keyName = "skillsHudLevel", - name = "XP-HUD: Level", - description = "Zeigt das aktuelle Level pro Skill im XP-HUD.", + name = "XP HUD: Level", + description = "Show the current level of each skill in the XP HUD.", position = 10 ) default boolean skillsHudLevel() @@ -121,8 +123,8 @@ public interface EssentialsPlusConfig extends Config @ConfigItem( keyName = "skillsHudPercent", - name = "XP-HUD: Fortschritt %", - description = "Zeigt den Fortschritt im aktuellen Level in Prozent.", + name = "XP HUD: Progress %", + description = "Show progress through the current level as a percentage.", position = 11 ) default boolean skillsHudPercent() @@ -132,8 +134,8 @@ public interface EssentialsPlusConfig extends Config @ConfigItem( keyName = "skillsHudXpLeft", - name = "XP-HUD: XP bis Level", - description = "Zeigt die verbleibende XP bis zum naechsten Level.", + name = "XP HUD: XP to level", + description = "Show the XP remaining until the next level.", position = 12 ) default boolean skillsHudXpLeft() @@ -143,8 +145,8 @@ public interface EssentialsPlusConfig extends Config @ConfigItem( keyName = "skillsHudTimeToLevel", - name = "XP-HUD: Zeit bis Level", - description = "Geschaetzte Zeit bis zum naechsten Level (basierend auf XP/h im gewaehlten Zeitraum).", + name = "XP HUD: Time to level", + description = "Estimate time to the next level using XP per hour in the selected period.", position = 13 ) default boolean skillsHudTimeToLevel() @@ -154,8 +156,8 @@ public interface EssentialsPlusConfig extends Config @ConfigItem( keyName = "skillsHudXpHour", - name = "XP-HUD: XP/h", - description = "Zeigt XP pro Stunde aktiver Trainingszeit im gewaehlten Zeitraum.", + name = "XP HUD: XP/h", + description = "Show XP per hour of active training in the selected period.", position = 14 ) default boolean skillsHudXpHour() @@ -165,8 +167,8 @@ public interface EssentialsPlusConfig extends Config @ConfigItem( keyName = "skillsHudProgressBar", - name = "XP-HUD: Fortschrittsbalken", - description = "Zeigt einen Balken mit dem Fortschritt zum naechsten Level.", + name = "XP HUD: Progress bar", + description = "Show a progress bar toward the next level.", position = 15 ) default boolean skillsHudProgressBar() @@ -176,18 +178,30 @@ public interface EssentialsPlusConfig extends Config @ConfigItem( keyName = "wikiMenuOption", - name = "Wiki im Rechtsklick-Menue", - description = "Fuegt beim Rechtsklick auf NPCs, Objekte und Items eine 'Wiki'-Option hinzu, die die Seite im Spiel oeffnet.", + name = "Right-click Wiki option", + description = "Add a Wiki option to NPC, object, and item menus to open the page in game.", position = 20 ) default boolean wikiMenuOption() { return true; } + + @ConfigItem( + keyName = "attackDistance", + name = "Attack Distance", + description = "Outline the attack range based on the active style while a bow or crossbow is equipped.", + position = 29 + ) + default boolean attackDistance() + { + return false; + } + @ConfigItem( keyName = "attackTimer", name = "Attack Timer", - description = "Zeigt ueber dem eigenen Kopf, wie viele Ticks bis zum naechsten Angriff verbleiben (0 = Angriff).", + description = "Show ticks until your next attack above your head (0 = attack).", position = 30 ) default boolean attackTimer() @@ -198,7 +212,7 @@ public interface EssentialsPlusConfig extends Config @ConfigItem( keyName = "enemyAttackTimer", name = "Enemy Attack Timer", - description = "Zeigt ueber NPCs, die dich angreifen, die Ticks bis zu ihrem naechsten Angriff. Die Angriffsgeschwindigkeit wird aus ihren Angriffen gelernt (Standard 4).", + description = "Show ticks until the next attack above attacking NPCs. Attack speed is learned from their attacks (default: 4 ticks).", position = 31 ) default boolean enemyAttackTimer() @@ -208,7 +222,7 @@ public interface EssentialsPlusConfig extends Config @ConfigItem( keyName = "enemyAttackStyle", name = "Enemy Attack Style", - description = "Zeigt ueber NPCs, die dich angreifen, womit sie zuletzt angegriffen haben (Nahkampf, Fernkampf, Magie).", + description = "Show the last attack style used by attacking NPCs (melee, ranged, or magic).", position = 32 ) default boolean enemyAttackStyle() @@ -217,33 +231,73 @@ public interface EssentialsPlusConfig extends Config } @ConfigItem( - keyName = "itemPrices", - name = "Item Preise", - description = "Zeigt beim Hovern ueber Inventar-Items eine Zeile mit GE- und/oder HA-Preis.", + keyName = "itemInformation", + name = "Item information", + description = "Shows item prices, banked quantities and charge information. Right-click for options.", position = 33 ) - default boolean itemPrices() + default boolean itemInformation() { return false; } @ConfigItem( - keyName = "itemPricesGe", - name = "Item Preise: GE", - description = "GE-Preis in der Hover-Zeile anzeigen.", + keyName = "itemInformationGe", + name = "Item info: GE price", + description = "Show the GE price in the hover tooltip.", position = 34 ) - default boolean itemPricesGe() + default boolean itemInformationGe() { return true; } @ConfigItem( - keyName = "itemPricesHa", - name = "Item Preise: HA", - description = "High-Alch-Wert in der Hover-Zeile anzeigen.", + keyName = "itemInformationHa", + name = "Item info: High alch", + description = "Show the high alchemy value in the hover tooltip.", position = 35 ) + default boolean itemInformationHa() + { + return true; + } + + @ConfigItem( + keyName = "itemInformationBanked", + name = "Item info: Banked quantity", + description = "Show the banked quantity in the hover tooltip.", + position = 36 + ) + default boolean itemInformationBanked() + { + return true; + } + + @ConfigItem( + keyName = "itemInformationCharges", + name = "Item info: Item charges", + description = "Show charges and fill levels on item icons.", + position = 37 + ) + default boolean itemInformationCharges() + { + return true; + } + + @Deprecated + default boolean itemPrices() + { + return false; + } + + @Deprecated + default boolean itemPricesGe() + { + return true; + } + + @Deprecated default boolean itemPricesHa() { return true; @@ -252,20 +306,15 @@ public interface EssentialsPlusConfig extends Config @ConfigItem( keyName = "treeCooldowns", name = "Tree Cooldowns", - description = "Zeigt Respawn-Countdown auf gefaellten Baeumen und verbleibende Hack-Zeit auf langlebigen Baeumen.", - position = 36 + description = "Show respawn countdowns on felled trees and remaining chopping time on longer-lasting trees.", + position = 38 ) default boolean treeCooldowns() { return false; } - @ConfigItem( - keyName = "itemCharges", - name = "Item charges", - description = "Zeigt Ladungen und Füllstände direkt auf Item-Icons an (Blowpipe, Trident, Tome, Quiver, Loot-Bag, Coal-Bag).", - position = 37 - ) + @Deprecated default boolean itemCharges() { return false; @@ -284,8 +333,8 @@ public interface EssentialsPlusConfig extends Config @ConfigItem( keyName = "enemyAttackStyleText", - name = "Enemy Attack Style: Text statt Icon", - description = "Zeigt 'Melee', 'Range' oder 'Mage' statt des Gebets-Icons.", + name = "Enemy Attack Style: Text instead of icon", + description = "Show Melee, Range, or Mage instead of the prayer icon.", position = 60 ) default boolean enemyAttackStyleText() @@ -295,8 +344,8 @@ public interface EssentialsPlusConfig extends Config @ConfigItem( keyName = "enemyAttackStyleSize", - name = "Enemy Attack Style: Groesse", - description = "Groesse von Icon bzw. Text.", + name = "Enemy Attack Style: Size", + description = "Size of the icon or text.", position = 61 ) default TimerTextSize enemyAttackStyleSize() @@ -306,8 +355,8 @@ public interface EssentialsPlusConfig extends Config @ConfigItem( keyName = "enemyAttackStyleOffset", - name = "Enemy Attack Style: Hoehe", - description = "Abstand ueber dem Kopf, unabhaengig vom Enemy Attack Timer.", + name = "Enemy Attack Style: Height", + description = "Height above the head, independent of the enemy attack timer.", position = 62 ) @Range(min = -100, max = 300) @@ -318,8 +367,8 @@ public interface EssentialsPlusConfig extends Config @ConfigItem( keyName = "attackTimerBar", - name = "Attack Timer: Tick-Balken", - description = "Zeigt unter der Zahl einen Balken mit einem Kaestchen pro Tick.", + name = "Attack Timer: Tick bar", + description = "Show a bar below the number with one box per tick.", position = 40 ) default boolean attackTimerBar() @@ -329,8 +378,8 @@ public interface EssentialsPlusConfig extends Config @ConfigItem( keyName = "attackTimerSize", - name = "Attack Timer: Textgroesse", - description = "Groesse der Zahl.", + name = "Attack Timer: Text size", + description = "Size of the number.", position = 41 ) default TimerTextSize attackTimerSize() @@ -340,8 +389,8 @@ public interface EssentialsPlusConfig extends Config @ConfigItem( keyName = "attackTimerColor", - name = "Attack Timer: Farbe", - description = "Farbe der Zahl und des Balkens.", + name = "Attack Timer: Color", + description = "Color of the number and bar.", position = 42 ) default Color attackTimerColor() @@ -351,8 +400,8 @@ public interface EssentialsPlusConfig extends Config @ConfigItem( keyName = "attackTimerReadyColor", - name = "Attack Timer: Farbe bei 0", - description = "Farbe, wenn der Angriff faellig ist (0).", + name = "Attack Timer: Color at 0", + description = "Color when the attack is ready (0).", position = 43 ) default Color attackTimerReadyColor() @@ -362,8 +411,8 @@ public interface EssentialsPlusConfig extends Config @ConfigItem( keyName = "attackTimerOffset", - name = "Attack Timer: Hoehe", - description = "Abstand ueber dem Kopf.", + name = "Attack Timer: Height", + description = "Height above the head.", position = 44 ) @Range(min = -100, max = 200) @@ -374,8 +423,8 @@ public interface EssentialsPlusConfig extends Config @ConfigItem( keyName = "enemyAttackTimerBar", - name = "Enemy Attack Timer: Tick-Balken", - description = "Zeigt unter der Zahl einen Balken mit einem Kaestchen pro Tick.", + name = "Enemy Attack Timer: Tick bar", + description = "Show a bar below the number with one box per tick.", position = 50 ) default boolean enemyAttackTimerBar() @@ -385,8 +434,8 @@ public interface EssentialsPlusConfig extends Config @ConfigItem( keyName = "enemyAttackTimerSize", - name = "Enemy Attack Timer: Textgroesse", - description = "Groesse der Zahl.", + name = "Enemy Attack Timer: Text size", + description = "Size of the number.", position = 51 ) default TimerTextSize enemyAttackTimerSize() @@ -396,8 +445,8 @@ public interface EssentialsPlusConfig extends Config @ConfigItem( keyName = "enemyAttackTimerColor", - name = "Enemy Attack Timer: Farbe", - description = "Farbe der Zahl und des Balkens.", + name = "Enemy Attack Timer: Color", + description = "Color of the number and bar.", position = 52 ) default Color enemyAttackTimerColor() @@ -407,8 +456,8 @@ public interface EssentialsPlusConfig extends Config @ConfigItem( keyName = "enemyAttackTimerReadyColor", - name = "Enemy Attack Timer: Farbe bei 0", - description = "Farbe, wenn der Angriff faellig ist (0).", + name = "Enemy Attack Timer: Color at 0", + description = "Color when the attack is ready (0).", position = 53 ) default Color enemyAttackTimerReadyColor() @@ -418,8 +467,8 @@ public interface EssentialsPlusConfig extends Config @ConfigItem( keyName = "enemyAttackTimerOffset", - name = "Enemy Attack Timer: Hoehe", - description = "Abstand ueber dem Kopf.", + name = "Enemy Attack Timer: Height", + description = "Height above the head.", position = 54 ) @Range(min = -100, max = 200) @@ -431,7 +480,7 @@ public interface EssentialsPlusConfig extends Config @ConfigItem( keyName = "treeCooldownRespawn", name = "Tree Cooldowns: Respawn", - description = "Respawn-Countdown ueber Baumstuempfen anzeigen.", + description = "Show respawn countdowns above tree stumps.", position = 70 ) default boolean treeCooldownRespawn() @@ -441,8 +490,8 @@ public interface EssentialsPlusConfig extends Config @ConfigItem( keyName = "treeCooldownChop", - name = "Tree Cooldowns: Hackzeit", - description = "Verbleibende Hack-Zeit ueber Baeumen waehrend des Faellens anzeigen.", + name = "Tree Cooldowns: Chopping time", + description = "Show remaining chopping time above trees while chopping.", position = 71 ) default boolean treeCooldownChop() @@ -452,8 +501,8 @@ public interface EssentialsPlusConfig extends Config @ConfigItem( keyName = "treeCooldownBar", - name = "Tree Cooldowns: Fortschrittsbalken", - description = "Zeigt einen kleinen Fortschrittsbalken unter der Sekundenzahl.", + name = "Tree Cooldowns: Progress bar", + description = "Show a small progress bar below the seconds remaining.", position = 72 ) default boolean treeCooldownBar() @@ -463,8 +512,8 @@ public interface EssentialsPlusConfig extends Config @ConfigItem( keyName = "treeCooldownSize", - name = "Tree Cooldowns: Textgroesse", - description = "Groesse der Zahl.", + name = "Tree Cooldowns: Text size", + description = "Size of the number.", position = 73 ) default TimerTextSize treeCooldownSize() @@ -474,8 +523,8 @@ public interface EssentialsPlusConfig extends Config @ConfigItem( keyName = "treeCooldownColor", - name = "Tree Cooldowns: Farbe", - description = "Farbe der Zahl.", + name = "Tree Cooldowns: Color", + description = "Color of the number.", position = 74 ) default Color treeCooldownColor() @@ -485,8 +534,8 @@ public interface EssentialsPlusConfig extends Config @ConfigItem( keyName = "treeCooldownReadyColor", - name = "Tree Cooldowns: Farbe bei Bereit", - description = "Farbe kurz vor bzw. beim Respawn.", + name = "Tree Cooldowns: Ready color", + description = "Color shortly before or at respawn.", position = 75 ) default Color treeCooldownReadyColor() @@ -496,8 +545,8 @@ public interface EssentialsPlusConfig extends Config @ConfigItem( keyName = "treeCooldownOffset", - name = "Tree Cooldowns: Hoehe", - description = "Abstand ueber dem Stumpf bzw. der Baum-Basis.", + name = "Tree Cooldowns: Height", + description = "Height above the stump or base of the tree.", position = 76 ) @Range(min = -100, max = 200) @@ -507,98 +556,168 @@ public interface EssentialsPlusConfig extends Config } @ConfigItem( - keyName = "itemChargesWeapons", - name = "Item Charges: Waffen & Rüstung", - description = "Ladungen von Waffen und Rüstungsteilen anzeigen.", + keyName = "itemInformationChargesWeapons", + name = "Item info: Weapons & armor", + description = "Show charges on weapons and armor.", position = 80 ) - default boolean itemChargesWeapons() + default boolean itemInformationChargesWeapons() { return true; } @ConfigItem( - keyName = "itemChargesBags", - name = "Item Charges: Beutel & Säcke", - description = "Geldwert im Looting Bag und Füllstand des Coal Bags anzeigen.", + keyName = "itemInformationChargesBags", + name = "Item info: Bags & containers", + description = "Show the value of the looting bag and container fill levels.", position = 81 ) - default boolean itemChargesBags() + default boolean itemInformationChargesBags() { return true; } @ConfigItem( - keyName = "itemChargesBank", - name = "Item Charges: Bank", - description = "Ladungen auch auf Items im Bank-Interface anzeigen (Achtung: Varbits sind charakterweit).", + keyName = "itemInformationBank", + name = "Item info: Show in bank", + description = "Show hover information tooltip on items in the bank.", position = 82 ) - default boolean itemChargesBank() + default boolean itemInformationBank() { return true; } @ConfigItem( - keyName = "itemChargesSize", - name = "Item Charges: Textgröße", - description = "Schriftgröße der Ladungsanzeige.", + keyName = "itemInformationEquipment", + name = "Item info: Equipment charges", + description = "Show charges on worn equipment.", position = 83 ) - default TimerTextSize itemChargesSize() + default boolean itemInformationEquipment() + { + return true; + } + + @ConfigItem( + keyName = "itemInformationChargesBank", + name = "Item info: Charges in bank", + description = "Also show charges on bank items.", + position = 84 + ) + default boolean itemInformationChargesBank() + { + return true; + } + + @ConfigItem( + keyName = "itemInformationSize", + name = "Item info: Text size", + description = "Font size of the charge display.", + position = 85 + ) + default TimerTextSize itemInformationSize() { return TimerTextSize.SMALL; } @ConfigItem( - keyName = "itemChargesColor", - name = "Item Charges: Farbe", - description = "Farbe der primären Ladungszahl.", - position = 84 + keyName = "itemInformationColor", + name = "Item info: Primary color", + description = "Color of the primary charge count.", + position = 86 ) - default Color itemChargesColor() + default Color itemInformationColor() { return Color.WHITE; } @ConfigItem( - keyName = "itemChargesSecondaryColor", - name = "Item Charges: Sekundärfarbe", - description = "Farbe für Sekundärwerte (z. B. Quiver-Blessing, Darts-Marker).", - position = 85 + keyName = "itemInformationSecondaryColor", + name = "Item info: Secondary color", + description = "Color of secondary charge values.", + position = 87 ) - default Color itemChargesSecondaryColor() + default Color itemInformationSecondaryColor() { return new Color(0xC0C0C0); } @ConfigItem( - keyName = "itemChargesLowColor", - name = "Item Charges: Warnfarbe", - description = "Farbe bei niedrigem Ladungsstand.", - position = 86 + keyName = "itemInformationLowColor", + name = "Item info: Warning color", + description = "Color when charges are low.", + position = 88 ) - default Color itemChargesLowColor() + default Color itemInformationLowColor() { return new Color(0xFF3333); } @ConfigItem( - keyName = "itemChargesLowAt", - name = "Item Charges: Niedrig ab", - description = "Schwellenwert für niedrige Ladungen (0 = deaktiviert).", - position = 87 + keyName = "itemInformationLowAt", + name = "Item info: Low charge threshold", + description = "Threshold for low charges (0 = disabled).", + position = 89 ) @Range(min = 0, max = 10000) - default int itemChargesLowAt() + default int itemInformationLowAt() { return 0; } + @Deprecated + default boolean itemChargesWeapons() + { + return itemInformationChargesWeapons(); + } + + @Deprecated + default boolean itemChargesBags() + { + return itemInformationChargesBags(); + } + + @Deprecated + default boolean itemChargesBank() + { + return itemInformationChargesBank(); + } + + @Deprecated + default TimerTextSize itemChargesSize() + { + return itemInformationSize(); + } + + @Deprecated + default Color itemChargesColor() + { + return itemInformationColor(); + } + + @Deprecated + default Color itemChargesSecondaryColor() + { + return itemInformationSecondaryColor(); + } + + @Deprecated + default Color itemChargesLowColor() + { + return itemInformationLowColor(); + } + + @Deprecated + default int itemChargesLowAt() + { + return itemInformationLowAt(); + } + @ConfigItem( keyName = "chatHelper", name = "Chat helper", - description = "Automatisches Aufräumen alter Nachrichten (nach 1 Minute), Duplikate (x2, x3), Spam-Filter, Namen-Highlight & Kopieren.", + description = "Clear old messages after one minute, collapse duplicates, filter spam, highlight your name, and copy messages.", position = 90 ) default boolean chatHelper() @@ -608,8 +727,8 @@ public interface EssentialsPlusConfig extends Config @ConfigItem( keyName = "chatAutoClear", - name = "Chat: Auto-Clear", - description = "Entfernt alte Nachrichten nach Ablauf der gewählten Zeit automatisch aus dem Chat.", + name = "Chat: Auto-clear", + description = "Automatically remove old messages after the selected time.", position = 91 ) default boolean chatAutoClear() @@ -619,11 +738,11 @@ public interface EssentialsPlusConfig extends Config @ConfigItem( keyName = "chatAutoClearTime", - name = "Chat: Auto-Clear Zeit", - description = "Zeit in Sekunden, nach der Nachrichten automatisch verschwinden.", + name = "Chat: Auto-clear delay", + description = "Time in seconds before messages disappear automatically (0 = disabled).", position = 92 ) - @Range(min = 10, max = 600) + @Range(min = 0, max = 600) @Units(Units.SECONDS) default int chatAutoClearTime() { @@ -632,8 +751,8 @@ public interface EssentialsPlusConfig extends Config @ConfigItem( keyName = "chatClearGame", - name = "Chat: Spiel-Nachrichten leeren", - description = "Spiel-, System- und Examine-Meldungen automatisch aufräumen.", + name = "Chat: Clear game messages", + description = "Automatically clear game, system, and examine messages.", position = 93 ) default boolean chatClearGame() @@ -643,8 +762,8 @@ public interface EssentialsPlusConfig extends Config @ConfigItem( keyName = "chatClearPublic", - name = "Chat: Öffentlichen Chat leeren", - description = "Öffentliche Spielernachrichten automatisch aufräumen.", + name = "Chat: Clear public chat", + description = "Automatically clear public player messages.", position = 94 ) default boolean chatClearPublic() @@ -654,8 +773,8 @@ public interface EssentialsPlusConfig extends Config @ConfigItem( keyName = "chatClearPrivate", - name = "Chat: Private Nachrichten leeren", - description = "Private Nachrichten (PMs) ebenfalls aufräumen (Standard: Aus, um Konversationen zu behalten).", + name = "Chat: Clear private messages", + description = "Also clear private messages (off by default to preserve conversations).", position = 95 ) default boolean chatClearPrivate() @@ -665,8 +784,8 @@ public interface EssentialsPlusConfig extends Config @ConfigItem( keyName = "chatClearClan", - name = "Chat: Clan- & Freunde-Chat leeren", - description = "Clan- und Gruppen-Nachrichten ebenfalls aufräumen (Standard: Aus).", + name = "Chat: Clear clan & friends chat", + description = "Also clear clan and friends chat messages (off by default).", position = 96 ) default boolean chatClearClan() @@ -676,8 +795,8 @@ public interface EssentialsPlusConfig extends Config @ConfigItem( keyName = "chatCollapseDuplicates", - name = "Chat: Duplikate zusammenfassen", - description = "Fasst aufeinanderfolgende gleiche Meldungen zu (x2, x3...) zusammen.", + name = "Chat: Collapse duplicates", + description = "Combine consecutive identical messages into a count (x2, x3, etc.).", position = 97 ) default boolean chatCollapseDuplicates() @@ -687,8 +806,8 @@ public interface EssentialsPlusConfig extends Config @ConfigItem( keyName = "chatFilterSpam", - name = "Chat: Spam & Systemlärm filtern", - description = "Filtert repetitive, unnötige Systemmeldungen ('Nothing interesting happens' etc.) und Spam.", + name = "Chat: Filter spam & game noise", + description = "Filter repetitive system messages such as 'Nothing interesting happens' and public spam.", position = 98 ) default boolean chatFilterSpam() @@ -698,8 +817,8 @@ public interface EssentialsPlusConfig extends Config @ConfigItem( keyName = "chatHighlightName", - name = "Chat: Eigenen Namen hervorheben", - description = "Hebt deinen Spielernamen in Spielernachrichten farblich hervor.", + name = "Chat: Highlight your name", + description = "Highlight your player name in player messages.", position = 99 ) default boolean chatHighlightName() @@ -709,8 +828,8 @@ public interface EssentialsPlusConfig extends Config @ConfigItem( keyName = "chatHighlightColor", - name = "Chat: Highlight-Farbe", - description = "Farbe für die Namens-Hervorhebung.", + name = "Chat: Highlight color", + description = "Color used to highlight your name.", position = 100 ) default Color chatHighlightColor() @@ -718,16 +837,203 @@ public interface EssentialsPlusConfig extends Config return new Color(0xFFD700); } + @ConfigItem( + keyName = "chatClanMessageColor", + name = "Chat: Clan message color", + description = "Color used for text in clan, guest clan, and Group Ironman chat.", + position = 102 + ) + default Color chatClanMessageColor() + { + return new Color(0x66FFFF); + } + @ConfigItem( keyName = "chatCopyToClipboard", - name = "Chat: Kopieren im Rechtsklick-Menü", - description = "Fügt beim Rechtsklick auf eine Chat-Nachricht die Option 'Copy message' hinzu.", + name = "Chat: Right-click copy", + description = "Add Copy message when right-clicking chat messages, including game messages in transparent chat.", position = 101 ) default boolean chatCopyToClipboard() { return true; } + + @ConfigItem( + keyName = "worldHopTracker", + name = "World hop tracker", + description = "Show world-hop attempts in the past hour and today in chat.", + position = 103 + ) + default boolean worldHopTracker() + { + return true; + } + + @ConfigItem( + keyName = "worldHopNextKey", + name = "World hop: Next world hotkey", + description = "Key used by the World hop tracker to select the next eligible world.", + position = 104 + ) + default Keybind worldHopNextKey() + { + return Keybind.NOT_SET; + } + + @ConfigItem( + keyName = "worldHopRandomKey", + name = "World hop: Random world hotkey", + description = "Key used by the World hop tracker to select a random eligible world.", + position = 105 + ) + default Keybind worldHopRandomKey() + { + return Keybind.NOT_SET; + } + + @ConfigItem( + keyName = "worldHopAvoidDangerous", + name = "World hop: Avoid PvP and high-risk", + description = "Skip PvP and high-risk worlds. Bounty worlds are always skipped.", + position = 106 + ) + default boolean worldHopAvoidDangerous() + { + return true; + } + + @ConfigItem( + keyName = "idleNotifier", + name = "Idle Notifier", + description = "Flash the screen after at least 5 seconds of activity followed by 5 seconds without activity.", + position = 107 + ) + default boolean idleNotifier() + { + return false; + } + + @ConfigItem( + keyName = "idleNotifierMinActionSeconds", + name = "Idle Notifier: Minimum action", + description = "How long an activity must continue before it can trigger the idle notification.", + position = 108 + ) + @Units(Units.SECONDS) + @Range(min = 5, max = 120) + default int idleNotifierMinActionSeconds() + { + return 5; + } + + @ConfigItem( + keyName = "idleNotifierIdleSeconds", + name = "Idle Notifier: Idle delay", + description = "How long to wait without activity before showing the notification.", + position = 109 + ) + @Units(Units.SECONDS) + @Range(min = 1, max = 120) + default int idleNotifierIdleSeconds() + { + return 5; + } + + @ConfigItem( + keyName = "idleNotifierFlashDurationMs", + name = "Idle Notifier: Blink duration", + description = "How long the screen blinks, in milliseconds.", + position = 110 + ) + @Units(" ms") + @Range(min = 500, max = 10000) + default int idleNotifierFlashDurationMs() + { + return 1500; + } + + @ConfigItem( + keyName = "idleNotifierBlinkIntervalMs", + name = "Idle Notifier: Blink interval", + description = "Time between blink state changes, in milliseconds.", + position = 111 + ) + @Units(" ms") + @Range(min = 100, max = 1000) + default int idleNotifierBlinkIntervalMs() + { + return 250; + } + + @ConfigItem( + keyName = "idleNotifierSound", + name = "Idle Notifier: Sound", + description = "Play a sound when the idle notification starts.", + position = 112 + ) + default boolean idleNotifierSound() + { + return true; + } + + @ConfigItem( + keyName = "idleNotifierSoundId", + name = "Idle Notifier: Sound effect", + description = "Sound effect used by the idle notification.", + position = 113 + ) + default int idleNotifierSoundId() + { + return SoundEffectID.GE_ADD_OFFER_DINGALING; + } + + @ConfigItem( + keyName = "idleNotifierSoundVolume", + name = "Idle Notifier: Sound volume", + description = "Volume used for the idle notification sound, from 0 to 127.", + position = 114 + ) + @Range(min = 0, max = 127) + default int idleNotifierSoundVolume() + { + return 64; + } + + @ConfigItem( + keyName = "idleNotifierRepeat", + name = "Idle Notifier: Repeat notifications", + description = "Repeat the notification while you remain idle.", + position = 115 + ) + default boolean idleNotifierRepeat() + { + return false; + } + + @ConfigItem( + keyName = "idleNotifierRepeatIntervalSeconds", + name = "Idle Notifier: Repeat interval", + description = "Time between repeated notifications.", + position = 116 + ) + @Units(Units.SECONDS) + @Range(min = 1, max = 60) + default int idleNotifierRepeatIntervalSeconds() + { + return 5; + } + + @ConfigItem( + keyName = "idleNotifierRepeatDurationSeconds", + name = "Idle Notifier: Repeat duration", + description = "How long to repeat notifications after the first notification.", + position = 117 + ) + @Units(Units.SECONDS) + @Range(min = 1, max = 300) + default int idleNotifierRepeatDurationSeconds() + { + return 15; + } } - - diff --git a/src/main/java/com/example/essentialsplus/EssentialsPlusPlugin.java b/src/main/java/com/example/essentialsplus/EssentialsPlusPlugin.java index 5590b76..9fd82de 100644 --- a/src/main/java/com/example/essentialsplus/EssentialsPlusPlugin.java +++ b/src/main/java/com/example/essentialsplus/EssentialsPlusPlugin.java @@ -6,6 +6,8 @@ import com.example.essentialsplus.data.ItemSnapshot; import com.example.essentialsplus.data.Snapshot; import com.example.essentialsplus.data.SourceValue; import com.example.essentialsplus.data.TopItem; +import com.example.essentialsplus.player.PlayerEquipmentFeature; +import com.example.essentialsplus.player.PlayerHiscoreHistory; import com.example.essentialsplus.skills.SkillHistory; import com.example.essentialsplus.skills.SkillStore; import com.example.essentialsplus.skills.SkillsHudOverlay; @@ -34,13 +36,17 @@ import java.time.ZoneId; import java.time.format.DateTimeFormatter; import java.util.ArrayList; import java.util.Arrays; +import java.util.Collections; import java.util.HashMap; +import java.util.IdentityHashMap; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Set; import java.util.TreeMap; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.TimeUnit; import java.util.function.Consumer; import javax.inject.Inject; @@ -61,8 +67,11 @@ import net.runelite.api.events.VarbitChanged; import net.runelite.api.gameval.InterfaceID; import net.runelite.api.gameval.InventoryID; import net.runelite.api.gameval.ItemID; +import net.runelite.api.gameval.VarClientID; import net.runelite.api.gameval.VarbitID; import net.runelite.api.widgets.ComponentID; +import net.runelite.api.widgets.WidgetInfo; +import net.runelite.api.widgets.WidgetType; import java.util.Iterator; import net.runelite.client.callback.ClientThread; import net.runelite.client.config.ConfigManager; @@ -89,15 +98,21 @@ import net.runelite.api.events.ScriptCallbackEvent; import net.runelite.api.gameval.InterfaceID; import net.runelite.api.widgets.Widget; import net.runelite.client.game.ItemStats; +import net.runelite.client.game.chatbox.ChatboxPanelManager; @Slf4j @PluginDescriptor( name = "Essentials Plus", - description = "Moderne Plugin-Suite & Übersicht (Wealth Tracker, Net Worth, Bank, GE limits und mehr)", + description = "Modern plugin suite and overview for wealth, net worth, bank, GE limits, and more", tags = {"essentials", "plus", "wiki", "wealth", "net worth", "bank", "gp", "graph", "coffer", "value", "history", "analysis"} ) public class EssentialsPlusPlugin extends Plugin { + private static final int BANK_LAYOUT_ITEMS_PER_ROW = 8; + private static final int BANK_LAYOUT_ITEM_START_X = 51; + private static final int BANK_LAYOUT_ITEM_STEP_X = 48; + private static final int BANK_LAYOUT_ITEM_STEP_Y = 36; + private static final int BANK_LAYOUT_ITEM_HEIGHT = 32; private static final int DOOM_REWARD_CONTAINER = 923; private static final int RUNEPOUCH_RUNE_ENUM = 982; private static final int[] RUNE_POUCH_TYPES = { @@ -182,6 +197,9 @@ public class EssentialsPlusPlugin extends Plugin @Inject private ClientThread clientThread; + @Inject + private ChatboxPanelManager chatboxPanelManager; + @Inject private ClientToolbar clientToolbar; @@ -189,6 +207,12 @@ public class EssentialsPlusPlugin extends Plugin @Getter private ItemManager itemManager; + @Inject + private PlayerEquipmentFeature playerEquipmentFeature; + + @Inject + private PlayerHiscoreHistory playerHiscoreHistory; + @Inject private EssentialsPlusConfig config; @@ -242,7 +266,10 @@ public class EssentialsPlusPlugin extends Plugin private com.example.essentialsplus.helpers.AttackTimerOverlay attackTimerOverlay; @Inject - private com.example.essentialsplus.helpers.ItemPriceOverlay itemPriceOverlay; + private com.example.essentialsplus.helpers.AttackDistanceOverlay attackDistanceOverlay; + + @Inject + private com.example.essentialsplus.helpers.ItemInformationOverlay itemInformationOverlay; @Inject private com.example.essentialsplus.helpers.TreeCooldowns treeCooldowns; @@ -271,6 +298,12 @@ public class EssentialsPlusPlugin extends Plugin @Inject private com.example.essentialsplus.helpers.ChatHelper chatHelper; + @Inject + private com.example.essentialsplus.helpers.WorldHopTracker worldHopTracker; + + @Inject + private com.example.essentialsplus.helpers.IdleNotifier idleNotifier; + @Inject private net.runelite.client.eventbus.EventBus eventBus; @@ -307,10 +340,11 @@ public class EssentialsPlusPlugin extends Plugin /** Wiki page loads, searches and thumbnails; kept off the shared executor because image downloads can take seconds. */ @Getter - private java.util.concurrent.ExecutorService wikiExecutor; + private volatile java.util.concurrent.ExecutorService wikiExecutor; - private WikiWindows wikiWindows; + private volatile WikiWindows wikiWindows; private WikiOverlay wikiOverlay; + private final Map playerProfileViews = Collections.synchronizedMap(new LinkedHashMap<>(16, 0.75f, true)); /** Global time range of all views; persisted in the config. */ @Getter @@ -321,6 +355,7 @@ public class EssentialsPlusPlugin extends Plugin private volatile String skillKey; private boolean xpDirty; private ScheduledFuture skillSaveTask; + private ScheduledFuture playerHistoryPruneTask; private String pendingCrateName; private int pendingCrateTick; @@ -362,6 +397,13 @@ public class EssentialsPlusPlugin extends Plugin private int ticksSinceLogin = -1; private boolean bankUpdated; private volatile boolean bankOpen; + /** Temporary client-side bank item visibility state; never changes bank contents. */ + private final Map bankItemOriginalStates = new IdentityHashMap<>(); + private boolean bankItemsHidden; + private Set bankLayoutItemIds; + private int bankScrollYBeforeMask = -1; + private int bankScrollHeightBeforeMask = -1; + private int bankScrollVarcBeforeMask = -1; private volatile boolean justOpenedBank; private Map lastInvItems; /** @@ -385,8 +427,15 @@ public class EssentialsPlusPlugin extends Plugin @Override protected void startUp() { - timeFilter = new TimeFilter(TimeFilter.parse(configManager.getConfiguration(EssentialsPlusConfig.GROUP, "timeRange")), - r -> configManager.setConfiguration(EssentialsPlusConfig.GROUP, "timeRange", r.name())); + playerHiscoreHistory.load(); + playerHistoryPruneTask = executor.scheduleWithFixedDelay( + () -> playerHiscoreHistory.pruneExpired(System.currentTimeMillis()), 1, 1, TimeUnit.HOURS); + timeFilter = new TimeFilter( + TimeFilter.parse(configManager.getConfiguration(EssentialsPlusConfig.GROUP, "timeRange")), + r -> configManager.setConfiguration(EssentialsPlusConfig.GROUP, "timeRange", r.name()), + configManager.getConfiguration(EssentialsPlusConfig.GROUP, "timeRangeSlots"), + s -> configManager.setConfiguration(EssentialsPlusConfig.GROUP, "timeRangeSlots", s) + ); awaitingLogin = client.getGameState() != GameState.LOGGED_IN; if (!awaitingLogin) { @@ -401,13 +450,19 @@ public class EssentialsPlusPlugin extends Plugin eventBus.register(itemCharges); eventBus.register(blastFurnace); eventBus.register(chatHelper); + eventBus.register(idleNotifier); + worldHopTracker.loadHistory(); + eventBus.register(worldHopTracker); + worldHopTracker.registerHotkeys(); overlayManager.add(attackTimerOverlay); - overlayManager.add(itemPriceOverlay); + overlayManager.add(attackDistanceOverlay); + overlayManager.add(itemInformationOverlay); overlayManager.add(treeCooldownOverlay); overlayManager.add(itemChargesOverlay); overlayManager.add(blastFurnaceOverlay); overlayManager.add(blastFurnacePanelOverlay); overlayManager.add(blastFurnaceItemsOverlay); + overlayManager.add(idleNotifier); // only safe here: reading the config from the overlay constructor would run before the plugin fields exist blastFurnacePanelOverlay.applyPosition(); currencyStore.load(); @@ -469,23 +524,30 @@ public class EssentialsPlusPlugin extends Plugin @Override protected void shutDown() { + clientThread.invokeLater(this::restoreBankItemsNow); stopWiki(); fightService = null; lootTrackerStore.save(); overlayManager.remove(skillsHudOverlay); overlayManager.remove(attackTimerOverlay); - overlayManager.remove(itemPriceOverlay); + overlayManager.remove(attackDistanceOverlay); + overlayManager.remove(itemInformationOverlay); overlayManager.remove(treeCooldownOverlay); overlayManager.remove(itemChargesOverlay); overlayManager.remove(blastFurnaceOverlay); overlayManager.remove(blastFurnacePanelOverlay); overlayManager.remove(blastFurnaceItemsOverlay); + overlayManager.remove(idleNotifier); eventBus.unregister(attackTimers); eventBus.unregister(attackStyles); eventBus.unregister(treeCooldowns); eventBus.unregister(itemCharges); eventBus.unregister(blastFurnace); eventBus.unregister(chatHelper); + eventBus.unregister(idleNotifier); + worldHopTracker.unregisterHotkeys(); + eventBus.unregister(worldHopTracker); + idleNotifier.reset(); attackTimers.reset(); attackStyles.reset(); treeCooldowns.reset(); @@ -497,6 +559,11 @@ public class EssentialsPlusPlugin extends Plugin skillSaveTask.cancel(false); skillSaveTask = null; } + if (playerHistoryPruneTask != null) + { + playerHistoryPruneTask.cancel(false); + playerHistoryPruneTask = null; + } skillStore.saveDirty(); currencyStore.saveDirty(); skillKey = null; @@ -561,7 +628,7 @@ public class EssentialsPlusPlugin extends Plugin }); wikiApi = new WikiApi(okHttpClient, gson); wikiWindows = new WikiWindows(wikiApi, wikiExecutor, client::getCanvasWidth, client::getCanvasHeight, - loadWikiBounds(), this::saveWikiBounds, this::addWikiRecent); + loadWikiBounds(), this::saveWikiBounds, this::addWikiRecent, this::promptPlayerSearch, this::selectPlayerRange); wikiOverlay = new WikiOverlay(spriteManager, wikiWindows); overlayManager.add(wikiOverlay); mouseManager.registerMouseListener(wikiWindows); @@ -627,32 +694,105 @@ public class EssentialsPlusPlugin extends Plugin } } + private static final Map HELPER_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") + ); + /** In-game helper switches (attack timers) by config key. */ public boolean isHelperOption(String key) { - switch (key) + switch (key) { + case "attackDistance": + return config.attackDistance(); case "attackTimer": return config.attackTimer(); case "enemyAttackTimer": return config.enemyAttackTimer(); case "enemyAttackStyle": return config.enemyAttackStyle(); + case "itemInformation": case "itemPrices": - return config.itemPrices(); + case "itemCharges": + return isItemInformationEnabled(); case "treeCooldowns": return config.treeCooldowns(); - case "itemCharges": - return config.itemCharges(); case "blastFurnace": return config.blastFurnace(); case "chatHelper": return config.chatHelper(); + case "worldHopTracker": + return config.worldHopTracker(); + case "idleNotifier": + return config.idleNotifier(); default: return false; } } + public boolean isItemInformationEnabled() + { + if (configManager != null) + { + Boolean val = configManager.getConfiguration(EssentialsPlusConfig.GROUP, "itemInformation", Boolean.class); + if (val != null) + { + return val; + } + Boolean legacyPrices = configManager.getConfiguration(EssentialsPlusConfig.GROUP, "itemPrices", Boolean.class); + Boolean legacyCharges = configManager.getConfiguration(EssentialsPlusConfig.GROUP, "itemCharges", Boolean.class); + if (Boolean.TRUE.equals(legacyPrices) || Boolean.TRUE.equals(legacyCharges)) + { + return true; + } + } + if (config != null) + { + try + { + if (Boolean.TRUE.equals(config.itemInformation())) + { + return true; + } + } + catch (Exception ignored) + { + } + try + { + if (Boolean.TRUE.equals(config.itemPrices())) + { + return true; + } + } + catch (Exception ignored) + { + } + try + { + if (Boolean.TRUE.equals(config.itemCharges())) + { + return true; + } + } + catch (Exception ignored) + { + } + } + return false; + } + /** Sets a helper config value; null restores the default. */ public void setHelperOption(String key, Object value) { @@ -666,11 +806,98 @@ public class EssentialsPlusPlugin extends Plugin } } + public void previewIdleNotifierSound() + { + if (clientThread != null && idleNotifier != null) + { + clientThread.invokeLater(idleNotifier::playConfiguredSound); + } + } + /** Typed config value with the interface default applied (for the helper option menus). */ public T getHelperValue(String key, Class type, T def) { + if (configManager == null) + { + return def; + } T v = configManager.getConfiguration(EssentialsPlusConfig.GROUP, key, type); - return v == null ? def : v; + if (v != null) + { + return v; + } + String fallbackKey = HELPER_FALLBACKS.get(key); + if (fallbackKey != null) + { + T fb = configManager.getConfiguration(EssentialsPlusConfig.GROUP, fallbackKey, type); + if (fb != null) + { + return fb; + } + } + return def; + } + + public int canonicalize(int id) + { + return itemManager != null ? itemManager.canonicalize(id) : id; + } + + public long getBankedQuantity(int itemId) + { + itemId = canonicalize(itemId); + + ItemContainer liveBank = client != null ? client.getItemContainer(InventoryID.BANK) : null; + if (liveBank != null && liveBank.getItems() != null) + { + long total = 0; + for (Item item : liveBank.getItems()) + { + if (item == null || item.getId() <= 0 || item.getQuantity() <= 0 || isPlaceholder(item.getId())) + { + continue; + } + int id = canonicalize(item.getId()); + if (id == itemId) + { + total += item.getQuantity(); + } + } + return total; + } + + AccountData acc = activeKey != null && store != null ? store.get(activeKey) : null; + if (acc == null || !acc.bankKnown) + { + return -1L; + } + + Map bank = acc.containers.get("bank"); + if (bank == null || bank.isEmpty()) + { + return 0L; + } + + long total = 0; + for (Map.Entry entry : bank.entrySet()) + { + int id = canonicalize(entry.getKey()); + if (id == itemId && entry.getValue() != null && entry.getValue() > 0) + { + total += entry.getValue(); + } + } + return total; + } + + public boolean isBankKnown() + { + if (client != null && client.getItemContainer(InventoryID.BANK) != null) + { + return true; + } + AccountData acc = activeKey != null && store != null ? store.get(activeKey) : null; + return acc != null && acc.bankKnown; } public com.example.essentialsplus.helpers.ItemCharges getItemCharges() @@ -709,12 +936,13 @@ public class EssentialsPlusPlugin extends Plugin @Subscribe public void onMenuOpened(net.runelite.api.events.MenuOpened event) { + net.runelite.api.Menu menu = client.getMenu(); + net.runelite.api.MenuEntry[] entries = menu.getMenuEntries(); + addPlayerEquipmentMenuEntry(menu, entries); if (!config.wikiMenuOption() || wikiWindows == null) { return; } - net.runelite.api.Menu menu = client.getMenu(); - net.runelite.api.MenuEntry[] entries = menu.getMenuEntries(); List targets = new ArrayList<>(); Set seen = new java.util.HashSet<>(); for (int i = entries.length - 1; i >= 0; i--) @@ -742,6 +970,258 @@ public class EssentialsPlusPlugin extends Plugin } } + private void addPlayerEquipmentMenuEntry(net.runelite.api.Menu menu, net.runelite.api.MenuEntry[] entries) + { + Player local = client.getLocalPlayer(); + Player target = null; + for (net.runelite.api.MenuEntry entry : entries) + { + Player candidate = entry.getPlayer(); + if (candidate == null || candidate == local || candidate.getName() == null) + { + continue; + } + if (local != null && (candidate.getId() == local.getId() || candidate.getName().equalsIgnoreCase(local.getName()))) + { + continue; + } + target = candidate; + break; + } + if (target == null) + { + return; + } + + Player player = target; + String name = net.runelite.client.util.Text.removeTags(player.getName()).trim(); + if (name.isEmpty()) + { + return; + } + for (net.runelite.api.MenuEntry entry : entries) + { + String entryTarget = entry.getTarget() == null ? "" : net.runelite.client.util.Text.removeTags(entry.getTarget()).trim(); + if ("Equipment".equals(entry.getOption()) && name.equalsIgnoreCase(entryTarget)) + { + return; + } + } + menu.createMenuEntry(Math.min(1, entries.length)) + .setOption("Equipment") + .setTarget(name) + .setType(net.runelite.api.MenuAction.RUNELITE) + .onClick(e -> openPlayerEquipment(player)); + } + + private void openPlayerEquipment(Player player) + { + if (playerEquipmentFeature == null) + { + return; + } + loadPlayerEquipment(playerEquipmentFeature.capture(player), null); + } + + private void promptPlayerSearch(WikiWindow window, String currentName) + { + if (window == null || chatboxPanelManager == null || playerEquipmentFeature == null) + { + return; + } + + chatboxPanelManager.openTextInput("Enter any player name") + .value(currentName == null ? "" : currentName) + .onDone(name -> + { + clientThread.invoke(() -> + { + String query = name == null ? "" : net.runelite.client.util.Text.removeTags(name).trim(); + if (query.isEmpty()) + { + return; + } + + Player visiblePlayer = findVisiblePlayer(query); + PlayerEquipmentFeature.Snapshot snapshot = visiblePlayer == null + ? playerEquipmentFeature.captureHiscoreTarget(query) + : playerEquipmentFeature.capture(visiblePlayer); + loadPlayerEquipment(snapshot, window); + }); + }) + .build(); + } + + private Player findVisiblePlayer(String name) + { + for (Player player : client.getPlayers()) + { + if (player != null && player.getName() != null + && net.runelite.client.util.Text.removeTags(player.getName()).trim().equalsIgnoreCase(name)) + { + return player; + } + } + return null; + } + + private void selectPlayerRange(WikiWindow window, PlayerHiscoreHistory.Range range) + { + WikiWindows windows = wikiWindows; + if (window == null || windows == null || playerEquipmentFeature == null || playerHiscoreHistory == null) + { + return; + } + String playerName = window.playerSearchName(); + PlayerProfileView profile = playerProfileViews.get(playerHistoryKey(playerName)); + if (profile == null) + { + return; + } + PlayerHiscoreHistory.Range selected = range == null ? PlayerHiscoreHistory.Range.LAST_VISIT : range; + long now = System.currentTimeMillis(); + PlayerHiscoreHistory.Comparison comparison = playerHiscoreHistory.comparison(playerName, selected, now); + String key = "player-equipment:" + playerHistoryKey(playerName); + windows.updateContent(key, "Equipment: " + profile.snapshot.name, + playerEquipmentFeature.render(profile.snapshot, profile.hiscores, false, profile.failed, profile.fetchedAt, comparison, selected), + profile.snapshot.images); + } + + private void rememberPlayerProfile(String playerName, PlayerProfileView profile) + { + synchronized (playerProfileViews) + { + playerProfileViews.put(playerHistoryKey(playerName), profile); + while (playerProfileViews.size() > 64) + { + Iterator keys = playerProfileViews.keySet().iterator(); + keys.next(); + keys.remove(); + } + } + } + + private static String playerHistoryKey(String playerName) + { + return playerName == null ? "" : net.runelite.client.util.Text.removeTags(playerName).trim().toLowerCase(Locale.ROOT); + } + + private static final class PlayerProfileView + { + final PlayerEquipmentFeature.Snapshot snapshot; + final net.runelite.client.hiscore.HiscoreResult hiscores; + final boolean failed; + final long fetchedAt; + + PlayerProfileView(PlayerEquipmentFeature.Snapshot snapshot, net.runelite.client.hiscore.HiscoreResult hiscores, + boolean failed, long fetchedAt) + { + this.snapshot = snapshot; + this.hiscores = hiscores; + this.failed = failed; + this.fetchedAt = fetchedAt; + } + } + + private void loadPlayerEquipment(PlayerEquipmentFeature.Snapshot snapshot, WikiWindow targetWindow) + { + WikiWindows windows = wikiWindows; + java.util.concurrent.ExecutorService background = wikiExecutor; + if (windows == null || background == null || playerEquipmentFeature == null || snapshot == null) + { + return; + } + + String key = "player-equipment:" + snapshot.name.toLowerCase(Locale.ROOT); + String title = "Equipment: " + snapshot.name; + String loadingHtml = playerEquipmentFeature.render(snapshot, null, true, false, 0, null, PlayerHiscoreHistory.Range.LAST_VISIT); + if (targetWindow == null) + { + windows.openContent(key, title, loadingHtml, snapshot.images); + } + else + { + windows.updateContent(targetWindow, key, title, loadingHtml, snapshot.images); + } + for (BufferedImage image : snapshot.images.values()) + { + if (image instanceof net.runelite.client.util.AsyncBufferedImage) + { + ((net.runelite.client.util.AsyncBufferedImage) image).onLoaded(() -> + { + if (wikiWindows == windows) + { + windows.refreshContent(key); + } + }); + } + } + background.execute(() -> + { + playerHiscoreHistory.viewed(snapshot.name, System.currentTimeMillis()); + net.runelite.client.hiscore.HiscoreResult hiscores = null; + boolean failed = false; + try + { + hiscores = playerEquipmentFeature.lookupHiscores(snapshot.name); + } + catch (Exception e) + { + failed = true; + log.debug("player hiscores lookup for {} failed", snapshot.name, e); + } + long fetchedAt = System.currentTimeMillis(); + final net.runelite.client.hiscore.HiscoreResult fetchedHiscores = hiscores; + final boolean lookupFailed = failed; + final long lookupFinishedAt = fetchedAt; + if (hiscores != null) + { + playerHiscoreHistory.record(snapshot.name, hiscores, fetchedAt, PlayerHiscoreHistory.Range.LAST_VISIT); + } + List bossKills = playerEquipmentFeature.getBossKills(hiscores); + List bossesWithSprites = new ArrayList<>(); + for (PlayerEquipmentFeature.BossKill boss : bossKills) + { + if (boss.skill.getSpriteId() > 0) + { + bossesWithSprites.add(boss); + } + } + Runnable publish = () -> + { + if (wikiWindows == windows) + { + rememberPlayerProfile(snapshot.name, new PlayerProfileView(snapshot, fetchedHiscores, lookupFailed, lookupFinishedAt)); + PlayerHiscoreHistory.Range range = windows.playerStatsRange(key); + PlayerHiscoreHistory.Comparison comparison = playerHiscoreHistory.comparison(snapshot.name, range, lookupFinishedAt); + windows.updateContent(key, title, + playerEquipmentFeature.render(snapshot, fetchedHiscores, false, lookupFailed, lookupFinishedAt, comparison, range), snapshot.images); + } + }; + if (bossesWithSprites.isEmpty()) + { + publish.run(); + return; + } + + AtomicInteger pendingSprites = new AtomicInteger(bossesWithSprites.size()); + for (PlayerEquipmentFeature.BossKill boss : bossesWithSprites) + { + spriteManager.getSpriteAsync(boss.skill.getSpriteId(), 0, image -> + { + if (image != null) + { + snapshot.images.put(PlayerEquipmentFeature.bossIcon(boss.skill), image); + } + if (pendingSprites.decrementAndGet() == 0) + { + publish.run(); + } + }); + } + }); + } + private static final class WikiTarget { final String type; @@ -1042,7 +1522,10 @@ public class EssentialsPlusPlugin extends Plugin @Subscribe public void onItemContainerChanged(ItemContainerChanged event) { - combatTracker.onItemContainerChanged(event); + if (combatTracker != null) + { + combatTracker.onItemContainerChanged(event); + } if (fightService != null) { fightService.onContainerChanged(); @@ -1821,7 +2304,7 @@ public class EssentialsPlusPlugin extends Plugin { blastFurnacePanelOverlay.applyPosition(); } - if (p != null && (event.getKey().toLowerCase().contains("attacktimer") || event.getKey().toLowerCase().contains("attackstyle") || event.getKey().toLowerCase().contains("treecooldown") || event.getKey().toLowerCase().contains("itemcharge") || event.getKey().toLowerCase().contains("blastfurnace") || event.getKey().toLowerCase().contains("chat"))) + if (p != null && (event.getKey().toLowerCase().contains("attacktimer") || event.getKey().toLowerCase().contains("attackstyle") || event.getKey().toLowerCase().contains("treecooldown") || event.getKey().toLowerCase().contains("itemcharge") || event.getKey().toLowerCase().contains("blastfurnace") || event.getKey().toLowerCase().contains("chat") || event.getKey().toLowerCase().contains("worldhoptracker") || event.getKey().toLowerCase().contains("idlenotifier"))) { p.refreshHelpersLater(); } @@ -1854,6 +2337,11 @@ public class EssentialsPlusPlugin extends Plugin { invalidateSources(); markUrgent(); + EssentialsPlusPanel currentPanel = panel; + if (currentPanel != null) + { + currentPanel.refreshEquipmentProfilesLater(); + } if (ticksSinceLogin < 0) { ticksSinceLogin = 0; @@ -2001,7 +2489,7 @@ public class EssentialsPlusPlugin extends Plugin com.example.essentialsplus.ui.TimeRange range = timeFilter.getRange(); EssentialsPlusPanel p = panel; com.example.essentialsplus.ui.WealthPanel wp = p != null ? p.getWealthPanel() : null; - boolean isSession = (wp != null && wp.isSessionView()) || range == com.example.essentialsplus.ui.TimeRange.SESSION; + boolean isSession = (wp != null && wp.isSessionView()) || (range != null && range.isSession()); boolean isCurrent = timeFilter.isCurrent() && !isSession; if (!isSession && !isCurrent) { @@ -2200,8 +2688,9 @@ public class EssentialsPlusPlugin extends Plugin if (c.category == WealthCategory.BANK) { // For bank, only read from client container if bank is not in memory yet, - // or if the bank interface was just opened this frame to sync the initial live state - if (!acc.containers.containsKey("bank") || acc.containers.get("bank").isEmpty() || justOpenedBank) + // or if the bank interface was just opened this frame to sync the initial live state, + // or if bank has not been seen yet this session and the bank interface is open + if (!acc.containers.containsKey("bank") || acc.containers.get("bank").isEmpty() || justOpenedBank || (isBankOpen() && !bankSeenThisSession)) { Map candidate = readContainer(container); Map existingBank = acc.containers.get("bank"); @@ -2209,6 +2698,8 @@ public class EssentialsPlusPlugin extends Plugin { log.debug("Wealth tracker: Bank container streaming ({} items vs {} known), deferring sync", candidate.size(), existingBank.size()); + dirty = true; + urgent = true; } else { @@ -2233,9 +2724,22 @@ public class EssentialsPlusPlugin extends Plugin { if (c.category == WealthCategory.BANK) { + boolean firstBankSync = !bankSeenThisSession; bankSeenThisSession = true; acc.bankKnown = true; + if (firstBankSync) + { + long sessStart = timeFilter.getSessionStart(); + if (sessStart > 0) + { + while (!acc.history.isEmpty() && acc.history.get(acc.history.size() - 1).t >= sessStart) + { + acc.history.remove(acc.history.size() - 1); + } + } + pinLedgerAt = sessStart > 0 ? sessStart : now; } + } acc.containers.put(c.key, items); } } @@ -2364,6 +2868,16 @@ public class EssentialsPlusPlugin extends Plugin if (last != null && acc.history.size() >= 2) { Snapshot prev = acc.history.get(acc.history.size() - 2); + if (acc.history.size() >= 3 && AccountData.isLoginStepGlitch( + acc.history.get(acc.history.size() - 3).t, acc.history.get(acc.history.size() - 3).total(), + prev.t, prev.total(), + last.t, last.total(), + now, valTotal)) + { + acc.history.remove(acc.history.size() - 2); + prev = acc.history.get(acc.history.size() - 2); + unsaved = true; + } if (AccountData.isTransientSpike(prev.t, prev.total(), last.t, last.total(), now, valTotal)) { acc.history.remove(acc.history.size() - 1); @@ -2391,6 +2905,23 @@ public class EssentialsPlusPlugin extends Plugin last = acc.lastSnapshot(); unsaved = true; } + else + { + for (int k = Math.min(25, acc.history.size() - 1); k >= 4; k--) + { + int startIdx = acc.history.size() - k; + if (AccountData.isPlateauSpike(acc.history, startIdx, k, now, valTotal)) + { + for (int r = 0; r < k; r++) + { + acc.history.remove(acc.history.size() - 1); + } + last = acc.lastSnapshot(); + unsaved = true; + break; + } + } + } } } } @@ -2411,6 +2942,10 @@ public class EssentialsPlusPlugin extends Plugin pinLedgerAt = 0; ItemSnapshot pinned = val.itemSnapshot(pin); pinned.k = true; + while (!ledger.snapshots.isEmpty() && ledger.snapshots.get(ledger.snapshots.size() - 1).t >= pin) + { + ledger.snapshots.remove(ledger.snapshots.size() - 1); + } ledger.snapshots.add(pinned); ledger.dirty = true; return; @@ -2442,7 +2977,7 @@ public class EssentialsPlusPlugin extends Plugin long effect = 0; for (int i = 0; i < prev.ids.length; i++) { - Integer price = val.prices.get(prev.ids[i]); + Long price = val.prices.get(prev.ids[i]); if (price != null && price > 0 && prev.p[i] > 0) { effect += prev.q[i] * (long) (price - prev.p[i]); @@ -2581,8 +3116,12 @@ public class EssentialsPlusPlugin extends Plugin } /** Bank placeholders canonicalize to the real item, so they would be valued as if the item was still banked. */ - private boolean isPlaceholder(int itemId) + boolean isPlaceholder(int itemId) { + if (itemManager == null) + { + return false; + } ItemComposition comp = itemManager.getItemComposition(itemId); return comp != null && comp.getPlaceholderTemplateId() != -1; } @@ -2608,17 +3147,17 @@ public class EssentialsPlusPlugin extends Plugin return items; } - private int price(int canonicalId, Map priceCache) + private long price(int canonicalId, Map priceCache) { return priceCache.computeIfAbsent(canonicalId, id -> { if (id == ItemID.COINS) { - return 1; + return 1L; } if (id == ItemID.PLATINUM) { - return 1000; + return 1000L; } return Math.max(0, itemManager.getItemPrice(id)); }); @@ -2711,7 +3250,7 @@ public class EssentialsPlusPlugin extends Plugin } } } - Map prices = new HashMap<>(); + Map prices = new HashMap<>(); merged.forEach((id, q) -> { if (q > 0) @@ -2730,7 +3269,7 @@ public class EssentialsPlusPlugin extends Plugin clientThread.invokeLater(() -> { List rows = new ArrayList<>(); - Map prices = new HashMap<>(); + Map prices = new HashMap<>(); for (AccountData acc : accounts) { com.example.essentialsplus.data.GeSlot[] slots = acc.geSlots; @@ -2875,9 +3414,542 @@ public class EssentialsPlusPlugin extends Plugin @Subscribe public void onClientTick(ClientTick event) { + if (bankItemsHidden) + { + if (isBankInterfaceVisible()) + { + applyBankItemMask(); + } + else + { + restoreBankItemsNow(); + } + } updateGeBuyLimitWidgets(); } + /** Temporarily hides bank item widgets in the open bank interface; items remain untouched. */ + public void hideBankItems() + { + clientThread.invokeLater(() -> + { + if (isBankInterfaceVisible()) + { + bankLayoutItemIds = null; + bankItemsHidden = true; + applyBankItemMask(); + } + }); + } + + /** Names of saved equipment and inventory layouts for the current RuneScape profile. */ + public List getSavedEquipmentLayoutNames() + { + List names = new ArrayList<>(loadSavedEquipmentLayouts().keySet()); + names.sort(String.CASE_INSENSITIVE_ORDER); + return names; + } + + public boolean hasSavedEquipmentLayout(String name) + { + String normalized = normalizeEquipmentLayoutName(name); + return !normalized.isEmpty() && findSavedEquipmentLayoutName(loadSavedEquipmentLayouts(), normalized) != null; + } + + /** Saves worn equipment and inventory slot contents under a named layout. */ + public void saveEquipmentAndInventory(String name, Consumer onSaved) + { + String normalized = normalizeEquipmentLayoutName(name); + if (normalized.isEmpty()) + { + showLayoutStatus(onSaved, "Enter a layout name."); + return; + } + clientThread.invokeLater(() -> + { + Map layouts = loadSavedEquipmentLayouts(); + String existingName = findSavedEquipmentLayoutName(layouts, normalized); + String storedName = existingName == null ? normalized : existingName; + SavedEquipmentLayout layout = new SavedEquipmentLayout( + itemIds(client.getItemContainer(InventoryID.WORN)), + itemIds(client.getItemContainer(InventoryID.INV))); + layouts.put(storedName, layout); + storeSavedEquipmentLayouts(layouts); + int saved = countItemIds(layout.equipment) + countItemIds(layout.inventory); + showLayoutStatus(onSaved, "Saved '" + storedName + "' (" + saved + " equipment and inventory slots)."); + }); + } + + /** Shows only items from the selected equipment and inventory snapshot in the real bank grid. */ + public void showSavedEquipmentLayout(String name, Consumer onShown) + { + clientThread.invokeLater(() -> + { + if (!isBankInterfaceVisible()) + { + showLayoutStatus(onShown, "Open your bank first."); + return; + } + Map layouts = loadSavedEquipmentLayouts(); + String savedName = findSavedEquipmentLayoutName(layouts, name); + SavedEquipmentLayout layout = savedName == null ? null : layouts.get(savedName); + if (layout == null) + { + showLayoutStatus(onShown, "Select a saved layout first."); + return; + } + bankLayoutItemIds = new java.util.HashSet<>(); + addItemIds(bankLayoutItemIds, layout.equipment); + addItemIds(bankLayoutItemIds, layout.inventory); + bankItemsHidden = true; + int shown = applyBankItemMask(); + showLayoutStatus(onShown, "Showing '" + savedName + "' (" + shown + " matching bank slots)."); + }); + } + + public void renameSavedEquipmentLayout(String oldName, String newName, Consumer onRenamed) + { + String normalized = normalizeEquipmentLayoutName(newName); + if (normalized.isEmpty()) + { + showLayoutStatus(onRenamed, "Enter a layout name."); + return; + } + clientThread.invokeLater(() -> + { + Map layouts = loadSavedEquipmentLayouts(); + String existingName = findSavedEquipmentLayoutName(layouts, oldName); + if (existingName == null) + { + showLayoutStatus(onRenamed, "Saved layout no longer exists."); + return; + } + String conflict = findSavedEquipmentLayoutName(layouts, normalized); + if (conflict != null && !conflict.equals(existingName)) + { + showLayoutStatus(onRenamed, "Another layout already uses that name."); + return; + } + if (existingName.equals(normalized)) + { + showLayoutStatus(onRenamed, "Layout name unchanged."); + return; + } + Map renamed = new LinkedHashMap<>(); + for (Map.Entry entry : layouts.entrySet()) + { + renamed.put(entry.getKey().equals(existingName) ? normalized : entry.getKey(), entry.getValue()); + } + storeSavedEquipmentLayouts(renamed); + showLayoutStatus(onRenamed, "Renamed '" + existingName + "' to '" + normalized + "'."); + }); + } + + public void deleteSavedEquipmentLayout(String name, Consumer onDeleted) + { + clientThread.invokeLater(() -> + { + Map layouts = loadSavedEquipmentLayouts(); + String savedName = findSavedEquipmentLayoutName(layouts, name); + if (savedName == null) + { + showLayoutStatus(onDeleted, "Saved layout no longer exists."); + return; + } + layouts.remove(savedName); + storeSavedEquipmentLayouts(layouts); + showLayoutStatus(onDeleted, "Deleted '" + savedName + "'."); + }); + } + + private Map loadSavedEquipmentLayouts() + { + String raw = configManager.getRSProfileConfiguration("essentialsplus", "equipmentLayouts"); + if (raw != null && !raw.isEmpty()) + { + try + { + SavedEquipmentLayouts saved = gson.fromJson(raw, SavedEquipmentLayouts.class); + Map layouts = new LinkedHashMap<>(); + if (saved != null && saved.profiles != null) + { + for (Map.Entry entry : saved.profiles.entrySet()) + { + String name = normalizeEquipmentLayoutName(entry.getKey()); + if (!name.isEmpty() && entry.getValue() != null) + { + layouts.put(name, entry.getValue()); + } + } + } + return layouts; + } + catch (RuntimeException e) + { + log.warn("Unable to read saved equipment layouts", e); + return new LinkedHashMap<>(); + } + } + + // Migrate the original single-layout setting on its first update. + String legacy = configManager.getRSProfileConfiguration("essentialsplus", "equipmentLayout"); + if (legacy != null && !legacy.isEmpty()) + { + try + { + SavedEquipmentLayout layout = gson.fromJson(legacy, SavedEquipmentLayout.class); + if (layout != null) + { + Map layouts = new LinkedHashMap<>(); + layouts.put("Default", layout); + return layouts; + } + } + catch (RuntimeException e) + { + log.warn("Unable to migrate the saved equipment layout", e); + } + } + return new LinkedHashMap<>(); + } + + private void storeSavedEquipmentLayouts(Map layouts) + { + SavedEquipmentLayouts saved = new SavedEquipmentLayouts(); + saved.profiles = new LinkedHashMap<>(layouts); + configManager.setRSProfileConfiguration("essentialsplus", "equipmentLayouts", gson.toJson(saved)); + } + + private static String normalizeEquipmentLayoutName(String name) + { + return name == null ? "" : name.trim(); + } + + private static String findSavedEquipmentLayoutName(Map layouts, String name) + { + String normalized = normalizeEquipmentLayoutName(name); + for (String savedName : layouts.keySet()) + { + if (savedName.equalsIgnoreCase(normalized)) + { + return savedName; + } + } + return null; + } + + private static void showLayoutStatus(Consumer onShown, String message) + { + if (onShown != null) + { + javax.swing.SwingUtilities.invokeLater(() -> onShown.accept(message)); + } + } + + /** Restores the original bank widgets and scroll range captured by the temporary mask. */ + public void restoreBankItems() + { + clientThread.invokeLater(this::restoreBankItemsNow); + } + + private void restoreBankItemsNow() + { + bankItemsHidden = false; + bankLayoutItemIds = null; + for (Map.Entry entry : bankItemOriginalStates.entrySet()) + { + Widget widget = entry.getKey(); + BankWidgetState state = entry.getValue(); + widget.setOriginalX(state.x).setOriginalY(state.y); + widget.revalidate(); + widget.setHidden(state.hidden); + } + bankItemOriginalStates.clear(); + if (bankScrollYBeforeMask >= 0 || bankScrollHeightBeforeMask >= 0 || bankScrollVarcBeforeMask >= 0) + { + Widget itemContainer = client.getWidget(ComponentID.BANK_ITEM_CONTAINER); + if (itemContainer != null) + { + if (bankScrollHeightBeforeMask >= 0) + { + itemContainer.setScrollHeight(bankScrollHeightBeforeMask); + } + if (bankScrollYBeforeMask >= 0) + { + itemContainer.setScrollY(bankScrollYBeforeMask); + } + itemContainer.revalidateScroll(); + } + if (bankScrollVarcBeforeMask >= 0) + { + client.setVarcIntValue(VarClientID.BANK_SCROLLPOS, bankScrollVarcBeforeMask); + } + bankScrollYBeforeMask = -1; + bankScrollHeightBeforeMask = -1; + bankScrollVarcBeforeMask = -1; + } + } + + private boolean isBankInterfaceVisible() + { + Widget bank = client.getWidget(ComponentID.BANK_CONTAINER); + Widget items = client.getWidget(ComponentID.BANK_ITEM_CONTAINER); + return (bank != null && !bank.isHidden()) + || (items != null && !items.isHidden()) + || isWidgetOpen(InterfaceID.BANKMAIN) + || isWidgetOpen(InterfaceID.BANKSIDE); + } + + private int applyBankItemMask() + { + maskBankWidget(client.getWidget(ComponentID.BANK_TAB_CONTAINER)); + maskBankWidget(client.getWidget(ComponentID.BANK_SCROLLBAR)); + maskBankWidget(client.getWidget(WidgetInfo.BANK_SEARCH_BUTTON_BACKGROUND)); + maskBankWidget(client.getWidget(WidgetInfo.BANK_ITEM_COUNT_TOP)); + maskBankWidget(client.getWidget(WidgetInfo.BANK_ITEM_COUNT_BAR)); + maskBankWidget(client.getWidget(WidgetInfo.BANK_ITEM_COUNT_BOTTOM)); + Widget container = client.getWidget(ComponentID.BANK_ITEM_CONTAINER); + ItemContainer bankContainer = client.getItemContainer(InventoryID.BANK); + if (container == null || bankContainer == null) + { + return 0; + } + if (bankScrollYBeforeMask < 0) + { + bankScrollYBeforeMask = container.getScrollY(); + } + if (bankScrollHeightBeforeMask < 0) + { + bankScrollHeightBeforeMask = container.getScrollHeight(); + } + if (bankScrollVarcBeforeMask < 0) + { + bankScrollVarcBeforeMask = client.getVarcIntValue(VarClientID.BANK_SCROLLPOS); + } + container.setScrollY(0); + client.setVarcIntValue(VarClientID.BANK_SCROLLPOS, 0); + Widget scrollContainer = client.getWidget(ComponentID.BANK_CONTENT_CONTAINER); + maskBankDividers(scrollContainer, container); + Widget[] children = container.getChildren(); + Item[] bankItems = bankContainer.getItems(); + if (children == null || bankItems == null) + { + removeBankScrollRange(container, 0); + return 0; + } + int shown = 0; + List matchingWidgets = new ArrayList<>(); + for (int i = 0; i < children.length; i++) + { + Widget child = children[i]; + if (child == null) + { + continue; + } + if (child.getOriginalHeight() < BANK_LAYOUT_ITEM_HEIGHT) + { + break; + } + rememberBankWidget(child); + Item bankItem = i < bankItems.length ? bankItems[i] : null; + int itemId = bankItem == null ? -1 : bankItem.getId(); + int canonicalId = itemId > 0 ? itemManager.canonicalize(itemId) : -1; + boolean keep = bankLayoutItemIds != null && itemId > 0 + && (bankLayoutItemIds.contains(itemId) || bankLayoutItemIds.contains(canonicalId)); + child.setHidden(!keep); + if (keep) + { + shown++; + matchingWidgets.add(child); + } + else + { + setBankItemPosition(child, 0); + } + } + if (!matchingWidgets.isEmpty()) + { + compactBankItems(matchingWidgets); + } + removeBankScrollRange(container, matchingWidgets.size()); + if (bankLayoutItemIds != null) + { + log.debug("Saved equipment layout displayed {} bank slots for {} stored item types", shown, bankLayoutItemIds.size()); + } + return shown; + } + + private void removeBankScrollRange(Widget itemContainer, int visibleItems) + { + if (itemContainer != null) + { + int rows = (visibleItems + BANK_LAYOUT_ITEMS_PER_ROW - 1) / BANK_LAYOUT_ITEMS_PER_ROW; + int compactHeight = rows * BANK_LAYOUT_ITEM_STEP_Y; + int viewportHeight = itemContainer.getHeight(); + int scrollHeight = Math.max(viewportHeight, compactHeight); + if (scrollHeight > 0) + { + itemContainer.setScrollHeight(scrollHeight); + } + itemContainer.setScrollY(0); + client.setVarcIntValue(VarClientID.BANK_SCROLLPOS, 0); + itemContainer.revalidateScroll(); + } + } + + private void compactBankItems(List matchingWidgets) + { + for (int i = 0; i < matchingWidgets.size(); i++) + { + setBankItemPosition(matchingWidgets.get(i), i); + } + } + + private void setBankItemPosition(Widget widget, int layoutIndex) + { + int x = BANK_LAYOUT_ITEM_START_X + (layoutIndex % BANK_LAYOUT_ITEMS_PER_ROW) * BANK_LAYOUT_ITEM_STEP_X; + int y = (layoutIndex / BANK_LAYOUT_ITEMS_PER_ROW) * BANK_LAYOUT_ITEM_STEP_Y; + if (widget.getOriginalX() != x || widget.getOriginalY() != y) + { + widget.setOriginalX(x).setOriginalY(y); + widget.revalidate(); + } + } + + private void maskBankDividers(Widget firstRoot, Widget secondRoot) + { + Widget itemContainer = client.getWidget(ComponentID.BANK_ITEM_CONTAINER); + int itemAreaWidth = itemContainer == null ? 0 : Math.max(itemContainer.getWidth(), itemContainer.getOriginalWidth()); + Set visited = Collections.newSetFromMap(new IdentityHashMap<>()); + maskBankDividers(firstRoot, itemAreaWidth, visited); + maskBankDividers(secondRoot, itemAreaWidth, visited); + } + + private void maskBankDividers(Widget root, int itemAreaWidth, Set visited) + { + if (root == null || !visited.add(root)) + { + return; + } + Widget[] children = root.getChildren(); + if (children == null) + { + return; + } + for (Widget child : children) + { + if (child == null) + { + continue; + } + boolean horizontalDivider = child.getType() == WidgetType.LINE + || (child.getType() == WidgetType.RECTANGLE && child.getHeight() <= 2 + && itemAreaWidth > 0 && child.getWidth() >= itemAreaWidth / 2); + if (horizontalDivider) + { + maskBankWidget(child); + } + else + { + maskBankDividers(child, itemAreaWidth, visited); + } + } + } + + private void maskBankWidget(Widget widget) + { + if (widget != null) + { + rememberBankWidget(widget); + widget.setHidden(true); + } + } + + private BankWidgetState rememberBankWidget(Widget widget) + { + return bankItemOriginalStates.computeIfAbsent(widget, + w -> new BankWidgetState(w.isSelfHidden(), w.getOriginalX(), w.getOriginalY())); + } + + private static int[] itemIds(ItemContainer container) + { + if (container == null || container.getItems() == null) + { + return new int[0]; + } + Item[] items = container.getItems(); + int[] ids = new int[items.length]; + Arrays.fill(ids, -1); + for (int i = 0; i < items.length; i++) + { + Item item = items[i]; + if (item != null && item.getId() > 0) + { + ids[i] = item.getId(); + } + } + return ids; + } + + private void addItemIds(Set ids, int[] slots) + { + if (slots != null) + { + for (int id : slots) + { + if (id > 0) + { + ids.add(id); + ids.add(itemManager.canonicalize(id)); + } + } + } + } + + private static int countItemIds(int[] slots) + { + int count = 0; + for (int id : slots) + { + if (id > 0) + { + count++; + } + } + return count; + } + + private static final class SavedEquipmentLayout + { + private final int[] equipment; + private final int[] inventory; + + private SavedEquipmentLayout(int[] equipment, int[] inventory) + { + this.equipment = equipment; + this.inventory = inventory; + } + } + + private static final class SavedEquipmentLayouts + { + private Map profiles = new LinkedHashMap<>(); + } + + private static final class BankWidgetState + { + private final boolean hidden; + private final int x; + private final int y; + + private BankWidgetState(boolean hidden, int x, int y) + { + this.hidden = hidden; + this.x = x; + this.y = y; + } + } + @Subscribe public void onScriptCallbackEvent(ScriptCallbackEvent event) { @@ -3444,7 +4516,7 @@ public class EssentialsPlusPlugin extends Plugin final long[] values = new long[WealthCategory.COUNT]; final List sources = new ArrayList<>(); final Map merged = new HashMap<>(); - final Map prices = new HashMap<>(); + final Map prices = new HashMap<>(); long total() { @@ -3489,7 +4561,7 @@ public class EssentialsPlusPlugin extends Plugin TreeMap sorted = new TreeMap<>(merged); int[] ids = new int[sorted.size()]; long[] q = new long[sorted.size()]; - int[] p = new int[sorted.size()]; + long[] p = new long[sorted.size()]; int i = 0; for (Map.Entry e : sorted.entrySet()) { diff --git a/src/main/java/com/example/essentialsplus/GrandExchangeTracker.java b/src/main/java/com/example/essentialsplus/GrandExchangeTracker.java index ddec402..869d86f 100644 --- a/src/main/java/com/example/essentialsplus/GrandExchangeTracker.java +++ b/src/main/java/com/example/essentialsplus/GrandExchangeTracker.java @@ -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 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; } } diff --git a/src/main/java/com/example/essentialsplus/currency/CurrencyHistory.java b/src/main/java/com/example/essentialsplus/currency/CurrencyHistory.java index a651141..697bd3c 100644 --- a/src/main/java/com/example/essentialsplus/currency/CurrencyHistory.java +++ b/src/main/java/com/example/essentialsplus/currency/CurrencyHistory.java @@ -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--) diff --git a/src/main/java/com/example/essentialsplus/data/AccountData.java b/src/main/java/com/example/essentialsplus/data/AccountData.java index 6592052..952ac31 100644 --- a/src/main/java/com/example/essentialsplus/data/AccountData.java +++ b/src/main/java/com/example/essentialsplus/data/AccountData.java @@ -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 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 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 } } } - diff --git a/src/main/java/com/example/essentialsplus/data/GeRow.java b/src/main/java/com/example/essentialsplus/data/GeRow.java index da8fe28..25967e5 100644 --- a/src/main/java/com/example/essentialsplus/data/GeRow.java +++ b/src/main/java/com/example/essentialsplus/data/GeRow.java @@ -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; diff --git a/src/main/java/com/example/essentialsplus/data/GeSlot.java b/src/main/java/com/example/essentialsplus/data/GeSlot.java index 587e433..33b2257 100644 --- a/src/main/java/com/example/essentialsplus/data/GeSlot.java +++ b/src/main/java/com/example/essentialsplus/data/GeSlot.java @@ -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; } diff --git a/src/main/java/com/example/essentialsplus/data/ItemSnapshot.java b/src/main/java/com/example/essentialsplus/data/ItemSnapshot.java index 4a96152..a89591b 100644 --- a/src/main/java/com/example/essentialsplus/data/ItemSnapshot.java +++ b/src/main/java/com/example/essentialsplus/data/ItemSnapshot.java @@ -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; diff --git a/src/main/java/com/example/essentialsplus/helpers/AttackDistance.java b/src/main/java/com/example/essentialsplus/helpers/AttackDistance.java new file mode 100644 index 0000000..d309f84 --- /dev/null +++ b/src/main/java/com/example/essentialsplus/helpers/AttackDistance.java @@ -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; + } + } +} diff --git a/src/main/java/com/example/essentialsplus/helpers/AttackDistanceOverlay.java b/src/main/java/com/example/essentialsplus/helpers/AttackDistanceOverlay.java new file mode 100644 index 0000000..82c21b5 --- /dev/null +++ b/src/main/java/com/example/essentialsplus/helpers/AttackDistanceOverlay.java @@ -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 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 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 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); + } + } +} diff --git a/src/main/java/com/example/essentialsplus/helpers/ChatHelper.java b/src/main/java/com/example/essentialsplus/helpers/ChatHelper.java index 1b4bf7b..3c9993c 100644 --- a/src/main/java/com/example/essentialsplus/helpers/ChatHelper.java +++ b/src/main/java/com/example/essentialsplus/helpers/ChatHelper.java @@ -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 = "" + message + ""; + 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(); }); } } diff --git a/src/main/java/com/example/essentialsplus/helpers/IdleNotifier.java b/src/main/java/com/example/essentialsplus/helpers/IdleNotifier.java new file mode 100644 index 0000000..44fea3f --- /dev/null +++ b/src/main/java/com/example/essentialsplus/helpers/IdleNotifier.java @@ -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; + } +} diff --git a/src/main/java/com/example/essentialsplus/helpers/ItemCharges.java b/src/main/java/com/example/essentialsplus/helpers/ItemCharges.java index a428705..0af7442 100644 --- a/src/main/java/com/example/essentialsplus/helpers/ItemCharges.java +++ b/src/main/java/com/example/essentialsplus/helpers/ItemCharges.java @@ -30,6 +30,7 @@ import net.runelite.api.events.MenuOptionClicked; import net.runelite.api.gameval.InventoryID; import net.runelite.api.gameval.ItemID; import net.runelite.api.gameval.VarbitID; +import net.runelite.api.widgets.WidgetUtil; import net.runelite.client.config.ConfigManager; import net.runelite.client.config.RuneScapeProfileType; import net.runelite.client.eventbus.Subscribe; @@ -252,6 +253,11 @@ public class ItemCharges return new ItemChargeDef(SourceType.CONFIG_COUNT, -1, -1, isUncharged, false, false, null, 0, null, 0, configKey, defaultCharges, 0); } + static ItemChargeDef configCount(String configKey, int primaryVarbit, Integer defaultCharges, boolean isUncharged) + { + return new ItemChargeDef(SourceType.CONFIG_COUNT, primaryVarbit, -1, isUncharged, false, false, null, 0, null, 0, configKey, defaultCharges, 0); + } + static ItemChargeDef bag(String configKey) { return new ItemChargeDef(SourceType.BAG, -1, -1, false, false, false, null, 0, null, 0, configKey, null, 0); @@ -527,47 +533,47 @@ public class ItemCharges // 12. Helms & Staffs & Tridents CHARGE_MAP.put(ItemID.SERPENTINE_HELM, ItemChargeDef.uncharged()); - CHARGE_MAP.put(ItemID.SERPENTINE_HELM_CHARGED, ItemChargeDef.varbit(VarbitID.CHARGES_SERPENTINE_HELM_QUANTITY)); + CHARGE_MAP.put(ItemID.SERPENTINE_HELM_CHARGED, ItemChargeDef.configCount("serpentine_helm", VarbitID.CHARGES_SERPENTINE_HELM_QUANTITY, null, false)); CHARGE_MAP.put(ItemID.SERPENTINE_HELM_CYAN, ItemChargeDef.uncharged()); - CHARGE_MAP.put(ItemID.SERPENTINE_HELM_CHARGED_CYAN, ItemChargeDef.varbit(VarbitID.CHARGES_SERPENTINE_HELM_QUANTITY)); + CHARGE_MAP.put(ItemID.SERPENTINE_HELM_CHARGED_CYAN, ItemChargeDef.configCount("serpentine_helm", VarbitID.CHARGES_SERPENTINE_HELM_QUANTITY, null, false)); CHARGE_MAP.put(ItemID.SERPENTINE_HELM_RED, ItemChargeDef.uncharged()); - CHARGE_MAP.put(ItemID.SERPENTINE_HELM_CHARGED_RED, ItemChargeDef.varbit(VarbitID.CHARGES_SERPENTINE_HELM_QUANTITY)); + CHARGE_MAP.put(ItemID.SERPENTINE_HELM_CHARGED_RED, ItemChargeDef.configCount("serpentine_helm", VarbitID.CHARGES_SERPENTINE_HELM_QUANTITY, null, false)); CHARGE_MAP.put(ItemID.WARPED_SCEPTRE_UNCHARGED, ItemChargeDef.uncharged()); - CHARGE_MAP.put(ItemID.WARPED_SCEPTRE, ItemChargeDef.varbit(VarbitID.CHARGES_WARPED_SCEPTRE_QUANTITY)); + CHARGE_MAP.put(ItemID.WARPED_SCEPTRE, ItemChargeDef.configCount("warped_sceptre", VarbitID.CHARGES_WARPED_SCEPTRE_QUANTITY, null, false)); - CHARGE_MAP.put(ItemID.TUMEKENS_SHADOW, ItemChargeDef.varbit(VarbitID.CHARGES_TUMEKENS_SHADOW_QUANTITY)); + CHARGE_MAP.put(ItemID.TUMEKENS_SHADOW, ItemChargeDef.configCount("tumekens_shadow", VarbitID.CHARGES_TUMEKENS_SHADOW_QUANTITY, null, false)); CHARGE_MAP.put(ItemID.TUMEKENS_SHADOW_UNCHARGED, ItemChargeDef.uncharged()); - CHARGE_MAP.put(ItemID.DEADMAN_BLIGHTED_TUMEKENS_SHADOW, ItemChargeDef.varbit(VarbitID.CHARGES_TUMEKENS_SHADOW_QUANTITY)); + CHARGE_MAP.put(ItemID.DEADMAN_BLIGHTED_TUMEKENS_SHADOW, ItemChargeDef.configCount("tumekens_shadow", VarbitID.CHARGES_TUMEKENS_SHADOW_QUANTITY, null, false)); CHARGE_MAP.put(ItemID.DEADMAN_BLIGHTED_TUMEKENS_SHADOW_UNCHARGED, ItemChargeDef.uncharged()); - CHARGE_MAP.put(ItemID.TUMEKENS_HEKA, ItemChargeDef.varbit(VarbitID.CHARGES_TUMEKENS_SHADOW_QUANTITY)); + CHARGE_MAP.put(ItemID.TUMEKENS_HEKA, ItemChargeDef.configCount("tumekens_shadow", VarbitID.CHARGES_TUMEKENS_SHADOW_QUANTITY, null, false)); CHARGE_MAP.put(ItemID.TUMEKENS_HEKA_UNCHARGED, ItemChargeDef.uncharged()); - CHARGE_MAP.put(ItemID.SANGUINESTI_STAFF, ItemChargeDef.varbit(VarbitID.CHARGES_SANGUINESTI_STAFF_QUANTITY)); + CHARGE_MAP.put(ItemID.SANGUINESTI_STAFF, ItemChargeDef.configCount("sanguinesti_staff", VarbitID.CHARGES_SANGUINESTI_STAFF_QUANTITY, null, false)); CHARGE_MAP.put(ItemID.SANGUINESTI_STAFF_UNCHARGED, ItemChargeDef.uncharged()); - CHARGE_MAP.put(ItemID.SANGUINESTI_STAFF_OR, ItemChargeDef.varbit(VarbitID.CHARGES_SANGUINESTI_STAFF_QUANTITY)); + CHARGE_MAP.put(ItemID.SANGUINESTI_STAFF_OR, ItemChargeDef.configCount("sanguinesti_staff", VarbitID.CHARGES_SANGUINESTI_STAFF_QUANTITY, null, false)); CHARGE_MAP.put(ItemID.SANGUINESTI_STAFF_UNCHARGED_OR, ItemChargeDef.uncharged()); // Trident of the Seas - CHARGE_MAP.put(ItemID.TOTS, ItemChargeDef.varbit(VarbitID.CHARGES_TRIDENT_OF_THE_SEAS_QUANTITY)); - CHARGE_MAP.put(ItemID.TOTS_CHARGED, ItemChargeDef.varbit(VarbitID.CHARGES_TRIDENT_OF_THE_SEAS_QUANTITY)); + CHARGE_MAP.put(ItemID.TOTS, ItemChargeDef.configCount("trident_of_the_seas", VarbitID.CHARGES_TRIDENT_OF_THE_SEAS_QUANTITY, null, false)); + CHARGE_MAP.put(ItemID.TOTS_CHARGED, ItemChargeDef.configCount("trident_of_the_seas", VarbitID.CHARGES_TRIDENT_OF_THE_SEAS_QUANTITY, null, false)); CHARGE_MAP.put(ItemID.TOTS_UNCHARGED, ItemChargeDef.uncharged()); - CHARGE_MAP.put(ItemID.TOTS_ORN, ItemChargeDef.varbit(VarbitID.CHARGES_TRIDENT_OF_THE_SEAS_QUANTITY)); - CHARGE_MAP.put(ItemID.TOTS_CHARGED_ORN, ItemChargeDef.varbit(VarbitID.CHARGES_TRIDENT_OF_THE_SEAS_QUANTITY)); + CHARGE_MAP.put(ItemID.TOTS_ORN, ItemChargeDef.configCount("trident_of_the_seas", VarbitID.CHARGES_TRIDENT_OF_THE_SEAS_QUANTITY, null, false)); + CHARGE_MAP.put(ItemID.TOTS_CHARGED_ORN, ItemChargeDef.configCount("trident_of_the_seas", VarbitID.CHARGES_TRIDENT_OF_THE_SEAS_QUANTITY, null, false)); CHARGE_MAP.put(ItemID.TOTS_UNCHARGED_ORN, ItemChargeDef.uncharged()); - CHARGE_MAP.put(ItemID.TOTS_I_CHARGED, ItemChargeDef.varbit(VarbitID.CHARGES_TRIDENT_OF_THE_SEAS_E_QUANTITY)); + CHARGE_MAP.put(ItemID.TOTS_I_CHARGED, ItemChargeDef.configCount("trident_of_the_seas_e", VarbitID.CHARGES_TRIDENT_OF_THE_SEAS_E_QUANTITY, null, false)); CHARGE_MAP.put(ItemID.TOTS_I_UNCHARGED, ItemChargeDef.uncharged()); - CHARGE_MAP.put(ItemID.TOTS_I_CHARGED_ORN, ItemChargeDef.varbit(VarbitID.CHARGES_TRIDENT_OF_THE_SEAS_E_QUANTITY)); + CHARGE_MAP.put(ItemID.TOTS_I_CHARGED_ORN, ItemChargeDef.configCount("trident_of_the_seas_e", VarbitID.CHARGES_TRIDENT_OF_THE_SEAS_E_QUANTITY, null, false)); CHARGE_MAP.put(ItemID.TOTS_I_UNCHARGED_ORN, ItemChargeDef.uncharged()); // Trident of the Swamp - CHARGE_MAP.put(ItemID.TOXIC_TOTS_CHARGED, ItemChargeDef.varbit(665)); + CHARGE_MAP.put(ItemID.TOXIC_TOTS_CHARGED, ItemChargeDef.configCount("trident_of_the_swamp", -1, null, false)); CHARGE_MAP.put(ItemID.TOXIC_TOTS_UNCHARGED, ItemChargeDef.uncharged()); - CHARGE_MAP.put(ItemID.TOXIC_TOTS_CHARGED_ORN, ItemChargeDef.varbit(665)); + CHARGE_MAP.put(ItemID.TOXIC_TOTS_CHARGED_ORN, ItemChargeDef.configCount("trident_of_the_swamp", -1, null, false)); CHARGE_MAP.put(ItemID.TOXIC_TOTS_UNCHARGED_ORN, ItemChargeDef.uncharged()); - CHARGE_MAP.put(ItemID.TOXIC_TOTS_I_CHARGED, ItemChargeDef.varbit(665)); + CHARGE_MAP.put(ItemID.TOXIC_TOTS_I_CHARGED, ItemChargeDef.configCount("trident_of_the_swamp_e", -1, null, false)); CHARGE_MAP.put(ItemID.TOXIC_TOTS_I_UNCHARGED, ItemChargeDef.uncharged()); - CHARGE_MAP.put(ItemID.TOXIC_TOTS_I_CHARGED_ORN, ItemChargeDef.varbit(665)); + CHARGE_MAP.put(ItemID.TOXIC_TOTS_I_CHARGED_ORN, ItemChargeDef.configCount("trident_of_the_swamp_e", -1, null, false)); CHARGE_MAP.put(ItemID.TOXIC_TOTS_I_UNCHARGED_ORN, ItemChargeDef.uncharged()); // Toxic Staff of the Dead @@ -606,14 +612,14 @@ public class ItemCharges // Venator Bow CHARGE_MAP.put(ItemID.VENATOR_BOW_UNCHARGED, ItemChargeDef.uncharged()); - CHARGE_MAP.put(ItemID.VENATOR_BOW, ItemChargeDef.varbit(VarbitID.CHARGES_VENATOR_BOW_QUANTITY)); - CHARGE_MAP.put(ItemID.VENATOR_BOW_ORNAMENT, ItemChargeDef.varbit(VarbitID.CHARGES_VENATOR_BOW_QUANTITY)); + CHARGE_MAP.put(ItemID.VENATOR_BOW, ItemChargeDef.configCount("venator_bow", VarbitID.CHARGES_VENATOR_BOW_QUANTITY, null, false)); + CHARGE_MAP.put(ItemID.VENATOR_BOW_ORNAMENT, ItemChargeDef.configCount("venator_bow", VarbitID.CHARGES_VENATOR_BOW_QUANTITY, null, false)); CHARGE_MAP.put(ItemID.VENATOR_BOW_ORNAMENT_UNCHARGED, ItemChargeDef.uncharged()); // Tonalztics & Arclight & Dizana's Quiver CHARGE_MAP.put(ItemID.TONALZTICS_OF_RALOS_UNCHARGED, ItemChargeDef.uncharged()); CHARGE_MAP.put(ItemID.TONALZTICS_OF_RALOS_CHARGED, ItemChargeDef.varbit(VarbitID.CHARGES_TONALZTICS_OF_RALOS_QUANTITY)); - CHARGE_MAP.put(ItemID.ARCLIGHT, ItemChargeDef.varbit(VarbitID.CHARGES_ARCLIGHT_QUANTITY)); + CHARGE_MAP.put(ItemID.ARCLIGHT, ItemChargeDef.configCount("arclight", VarbitID.CHARGES_ARCLIGHT_QUANTITY, null, false)); CHARGE_MAP.put(ItemID.ARCLIGHT_INACTIVE, ItemChargeDef.uncharged()); CHARGE_MAP.put(ItemID.DIZANAS_QUIVER_CHARGED, ItemChargeDef.quiver(false, false)); @@ -635,17 +641,17 @@ public class ItemCharges CHARGE_MAP.put(ItemID.SKILLCAPE_MAX_DIZANAS_TROUVER_MANGLED, ItemChargeDef.quiver(true, false)); // Tomes & Ash Sanctifier & Bonecrusher - CHARGE_MAP.put(ItemID.TOME_OF_FIRE, ItemChargeDef.varbit(VarbitID.CHARGES_TOME_OF_FIRE_QUANTITY)); + CHARGE_MAP.put(ItemID.TOME_OF_FIRE, ItemChargeDef.configCount("tome_of_fire", VarbitID.CHARGES_TOME_OF_FIRE_QUANTITY, null, false)); CHARGE_MAP.put(ItemID.TOME_OF_FIRE_UNCHARGED, ItemChargeDef.uncharged()); - CHARGE_MAP.put(ItemID.BR_TOME_OF_FIRE, ItemChargeDef.varbit(VarbitID.CHARGES_TOME_OF_FIRE_QUANTITY)); - CHARGE_MAP.put(ItemID.TOME_OF_WATER, ItemChargeDef.varbit(VarbitID.CHARGES_TOME_OF_WATER_QUANTITY)); + CHARGE_MAP.put(ItemID.BR_TOME_OF_FIRE, ItemChargeDef.configCount("tome_of_fire", VarbitID.CHARGES_TOME_OF_FIRE_QUANTITY, null, false)); + CHARGE_MAP.put(ItemID.TOME_OF_WATER, ItemChargeDef.configCount("tome_of_water", VarbitID.CHARGES_TOME_OF_WATER_QUANTITY, null, false)); CHARGE_MAP.put(ItemID.TOME_OF_WATER_UNCHARGED, ItemChargeDef.uncharged()); - CHARGE_MAP.put(ItemID.TOME_OF_EARTH, ItemChargeDef.varbit(VarbitID.CHARGES_TOME_OF_EARTH_QUANTITY)); + CHARGE_MAP.put(ItemID.TOME_OF_EARTH, ItemChargeDef.configCount("tome_of_earth", VarbitID.CHARGES_TOME_OF_EARTH_QUANTITY, null, false)); CHARGE_MAP.put(ItemID.TOME_OF_EARTH_UNCHARGED, ItemChargeDef.uncharged()); - CHARGE_MAP.put(ItemID.ASH_SANCTIFIER, ItemChargeDef.varbit(VarbitID.CHARGES_ASH_SANCTIFIER_QUANTITY)); - CHARGE_MAP.put(ItemID.BONECRUSHER, ItemChargeDef.varbit(VarbitID.CHARGES_BONECRUSHER_QUANTITY)); - CHARGE_MAP.put(ItemID.BONECRUSHER_NECKLACE, ItemChargeDef.varbit(VarbitID.CHARGES_BONECRUSHER_QUANTITY)); + CHARGE_MAP.put(ItemID.ASH_SANCTIFIER, ItemChargeDef.configCount("ash_sanctifier", VarbitID.CHARGES_ASH_SANCTIFIER_QUANTITY, null, false)); + CHARGE_MAP.put(ItemID.BONECRUSHER, ItemChargeDef.configCount("bonecrusher", VarbitID.CHARGES_BONECRUSHER_QUANTITY, null, false)); + CHARGE_MAP.put(ItemID.BONECRUSHER_NECKLACE, ItemChargeDef.configCount("bonecrusher", VarbitID.CHARGES_BONECRUSHER_QUANTITY, null, false)); CHARGE_MAP.put(ItemID.NATURE_STAFF_CHARGED, ItemChargeDef.varbit(VarbitID.CHARGES_BRYOPHYTAS_STAFF_QUANTITY)); CHARGE_MAP.put(22368, ItemChargeDef.uncharged()); // BRYOPHYTAS_STAFF_UNCHARGED CHARGE_MAP.put(ItemID.WATER_CIRCLET_CHARGED, ItemChargeDef.varbit(VarbitID.CHARGES_CIRCLET_OF_WATER_QUANTITY)); @@ -657,41 +663,41 @@ public class ItemCharges CHARGE_MAP.put(ItemID.CELESTIAL_RING, ItemChargeDef.uncharged()); // Wilderness Weapons & Bracelet - CHARGE_MAP.put(ItemID.WILD_CAVE_CHAINMACE_CHARGED, ItemChargeDef.varbit(VarbitID.CHARGES_WILDERNESS_WEAPON_QUANTITY)); + CHARGE_MAP.put(ItemID.WILD_CAVE_CHAINMACE_CHARGED, ItemChargeDef.configCount("viggoras_chainmace", VarbitID.CHARGES_WILDERNESS_WEAPON_QUANTITY, null, false)); CHARGE_MAP.put(ItemID.WILD_CAVE_CHAINMACE_UNCHARGED, ItemChargeDef.uncharged()); - CHARGE_MAP.put(ItemID.WILD_CAVE_BOW_CHARGED, ItemChargeDef.varbit(VarbitID.CHARGES_WILDERNESS_WEAPON_QUANTITY)); + CHARGE_MAP.put(ItemID.WILD_CAVE_BOW_CHARGED, ItemChargeDef.configCount("craws_bow", VarbitID.CHARGES_WILDERNESS_WEAPON_QUANTITY, null, false)); CHARGE_MAP.put(ItemID.WILD_CAVE_BOW_UNCHARGED, ItemChargeDef.uncharged()); - CHARGE_MAP.put(ItemID.WILD_CAVE_SCEPTRE_CHARGED, ItemChargeDef.varbit(VarbitID.CHARGES_WILDERNESS_WEAPON_QUANTITY)); + CHARGE_MAP.put(ItemID.WILD_CAVE_SCEPTRE_CHARGED, ItemChargeDef.configCount("thammarons_sceptre", VarbitID.CHARGES_WILDERNESS_WEAPON_QUANTITY, null, false)); CHARGE_MAP.put(ItemID.WILD_CAVE_SCEPTRE_UNCHARGED, ItemChargeDef.uncharged()); - CHARGE_MAP.put(ItemID.WILD_CAVE_SCEPTRE_CHARGED_RECOL, ItemChargeDef.varbit(VarbitID.CHARGES_WILDERNESS_WEAPON_QUANTITY)); + CHARGE_MAP.put(ItemID.WILD_CAVE_SCEPTRE_CHARGED_RECOL, ItemChargeDef.configCount("thammarons_sceptre", VarbitID.CHARGES_WILDERNESS_WEAPON_QUANTITY, null, false)); CHARGE_MAP.put(ItemID.WILD_CAVE_SCEPTRE_UNCHARGED_RECOL, ItemChargeDef.uncharged()); - CHARGE_MAP.put(ItemID.WILD_CAVE_WEBWEAVER_CHARGED, ItemChargeDef.varbit(VarbitID.CHARGES_WILDERNESS_WEAPON_QUANTITY)); + CHARGE_MAP.put(ItemID.WILD_CAVE_WEBWEAVER_CHARGED, ItemChargeDef.configCount("webweaver_bow", VarbitID.CHARGES_WILDERNESS_WEAPON_QUANTITY, null, false)); CHARGE_MAP.put(ItemID.WILD_CAVE_WEBWEAVER_UNCHARGED, ItemChargeDef.uncharged()); - CHARGE_MAP.put(ItemID.WILD_CAVE_URSINE_CHARGED, ItemChargeDef.varbit(VarbitID.CHARGES_WILDERNESS_WEAPON_QUANTITY)); + CHARGE_MAP.put(ItemID.WILD_CAVE_URSINE_CHARGED, ItemChargeDef.configCount("ursine_chainmace", VarbitID.CHARGES_WILDERNESS_WEAPON_QUANTITY, null, false)); CHARGE_MAP.put(ItemID.WILD_CAVE_URSINE_UNCHARGED, ItemChargeDef.uncharged()); - CHARGE_MAP.put(ItemID.WILD_CAVE_ACCURSED_CHARGED, ItemChargeDef.varbit(VarbitID.CHARGES_WILDERNESS_WEAPON_QUANTITY)); + CHARGE_MAP.put(ItemID.WILD_CAVE_ACCURSED_CHARGED, ItemChargeDef.configCount("accursed_sceptre", VarbitID.CHARGES_WILDERNESS_WEAPON_QUANTITY, null, false)); CHARGE_MAP.put(ItemID.WILD_CAVE_ACCURSED_UNCHARGED, ItemChargeDef.uncharged()); - CHARGE_MAP.put(ItemID.WILD_CAVE_ACCURSED_CHARGED_RECOL, ItemChargeDef.varbit(VarbitID.CHARGES_WILDERNESS_WEAPON_QUANTITY)); + CHARGE_MAP.put(ItemID.WILD_CAVE_ACCURSED_CHARGED_RECOL, ItemChargeDef.configCount("accursed_sceptre", VarbitID.CHARGES_WILDERNESS_WEAPON_QUANTITY, null, false)); CHARGE_MAP.put(ItemID.WILD_CAVE_ACCURSED_UNCHARGED_RECOL, ItemChargeDef.uncharged()); CHARGE_MAP.put(ItemID.WILD_CAVE_BRACELET_CHARGED, ItemChargeDef.varbit(VarbitID.CHARGES_BRACELET_OF_ETHEREUM_QUANTITY)); CHARGE_MAP.put(ItemID.WILD_CAVE_BRACELET_UNCHARGED, ItemChargeDef.uncharged()); // Ring of Suffering & Shadows & Echo boots & Blood fury & Misc - CHARGE_MAP.put(ItemID.ZENYTE_RING_ENCHANTED, ItemChargeDef.varbit(VarbitID.CHARGES_RING_OF_SUFFERING_QUANTITY)); - CHARGE_MAP.put(19710, ItemChargeDef.varbit(VarbitID.CHARGES_RING_OF_SUFFERING_QUANTITY)); - CHARGE_MAP.put(20655, ItemChargeDef.varbit(VarbitID.CHARGES_RING_OF_SUFFERING_QUANTITY)); - CHARGE_MAP.put(20657, ItemChargeDef.varbit(VarbitID.CHARGES_RING_OF_SUFFERING_QUANTITY)); - CHARGE_MAP.put(25246, ItemChargeDef.varbit(VarbitID.CHARGES_RING_OF_SUFFERING_QUANTITY)); - CHARGE_MAP.put(25248, ItemChargeDef.varbit(VarbitID.CHARGES_RING_OF_SUFFERING_QUANTITY)); - CHARGE_MAP.put(26761, ItemChargeDef.varbit(VarbitID.CHARGES_RING_OF_SUFFERING_QUANTITY)); - CHARGE_MAP.put(26762, ItemChargeDef.varbit(VarbitID.CHARGES_RING_OF_SUFFERING_QUANTITY)); + CHARGE_MAP.put(ItemID.ZENYTE_RING_ENCHANTED, ItemChargeDef.uncharged()); // 19550 + CHARGE_MAP.put(ItemID.NZONE_ZENYTE_RING_ENCHANTED, ItemChargeDef.uncharged()); // 19710 + CHARGE_MAP.put(ItemID.SW_ZENYTE_RING_ENCHANTED, ItemChargeDef.uncharged()); // 25246 + CHARGE_MAP.put(ItemID.PVPA_ZENYTE_RING_ENCHANTED, ItemChargeDef.uncharged()); // 26761 + CHARGE_MAP.put(ItemID.ZENYTE_RING_ENCHANTED_RECOIL, ItemChargeDef.configCount("ring_of_suffering", VarbitID.CHARGES_RING_OF_SUFFERING_QUANTITY, null, false)); // 20655 + CHARGE_MAP.put(ItemID.NZONE_ZENYTE_RING_ENCHANTED_RECOIL, ItemChargeDef.configCount("ring_of_suffering", VarbitID.CHARGES_RING_OF_SUFFERING_QUANTITY, null, false)); // 20657 + CHARGE_MAP.put(ItemID.SW_ZENYTE_RING_ENCHANTED_RECOIL, ItemChargeDef.configCount("ring_of_suffering", VarbitID.CHARGES_RING_OF_SUFFERING_QUANTITY, null, false)); // 25248 + CHARGE_MAP.put(ItemID.PVPA_ZENYTE_RING_ENCHANTED_RECOIL, ItemChargeDef.configCount("ring_of_suffering", VarbitID.CHARGES_RING_OF_SUFFERING_QUANTITY, null, false)); // 26762 CHARGE_MAP.put(ItemID.XERIC_TALISMAN, ItemChargeDef.varbit(VarbitID.CHARGES_XERICS_TALISMAN_QUANTITY)); CHARGE_MAP.put(ItemID.XERIC_TALISMAN_EMPTY, ItemChargeDef.uncharged()); CHARGE_MAP.put(ItemID.RING_OF_SHADOWS, ItemChargeDef.varbit(VarbitID.CHARGES_RING_OF_SHADOWS_QUANTITY)); CHARGE_MAP.put(ItemID.RING_OF_SHADOWS_UNCHARGED, ItemChargeDef.uncharged()); CHARGE_MAP.put(ItemID.ECHO_BOOTS, ItemChargeDef.varbit(VarbitID.CHARGES_ECHO_BOOTS_QUANTITY)); - CHARGE_MAP.put(ItemID.BLOOD_AMULET, ItemChargeDef.varbit(VarbitID.CHARGES_BLOOD_FURY_QUANTITY)); + CHARGE_MAP.put(ItemID.BLOOD_AMULET, ItemChargeDef.configCount("amulet_of_blood_fury", VarbitID.CHARGES_BLOOD_FURY_QUANTITY, null, false)); CHARGE_MAP.put(ItemID.GIANTSOUL_AMULET_CHARGED, ItemChargeDef.varbit(VarbitID.CHARGES_GIANTSOUL_AMULET_QUANTITY)); CHARGE_MAP.put(ItemID.GIANTSOUL_AMULET_UNCHARGED, ItemChargeDef.uncharged()); CHARGE_MAP.put(ItemID.EYE_OF_AYAK, ItemChargeDef.varbit(VarbitID.CHARGES_EYE_OF_AYAK_QUANTITY)); @@ -700,9 +706,9 @@ public class ItemCharges CHARGE_MAP.put(ItemID.HORN_OF_PLENTY_UNCHARGED, ItemChargeDef.uncharged()); CHARGE_MAP.put(ItemID.SAILORS_AMULET, ItemChargeDef.varbit(VarbitID.CHARGES_SAILORS_AMULET_QUANTITY)); CHARGE_MAP.put(ItemID.SAILORS_AMULET_EMPTY, ItemChargeDef.uncharged()); - CHARGE_MAP.put(ItemID.COWBELL_AMULET, ItemChargeDef.varbit(VarbitID.CHARGES_COWBELL_AMULET_QUANTITY)); + CHARGE_MAP.put(ItemID.COWBELL_AMULET, ItemChargeDef.configCount("cowbell_amulet", null, false)); CHARGE_MAP.put(ItemID.COWBELL_AMULET_EMPTY, ItemChargeDef.uncharged()); - CHARGE_MAP.put(ItemID.PENDANT_OF_ATES, ItemChargeDef.varbit(VarbitID.CHARGES_PENDANT_OF_ATES_QUANTITY)); + CHARGE_MAP.put(ItemID.PENDANT_OF_ATES, ItemChargeDef.configCount("pendant_of_ates", VarbitID.CHARGES_PENDANT_OF_ATES_QUANTITY, null, false)); CHARGE_MAP.put(ItemID.PENDANT_OF_ATES_EMPTY, ItemChargeDef.uncharged()); CHARGE_MAP.put(ItemID.ALCHEMIST_RING, ItemChargeDef.varbit(VarbitID.CHARGES_ALCHEMISTS_AMULET_QUANTITY)); CHARGE_MAP.put(ItemID.AMULET_OF_CHEMISTRY_IMBUED_CHARGED, ItemChargeDef.varbit(VarbitID.CHARGES_ALCHEMISTS_AMULET_QUANTITY)); @@ -736,9 +742,11 @@ public class ItemCharges CHARGE_MAP.put(ItemID.CRYSTAL_HARPOON, ItemChargeDef.varbit(VarbitID.CHARGES_CRYSTAL_TOOLS_QUANTITY)); CHARGE_MAP.put(ItemID.CRYSTAL_HARPOON_INACTIVE, ItemChargeDef.uncharged()); - CHARGE_MAP.put(ItemID.CRYSTAL_BOW, ItemChargeDef.varbit(VarbitID.CHARGES_CRYSTAL_WEAK_QUANTITY)); + CHARGE_MAP.put(ItemID.CRYSTAL_BOW, ItemChargeDef.configCount("crystal_bow", VarbitID.CHARGES_CRYSTAL_WEAK_QUANTITY, null, false)); + CHARGE_MAP.put(24123, ItemChargeDef.configCount("crystal_bow", VarbitID.CHARGES_CRYSTAL_WEAK_QUANTITY, null, false)); CHARGE_MAP.put(ItemID.CRYSTAL_BOW_INACTIVE, ItemChargeDef.uncharged()); - CHARGE_MAP.put(ItemID.CRYSTAL_HALBERD, ItemChargeDef.varbit(VarbitID.CHARGES_CRYSTAL_WEAK_QUANTITY)); + CHARGE_MAP.put(ItemID.CRYSTAL_HALBERD, ItemChargeDef.configCount("crystal_halberd", VarbitID.CHARGES_CRYSTAL_WEAK_QUANTITY, null, false)); + CHARGE_MAP.put(24125, ItemChargeDef.configCount("crystal_halberd", VarbitID.CHARGES_CRYSTAL_WEAK_QUANTITY, null, false)); CHARGE_MAP.put(ItemID.CRYSTAL_HALBERD_INACTIVE, ItemChargeDef.uncharged()); CHARGE_MAP.put(ItemID.CRYSTAL_SHIELD, ItemChargeDef.varbit(VarbitID.CHARGES_CRYSTAL_WEAK_QUANTITY)); CHARGE_MAP.put(ItemID.CRYSTAL_SHIELD_INACTIVE, ItemChargeDef.uncharged()); @@ -1013,6 +1021,21 @@ public class ItemCharges "You add (?:[0-9,]+|a single) charges? to your book\\. It now has ([0-9,]+|one) charges?\\.", Pattern.CASE_INSENSITIVE ); + private static final Pattern COWBELL_CHARGES_PATTERN = Pattern.compile( + "(?:(?[0-9,]+|one|a single)\\s+(?:wind\\s+)?charges?|(?:wind\\s+)?charges?(?:\\s+(?:left|remaining))?\\s*:?\\s*(?[0-9,]+|one|a single))\\b", + Pattern.CASE_INSENSITIVE + ); + private static final Pattern ATES_CHARGES_PATTERN = Pattern.compile( + "(?:(?:your|the)\\s+)?pendant(?:\\s+of\\s+ates)?\\s+(?:(?:now|currently)\\s+)?(?:has|contains)\\s+([0-9,]+|one|a single)\\s+(?:charges?|frozen\\s+tears?)" + + "|(?:it|the\\s+pendant)\\s+now\\s+has\\s+([0-9,]+|one|a single)\\s+charges?" + + "|([0-9,]+|one|a single)\\s+charges?\\s+(?:remaining|left)" + + "|charges?(?:\\s+(?:remaining|left))?\\s*:?\\s*([0-9,]+|one|a single)", + Pattern.CASE_INSENSITIVE + ); + private static final Pattern ATES_ADD_PATTERN = Pattern.compile( + "(?:add|charge)\\s+(?:(?:your|the)\\s+pendant(?:\\s+of\\s+ates)?\\s+with\\s+)?([0-9,]+|one|a single)\\s+frozen\\s+tears?(?:\\s+to\\s+(?:your|the)\\s+pendant(?:\\s+of\\s+ates)?)?", + Pattern.CASE_INSENSITIVE + ); // 7. Buckets, Cans, Diary & Containers private static final Pattern COMPOST_BUCKET_HOLDING_PATTERN = Pattern.compile( @@ -1165,6 +1188,232 @@ public class ItemCharges Pattern.CASE_INSENSITIVE ); + // Tridents + private static final Pattern TRIDENT_CHECK_PATTERN = Pattern.compile( + "^Your Trident of the (?seas|swamp)(?: \\(e\\))? has (?[\\d,]+|one|no) charges?\\.?$", + Pattern.CASE_INSENSITIVE + ); + private static final Pattern TRIDENT_NEW_TOTAL_PATTERN = Pattern.compile( + "(?:You add .+ to the weapon\\.\\s*)?New total:\\s*(?[\\d,]+)\\.?", + Pattern.CASE_INSENSITIVE + ); + private static final Pattern TRIDENT_WARNING_PATTERN = Pattern.compile( + "^Your trident only has (?[\\d,]+) charges left!?$", + Pattern.CASE_INSENSITIVE + ); + private static final Pattern TRIDENT_EMPTY_PATTERN = Pattern.compile( + "^Your trident has run out of charges\\.?$", + Pattern.CASE_INSENSITIVE + ); + private static final Pattern TRIDENT_UNCHARGE_PATTERN = Pattern.compile( + "^You uncharge your weapon\\.?$", + Pattern.CASE_INSENSITIVE + ); + + // Sanguinesti staff + private static final Pattern SANG_CHECK_PATTERN = Pattern.compile( + "^Your (?:Holy s|S)anguinesti staff has (?[\\d,]+|1) charges remaining\\.?$", + Pattern.CASE_INSENSITIVE + ); + private static final Pattern SANG_FULL_PATTERN = Pattern.compile( + "^Your (?:Holy s|S)anguinesti staff is already fully charged\\.?$", + Pattern.CASE_INSENSITIVE + ); + private static final Pattern SANG_LOW_PATTERN = Pattern.compile( + "^Your (?:Holy s|S)anguinesti staff only has (?[\\d,]+) charges left!?$", + Pattern.CASE_INSENSITIVE + ); + private static final Pattern SANG_EMPTY_PATTERN = Pattern.compile( + "^Your (?:[hH]oly s|[sS])anguinesti staff (?:has run out of charges|has no charges).*$", + Pattern.CASE_INSENSITIVE + ); + private static final Pattern SANG_APPLY_PATTERN = Pattern.compile( + "^You apply (?:an additional )?(?[\\d,]+) charges to your (?:Holy s|S)anguinesti staff\\.(?:\\s*It now has (?[\\d,]+) charges in total\\.)?", + Pattern.CASE_INSENSITIVE + ); + + // Tumeken's shadow + private static final Pattern SHADOW_CHECK_PATTERN = Pattern.compile( + "^Tumeken's [sS]hadow has (?[\\d,]+) charges remaining\\.?$", + Pattern.CASE_INSENSITIVE + ); + private static final Pattern SHADOW_FULL_PATTERN = Pattern.compile( + "^Your Tumeken's shadow is already fully charged\\.?$", + Pattern.CASE_INSENSITIVE + ); + private static final Pattern SHADOW_LOW_PATTERN = Pattern.compile( + "^Tumeken's [sS]hadow only has (?[\\d,]+) charges left!?$", + Pattern.CASE_INSENSITIVE + ); + private static final Pattern SHADOW_EMPTY_PATTERN = Pattern.compile( + "^Tumeken's [sS]hadow (?:has run out of charges|has no charges).*$", + Pattern.CASE_INSENSITIVE + ); + private static final Pattern SHADOW_APPLY_PATTERN = Pattern.compile( + "^You apply (?:an additional )?(?[\\d,]+) charges to your Tumeken's shadow\\.(?:\\s*It now has (?[\\d,]+) charges in total\\.)?", + Pattern.CASE_INSENSITIVE + ); + + // Warped sceptre + private static final Pattern WARPED_CHECK_PATTERN = Pattern.compile( + "^Your warped sceptre has (?[\\d,]+|1) charges? remaining\\.?$", + Pattern.CASE_INSENSITIVE + ); + private static final Pattern WARPED_EMPTY_PATTERN = Pattern.compile( + "^Your warped sceptre has run out of charges!?$", + Pattern.CASE_INSENSITIVE + ); + private static final Pattern WARPED_APPLY_PATTERN = Pattern.compile( + "^You add (?:an additional )?(?[\\d,]+) charges? to your warped sceptre\\.(?:\\s*It now has (?[\\d,]+) charges in total\\.)?", + Pattern.CASE_INSENSITIVE + ); + + // Serpentine helm + private static final Pattern SERP_CHECK_PATTERN = Pattern.compile( + "^Scales:\\s*(?[\\d,]+)\\s*\\(\\d+(?:\\.\\d+)?%\\)$", + Pattern.CASE_INSENSITIVE + ); + private static final Pattern SERP_EMPTY_PATTERN = Pattern.compile( + "^Your (?:serpentine|tanzanite|magma) helm has run out of Zulrah's scales\\.?$", + Pattern.CASE_INSENSITIVE + ); + + // Venator bow + private static final Pattern VENATOR_CHECK_PATTERN = Pattern.compile( + "^Your [vV]enator bow has (?[\\d,]+) charges remaining\\.?$", + Pattern.CASE_INSENSITIVE + ); + private static final Pattern VENATOR_CHARGE_PATTERN = Pattern.compile( + "^You use [\\d,]+ ancient essence to charge your venator bow\\. It now has (?[\\d,]+) charges\\.?$", + Pattern.CASE_INSENSITIVE + ); + + // Tomes + private static final Pattern TOME_PAGES_PATTERN = Pattern.compile( + "^Your tome has been charged with (?:Burnt|Searing|Water|Earth) Pages\\. It currently holds (?[\\d,]+|one) charges?\\.?$", + Pattern.CASE_INSENSITIVE + ); + private static final Pattern TOME_REMOVE_PATTERN = Pattern.compile( + "^You remove [\\S]+ pages? from the book\\. Your tome currently holds (?[\\d,]+|one) charges?\\.?$", + Pattern.CASE_INSENSITIVE + ); + private static final Pattern TOME_HOLDS_PATTERN = Pattern.compile( + "^Your tome currently holds (?[\\d,]+|one) charges?\\.?$", + Pattern.CASE_INSENSITIVE + ); + private static final Pattern TOME_EMPTY_PATTERN = Pattern.compile( + "^Your Tome of (Fire|Water|Earth) is now empty\\.?$", + Pattern.CASE_INSENSITIVE + ); + private static final Pattern TOME_EMPTY_ALL_PATTERN = Pattern.compile( + "^You empty your book of pages\\.?$", + Pattern.CASE_INSENSITIVE + ); + + // Blood fury + private static final Pattern BLOOD_FURY_CHECK_PATTERN = Pattern.compile( + "^Your Amulet of blood fury (?:will work|can perform) for (?[\\d,]+) more hits\\.?$", + Pattern.CASE_INSENSITIVE + ); + private static final Pattern BLOOD_FURY_CREATE_PATTERN = Pattern.compile( + "^You have successfully created an Amulet of blood fury\\.?$", + Pattern.CASE_INSENSITIVE + ); + + // Ether weapons + private static final Pattern ETHER_CHECK_PATTERN = Pattern.compile( + "^Your (?:weapon|bow|chainmace|sceptre) has (?[\\d,]+) charges?(?: left powering it)?\\.?$", + Pattern.CASE_INSENSITIVE + ); + private static final Pattern ETHER_ADD_PATTERN = Pattern.compile( + "^You add (?:a further )?[\\d,]+ revenant ether to your weapon, giving it a total of (?[\\d,]+) charges?\\.?$", + Pattern.CASE_INSENSITIVE + ); + private static final Pattern ETHER_LOW_PATTERN = Pattern.compile( + "^Your weapon only has (?[\\d,]+) charges left\\.?$", + Pattern.CASE_INSENSITIVE + ); + private static final Pattern ETHER_EMPTY_PATTERN = Pattern.compile( + "^(?:Your weapon has run out of revenant ether|There is not enough revenant ether left powering your bow)\\.?$", + Pattern.CASE_INSENSITIVE + ); + + // Ring of suffering + private static final Pattern SUFFERING_CHECK_PATTERN = Pattern.compile( + "^Your Ring of suffering(?: \\([ir]+\\))? has (?[\\d,]+|1|no) charges?(?: left| remaining)?\\.?$", + Pattern.CASE_INSENSITIVE + ); + private static final Pattern SUFFERING_LOAD_PATTERN = Pattern.compile( + "^You load (?:a|[\\d,]+) rings? of recoil into your Ring of suffering\\. It now has (?[\\d,]+) charges\\.?$", + Pattern.CASE_INSENSITIVE + ); + private static final Pattern SUFFERING_FULL_PATTERN = Pattern.compile( + "^Your Ring of suffering is already fully charged\\.?$", + Pattern.CASE_INSENSITIVE + ); + private static final Pattern SUFFERING_UNCHARGE_PATTERN = Pattern.compile( + "^You uncharge your Ring of suffering.*$", + Pattern.CASE_INSENSITIVE + ); + + // Bonecrusher & Ash sanctifier + private static final Pattern BONECRUSHER_CHECK_PATTERN = Pattern.compile( + "^Your bonecrusher(?: necklace)? has (?[\\d,]+) charges left\\.?$", + Pattern.CASE_INSENSITIVE + ); + private static final Pattern BONECRUSHER_CHARGE_PATTERN = Pattern.compile( + "^You charge your bonecrusher with [\\d,]+ ecto-tokens\\. It now has (?[\\d,]+) charges\\.?$", + Pattern.CASE_INSENSITIVE + ); + private static final Pattern BONECRUSHER_EMPTY_PATTERN = Pattern.compile( + "^Your bonecrusher has run out of charges\\.?$", + Pattern.CASE_INSENSITIVE + ); + private static final Pattern SANCTIFIER_CHECK_PATTERN = Pattern.compile( + "^Your ash sanctifier has (?[\\d,]+) charges left\\.?$", + Pattern.CASE_INSENSITIVE + ); + private static final Pattern SANCTIFIER_CHARGE_PATTERN = Pattern.compile( + "^You charge your ash sanctifier with [\\d,]+ death runes\\. It now has (?[\\d,]+) charges\\.?$", + Pattern.CASE_INSENSITIVE + ); + private static final Pattern SANCTIFIER_EMPTY_PATTERN = Pattern.compile( + "^Your ash sanctifier has run out of charges\\.?$", + Pattern.CASE_INSENSITIVE + ); + + // Arclight, Crystal Halberd, Crystal Bow + private static final Pattern ARCLIGHT_CHECK_PATTERN = Pattern.compile( + "^Your arclight has (?[\\d,]+) charges left\\.?$", + Pattern.CASE_INSENSITIVE + ); + private static final Pattern CHALLY_CHECK_PATTERN = Pattern.compile( + "^Your crystal halberd has (?[\\d,]+) charges remaining\\.?$", + Pattern.CASE_INSENSITIVE + ); + private static final Pattern CBOW_CHECK_PATTERN = Pattern.compile( + "^Your crystal bow has (?[\\d,]+) charges remaining\\.?$", + Pattern.CASE_INSENSITIVE + ); + + private static final String[] TRACKED_COUNT_KEYS = { + "coal_bag", "gem_bag", "herb_sack", "fish_barrel", "plank_sack", "seed_box", + "abyssal_tentacle", "scythe_of_vitur", "ibans_staff", "slayers_staff_e", "infernal_tools", "skull_sceptre", + "crystal_saw", "jar_generator", "blood_essence", "dodgy_necklace", "ring_of_forging", "ring_of_recoil", + "binding_necklace", "amulet_of_chemistry", "amulet_of_bounty", "cowbell_amulet", "bracelet_of_slaughter", "expeditious_bracelet", + "bracelet_of_clay", "flamtaer_bracelet", "inoculation_bracelet", "ring_of_endurance", "strange_old_lockpick", "chronicle", + "dragonfire_shield", "dragonfire_ward", "ancient_wyvern_shield", "bottomless_compost_bucket", "bottomless_milk_bucket", + "gricollers_can", "colossal_pouch", "essence_pouch_medium", "essence_pouch_large", "essence_pouch_giant", + "meat_pouch", "fur_pouch", "flamtaer_bag", "log_basket", "quetzal_whistle", "chugging_barrel", + "falador_shield", "kharedsts_memoirs", "ardougne_cloak", "explorers_ring", "fremennik_sea_boots", + "kandarin_headgear", "morytania_legs", "western_banner", "magic_cape", "pendant_of_ates", + "trident_of_the_swamp", "trident_of_the_swamp_e", "trident_of_the_seas", "trident_of_the_seas_e", + "sanguinesti_staff", "tumekens_shadow", "warped_sceptre", "serpentine_helm", "venator_bow", + "tome_of_fire", "tome_of_water", "tome_of_earth", "amulet_of_blood_fury", + "craws_bow", "webweaver_bow", "viggoras_chainmace", "ursine_chainmace", "thammarons_sceptre", "accursed_sceptre", + "ring_of_suffering", "bonecrusher", "ash_sanctifier", "arclight", "crystal_halberd", "crystal_bow" + }; + private final Client client; private final EssentialsPlusConfig config; private final EssentialsPlusPlugin plugin; @@ -1189,10 +1438,18 @@ public class ItemCharges private int pendingCoalFillTick = 0; private boolean pendingCoalEmpty = false; private int pendingCoalEmptyTick = 0; + private boolean pendingCowbellResult = false; + private int pendingCowbellResultUntilTick = -1; + private boolean pendingAtesResult = false; + private int pendingAtesResultUntilTick = -1; private long cachedLootingBagCoins = 0; private boolean lootingBagKnown = false; private String lastAccountKey = null; + private int lastCheckedItemId = -1; + private int lastCheckedItemTick = -1; + private int serpCombatTicks = 0; + private boolean isSmithingOrMaxCape(int itemId) { if (itemId <= 0 || itemManager == null) @@ -1243,6 +1500,68 @@ public class ItemCharges this.configManager = configManager; } + public boolean isEnabled() + { + boolean main = false; + if (plugin != null) + { + try + { + main = plugin.isItemInformationEnabled(); + } + catch (Exception ignored) + { + } + } + if (!main && config != null) + { + try + { + if (Boolean.TRUE.equals(config.itemInformation())) + { + main = true; + } + } + catch (Exception ignored) + { + } + if (!main) + { + try + { + if (Boolean.TRUE.equals(config.itemCharges())) + { + main = true; + } + } + catch (Exception ignored) + { + } + } + } + if (!main) + { + return false; + } + if (plugin != null) + { + return plugin.getHelperValue("itemInformationCharges", Boolean.class, + plugin.getHelperValue("itemCharges", Boolean.class, true)); + } + if (config != null) + { + try + { + return Boolean.TRUE.equals(config.itemInformationCharges()); + } + catch (Exception ignored) + { + return true; + } + } + return true; + } + public void reset() { saveAccountData(); @@ -1258,7 +1577,14 @@ public class ItemCharges lastCoalCount = -1; cachedLootingBagCoins = 0; lootingBagKnown = false; + pendingCowbellResult = false; + pendingCowbellResultUntilTick = -1; + pendingAtesResult = false; + pendingAtesResultUntilTick = -1; lastAccountKey = null; + lastCheckedItemId = -1; + lastCheckedItemTick = -1; + serpCombatTicks = 0; } public void resetCoalBag() @@ -1282,6 +1608,9 @@ public class ItemCharges cachedCounts.clear(); barrowsCombatTicks.clear(); moonsCombatTicks.clear(); + lastCheckedItemId = -1; + lastCheckedItemTick = -1; + serpCombatTicks = 0; String currentKey = currentAccountKey(); if (currentKey != null && configManager != null) @@ -1300,19 +1629,7 @@ public class ItemCharges configManager.unsetConfiguration(EssentialsPlusConfig.GROUP, CONFIG_PREFIX + "moons_" + piece.getConfigKey() + "." + currentKey); } - String[] keys = { - "coal_bag", "gem_bag", "herb_sack", "fish_barrel", "plank_sack", "seed_box", - "abyssal_tentacle", "scythe_of_vitur", "ibans_staff", "slayers_staff_e", "infernal_tools", "skull_sceptre", - "crystal_saw", "jar_generator", "blood_essence", "dodgy_necklace", "ring_of_forging", "ring_of_recoil", - "binding_necklace", "amulet_of_chemistry", "amulet_of_bounty", "bracelet_of_slaughter", "expeditious_bracelet", - "bracelet_of_clay", "flamtaer_bracelet", "inoculation_bracelet", "ring_of_endurance", "strange_old_lockpick", "chronicle", - "dragonfire_shield", "dragonfire_ward", "ancient_wyvern_shield", "bottomless_compost_bucket", "bottomless_milk_bucket", - "gricollers_can", "colossal_pouch", "essence_pouch_medium", "essence_pouch_large", "essence_pouch_giant", - "meat_pouch", "fur_pouch", "flamtaer_bag", "log_basket", "quetzal_whistle", "chugging_barrel", - "falador_shield", "kharedsts_memoirs", "ardougne_cloak", "explorers_ring", "fremennik_sea_boots", - "kandarin_headgear", "morytania_legs", "western_banner", "magic_cape" - }; - for (String k : keys) + for (String k : TRACKED_COUNT_KEYS) { configManager.unsetConfiguration(EssentialsPlusConfig.GROUP, CONFIG_PREFIX + k + "." + currentKey); } @@ -1397,19 +1714,7 @@ public class ItemCharges } // 4. Counts & Bags - String[] keys = { - "coal_bag", "gem_bag", "herb_sack", "fish_barrel", "plank_sack", "seed_box", - "abyssal_tentacle", "scythe_of_vitur", "ibans_staff", "slayers_staff_e", "infernal_tools", "skull_sceptre", - "crystal_saw", "jar_generator", "blood_essence", "dodgy_necklace", "ring_of_forging", "ring_of_recoil", - "binding_necklace", "amulet_of_chemistry", "amulet_of_bounty", "bracelet_of_slaughter", "expeditious_bracelet", - "bracelet_of_clay", "flamtaer_bracelet", "inoculation_bracelet", "ring_of_endurance", "strange_old_lockpick", "chronicle", - "dragonfire_shield", "dragonfire_ward", "ancient_wyvern_shield", "bottomless_compost_bucket", "bottomless_milk_bucket", - "gricollers_can", "colossal_pouch", "essence_pouch_medium", "essence_pouch_large", "essence_pouch_giant", - "meat_pouch", "fur_pouch", "flamtaer_bag", "log_basket", "quetzal_whistle", "chugging_barrel", - "falador_shield", "kharedsts_memoirs", "ardougne_cloak", "explorers_ring", "fremennik_sea_boots", - "kandarin_headgear", "morytania_legs", "western_banner", "magic_cape" - }; - for (String k : keys) + for (String k : TRACKED_COUNT_KEYS) { String val = configManager.getConfiguration(EssentialsPlusConfig.GROUP, CONFIG_PREFIX + k + "." + currentKey); Integer parsed = parseIntOrNull(val); @@ -1543,11 +1848,75 @@ public class ItemCharges } } + private String keyForItemId(int id) + { + ItemChargeDef def = CHARGE_MAP.get(id); + return (def != null && def.configKey != null) ? def.configKey : null; + } + + private String resolveKeyForSlots(EquipmentInventorySlot[] slots, String fallback) + { + if (lastCheckedItemId > 0 && client != null && (client.getTickCount() - lastCheckedItemTick <= 10)) + { + String k = keyForItemId(lastCheckedItemId); + if (k != null) + { + return k; + } + } + ItemContainer worn = client != null ? client.getItemContainer(InventoryID.WORN) : null; + if (worn != null && slots != null) + { + for (EquipmentInventorySlot slot : slots) + { + Item item = worn.getItem(slot.getSlotIdx()); + if (item != null) + { + String k = keyForItemId(item.getId()); + if (k != null) + { + return k; + } + } + } + } + return fallback; + } + @Subscribe public void onGameTick(GameTick event) { loadAccountDataIfNecessary(); updateLootingBag(); + if (lastCheckedItemTick != -1 && client != null && (client.getTickCount() - lastCheckedItemTick > 10)) + { + lastCheckedItemId = -1; + lastCheckedItemTick = -1; + } + if (pendingCowbellResult && client.getTickCount() > pendingCowbellResultUntilTick) + { + pendingCowbellResult = false; + pendingCowbellResultUntilTick = -1; + } + if (pendingAtesResult && client.getTickCount() > pendingAtesResultUntilTick) + { + pendingAtesResult = false; + pendingAtesResultUntilTick = -1; + } + + if (client != null) + { + int atesVarbit = client.getVarbitValue(VarbitID.CHARGES_PENDANT_OF_ATES_QUANTITY); + if (atesVarbit > 0) + { + Integer current = cachedCounts.get("pendant_of_ates"); + if (current == null || current != atesVarbit) + { + cachedCounts.put("pendant_of_ates", atesVarbit); + saveAccountData(); + } + } + } // Combat tick degradation for Barrows (~900 ticks = 1%) and Moons (~90 ticks = 1 charge) Player me = client.getLocalPlayer(); @@ -1556,6 +1925,20 @@ public class ItemCharges ItemContainer worn = client.getItemContainer(InventoryID.WORN); if (worn != null) { + // Serpentine helm degradation: 10 scales per 90 ticks in combat + Item helm = worn.getItem(EquipmentInventorySlot.HEAD.getSlotIdx()); + if (helm != null && (helm.getId() == ItemID.SERPENTINE_HELM_CHARGED + || helm.getId() == ItemID.SERPENTINE_HELM_CHARGED_CYAN + || helm.getId() == ItemID.SERPENTINE_HELM_CHARGED_RED)) + { + serpCombatTicks++; + if (serpCombatTicks >= 90) + { + serpCombatTicks = 0; + decrementCount("serpentine_helm", 10); + } + } + for (EquipmentInventorySlot slot : new EquipmentInventorySlot[]{ EquipmentInventorySlot.HEAD, EquipmentInventorySlot.BODY, EquipmentInventorySlot.LEGS, EquipmentInventorySlot.WEAPON}) @@ -1606,7 +1989,7 @@ public class ItemCharges @Subscribe public void onAnimationChanged(AnimationChanged event) { - if (!config.itemCharges()) + if (!isEnabled()) { return; } @@ -1630,64 +2013,188 @@ public class ItemCharges } Item weapon = worn.getItem(EquipmentInventorySlot.WEAPON.getSlotIdx()); - if (weapon == null) + if (weapon != null) { - return; - } + int weaponId = weapon.getId(); - int weaponId = weapon.getId(); - - // 1. Abyssal Tentacle (whip slash anim 1658) - if ((weaponId == ItemID.ABYSSAL_TENTACLE || weaponId == ItemID.LEAGUE_3_WHIP_TENTACLE) && anim == 1658) - { - decrementCount("abyssal_tentacle", 1); - } - // 2. Scythe of Vitur (scythe slash anim 8056, 1203) - else if ((weaponId == ItemID.SCYTHE_OF_VITUR || weaponId == ItemID.SCYTHE_OF_VITUR_OR - || weaponId == ItemID.SCYTHE_OF_VITUR_BL || weaponId == ItemID.DEADMAN_BLIGHTED_SCYTHE_OF_VITUR) - && (anim == 8056 || anim == 1203)) - { - decrementCount("scythe_of_vitur", 1); - } - // 3. Iban's Staff (cast anim 708, 710, 711) - else if ((weaponId == ItemID.IBANSTAFF || weaponId == ItemID.IBANSTAFF_UPGRADED - || weaponId == ItemID.IBAN_STAFF_ORN || weaponId == ItemID.IBAN_STAFF_UPGRADED_ORN) - && (anim == 708 || anim == 710 || anim == 711)) - { - decrementCount("ibans_staff", 1); - } - // 4. Toxic Blowpipe (attack anim 5061, 10023) - else if (CHARGE_MAP.containsKey(weaponId) && CHARGE_MAP.get(weaponId).type == SourceType.BLOWPIPE - && (anim == 5061 || anim == 10023)) - { - loadAccountDataIfNecessary(); - if (cachedBlowpipeDarts != null && cachedBlowpipeDarts > 0) + // 1. Abyssal Tentacle (whip slash anim 1658) + if ((weaponId == ItemID.ABYSSAL_TENTACLE || weaponId == ItemID.LEAGUE_3_WHIP_TENTACLE) && anim == 1658) { - Item cape = worn.getItem(EquipmentInventorySlot.CAPE.getSlotIdx()); - int capeId = cape != null ? cape.getId() : -1; - boolean hasAssembler = false; - boolean hasAccumulator = false; - if (capeId > 0 && itemManager != null) + decrementCount("abyssal_tentacle", 1); + } + // 2. Scythe of Vitur (scythe slash anim 8056, 1203) + else if ((weaponId == ItemID.SCYTHE_OF_VITUR || weaponId == ItemID.SCYTHE_OF_VITUR_OR + || weaponId == ItemID.SCYTHE_OF_VITUR_BL || weaponId == ItemID.DEADMAN_BLIGHTED_SCYTHE_OF_VITUR) + && (anim == 8056 || anim == 1203)) + { + decrementCount("scythe_of_vitur", 1); + } + // 3. Iban's Staff (cast anim 708, 710, 711) + else if ((weaponId == ItemID.IBANSTAFF || weaponId == ItemID.IBANSTAFF_UPGRADED + || weaponId == ItemID.IBAN_STAFF_ORN || weaponId == ItemID.IBAN_STAFF_UPGRADED_ORN) + && (anim == 708 || anim == 710 || anim == 711)) + { + decrementCount("ibans_staff", 1); + } + // 4. Toxic Blowpipe (attack anim 5061, 10023) + else if (CHARGE_MAP.containsKey(weaponId) && CHARGE_MAP.get(weaponId).type == SourceType.BLOWPIPE + && (anim == 5061 || anim == 10023)) + { + loadAccountDataIfNecessary(); + if (cachedBlowpipeDarts != null && cachedBlowpipeDarts > 0) { - String name = itemManager.getItemComposition(capeId).getName().toLowerCase(); - hasAssembler = name.contains("assembler") || name.contains("dizana") || name.contains("quiver"); - hasAccumulator = name.contains("accumulator"); - } + Item cape = worn.getItem(EquipmentInventorySlot.CAPE.getSlotIdx()); + int capeId = cape != null ? cape.getId() : -1; + boolean hasAssembler = false; + boolean hasAccumulator = false; + if (capeId > 0 && itemManager != null) + { + String name = itemManager.getItemComposition(capeId).getName().toLowerCase(); + hasAssembler = name.contains("assembler") || name.contains("dizana") || name.contains("quiver"); + hasAccumulator = name.contains("accumulator"); + } - double consumeChance = hasAssembler ? 0.20 : (hasAccumulator ? 0.28 : 1.0); - if (java.util.concurrent.ThreadLocalRandom.current().nextDouble() < consumeChance) - { - cachedBlowpipeDarts = Math.max(0, cachedBlowpipeDarts - 1); - saveAccountData(); + double consumeChance = hasAssembler ? 0.20 : (hasAccumulator ? 0.28 : 1.0); + if (java.util.concurrent.ThreadLocalRandom.current().nextDouble() < consumeChance) + { + cachedBlowpipeDarts = Math.max(0, cachedBlowpipeDarts - 1); + saveAccountData(); + } } } + // 5. Tridents & Sanguinesti & Thammaron's & Accursed (anim 11430) + else if (anim == 11430) + { + if (weaponId == ItemID.TOXIC_TOTS_CHARGED || weaponId == ItemID.TOXIC_TOTS_CHARGED_ORN) + { + decrementCount("trident_of_the_swamp", 1); + } + else if (weaponId == ItemID.TOXIC_TOTS_I_CHARGED || weaponId == ItemID.TOXIC_TOTS_I_CHARGED_ORN) + { + decrementCount("trident_of_the_swamp_e", 1); + } + else if (weaponId == ItemID.TOTS || weaponId == ItemID.TOTS_CHARGED || weaponId == ItemID.TOTS_ORN || weaponId == ItemID.TOTS_CHARGED_ORN) + { + decrementCount("trident_of_the_seas", 1); + } + else if (weaponId == ItemID.TOTS_I_CHARGED || weaponId == ItemID.TOTS_I_CHARGED_ORN) + { + decrementCount("trident_of_the_seas_e", 1); + } + else if (weaponId == ItemID.SANGUINESTI_STAFF || weaponId == ItemID.SANGUINESTI_STAFF_OR) + { + decrementCount("sanguinesti_staff", 1); + } + else if (weaponId == ItemID.WILD_CAVE_SCEPTRE_CHARGED || weaponId == ItemID.WILD_CAVE_SCEPTRE_CHARGED_RECOL || weaponId == 27788) + { + decrementCount("thammarons_sceptre", 1); + } + else if (weaponId == ItemID.WILD_CAVE_ACCURSED_CHARGED || weaponId == ItemID.WILD_CAVE_ACCURSED_CHARGED_RECOL) + { + decrementCount("accursed_sceptre", 1); + } + } + // 6. Tumeken's Shadow (anim 9493) + else if (anim == 9493) + { + if (weaponId == ItemID.TUMEKENS_SHADOW || weaponId == ItemID.DEADMAN_BLIGHTED_TUMEKENS_SHADOW || weaponId == ItemID.TUMEKENS_HEKA) + { + decrementCount("tumekens_shadow", 1); + } + else if (weaponId == ItemID.WILD_CAVE_ACCURSED_CHARGED || weaponId == ItemID.WILD_CAVE_ACCURSED_CHARGED_RECOL) + { + decrementCount("accursed_sceptre", 1); + } + } + // 7. Warped Sceptre (anim 10501) + else if (weaponId == ItemID.WARPED_SCEPTRE && anim == 10501) + { + decrementCount("warped_sceptre", 1); + } + // 8. Venator Bow (anim 9858) + else if ((weaponId == ItemID.VENATOR_BOW || weaponId == ItemID.VENATOR_BOW_ORNAMENT) && anim == 9858) + { + decrementCount("venator_bow", 1); + } + // 9. Craw's Bow & Webweaver & Crystal Bow (anim 426, 9964) + else if (anim == 426 || anim == 9964) + { + if (weaponId == ItemID.WILD_CAVE_BOW_CHARGED) + { + decrementCount("craws_bow", 1); + } + else if (weaponId == ItemID.WILD_CAVE_WEBWEAVER_CHARGED) + { + decrementCount("webweaver_bow", 1); + } + else if (weaponId == ItemID.CRYSTAL_BOW || weaponId == 24123) + { + decrementCount("crystal_bow", 1); + } + } + // 10. Viggora's & Ursine chainmaces (anim 395, 245, 9963) + else if (anim == 395 || anim == 245 || anim == 9963) + { + if (weaponId == ItemID.WILD_CAVE_CHAINMACE_CHARGED) + { + decrementCount("viggoras_chainmace", 1); + } + else if (weaponId == ItemID.WILD_CAVE_URSINE_CHARGED) + { + decrementCount("ursine_chainmace", 1); + } + } + // 11. Arclight (anim 386, 390) + else if (weaponId == ItemID.ARCLIGHT && (anim == 386 || anim == 390)) + { + decrementCount("arclight", 1); + } + // 12. Crystal Halberd (anim 428, 440) + else if ((weaponId == ItemID.CRYSTAL_HALBERD || weaponId == 24125) && (anim == 428 || anim == 440)) + { + decrementCount("crystal_halberd", 1); + } + } + + // Tomes in shield slot (spell casting anims 711, 1162, 1167, 7855, 811) + if (anim == 711 || anim == 1162 || anim == 1167 || anim == 7855 || anim == 811) + { + Item shield = worn.getItem(EquipmentInventorySlot.SHIELD.getSlotIdx()); + if (shield != null) + { + int shieldId = shield.getId(); + if (shieldId == ItemID.TOME_OF_FIRE || shieldId == ItemID.BR_TOME_OF_FIRE) + { + decrementCount("tome_of_fire", 1); + } + else if (shieldId == ItemID.TOME_OF_WATER) + { + decrementCount("tome_of_water", 1); + } + else if (shieldId == ItemID.TOME_OF_EARTH) + { + decrementCount("tome_of_earth", 1); + } + } + } + + // Amulet of Blood Fury in amulet slot (melee attack anims) + Item amulet = worn.getItem(EquipmentInventorySlot.AMULET.getSlotIdx()); + if (amulet != null && amulet.getId() == ItemID.BLOOD_AMULET) + { + if (anim == 1658 || anim == 8056 || anim == 1203 || anim == 386 || anim == 390 + || anim == 395 || anim == 401 || anim == 422 || anim == 423 || anim == 428 + || anim == 440 || anim == 245 || anim == 9963) + { + decrementCount("amulet_of_blood_fury", 1); + } } } @Subscribe public void onMenuOptionClicked(MenuOptionClicked event) { - if (!config.itemCharges()) + if (!isEnabled()) { return; } @@ -1699,6 +2206,107 @@ public class ItemCharges return; } + String opt = option.toLowerCase(); + if (itemId > 0 && (opt.equals("check") || opt.equals("charge") || opt.equals("pages") || opt.equals("uncharge") || opt.equals("inspect") || opt.equals("empty") || opt.equals("rub"))) + { + lastCheckedItemId = itemId; + lastCheckedItemTick = client.getTickCount(); + } + + if (opt.equals("uncharge")) + { + if (itemId == ItemID.TOXIC_TOTS_CHARGED || itemId == ItemID.TOXIC_TOTS_CHARGED_ORN) + { + cachedCounts.put("trident_of_the_swamp", 0); + saveAccountData(); + } + else if (itemId == ItemID.TOXIC_TOTS_I_CHARGED || itemId == ItemID.TOXIC_TOTS_I_CHARGED_ORN) + { + cachedCounts.put("trident_of_the_swamp_e", 0); + saveAccountData(); + } + else if (itemId == ItemID.TOTS || itemId == ItemID.TOTS_CHARGED || itemId == ItemID.TOTS_ORN || itemId == ItemID.TOTS_CHARGED_ORN) + { + cachedCounts.put("trident_of_the_seas", 0); + saveAccountData(); + } + else if (itemId == ItemID.TOTS_I_CHARGED || itemId == ItemID.TOTS_I_CHARGED_ORN) + { + cachedCounts.put("trident_of_the_seas_e", 0); + saveAccountData(); + } + else if (itemId == ItemID.SANGUINESTI_STAFF || itemId == ItemID.SANGUINESTI_STAFF_OR) + { + cachedCounts.put("sanguinesti_staff", 0); + saveAccountData(); + } + else if (itemId == ItemID.TUMEKENS_SHADOW || itemId == ItemID.DEADMAN_BLIGHTED_TUMEKENS_SHADOW || itemId == ItemID.TUMEKENS_HEKA) + { + cachedCounts.put("tumekens_shadow", 0); + saveAccountData(); + } + else if (itemId == ItemID.WARPED_SCEPTRE) + { + cachedCounts.put("warped_sceptre", 0); + saveAccountData(); + } + else if (itemId == ItemID.SERPENTINE_HELM_CHARGED || itemId == ItemID.SERPENTINE_HELM_CHARGED_CYAN || itemId == ItemID.SERPENTINE_HELM_CHARGED_RED) + { + cachedCounts.put("serpentine_helm", 0); + saveAccountData(); + } + } + + if (itemId == ItemID.COWBELL_AMULET || itemId == ItemID.COWBELL_AMULET_EMPTY) + { + if (option.equalsIgnoreCase("Teleport")) + { + decrementCount("cowbell_amulet", 1); + } + else if (option.equalsIgnoreCase("Check") || option.equalsIgnoreCase("Charge")) + { + pendingCowbellResult = true; + pendingCowbellResultUntilTick = client.getTickCount() + 5; + } + } + + String target = event.getMenuTarget() != null ? Text.removeTags(event.getMenuTarget()).toLowerCase() : ""; + boolean isAtesItem = itemId == ItemID.PENDANT_OF_ATES || itemId == ItemID.PENDANT_OF_ATES_EMPTY + || target.contains("pendant of ates"); + + if (isAtesItem) + { + if (option.equalsIgnoreCase("Check") || option.equalsIgnoreCase("Charge") || option.equalsIgnoreCase("Rub")) + { + pendingAtesResult = true; + pendingAtesResultUntilTick = client.getTickCount() + 5; + } + else if (option.equalsIgnoreCase("Uncharge") || option.equalsIgnoreCase("Dismantle")) + { + cachedCounts.put("pendant_of_ates", 0); + saveAccountData(); + } + else if (option.equalsIgnoreCase("Darkfrost") + || option.equalsIgnoreCase("The Darkfrost") + || option.equalsIgnoreCase("Twilight Temple") + || option.equalsIgnoreCase("Ralos' Rise") + || option.equalsIgnoreCase("North Aldarin") + || option.equalsIgnoreCase("Kastori") + || option.equalsIgnoreCase("Nemus Retreat") + || option.equalsIgnoreCase("Teleport")) + { + decrementCount("pendant_of_ates", 1); + } + } + + if (WidgetUtil.componentToInterface(event.getParam1()) == 879) + { + if (!option.equalsIgnoreCase("Close") && !option.equalsIgnoreCase("Cancel")) + { + decrementCount("pendant_of_ates", 1); + } + } + if (itemId == ItemID.COAL_BAG || itemId == ItemID.COAL_BAG_OPEN) { if (option.equalsIgnoreCase("Fill")) @@ -1717,7 +2325,7 @@ public class ItemCharges @Subscribe public void onItemContainerChanged(ItemContainerChanged event) { - if (!config.itemCharges()) + if (!isEnabled()) { return; } @@ -1873,6 +2481,527 @@ public class ItemCharges String message = Text.removeTags(event.getMessage()).replace('’', '\'').replace('‘', '\'').trim(); loadAccountDataIfNecessary(); + // Tridents + Matcher mTridentCheck = TRIDENT_CHECK_PATTERN.matcher(message); + if (mTridentCheck.find()) + { + String type = mTridentCheck.group("type").toLowerCase(); + boolean isE = message.toLowerCase().contains("(e)"); + String key = type.contains("swamp") + ? (isE ? "trident_of_the_swamp_e" : "trident_of_the_swamp") + : (isE ? "trident_of_the_seas_e" : "trident_of_the_seas"); + String amtStr = mTridentCheck.group("amount").toLowerCase(); + int amt = amtStr.equals("no") ? 0 : (amtStr.equals("one") ? 1 : parseWordOrInt(amtStr)); + cachedCounts.put(key, amt); + saveAccountData(); + return; + } + + Matcher mTridentTotal = TRIDENT_NEW_TOTAL_PATTERN.matcher(message); + if (mTridentTotal.find()) + { + String key = resolveKeyForSlots(new EquipmentInventorySlot[]{EquipmentInventorySlot.WEAPON}, "trident_of_the_swamp"); + cachedCounts.put(key, parseIntOrNull(mTridentTotal.group("amount"))); + saveAccountData(); + return; + } + + Matcher mTridentWarn = TRIDENT_WARNING_PATTERN.matcher(message); + if (mTridentWarn.find()) + { + String key = resolveKeyForSlots(new EquipmentInventorySlot[]{EquipmentInventorySlot.WEAPON}, "trident_of_the_swamp"); + cachedCounts.put(key, parseIntOrNull(mTridentWarn.group("amount"))); + saveAccountData(); + return; + } + + if (TRIDENT_EMPTY_PATTERN.matcher(message).find() || TRIDENT_UNCHARGE_PATTERN.matcher(message).find()) + { + String key = resolveKeyForSlots(new EquipmentInventorySlot[]{EquipmentInventorySlot.WEAPON}, "trident_of_the_swamp"); + cachedCounts.put(key, 0); + saveAccountData(); + return; + } + + if (message.equalsIgnoreCase("Your weapon is already fully charged.")) + { + String key = resolveKeyForSlots(new EquipmentInventorySlot[]{EquipmentInventorySlot.WEAPON}, "trident_of_the_swamp"); + int max = (key.endsWith("_e") || key.equals("warped_sceptre") || key.equals("sanguinesti_staff") || key.equals("tumekens_shadow")) + ? (key.endsWith("_e") ? 10000 : 20000) + : 2500; + cachedCounts.put(key, max); + saveAccountData(); + return; + } + + // Sanguinesti staff + Matcher mSang = SANG_CHECK_PATTERN.matcher(message); + if (mSang.find()) + { + cachedCounts.put("sanguinesti_staff", parseIntOrNull(mSang.group("amount"))); + saveAccountData(); + return; + } + if (SANG_FULL_PATTERN.matcher(message).find()) + { + cachedCounts.put("sanguinesti_staff", 20000); + saveAccountData(); + return; + } + Matcher mSangLow = SANG_LOW_PATTERN.matcher(message); + if (mSangLow.find()) + { + cachedCounts.put("sanguinesti_staff", parseIntOrNull(mSangLow.group("amount"))); + saveAccountData(); + return; + } + if (SANG_EMPTY_PATTERN.matcher(message).find()) + { + cachedCounts.put("sanguinesti_staff", 0); + saveAccountData(); + return; + } + Matcher mSangApply = SANG_APPLY_PATTERN.matcher(message); + if (mSangApply.find()) + { + String total = mSangApply.group("total"); + if (total != null) + { + cachedCounts.put("sanguinesti_staff", parseIntOrNull(total)); + } + else + { + int added = parseIntOrNull(mSangApply.group("added")); + int cur = cachedCounts.getOrDefault("sanguinesti_staff", 0); + cachedCounts.put("sanguinesti_staff", cur + added); + } + saveAccountData(); + return; + } + + // Tumeken's shadow + Matcher mShadow = SHADOW_CHECK_PATTERN.matcher(message); + if (mShadow.find()) + { + cachedCounts.put("tumekens_shadow", parseIntOrNull(mShadow.group("amount"))); + saveAccountData(); + return; + } + if (SHADOW_FULL_PATTERN.matcher(message).find()) + { + cachedCounts.put("tumekens_shadow", 20000); + saveAccountData(); + return; + } + Matcher mShadowLow = SHADOW_LOW_PATTERN.matcher(message); + if (mShadowLow.find()) + { + cachedCounts.put("tumekens_shadow", parseIntOrNull(mShadowLow.group("amount"))); + saveAccountData(); + return; + } + if (SHADOW_EMPTY_PATTERN.matcher(message).find()) + { + cachedCounts.put("tumekens_shadow", 0); + saveAccountData(); + return; + } + Matcher mShadowApply = SHADOW_APPLY_PATTERN.matcher(message); + if (mShadowApply.find()) + { + String total = mShadowApply.group("total"); + if (total != null) + { + cachedCounts.put("tumekens_shadow", parseIntOrNull(total)); + } + else + { + int added = parseIntOrNull(mShadowApply.group("added")); + int cur = cachedCounts.getOrDefault("tumekens_shadow", 0); + cachedCounts.put("tumekens_shadow", cur + added); + } + saveAccountData(); + return; + } + + // Warped sceptre + Matcher mWarped = WARPED_CHECK_PATTERN.matcher(message); + if (mWarped.find()) + { + String amt = mWarped.group("amount"); + cachedCounts.put("warped_sceptre", parseIntOrNull(amt)); + saveAccountData(); + return; + } + if (WARPED_EMPTY_PATTERN.matcher(message).find()) + { + cachedCounts.put("warped_sceptre", 0); + saveAccountData(); + return; + } + Matcher mWarpedApply = WARPED_APPLY_PATTERN.matcher(message); + if (mWarpedApply.find()) + { + String total = mWarpedApply.group("total"); + if (total != null) + { + cachedCounts.put("warped_sceptre", parseIntOrNull(total)); + } + else + { + int added = parseIntOrNull(mWarpedApply.group("added")); + int cur = cachedCounts.getOrDefault("warped_sceptre", 0); + cachedCounts.put("warped_sceptre", cur + added); + } + saveAccountData(); + return; + } + + // Serpentine helm + Matcher mSerp = SERP_CHECK_PATTERN.matcher(message); + if (mSerp.find()) + { + cachedCounts.put("serpentine_helm", parseIntOrNull(mSerp.group("amount"))); + saveAccountData(); + return; + } + if (SERP_EMPTY_PATTERN.matcher(message).find()) + { + cachedCounts.put("serpentine_helm", 0); + saveAccountData(); + return; + } + + // Venator bow + Matcher mVenator = VENATOR_CHECK_PATTERN.matcher(message); + if (mVenator.find()) + { + cachedCounts.put("venator_bow", parseIntOrNull(mVenator.group("amount"))); + saveAccountData(); + return; + } + Matcher mVenatorCharge = VENATOR_CHARGE_PATTERN.matcher(message); + if (mVenatorCharge.find()) + { + cachedCounts.put("venator_bow", parseIntOrNull(mVenatorCharge.group("amount"))); + saveAccountData(); + return; + } + + // Tomes (Fire, Water, Earth) + Matcher mTomePages = TOME_PAGES_PATTERN.matcher(message); + if (mTomePages.find()) + { + String lower = message.toLowerCase(); + String key = lower.contains("water") ? "tome_of_water" + : (lower.contains("earth") ? "tome_of_earth" : "tome_of_fire"); + String amtStr = mTomePages.group("amount").toLowerCase(); + int amt = amtStr.equals("one") ? 1 : parseWordOrInt(amtStr); + cachedCounts.put(key, amt); + saveAccountData(); + return; + } + Matcher mTomeRemove = TOME_REMOVE_PATTERN.matcher(message); + if (mTomeRemove.find()) + { + String key = resolveKeyForSlots(new EquipmentInventorySlot[]{EquipmentInventorySlot.SHIELD}, "tome_of_fire"); + String amtStr = mTomeRemove.group("amount").toLowerCase(); + int amt = amtStr.equals("one") ? 1 : parseWordOrInt(amtStr); + cachedCounts.put(key, amt); + saveAccountData(); + return; + } + Matcher mTomeHolds = TOME_HOLDS_PATTERN.matcher(message); + if (mTomeHolds.find()) + { + String key = resolveKeyForSlots(new EquipmentInventorySlot[]{EquipmentInventorySlot.SHIELD}, "tome_of_water"); + String amtStr = mTomeHolds.group("amount").toLowerCase(); + int amt = amtStr.equals("one") ? 1 : parseWordOrInt(amtStr); + cachedCounts.put(key, amt); + saveAccountData(); + return; + } + Matcher mTomeEmpty = TOME_EMPTY_PATTERN.matcher(message); + if (mTomeEmpty.find()) + { + String type = mTomeEmpty.group(1).toLowerCase(); + String key = type.contains("water") ? "tome_of_water" + : (type.contains("earth") ? "tome_of_earth" : "tome_of_fire"); + cachedCounts.put(key, 0); + saveAccountData(); + return; + } + if (TOME_EMPTY_ALL_PATTERN.matcher(message).find()) + { + String key = resolveKeyForSlots(new EquipmentInventorySlot[]{EquipmentInventorySlot.SHIELD}, "tome_of_fire"); + cachedCounts.put(key, 0); + saveAccountData(); + return; + } + + // Amulet of blood fury + Matcher mBlood = BLOOD_FURY_CHECK_PATTERN.matcher(message); + if (mBlood.find()) + { + cachedCounts.put("amulet_of_blood_fury", parseIntOrNull(mBlood.group("amount"))); + saveAccountData(); + return; + } + if (BLOOD_FURY_CREATE_PATTERN.matcher(message).find()) + { + cachedCounts.put("amulet_of_blood_fury", 10000); + saveAccountData(); + return; + } + + // Ether weapons (Craw's, Webweaver, Viggora's, Ursine, Thammaron's, Accursed) + Matcher mEtherCheck = ETHER_CHECK_PATTERN.matcher(message); + if (mEtherCheck.find()) + { + String key = resolveKeyForSlots(new EquipmentInventorySlot[]{EquipmentInventorySlot.WEAPON}, "craws_bow"); + cachedCounts.put(key, parseIntOrNull(mEtherCheck.group("amount"))); + saveAccountData(); + return; + } + Matcher mEtherAdd = ETHER_ADD_PATTERN.matcher(message); + if (mEtherAdd.find()) + { + String key = resolveKeyForSlots(new EquipmentInventorySlot[]{EquipmentInventorySlot.WEAPON}, "craws_bow"); + cachedCounts.put(key, parseIntOrNull(mEtherAdd.group("amount"))); + saveAccountData(); + return; + } + if (message.equalsIgnoreCase("You use 1000 ether to activate the weapon.")) + { + String key = resolveKeyForSlots(new EquipmentInventorySlot[]{EquipmentInventorySlot.WEAPON}, "craws_bow"); + cachedCounts.put(key, 0); + saveAccountData(); + return; + } + Matcher mEtherLow = ETHER_LOW_PATTERN.matcher(message); + if (mEtherLow.find()) + { + String key = resolveKeyForSlots(new EquipmentInventorySlot[]{EquipmentInventorySlot.WEAPON}, "craws_bow"); + cachedCounts.put(key, parseIntOrNull(mEtherLow.group("amount"))); + saveAccountData(); + return; + } + if (ETHER_EMPTY_PATTERN.matcher(message).find()) + { + String key = resolveKeyForSlots(new EquipmentInventorySlot[]{EquipmentInventorySlot.WEAPON}, "craws_bow"); + cachedCounts.put(key, 0); + saveAccountData(); + return; + } + + // Ring of suffering + Matcher mSuffering = SUFFERING_CHECK_PATTERN.matcher(message); + if (mSuffering.find()) + { + String amt = mSuffering.group("amount").toLowerCase(); + int count = amt.equals("no") ? 0 : (amt.equals("1") ? 1 : parseWordOrInt(amt)); + cachedCounts.put("ring_of_suffering", count); + saveAccountData(); + return; + } + Matcher mSufferingLoad = SUFFERING_LOAD_PATTERN.matcher(message); + if (mSufferingLoad.find()) + { + cachedCounts.put("ring_of_suffering", parseIntOrNull(mSufferingLoad.group("amount"))); + saveAccountData(); + return; + } + if (SUFFERING_FULL_PATTERN.matcher(message).find()) + { + cachedCounts.put("ring_of_suffering", 100000); + saveAccountData(); + return; + } + if (SUFFERING_UNCHARGE_PATTERN.matcher(message).find()) + { + cachedCounts.put("ring_of_suffering", 0); + saveAccountData(); + return; + } + + // Bonecrusher & Ash sanctifier + Matcher mBone = BONECRUSHER_CHECK_PATTERN.matcher(message); + if (mBone.find()) + { + cachedCounts.put("bonecrusher", parseIntOrNull(mBone.group("amount"))); + saveAccountData(); + return; + } + Matcher mBoneCharge = BONECRUSHER_CHARGE_PATTERN.matcher(message); + if (mBoneCharge.find()) + { + cachedCounts.put("bonecrusher", parseIntOrNull(mBoneCharge.group("amount"))); + saveAccountData(); + return; + } + if (BONECRUSHER_EMPTY_PATTERN.matcher(message).find()) + { + cachedCounts.put("bonecrusher", 0); + saveAccountData(); + return; + } + Matcher mSanct = SANCTIFIER_CHECK_PATTERN.matcher(message); + if (mSanct.find()) + { + cachedCounts.put("ash_sanctifier", parseIntOrNull(mSanct.group("amount"))); + saveAccountData(); + return; + } + Matcher mSanctCharge = SANCTIFIER_CHARGE_PATTERN.matcher(message); + if (mSanctCharge.find()) + { + cachedCounts.put("ash_sanctifier", parseIntOrNull(mSanctCharge.group("amount"))); + saveAccountData(); + return; + } + if (SANCTIFIER_EMPTY_PATTERN.matcher(message).find()) + { + cachedCounts.put("ash_sanctifier", 0); + saveAccountData(); + return; + } + + // Arclight, Crystal Halberd, Crystal Bow + Matcher mArclight = ARCLIGHT_CHECK_PATTERN.matcher(message); + if (mArclight.find()) + { + cachedCounts.put("arclight", parseIntOrNull(mArclight.group("amount"))); + saveAccountData(); + return; + } + Matcher mChally = CHALLY_CHECK_PATTERN.matcher(message); + if (mChally.find()) + { + cachedCounts.put("crystal_halberd", parseIntOrNull(mChally.group("amount"))); + saveAccountData(); + return; + } + Matcher mCBow = CBOW_CHECK_PATTERN.matcher(message); + if (mCBow.find()) + { + cachedCounts.put("crystal_bow", parseIntOrNull(mCBow.group("amount"))); + saveAccountData(); + return; + } + + String lowerMessage = message.toLowerCase(); + if ((lowerMessage.contains("cowbell") || pendingCowbellResult) && + (lowerMessage.contains("charge") || lowerMessage.contains("air rune"))) + { + Matcher mCowbellCharges = COWBELL_CHARGES_PATTERN.matcher(message); + Integer charges = null; + while (mCowbellCharges.find()) + { + String amount = mCowbellCharges.group("amountBefore") != null + ? mCowbellCharges.group("amountBefore") + : mCowbellCharges.group("amountAfter"); + charges = parseWordOrInt(amount); + } + if (charges != null) + { + cachedCounts.put("cowbell_amulet", Math.min(1000, Math.max(0, charges))); + pendingCowbellResult = false; + pendingCowbellResultUntilTick = -1; + saveAccountData(); + } + else + { + pendingCowbellResult = false; + pendingCowbellResultUntilTick = -1; + if (lowerMessage.contains("cowbell") && !lowerMessage.contains("already fully charged")) + { + cachedCounts.remove("cowbell_amulet"); + if (lastAccountKey != null) + { + setOrUnset(CONFIG_PREFIX + "cowbell_amulet." + lastAccountKey, null); + } + } + else if (lowerMessage.contains("already fully charged")) + { + cachedCounts.put("cowbell_amulet", 1000); + saveAccountData(); + } + } + } + + if (lowerMessage.contains("pendant") || pendingAtesResult) + { + if (lowerMessage.contains("already be at that location")) + { + Integer current = cachedCounts.get("pendant_of_ates"); + if (current != null) + { + cachedCounts.put("pendant_of_ates", Math.min(1000, current + 1)); + saveAccountData(); + } + } + else if (lowerMessage.contains("already fully charged")) + { + cachedCounts.put("pendant_of_ates", 1000); + pendingAtesResult = false; + pendingAtesResultUntilTick = -1; + saveAccountData(); + } + else if (lowerMessage.contains("has no charges") || lowerMessage.contains("run out of charges") + || lowerMessage.contains("uncharge your pendant") || lowerMessage.contains("uncharges your pendant") + || lowerMessage.contains("dismantle the pendant")) + { + cachedCounts.put("pendant_of_ates", 0); + pendingAtesResult = false; + pendingAtesResultUntilTick = -1; + saveAccountData(); + } + else + { + Matcher mAtesCharges = ATES_CHARGES_PATTERN.matcher(message); + Integer charges = null; + while (mAtesCharges.find()) + { + for (int i = 1; i <= mAtesCharges.groupCount(); i++) + { + String grp = mAtesCharges.group(i); + if (grp != null) + { + charges = parseWordOrInt(grp); + break; + } + } + } + + if (charges == null) + { + Matcher mAtesAdd = ATES_ADD_PATTERN.matcher(message); + if (mAtesAdd.find()) + { + int added = parseWordOrInt(mAtesAdd.group(1)); + Integer cur = cachedCounts.get("pendant_of_ates"); + if (cur != null) + { + charges = Math.min(1000, cur + added); + } + } + } + + if (charges != null) + { + cachedCounts.put("pendant_of_ates", Math.min(1000, Math.max(0, charges))); + pendingAtesResult = false; + pendingAtesResultUntilTick = -1; + saveAccountData(); + } + else if (pendingAtesResult) + { + pendingAtesResult = false; + pendingAtesResultUntilTick = -1; + } + } + } + // 1. Toxic Blowpipe Matcher mBpCheck = BLOWPIPE_CHECK_PATTERN.matcher(message); if (mBpCheck.find()) @@ -2731,18 +3860,30 @@ public class ItemCharges public List chargesFor(int itemId) { - if (!config.itemCharges()) + if (!isEnabled()) { return Collections.emptyList(); } - boolean weapons = plugin.getHelperValue("itemChargesWeapons", Boolean.class, true); - boolean bags = plugin.getHelperValue("itemChargesBags", Boolean.class, true); + boolean weapons = plugin != null + ? plugin.getHelperValue("itemInformationChargesWeapons", Boolean.class, plugin.getHelperValue("itemChargesWeapons", Boolean.class, true)) + : true; + boolean bags = plugin != null + ? plugin.getHelperValue("itemInformationChargesBags", Boolean.class, plugin.getHelperValue("itemChargesBags", Boolean.class, true)) + : true; - Color primaryColor = plugin.getHelperValue("itemChargesColor", Color.class, Color.WHITE); - Color secondaryColor = plugin.getHelperValue("itemChargesSecondaryColor", Color.class, new Color(0xC0C0C0)); - Color lowColor = plugin.getHelperValue("itemChargesLowColor", Color.class, new Color(0xFF3333)); - int lowAt = plugin.getHelperValue("itemChargesLowAt", Integer.class, 0); + Color primaryColor = plugin != null + ? plugin.getHelperValue("itemInformationColor", Color.class, plugin.getHelperValue("itemChargesColor", Color.class, Color.WHITE)) + : Color.WHITE; + Color secondaryColor = plugin != null + ? plugin.getHelperValue("itemInformationSecondaryColor", Color.class, plugin.getHelperValue("itemChargesSecondaryColor", Color.class, new Color(0xC0C0C0))) + : new Color(0xC0C0C0); + Color lowColor = plugin != null + ? plugin.getHelperValue("itemInformationLowColor", Color.class, plugin.getHelperValue("itemChargesLowColor", Color.class, new Color(0xFF3333))) + : new Color(0xFF3333); + int lowAt = plugin != null + ? plugin.getHelperValue("itemInformationLowAt", Integer.class, plugin.getHelperValue("itemChargesLowAt", Integer.class, 0)) + : 0; ItemChargeDef def = CHARGE_MAP.get(itemId); if (def == null && itemManager != null) @@ -2779,7 +3920,10 @@ public class ItemCharges { int val = client.getVarbitValue(def.primaryVarbit); Color c = (lowAt > 0 && val <= lowAt) ? lowColor : primaryColor; - result.add(new ChargeValue(formatQuantity(val), c)); + String quantity = def.primaryVarbit == VarbitID.CHARGES_EYE_OF_AYAK_QUANTITY + ? QuantityFormatter.formatNumber(val) + : formatQuantity(val); + result.add(new ChargeValue(quantity, c)); } return result; @@ -2981,6 +4125,16 @@ public class ItemCharges } loadAccountDataIfNecessary(); Integer count = cachedCounts.get(def.configKey); + if (count == null && def.primaryVarbit != -1 && client != null) + { + int val = client.getVarbitValue(def.primaryVarbit); + if (val > 0) + { + count = val; + cachedCounts.put(def.configKey, val); + saveAccountData(); + } + } if (count == null) { if (def.defaultCharges != null) diff --git a/src/main/java/com/example/essentialsplus/helpers/ItemChargesOverlay.java b/src/main/java/com/example/essentialsplus/helpers/ItemChargesOverlay.java index 3390e83..9b67bb0 100644 --- a/src/main/java/com/example/essentialsplus/helpers/ItemChargesOverlay.java +++ b/src/main/java/com/example/essentialsplus/helpers/ItemChargesOverlay.java @@ -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); diff --git a/src/main/java/com/example/essentialsplus/helpers/ItemInformationOverlay.java b/src/main/java/com/example/essentialsplus/helpers/ItemInformationOverlay.java new file mode 100644 index 0000000..60b4beb --- /dev/null +++ b/src/main/java/com/example/essentialsplus/helpers/ItemInformationOverlay.java @@ -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("
"); + } + full.append(bankedStr); + } + + return full.toString(); + } + + private static String gp(long v) + { + return QuantityFormatter.quantityToStackSize(v) + " gp"; + } +} diff --git a/src/main/java/com/example/essentialsplus/helpers/ItemPriceOverlay.java b/src/main/java/com/example/essentialsplus/helpers/ItemPriceOverlay.java index b56ac19..208c7a0 100644 --- a/src/main/java/com/example/essentialsplus/helpers/ItemPriceOverlay.java +++ b/src/main/java/com/example/essentialsplus/helpers/ItemPriceOverlay.java @@ -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); } } diff --git a/src/main/java/com/example/essentialsplus/helpers/WorldHopTracker.java b/src/main/java/com/example/essentialsplus/helpers/WorldHopTracker.java new file mode 100644 index 0000000..742264c --- /dev/null +++ b/src/main/java/com/example/essentialsplus/helpers/WorldHopTracker.java @@ -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 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 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 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 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 currentTypes = normalizedTypes(current, totalLevel, f2pTotalLevel); + List 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 normalizedTypes(net.runelite.http.api.worlds.World world, int totalLevel, int f2pTotalLevel) + { + EnumSet 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 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 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 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. + } + } + } + } +} diff --git a/src/main/java/com/example/essentialsplus/loot/LootRecord.java b/src/main/java/com/example/essentialsplus/loot/LootRecord.java index acc3c0b..5a13e81 100644 --- a/src/main/java/com/example/essentialsplus/loot/LootRecord.java +++ b/src/main/java/com/example/essentialsplus/loot/LootRecord.java @@ -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); diff --git a/src/main/java/com/example/essentialsplus/loot/LootTrackerStore.java b/src/main/java/com/example/essentialsplus/loot/LootTrackerStore.java index 5c26154..ff5d2d0 100644 --- a/src/main/java/com/example/essentialsplus/loot/LootTrackerStore.java +++ b/src/main/java/com/example/essentialsplus/loot/LootTrackerStore.java @@ -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); diff --git a/src/main/java/com/example/essentialsplus/loot/combat/CombatTracker.java b/src/main/java/com/example/essentialsplus/loot/combat/CombatTracker.java index 6373c60..d23401c 100644 --- a/src/main/java/com/example/essentialsplus/loot/combat/CombatTracker.java +++ b/src/main/java/com/example/essentialsplus/loot/combat/CombatTracker.java @@ -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(); diff --git a/src/main/java/com/example/essentialsplus/player/PlayerEquipmentFeature.java b/src/main/java/com/example/essentialsplus/player/PlayerEquipmentFeature.java new file mode 100644 index 0000000..2b36ab5 --- /dev/null +++ b/src/main/java/com/example/essentialsplus/player/PlayerEquipmentFeature.java @@ -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 equipment = new ArrayList<>(); + Map 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 skillImages() + { + Map 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("

Player

"); + + Skill hitpoints = hiscores == null ? null : hiscores.getSkill(HiscoreSkill.HITPOINTS); + int hpLevel = hitpoints == null ? 0 : hitpoints.getLevel(); + html.append(""); + String liveHealth = liveHealth(snapshot, hpLevel); + html.append(""); + + 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("") + .append("") + .append("") + .append("
Combat level") + .append(combatLevel > 0 ? combatLevel : "—") + .append(deltaMarkup(combatLevelDelta(hiscores, comparison))) + .append("
Hitpoints level").append(hpLevel > 0 ? hpLevel : "—") + .append(deltaMarkup(comparison == null ? null : comparison.levelDelta(HiscoreSkill.HITPOINTS))).append("
Current health").append(escape(liveHealth)).append("
Last fetched").append(fetchedAtMillis <= 0 ? (loading ? "Loading…" : "—") : FETCHED_AT.format(Instant.ofEpochMilli(fetchedAtMillis))).append("
Changes").append(escape(comparisonLabel(comparison, range))).append("
Total level").append(hiscores == null ? "—" : number(totalLevel)) + .append(deltaMarkup(comparison == null ? null : comparison.levelDelta(HiscoreSkill.OVERALL))).append("
Total XP").append(hiscores == null ? "—" : number(totalXp)) + .append(deltaMarkup(comparison == null ? null : comparison.experienceDelta(HiscoreSkill.OVERALL))).append("
"); + + if (loading) + { + html.append("

Loading public hiscores…

"); + } + else if (hiscores == null) + { + html.append("

").append(failed ? "Could not load public hiscores." : "No public hiscores found.").append("

"); + } + else + { + html.append("

Skill levels and XP come from public hiscores and may be out of date.

") + .append("

Skills

"); + for (HiscoreSkill skill : SKILLS) + { + Skill value = hiscores.getSkill(skill); + html.append(""); + } + html.append("
SkillLevelXP
") + .append(escape(skill.getName())).append("") + .append(value == null || value.getLevel() < 0 ? "—" : number(value.getLevel())) + .append(deltaMarkup(comparison == null ? null : comparison.levelDelta(skill))).append("") + .append(value == null || value.getExperience() < 0 ? "—" : number(value.getExperience())) + .append(deltaMarkup(comparison == null ? null : comparison.experienceDelta(skill))).append("
"); + appendBossKills(html, hiscores, comparison); + } + + return html.toString(); + } + + private static void appendEquipment(StringBuilder html, Snapshot snapshot) + { + html.append("

Visible equipment

"); + if (snapshot.equipment.isEmpty()) + { + html.append(snapshot.publicSearchOnly + ? "

Public hiscores don't include equipment; gear is only available for players visible nearby.

" + : "

No equipment is visible in this player's model.

"); + } + else + { + html.append(""); + for (EquipmentItem item : snapshot.equipment) + { + html.append(""); + } + html.append("
SlotItemGEHA
").append(escape(item.slot)).append("").append(escape(item.name)) + .append("").append(price(item.gePrice)).append("").append(price(item.haPrice)).append("
Known total").append(price(snapshot.geTotal)) + .append("").append(price(snapshot.haTotal)).append("
"); + } + html.append("

Only visible items can be identified; rings and ammunition are not shown on the player model.

"); + } + + 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 kills = bossKills(hiscores); + html.append("

Boss kills

"); + if (kills.isEmpty()) + { + html.append("

No boss kills listed in public hiscores.

"); + return; + } + + html.append(""); + for (BossKill kill : kills) + { + html.append(""); + } + html.append("
BossKills
") + .append(escape(kill.skill.getName())).append("").append(number(kill.kills)) + .append(deltaMarkup(comparison == null ? null : comparison.levelDelta(kill.skill))).append("
"); + } + + 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 " (" + (delta > 0 ? "+" : "") + number(delta) + ")"; + } + + public List getBossKills(HiscoreResult hiscores) + { + return bossKills(hiscores); + } + + private static List bossKills(HiscoreResult hiscores) + { + if (hiscores == null) + { + return Collections.emptyList(); + } + List 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("&", "&").replace("<", "<").replace(">", ">").replace("\"", """).replace("'", "'"); + } + + public static final class Snapshot + { + public final String name; + public final int combatLevel; + public final int healthRatio; + public final int healthScale; + public final List equipment; + public final long geTotal; + public final long haTotal; + public final Map images; + public final boolean publicSearchOnly; + + private Snapshot(String name, int combatLevel, int healthRatio, int healthScale, List equipment, + Map 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; + } + } +} diff --git a/src/main/java/com/example/essentialsplus/player/PlayerHiscoreHistory.java b/src/main/java/com/example/essentialsplus/player/PlayerHiscoreHistory.java new file mode 100644 index 0000000..6f42885 --- /dev/null +++ b/src/main/java/com/example/essentialsplus/player/PlayerHiscoreHistory.java @@ -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>() + { + }.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 levelDeltas; + public final Map experienceDeltas; + + private Comparison(Range range, long fromTime, long toTime, Map levelDeltas, + Map 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 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 stored = gson.fromJson(Files.readString(FILE, StandardCharsets.UTF_8), STORE_TYPE); + if (stored != null) + { + for (Map.Entry 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 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 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 differences(Map baseline, Map current) + { + Map deltas = new LinkedHashMap<>(); + for (Map.Entry 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 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 levels = new HashMap<>(); + Map experience = new HashMap<>(); + + void normalize() + { + if (levels == null) + { + levels = new HashMap<>(); + } + if (experience == null) + { + experience = new HashMap<>(); + } + } + } +} diff --git a/src/main/java/com/example/essentialsplus/skills/SkillHistory.java b/src/main/java/com/example/essentialsplus/skills/SkillHistory.java index 344c8d8..1170950 100644 --- a/src/main/java/com/example/essentialsplus/skills/SkillHistory.java +++ b/src/main/java/com/example/essentialsplus/skills/SkillHistory.java @@ -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); } diff --git a/src/main/java/com/example/essentialsplus/ui/EssentialsPlusPanel.java b/src/main/java/com/example/essentialsplus/ui/EssentialsPlusPanel.java index d4d9f34..a9213a7 100644 --- a/src/main/java/com/example/essentialsplus/ui/EssentialsPlusPanel.java +++ b/src/main/java/com/example/essentialsplus/ui/EssentialsPlusPanel.java @@ -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 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("No loadouts yet.
Save your current setup to start."); + 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 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 DEFAULT_ORDER = Arrays.asList(CURRENCY, WEALTH, LOOT, SKILLS, PROGRESS, WIKI, HELPERS); + private static final List DEFAULT_ORDER = Arrays.asList(CURRENCY, WEALTH, LOOT, SKILLS, PROGRESS, WIKI, EQUIPMENT, HELPERS); private static final Map 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(); + } + } } } diff --git a/src/main/java/com/example/essentialsplus/ui/HelpersCard.java b/src/main/java/com/example/essentialsplus/ui/HelpersCard.java index 52bcedc..7cb4ca9 100644 --- a/src/main/java/com/example/essentialsplus/ui/HelpersCard.java +++ b/src/main/java/com/example/essentialsplus/ui/HelpersCard.java @@ -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) diff --git a/src/main/java/com/example/essentialsplus/ui/Segmented.java b/src/main/java/com/example/essentialsplus/ui/Segmented.java index 2bcd536..f8bd5af 100644 --- a/src/main/java/com/example/essentialsplus/ui/Segmented.java +++ b/src/main/java/com/example/essentialsplus/ui/Segmented.java @@ -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 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 onSelect) + public void setOnRightClick(BiConsumer 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 onSelect) + { + List 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 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 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 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(); } diff --git a/src/main/java/com/example/essentialsplus/ui/Series.java b/src/main/java/com/example/essentialsplus/ui/Series.java index 11e2a8a..a398792 100644 --- a/src/main/java/com/example/essentialsplus/ui/Series.java +++ b/src/main/java/com/example/essentialsplus/ui/Series.java @@ -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 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]); diff --git a/src/main/java/com/example/essentialsplus/ui/SkillsPanel.java b/src/main/java/com/example/essentialsplus/ui/SkillsPanel.java index 1b155e6..a3255bd 100644 --- a/src/main/java/com/example/essentialsplus/ui/SkillsPanel.java +++ b/src/main/java/com/example/essentialsplus/ui/SkillsPanel.java @@ -345,7 +345,7 @@ public class SkillsPanel extends PluginPanel long[] rateStarts = h.rateStarts(timeFilter); List samples = h.range(start); long chartStart = samples.isEmpty() ? now - Ui.HOUR : Math.max(start, samples.get(0).t); - if (timeFilter.getRange() != TimeRange.ALL) + if (timeFilter.getRange() != null && !timeFilter.getRange().isAll()) { chartStart = start; } diff --git a/src/main/java/com/example/essentialsplus/ui/TimeFilter.java b/src/main/java/com/example/essentialsplus/ui/TimeFilter.java index e9011ca..bbc3e32 100644 --- a/src/main/java/com/example/essentialsplus/ui/TimeFilter.java +++ b/src/main/java/com/example/essentialsplus/ui/TimeFilter.java @@ -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 slots = new CopyOnWriteArrayList<>(); + private final Consumer onPersistSlots; + private volatile TimeRange range; private final Consumer onPersist; private final List listeners = new CopyOnWriteArrayList<>(); public TimeFilter(TimeRange initial, Consumer onPersist) { - this.range = initial == null ? TimeRange.DAY : initial; + this(initial, onPersist, null, null); + } + + public TimeFilter(TimeRange initial, Consumer onPersist, String savedSlots, Consumer 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 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) { diff --git a/src/main/java/com/example/essentialsplus/ui/TimeRange.java b/src/main/java/com/example/essentialsplus/ui/TimeRange.java index f3f8991..5cc7d38 100644 --- a/src/main/java/com/example/essentialsplus/ui/TimeRange.java +++ b/src/main/java/com/example/essentialsplus/ui/TimeRange.java @@ -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; + } } diff --git a/src/main/java/com/example/essentialsplus/ui/TimeRangeMenu.java b/src/main/java/com/example/essentialsplus/ui/TimeRangeMenu.java new file mode 100644 index 0000000..069c945 --- /dev/null +++ b/src/main/java/com/example/essentialsplus/ui/TimeRangeMenu.java @@ -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 onSelect) + { + List 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 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 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 onSelect, TimeRange range) + { + filter.setSlot(slotIndex, range); + if (onSelect != null) + { + onSelect.accept(range); + } + } +} diff --git a/src/main/java/com/example/essentialsplus/ui/WealthPanel.java b/src/main/java/com/example/essentialsplus/ui/WealthPanel.java index 7caa6b5..4e17818 100644 --- a/src/main/java/com/example/essentialsplus/ui/WealthPanel.java +++ b/src/main/java/com/example/essentialsplus/ui/WealthPanel.java @@ -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> 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 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 h = a.history; - accountStart = computeActiveStreakStart(h, a.lastUpdated, now); + accountStart = computeActiveStreakStart(a.history, a.lastUpdated, now, live); } - currentStart = Math.min(currentStart, accountStart); - List s = Series.forAccount(a, accountStart, now, ignorePrices); - allSeries.add(s); + List sCombined = Series.forAccount(a, start, now, ignorePrices); + allSeries.add(sCombined); + List 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 h, long lastUpdated, long now) + private static long computeActiveStreakStart(List 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; } } diff --git a/src/main/java/com/example/essentialsplus/ui/WealthPopout.java b/src/main/java/com/example/essentialsplus/ui/WealthPopout.java index 8e234ca..b3291d1 100644 --- a/src/main/java/com/example/essentialsplus/ui/WealthPopout.java +++ b/src/main/java/com/example/essentialsplus/ui/WealthPopout.java @@ -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)); diff --git a/src/main/java/com/example/essentialsplus/wiki/WikiHtml.java b/src/main/java/com/example/essentialsplus/wiki/WikiHtml.java index 064cc48..85aff05 100644 --- a/src/main/java/com/example/essentialsplus/wiki/WikiHtml.java +++ b/src/main/java/com/example/essentialsplus/wiki/WikiHtml.java @@ -35,7 +35,7 @@ final class WikiHtml /** Kept as they are (with filtered attributes). */ private static final Set KEEP = Set.of("a", "b", "strong", "i", "em", "u", "s", "strike", "p", "br", "hr", "h1", "h2", "h3", "h4", "h5", "h6", "ul", "ol", "li", "dl", "dt", "dd", "table", "tr", "td", "th", "caption", "img", - "div", "span", "pre", "code", "sup", "sub", "center", "blockquote", "small", "big", "tt", "kbd", "var", "cite"); + "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 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); diff --git a/src/main/java/com/example/essentialsplus/wiki/WikiOverlay.java b/src/main/java/com/example/essentialsplus/wiki/WikiOverlay.java index 488dc8c..93f139a 100644 --- a/src/main/java/com/example/essentialsplus/wiki/WikiOverlay.java +++ b/src/main/java/com/example/essentialsplus/wiki/WikiOverlay.java @@ -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)); diff --git a/src/main/java/com/example/essentialsplus/wiki/WikiWindow.java b/src/main/java/com/example/essentialsplus/wiki/WikiWindow.java index 4ca79e0..5496fd5 100644 --- a/src/main/java/com/example/essentialsplus/wiki/WikiWindow.java +++ b/src/main/java/com/example/essentialsplus/wiki/WikiWindow.java @@ -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 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 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; } diff --git a/src/main/java/com/example/essentialsplus/wiki/WikiWindows.java b/src/main/java/com/example/essentialsplus/wiki/WikiWindows.java index eefb347..35acf24 100644 --- a/src/main/java/com/example/essentialsplus/wiki/WikiWindows.java +++ b/src/main/java/com/example/essentialsplus/wiki/WikiWindows.java @@ -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 saveBounds; private final Consumer onPageOpened; + private final BiConsumer onPlayerSearch; + private final BiConsumer onPlayerRange; private final List 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 saveBounds, Consumer onPageOpened) + Rectangle savedBounds, Consumer saveBounds, Consumer onPageOpened, + BiConsumer onPlayerSearch, BiConsumer 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 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 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 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) { diff --git a/src/main/java/com/example/livedashboard/LiveDashboardConfig.java b/src/main/java/com/example/livedashboard/LiveDashboardConfig.java index bc2914c..b939a5b 100644 --- a/src/main/java/com/example/livedashboard/LiveDashboardConfig.java +++ b/src/main/java/com/example/livedashboard/LiveDashboardConfig.java @@ -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() diff --git a/src/main/java/com/example/livedashboard/LiveDashboardPlugin.java b/src/main/java/com/example/livedashboard/LiveDashboardPlugin.java index c910556..d275868 100644 --- a/src/main/java/com/example/livedashboard/LiveDashboardPlugin.java +++ b/src/main/java/com/example/livedashboard/LiveDashboardPlugin.java @@ -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); diff --git a/src/test/java/com/example/essentialsplus/GrandExchangeTrackerTest.java b/src/test/java/com/example/essentialsplus/GrandExchangeTrackerTest.java new file mode 100644 index 0000000..683dd56 --- /dev/null +++ b/src/test/java/com/example/essentialsplus/GrandExchangeTrackerTest.java @@ -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 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 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 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); + } +} diff --git a/src/test/java/com/example/essentialsplus/ItemChargesFullTest.java b/src/test/java/com/example/essentialsplus/ItemChargesFullTest.java index 818ac4b..493dbe9 100644 --- a/src/test/java/com/example/essentialsplus/ItemChargesFullTest.java +++ b/src/test/java/com/example/essentialsplus/ItemChargesFullTest.java @@ -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 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 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 empty = itemCharges.chargesFor(ItemID.PENDANT_OF_ATES_EMPTY); + Assert.assertEquals(1, empty.size()); + Assert.assertEquals("0", empty.get(0).getText()); + + List 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 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 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", "Pendant of Ates", 0); + List 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 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 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 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 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", "Trident of the swamp", 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")); + } } diff --git a/src/test/java/com/example/essentialsplus/ItemInformationTest.java b/src/test/java/com/example/essentialsplus/ItemInformationTest.java new file mode 100644 index 0000000..03143d7 --- /dev/null +++ b/src/test/java/com/example/essentialsplus/ItemInformationTest.java @@ -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 configStore = new HashMap<>(); + private ItemContainer mockBankContainer; + private MenuEntry[] currentMenuEntries; + private AccountData testAccount; + + @SuppressWarnings("unchecked") + private static T mockInterface(Class iface, java.lang.reflect.InvocationHandler handler) + { + return (T) Proxy.newProxyInstance(iface.getClassLoader(), new Class[]{iface}, handler); + } + + @Before + public void setup() 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 getHelperValue(String key, Class type, T def) + { + Object val = configStore.get(key); + if (val != null) + { + return (T) val; + } + Map 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 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("
")); + 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("
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 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 weaponCharges = itemCharges.chargesFor(ItemID.ABYSSAL_TENTACLE); + Assert.assertTrue(weaponCharges.isEmpty()); + + List 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()); + } +} diff --git a/src/test/java/com/example/essentialsplus/TimeRangeTest.java b/src/test/java/com/example/essentialsplus/TimeRangeTest.java new file mode 100644 index 0000000..8cc7e88 --- /dev/null +++ b/src/test/java/com/example/essentialsplus/TimeRangeTest.java @@ -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 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()); + } +} diff --git a/src/test/java/com/example/essentialsplus/WealthSpikeFilterTest.java b/src/test/java/com/example/essentialsplus/WealthSpikeFilterTest.java index 7521690..ee81f16 100644 --- a/src/test/java/com/example/essentialsplus/WealthSpikeFilterTest.java +++ b/src/test/java/com/example/essentialsplus/WealthSpikeFilterTest.java @@ -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 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 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 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 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()); + } } diff --git a/src/test/java/com/example/essentialsplus/helpers/ChatHelperTest.java b/src/test/java/com/example/essentialsplus/helpers/ChatHelperTest.java index 68530b7..a7f8d22 100644 --- a/src/test/java/com/example/essentialsplus/helpers/ChatHelperTest.java +++ b/src/test/java/com/example/essentialsplus/helpers/ChatHelperTest.java @@ -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 menuEntries = new ArrayList<>(); + private final Map 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 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, "You smelt a bar.", 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 mockInterface(Class 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("You smelt a bar.", 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("You smelt a bar.", 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 diff --git a/src/test/java/com/example/essentialsplus/ui/SeriesTest.java b/src/test/java/com/example/essentialsplus/ui/SeriesTest.java new file mode 100644 index 0000000..052502b --- /dev/null +++ b/src/test/java/com/example/essentialsplus/ui/SeriesTest.java @@ -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 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 s2 = new ArrayList<>(); + s2.add(snap(110, 162_000_000L)); + s2.add(snap(200, 162_000_000L)); + + List 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 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()); + } +} diff --git a/tree-cooldown.md b/tree-cooldown.md deleted file mode 100644 index 609a988..0000000 --- a/tree-cooldown.md +++ /dev/null @@ -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`. -- **Events** (im Plugin registriert): - - `GameTick`: Ist `client.getLocalPlayer().getAnimation()` ∈ `WOODCUT_ANIMS` → nächsten Baum in Reichweite suchen (Scan der Scene-Tiles wie `WorldObjectHighlighterOverlay.java:91`), als Ziel merken und `choppedMs += 600`. Kein Hacken (Animation weg / anderes Ziel) → `choppedMs` zurücksetzen (Depletion regeneriert). - - `GameObjectDespawned`: getracktes Ziel (Baum) verschwindet → felled: `respawnDeadlineMs = now + respawnSec(type)` am Tile setzen. - - `GameObjectSpawned`: - - Stump-Id → fremder Baum wurde in der Nähe gefällt → ebenfalls `respawnDeadlineMs = now + respawnSec(stumpType)` setzen (Fallback `TreeType.NORMAL` bei unbekannter Stump-Id). Nur wenn Tile in einem Radius von ~16 Tiles um den Spieler („in der Nähe gefällt"). - - Baum-Id an einem Tile mit aktiver Respawn-Deadline → Eintrag löschen (wiedergewachsen). - - `GameStateChanged` (`!= LOGGED_IN`) und Region-Wechsel → `reset()`. -- **API für das Overlay**: Iteratoren `respawnTimers()`, `chopTimers()` (nur Records mit Rest > 0) samt Referenz auf das Tile (`LocalPoint`, `sceneX/Y`, plane). - -### `helpers/TreeCooldownOverlay.java` (`Overlay`) - -- `OverlayPosition.DYNAMIC`, `OverlayLayer.ABOVE_SCENE`. -- Text-Draw mit Schatten, identisch zu `AttackTimerOverlay.shadowed`; Font über `TimerTextSize`. -- **Respawn**: Sekunden-Zahl über dem Stumpf/Perspektivenpunkt der Tree-Basis (Anchor über `Perspective.getCanvasTextLocation`, s. `AttackTimerOverlay.java:130`), Höhe = `treeCooldownOffset`. -- **Chop**: Rest-Sekunden über dem Baum, Höhe = `treeCooldownOffset + 80`. -- Optional Fortschrittsbalken (`treeCooldownBar`), Muster aus `AttackTimerOverlay.drawTimer` (drainende Pips). -- Nichts rendern wenn `!config.treeCooldowns()`. - -## Zu ändernde Dateien - -- **`EssentialsPlusConfig.java`**: `@ConfigItem treeCooldowns` (bool, default `false`, position 34). Stil/Toggles als `configManager`-Keys via `setHelperOption` (eingesetztes Muster): - - `treeCooldownRespawn` (bool, true), `treeCooldownChop` (bool, true), `treeCooldownBar` (bool, false) - - `treeCooldownSize` (`TimerTextSize`, NORMAL), `treeCooldownOffset` (int, 50), `treeCooldownColor` (Color, weiß oder grün `0x8FBF3F`), `treeCooldownReadyColor` (Color, `0x66FF66`) -- **`EssentialsPlusPlugin.java`**: `@Inject TreeCooldowns` + `TreeCooldownOverlay`; `startUp()`: `eventBus.register(treeCooldowns)`, `overlayManager.add(treeCooldownOverlay)`; `shutDown()`: spiegeln + `treeCooldowns.reset()`. `isHelperOption()`: `case "treeCooldowns"`. -- **`HelpersCard.java`**: neues `JCheckBox "Tree cooldowns"` (Tooltip: „Respawn-Countdown auf gefällten Bäumen + Rest-Zeit auf langlebigen Bäumen. Rechtsklick für Optionen"). Rechtsklick-Menü analog `priceMenu`/`styleMenu`: Toggles `treeCooldownRespawn`/`treeCooldownChop`/`treeCooldownBar`, Größe, Höhe, Farben, Reset. `refresh()` erweitern. - -## Verifikation - -- `./gradlew compileJava` (bzw. `./gradlew deploy` für Bolt). -- Manuell: Baum fällen (Oak/Willow → Respawn-Zahl auf Stump), Yew/Magic/Redwood hacken (Chop-Rest über Baum, zählt runter, fällt → Respawn-Zahl), weggehen und zurück (fremder frisch gefällter Stump in der Nähe → Timer), ausloggen (alles reset). - -## Hinweise / Einschränkungen - -- Nur Bäume, die selbst beobachtet gefällt wurden (eigene oder fremde in der Nähe); vorab vorhandene Stümpfe unbekannten Alters → kein Timer. -- Times anderer Spieler stimmen nicht exakt (Depletion läuft global); selbst gehackte Bäume sind exakt. \ No newline at end of file