From 15463c2e3cca9385edf2a551050c3d60537672a6 Mon Sep 17 00:00:00 2001 From: Hikyu Date: Fri, 18 Sep 2026 17:18:53 +0200 Subject: [PATCH] chore: init --- .gitignore | 9 + CLAUDE.md | 40 + README.md | 182 + Update-EssentialsPlus.bat | 94 + Update-EssentialsPlus.sh | 61 + build.gradle | 102 + gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 43583 bytes gradle/wrapper/gradle-wrapper.properties | 8 + gradlew | 252 ++ gradlew.bat | 94 + item-charges.md | 136 + runelite-plugin.properties | 7 + settings.gradle | 1 + skill-reset-hourly.md | 94 + src/main/java/com/example/ExampleConfig.java | 41 + src/main/java/com/example/ExamplePlugin.java | 245 ++ src/main/java/com/example/ProfileManager.java | 483 +++ .../essentialsplus/EssentialsPlusConfig.java | 587 +++ .../essentialsplus/EssentialsPlusPlugin.java | 3526 +++++++++++++++++ .../essentialsplus/GrandExchangeTracker.java | 318 ++ .../essentialsplus/WealthCategory.java | 27 + .../example/essentialsplus/WealthStore.java | 475 +++ .../collectionlog/CollectionLogService.java | 515 +++ .../essentialsplus/currency/Currency.java | 77 + .../currency/CurrencyHistory.java | 170 + .../currency/CurrencyStore.java | 147 + .../currency/CurrencyTracker.java | 269 ++ .../currency/ui/CurrencyCard.java | 217 + .../currency/ui/CurrencyIcons.java | 57 + .../currency/ui/CurrencyPanel.java | 353 ++ .../essentialsplus/data/AccountData.java | 462 +++ .../essentialsplus/data/GeBuyLimit.java | 24 + .../essentialsplus/data/GeLimitInfo.java | 24 + .../example/essentialsplus/data/GeRow.java | 28 + .../example/essentialsplus/data/GeSlot.java | 30 + .../essentialsplus/data/ItemLedger.java | 48 + .../example/essentialsplus/data/ItemRow.java | 20 + .../essentialsplus/data/ItemSnapshot.java | 44 + .../example/essentialsplus/data/Snapshot.java | 148 + .../essentialsplus/data/SourceValue.java | 20 + .../example/essentialsplus/data/TopItem.java | 21 + .../essentialsplus/helpers/AttackStyles.java | 256 ++ .../helpers/AttackTimerOverlay.java | 221 ++ .../essentialsplus/helpers/AttackTimers.java | 565 +++ .../essentialsplus/helpers/CombatStyle.java | 63 + .../essentialsplus/helpers/ItemCharges.java | 3187 +++++++++++++++ .../helpers/ItemChargesOverlay.java | 105 + .../helpers/ItemPriceOverlay.java | 114 + .../essentialsplus/helpers/TimerTextSize.java | 52 + .../helpers/TreeCooldownOverlay.java | 184 + .../essentialsplus/helpers/TreeCooldowns.java | 790 ++++ .../helpers/combat/AnimationData.java | 445 +++ .../helpers/combat/AttackProcedure.java | 8 + .../helpers/combat/AttackSpeed.java | 129 + .../helpers/combat/AttackStyle.java | 34 + .../helpers/combat/AttackType.java | 16 + .../helpers/combat/Attacking.java | 118 + .../helpers/combat/CastingSoundData.java | 85 + .../helpers/combat/CombatUtils.java | 196 + .../essentialsplus/helpers/combat/Damage.java | 73 + .../helpers/combat/ManualCasting.java | 15 + .../helpers/combat/PoweredStaves.java | 127 + .../helpers/combat/Spellbook.java | 35 + .../helpers/combat/WeaponType.java | 133 + .../combat/variablespeed/Amoxliatl.java | 30 + .../combat/variablespeed/BloodMoonSet.java | 22 + .../combat/variablespeed/DoomOfMokhaiotl.java | 127 + .../combat/variablespeed/EyeOfAyak.java | 21 + .../combat/variablespeed/IVariableSpeed.java | 21 + .../combat/variablespeed/MaggotKing.java | 67 + .../variablespeed/PurgingStaffSpec.java | 51 + .../variablespeed/RapidAttackStyle.java | 25 + .../combat/variablespeed/RedKerisSpec.java | 25 + .../combat/variablespeed/RoyalTitans.java | 174 + .../combat/variablespeed/Scurrius.java | 44 + .../combat/variablespeed/ShadowCrash.java | 257 ++ .../combat/variablespeed/TombsOfAmascut.java | 26 + .../combat/variablespeed/TormentedDemons.java | 184 + .../combat/variablespeed/VariableSpeed.java | 117 + .../variablespeed/state/IStateTracker.java | 26 + .../variablespeed/state/MarkOfDarkness.java | 47 + .../combat/variablespeed/state/TickCount.java | 30 + .../combat/variablespeed/state/Yama.java | 177 + .../combat/variablespeed/state/YamaPhase.java | 20 + .../essentialsplus/loot/LootEvent.java | 27 + .../essentialsplus/loot/LootRecord.java | 246 ++ .../essentialsplus/loot/LootTrackerStore.java | 456 +++ .../essentialsplus/loot/TrackedLootItem.java | 32 + .../loot/combat/CombatStats.java | 342 ++ .../loot/combat/CombatTracker.java | 1172 ++++++ .../loot/combat/FightSession.java | 279 ++ .../loot/combat/SingleFightRecord.java | 45 + .../loot/droptable/DropLine.java | 61 + .../loot/droptable/DropTable.java | 159 + .../loot/gear/CombatChoice.java | 320 ++ .../essentialsplus/loot/gear/DpsCalc.java | 333 ++ .../loot/gear/FightService.java | 492 +++ .../loot/gear/FightSnapshot.java | 111 + .../essentialsplus/loot/gear/GearItem.java | 121 + .../loot/gear/GearOptimizer.java | 158 + .../essentialsplus/loot/gear/Loadout.java | 134 + .../essentialsplus/loot/gear/Monster.java | 78 + .../loot/gear/MonsterService.java | 220 + .../essentialsplus/loot/gear/PlayerSetup.java | 49 + .../loot/ui/CollectionLogSection.java | 377 ++ .../essentialsplus/loot/ui/DropsSection.java | 284 ++ .../loot/ui/FightDetailPanel.java | 158 + .../essentialsplus/loot/ui/GearSection.java | 798 ++++ .../essentialsplus/loot/ui/LiveFightCard.java | 240 ++ .../essentialsplus/loot/ui/LootRecordBox.java | 547 +++ .../loot/ui/LootRecordDetailPanel.java | 827 ++++ .../loot/ui/LootSummaryCard.java | 386 ++ .../essentialsplus/loot/ui/LootTargetRow.java | 202 + .../loot/ui/LootTrackerPanel.java | 477 +++ .../essentialsplus/loot/ui/TargetBar.java | 195 + .../loot/ui/VerticalLayout.java | 74 + .../progress/ProgressService.java | 673 ++++ .../essentialsplus/progress/ui/HeroCard.java | 100 + .../progress/ui/ProgressCard.java | 176 + .../progress/ui/ProgressPanel.java | 1017 +++++ .../progress/ui/ProgressRow.java | 135 + .../progress/ui/ProgressUi.java | 137 + .../essentialsplus/progress/ui/TaskRow.java | 152 + .../essentialsplus/skills/SkillGoals.java | 130 + .../essentialsplus/skills/SkillHistory.java | 372 ++ .../essentialsplus/skills/SkillStore.java | 164 + .../skills/SkillsHudOverlay.java | 224 ++ .../essentialsplus/skills/XpSample.java | 42 + .../essentialsplus/ui/AccountCard.java | 404 ++ .../example/essentialsplus/ui/Analysis.java | 419 ++ .../essentialsplus/ui/BreakdownView.java | 300 ++ .../example/essentialsplus/ui/CardDrag.java | 138 + .../ui/EssentialsPlusPanel.java | 806 ++++ .../example/essentialsplus/ui/GeWindow.java | 428 ++ .../essentialsplus/ui/HelpersCard.java | 412 ++ .../example/essentialsplus/ui/ItemIcons.java | 50 + .../essentialsplus/ui/ItemsWindow.java | 434 ++ .../example/essentialsplus/ui/MoversView.java | 230 ++ .../example/essentialsplus/ui/Segmented.java | 134 + .../com/example/essentialsplus/ui/Series.java | 196 + .../example/essentialsplus/ui/SkillsCard.java | 338 ++ .../essentialsplus/ui/SkillsPanel.java | 1007 +++++ .../example/essentialsplus/ui/StatsView.java | 106 + .../example/essentialsplus/ui/TimeFilter.java | 169 + .../example/essentialsplus/ui/TimeRange.java | 22 + .../essentialsplus/ui/TopItemsView.java | 147 + .../com/example/essentialsplus/ui/Ui.java | 171 + .../essentialsplus/ui/WealthChart.java | 963 +++++ .../essentialsplus/ui/WealthPanel.java | 1070 +++++ .../essentialsplus/ui/WealthPopout.java | 339 ++ .../example/essentialsplus/ui/XpChart.java | 452 +++ .../example/essentialsplus/wiki/WikiApi.java | 380 ++ .../essentialsplus/wiki/WikiDocumentView.java | 444 +++ .../example/essentialsplus/wiki/WikiHtml.java | 344 ++ .../essentialsplus/wiki/WikiOverlay.java | 582 +++ .../essentialsplus/wiki/WikiWindow.java | 1120 ++++++ .../essentialsplus/wiki/WikiWindows.java | 256 ++ .../essentialsplus/wiki/ui/WikiCard.java | 132 + .../essentialsplus/wiki/ui/WikiPanel.java | 511 +++ .../livedashboard/LiveDashboardConfig.java | 33 + .../livedashboard/LiveDashboardPlugin.java | 709 ++++ .../livedashboard/LiveDashboardServer.java | 1505 +++++++ .../livedashboard/overlay/GpsPathOverlay.java | 183 + .../livedashboard/pathfinding/Pathfinder.java | 304 ++ .../pathfinding/ShortcutData.java | 42 + .../livedashboard/ui/LiveDashboardPanel.java | 223 ++ src/main/java/com/example/model/Folder.java | 34 + src/main/java/com/example/model/Profile.java | 301 ++ .../overlay/BankHighlighterOverlay.java | 185 + .../example/overlay/ScreenFlashOverlay.java | 67 + .../overlay/TileHighlighterOverlay.java | 186 + .../WorldObjectHighlighterOverlay.java | 260 ++ .../com/example/ui/ExamplePluginPanel.java | 73 + .../java/com/example/ui/ProfileEditPanel.java | 677 ++++ .../java/com/example/ui/ProfileListPanel.java | 729 ++++ src/main/java/com/example/ui/UIUtils.java | 335 ++ .../com/example/essentialsplus/icon.png | Bin 0 -> 269 bytes .../essentialsplus/progress/diaries.json | 1 + src/main/resources/com/example/icon.png | Bin 0 -> 273 bytes .../com/example/livedashboard/icon.png | Bin 0 -> 273 bytes src/main/resources/icon.png | Bin 0 -> 273 bytes .../java/com/example/ExamplePluginTest.java | 121 + .../essentialsplus/BankTransferTest.java | 93 + .../essentialsplus/DiaryTableTest.java | 24 + .../essentialsplus/DoomCombatTrackerTest.java | 134 + .../example/essentialsplus/DropTableTest.java | 55 + .../essentialsplus/GeBuyLimitTest.java | 246 ++ .../essentialsplus/ItemChargesFullTest.java | 264 ++ .../essentialsplus/ItemChargesTest.java | 73 + .../essentialsplus/LootRecordFilterTest.java | 40 + .../essentialsplus/SkillHistoryTest.java | 105 + .../WealthHourlyTrackerTest.java | 144 + .../essentialsplus/WealthSpikeFilterTest.java | 238 ++ .../currency/CurrencyHistoryTest.java | 50 + .../helpers/AttackTimersTest.java | 101 + .../helpers/CombatStyleTest.java | 32 + .../helpers/TreeCooldownsTest.java | 94 + .../essentialsplus/loot/gear/DpsCalcTest.java | 117 + src/test/resources/logback-test.xml | 56 + tree-cooldown.md | 74 + 200 files changed, 51032 insertions(+) create mode 100644 .gitignore create mode 100644 CLAUDE.md create mode 100644 README.md create mode 100644 Update-EssentialsPlus.bat create mode 100755 Update-EssentialsPlus.sh create mode 100644 build.gradle create mode 100644 gradle/wrapper/gradle-wrapper.jar create mode 100644 gradle/wrapper/gradle-wrapper.properties create mode 100755 gradlew create mode 100644 gradlew.bat create mode 100644 item-charges.md create mode 100644 runelite-plugin.properties create mode 100644 settings.gradle create mode 100644 skill-reset-hourly.md create mode 100644 src/main/java/com/example/ExampleConfig.java create mode 100644 src/main/java/com/example/ExamplePlugin.java create mode 100644 src/main/java/com/example/ProfileManager.java create mode 100644 src/main/java/com/example/essentialsplus/EssentialsPlusConfig.java create mode 100644 src/main/java/com/example/essentialsplus/EssentialsPlusPlugin.java create mode 100644 src/main/java/com/example/essentialsplus/GrandExchangeTracker.java create mode 100644 src/main/java/com/example/essentialsplus/WealthCategory.java create mode 100644 src/main/java/com/example/essentialsplus/WealthStore.java create mode 100644 src/main/java/com/example/essentialsplus/collectionlog/CollectionLogService.java create mode 100644 src/main/java/com/example/essentialsplus/currency/Currency.java create mode 100644 src/main/java/com/example/essentialsplus/currency/CurrencyHistory.java create mode 100644 src/main/java/com/example/essentialsplus/currency/CurrencyStore.java create mode 100644 src/main/java/com/example/essentialsplus/currency/CurrencyTracker.java create mode 100644 src/main/java/com/example/essentialsplus/currency/ui/CurrencyCard.java create mode 100644 src/main/java/com/example/essentialsplus/currency/ui/CurrencyIcons.java create mode 100644 src/main/java/com/example/essentialsplus/currency/ui/CurrencyPanel.java create mode 100644 src/main/java/com/example/essentialsplus/data/AccountData.java create mode 100644 src/main/java/com/example/essentialsplus/data/GeBuyLimit.java create mode 100644 src/main/java/com/example/essentialsplus/data/GeLimitInfo.java create mode 100644 src/main/java/com/example/essentialsplus/data/GeRow.java create mode 100644 src/main/java/com/example/essentialsplus/data/GeSlot.java create mode 100644 src/main/java/com/example/essentialsplus/data/ItemLedger.java create mode 100644 src/main/java/com/example/essentialsplus/data/ItemRow.java create mode 100644 src/main/java/com/example/essentialsplus/data/ItemSnapshot.java create mode 100644 src/main/java/com/example/essentialsplus/data/Snapshot.java create mode 100644 src/main/java/com/example/essentialsplus/data/SourceValue.java create mode 100644 src/main/java/com/example/essentialsplus/data/TopItem.java create mode 100644 src/main/java/com/example/essentialsplus/helpers/AttackStyles.java create mode 100644 src/main/java/com/example/essentialsplus/helpers/AttackTimerOverlay.java create mode 100644 src/main/java/com/example/essentialsplus/helpers/AttackTimers.java create mode 100644 src/main/java/com/example/essentialsplus/helpers/CombatStyle.java create mode 100644 src/main/java/com/example/essentialsplus/helpers/ItemCharges.java create mode 100644 src/main/java/com/example/essentialsplus/helpers/ItemChargesOverlay.java create mode 100644 src/main/java/com/example/essentialsplus/helpers/ItemPriceOverlay.java create mode 100644 src/main/java/com/example/essentialsplus/helpers/TimerTextSize.java create mode 100644 src/main/java/com/example/essentialsplus/helpers/TreeCooldownOverlay.java create mode 100644 src/main/java/com/example/essentialsplus/helpers/TreeCooldowns.java create mode 100644 src/main/java/com/example/essentialsplus/helpers/combat/AnimationData.java create mode 100644 src/main/java/com/example/essentialsplus/helpers/combat/AttackProcedure.java create mode 100644 src/main/java/com/example/essentialsplus/helpers/combat/AttackSpeed.java create mode 100644 src/main/java/com/example/essentialsplus/helpers/combat/AttackStyle.java create mode 100644 src/main/java/com/example/essentialsplus/helpers/combat/AttackType.java create mode 100644 src/main/java/com/example/essentialsplus/helpers/combat/Attacking.java create mode 100644 src/main/java/com/example/essentialsplus/helpers/combat/CastingSoundData.java create mode 100644 src/main/java/com/example/essentialsplus/helpers/combat/CombatUtils.java create mode 100644 src/main/java/com/example/essentialsplus/helpers/combat/Damage.java create mode 100644 src/main/java/com/example/essentialsplus/helpers/combat/ManualCasting.java create mode 100644 src/main/java/com/example/essentialsplus/helpers/combat/PoweredStaves.java create mode 100644 src/main/java/com/example/essentialsplus/helpers/combat/Spellbook.java create mode 100644 src/main/java/com/example/essentialsplus/helpers/combat/WeaponType.java create mode 100644 src/main/java/com/example/essentialsplus/helpers/combat/variablespeed/Amoxliatl.java create mode 100644 src/main/java/com/example/essentialsplus/helpers/combat/variablespeed/BloodMoonSet.java create mode 100644 src/main/java/com/example/essentialsplus/helpers/combat/variablespeed/DoomOfMokhaiotl.java create mode 100644 src/main/java/com/example/essentialsplus/helpers/combat/variablespeed/EyeOfAyak.java create mode 100644 src/main/java/com/example/essentialsplus/helpers/combat/variablespeed/IVariableSpeed.java create mode 100644 src/main/java/com/example/essentialsplus/helpers/combat/variablespeed/MaggotKing.java create mode 100644 src/main/java/com/example/essentialsplus/helpers/combat/variablespeed/PurgingStaffSpec.java create mode 100644 src/main/java/com/example/essentialsplus/helpers/combat/variablespeed/RapidAttackStyle.java create mode 100644 src/main/java/com/example/essentialsplus/helpers/combat/variablespeed/RedKerisSpec.java create mode 100644 src/main/java/com/example/essentialsplus/helpers/combat/variablespeed/RoyalTitans.java create mode 100644 src/main/java/com/example/essentialsplus/helpers/combat/variablespeed/Scurrius.java create mode 100644 src/main/java/com/example/essentialsplus/helpers/combat/variablespeed/ShadowCrash.java create mode 100644 src/main/java/com/example/essentialsplus/helpers/combat/variablespeed/TombsOfAmascut.java create mode 100644 src/main/java/com/example/essentialsplus/helpers/combat/variablespeed/TormentedDemons.java create mode 100644 src/main/java/com/example/essentialsplus/helpers/combat/variablespeed/VariableSpeed.java create mode 100644 src/main/java/com/example/essentialsplus/helpers/combat/variablespeed/state/IStateTracker.java create mode 100644 src/main/java/com/example/essentialsplus/helpers/combat/variablespeed/state/MarkOfDarkness.java create mode 100644 src/main/java/com/example/essentialsplus/helpers/combat/variablespeed/state/TickCount.java create mode 100644 src/main/java/com/example/essentialsplus/helpers/combat/variablespeed/state/Yama.java create mode 100644 src/main/java/com/example/essentialsplus/helpers/combat/variablespeed/state/YamaPhase.java create mode 100644 src/main/java/com/example/essentialsplus/loot/LootEvent.java create mode 100644 src/main/java/com/example/essentialsplus/loot/LootRecord.java create mode 100644 src/main/java/com/example/essentialsplus/loot/LootTrackerStore.java create mode 100644 src/main/java/com/example/essentialsplus/loot/TrackedLootItem.java create mode 100644 src/main/java/com/example/essentialsplus/loot/combat/CombatStats.java create mode 100644 src/main/java/com/example/essentialsplus/loot/combat/CombatTracker.java create mode 100644 src/main/java/com/example/essentialsplus/loot/combat/FightSession.java create mode 100644 src/main/java/com/example/essentialsplus/loot/combat/SingleFightRecord.java create mode 100644 src/main/java/com/example/essentialsplus/loot/droptable/DropLine.java create mode 100644 src/main/java/com/example/essentialsplus/loot/droptable/DropTable.java create mode 100644 src/main/java/com/example/essentialsplus/loot/gear/CombatChoice.java create mode 100644 src/main/java/com/example/essentialsplus/loot/gear/DpsCalc.java create mode 100644 src/main/java/com/example/essentialsplus/loot/gear/FightService.java create mode 100644 src/main/java/com/example/essentialsplus/loot/gear/FightSnapshot.java create mode 100644 src/main/java/com/example/essentialsplus/loot/gear/GearItem.java create mode 100644 src/main/java/com/example/essentialsplus/loot/gear/GearOptimizer.java create mode 100644 src/main/java/com/example/essentialsplus/loot/gear/Loadout.java create mode 100644 src/main/java/com/example/essentialsplus/loot/gear/Monster.java create mode 100644 src/main/java/com/example/essentialsplus/loot/gear/MonsterService.java create mode 100644 src/main/java/com/example/essentialsplus/loot/gear/PlayerSetup.java create mode 100644 src/main/java/com/example/essentialsplus/loot/ui/CollectionLogSection.java create mode 100644 src/main/java/com/example/essentialsplus/loot/ui/DropsSection.java create mode 100644 src/main/java/com/example/essentialsplus/loot/ui/FightDetailPanel.java create mode 100644 src/main/java/com/example/essentialsplus/loot/ui/GearSection.java create mode 100644 src/main/java/com/example/essentialsplus/loot/ui/LiveFightCard.java create mode 100644 src/main/java/com/example/essentialsplus/loot/ui/LootRecordBox.java create mode 100644 src/main/java/com/example/essentialsplus/loot/ui/LootRecordDetailPanel.java create mode 100644 src/main/java/com/example/essentialsplus/loot/ui/LootSummaryCard.java create mode 100644 src/main/java/com/example/essentialsplus/loot/ui/LootTargetRow.java create mode 100644 src/main/java/com/example/essentialsplus/loot/ui/LootTrackerPanel.java create mode 100644 src/main/java/com/example/essentialsplus/loot/ui/TargetBar.java create mode 100644 src/main/java/com/example/essentialsplus/loot/ui/VerticalLayout.java create mode 100644 src/main/java/com/example/essentialsplus/progress/ProgressService.java create mode 100644 src/main/java/com/example/essentialsplus/progress/ui/HeroCard.java create mode 100644 src/main/java/com/example/essentialsplus/progress/ui/ProgressCard.java create mode 100644 src/main/java/com/example/essentialsplus/progress/ui/ProgressPanel.java create mode 100644 src/main/java/com/example/essentialsplus/progress/ui/ProgressRow.java create mode 100644 src/main/java/com/example/essentialsplus/progress/ui/ProgressUi.java create mode 100644 src/main/java/com/example/essentialsplus/progress/ui/TaskRow.java create mode 100644 src/main/java/com/example/essentialsplus/skills/SkillGoals.java create mode 100644 src/main/java/com/example/essentialsplus/skills/SkillHistory.java create mode 100644 src/main/java/com/example/essentialsplus/skills/SkillStore.java create mode 100644 src/main/java/com/example/essentialsplus/skills/SkillsHudOverlay.java create mode 100644 src/main/java/com/example/essentialsplus/skills/XpSample.java create mode 100644 src/main/java/com/example/essentialsplus/ui/AccountCard.java create mode 100644 src/main/java/com/example/essentialsplus/ui/Analysis.java create mode 100644 src/main/java/com/example/essentialsplus/ui/BreakdownView.java create mode 100644 src/main/java/com/example/essentialsplus/ui/CardDrag.java create mode 100644 src/main/java/com/example/essentialsplus/ui/EssentialsPlusPanel.java create mode 100644 src/main/java/com/example/essentialsplus/ui/GeWindow.java create mode 100644 src/main/java/com/example/essentialsplus/ui/HelpersCard.java create mode 100644 src/main/java/com/example/essentialsplus/ui/ItemIcons.java create mode 100644 src/main/java/com/example/essentialsplus/ui/ItemsWindow.java create mode 100644 src/main/java/com/example/essentialsplus/ui/MoversView.java create mode 100644 src/main/java/com/example/essentialsplus/ui/Segmented.java create mode 100644 src/main/java/com/example/essentialsplus/ui/Series.java create mode 100644 src/main/java/com/example/essentialsplus/ui/SkillsCard.java create mode 100644 src/main/java/com/example/essentialsplus/ui/SkillsPanel.java create mode 100644 src/main/java/com/example/essentialsplus/ui/StatsView.java create mode 100644 src/main/java/com/example/essentialsplus/ui/TimeFilter.java create mode 100644 src/main/java/com/example/essentialsplus/ui/TimeRange.java create mode 100644 src/main/java/com/example/essentialsplus/ui/TopItemsView.java create mode 100644 src/main/java/com/example/essentialsplus/ui/Ui.java create mode 100644 src/main/java/com/example/essentialsplus/ui/WealthChart.java create mode 100644 src/main/java/com/example/essentialsplus/ui/WealthPanel.java create mode 100644 src/main/java/com/example/essentialsplus/ui/WealthPopout.java create mode 100644 src/main/java/com/example/essentialsplus/ui/XpChart.java create mode 100644 src/main/java/com/example/essentialsplus/wiki/WikiApi.java create mode 100644 src/main/java/com/example/essentialsplus/wiki/WikiDocumentView.java create mode 100644 src/main/java/com/example/essentialsplus/wiki/WikiHtml.java create mode 100644 src/main/java/com/example/essentialsplus/wiki/WikiOverlay.java create mode 100644 src/main/java/com/example/essentialsplus/wiki/WikiWindow.java create mode 100644 src/main/java/com/example/essentialsplus/wiki/WikiWindows.java create mode 100644 src/main/java/com/example/essentialsplus/wiki/ui/WikiCard.java create mode 100644 src/main/java/com/example/essentialsplus/wiki/ui/WikiPanel.java create mode 100644 src/main/java/com/example/livedashboard/LiveDashboardConfig.java create mode 100644 src/main/java/com/example/livedashboard/LiveDashboardPlugin.java create mode 100644 src/main/java/com/example/livedashboard/LiveDashboardServer.java create mode 100644 src/main/java/com/example/livedashboard/overlay/GpsPathOverlay.java create mode 100644 src/main/java/com/example/livedashboard/pathfinding/Pathfinder.java create mode 100644 src/main/java/com/example/livedashboard/pathfinding/ShortcutData.java create mode 100644 src/main/java/com/example/livedashboard/ui/LiveDashboardPanel.java create mode 100644 src/main/java/com/example/model/Folder.java create mode 100644 src/main/java/com/example/model/Profile.java create mode 100644 src/main/java/com/example/overlay/BankHighlighterOverlay.java create mode 100644 src/main/java/com/example/overlay/ScreenFlashOverlay.java create mode 100644 src/main/java/com/example/overlay/TileHighlighterOverlay.java create mode 100644 src/main/java/com/example/overlay/WorldObjectHighlighterOverlay.java create mode 100644 src/main/java/com/example/ui/ExamplePluginPanel.java create mode 100644 src/main/java/com/example/ui/ProfileEditPanel.java create mode 100644 src/main/java/com/example/ui/ProfileListPanel.java create mode 100644 src/main/java/com/example/ui/UIUtils.java create mode 100644 src/main/resources/com/example/essentialsplus/icon.png create mode 100644 src/main/resources/com/example/essentialsplus/progress/diaries.json create mode 100644 src/main/resources/com/example/icon.png create mode 100644 src/main/resources/com/example/livedashboard/icon.png create mode 100644 src/main/resources/icon.png create mode 100644 src/test/java/com/example/ExamplePluginTest.java create mode 100644 src/test/java/com/example/essentialsplus/BankTransferTest.java create mode 100644 src/test/java/com/example/essentialsplus/DiaryTableTest.java create mode 100644 src/test/java/com/example/essentialsplus/DoomCombatTrackerTest.java create mode 100644 src/test/java/com/example/essentialsplus/DropTableTest.java create mode 100644 src/test/java/com/example/essentialsplus/GeBuyLimitTest.java create mode 100644 src/test/java/com/example/essentialsplus/ItemChargesFullTest.java create mode 100644 src/test/java/com/example/essentialsplus/ItemChargesTest.java create mode 100644 src/test/java/com/example/essentialsplus/LootRecordFilterTest.java create mode 100644 src/test/java/com/example/essentialsplus/SkillHistoryTest.java create mode 100644 src/test/java/com/example/essentialsplus/WealthHourlyTrackerTest.java create mode 100644 src/test/java/com/example/essentialsplus/WealthSpikeFilterTest.java create mode 100644 src/test/java/com/example/essentialsplus/currency/CurrencyHistoryTest.java create mode 100644 src/test/java/com/example/essentialsplus/helpers/AttackTimersTest.java create mode 100644 src/test/java/com/example/essentialsplus/helpers/CombatStyleTest.java create mode 100644 src/test/java/com/example/essentialsplus/helpers/TreeCooldownsTest.java create mode 100644 src/test/java/com/example/essentialsplus/loot/gear/DpsCalcTest.java create mode 100644 src/test/resources/logback-test.xml create mode 100644 tree-cooldown.md diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a28bff2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +.gradle +build +.idea/ +.project +.settings/ +.classpath +nbactions.xml +nb-configuration.xml +nbproject/ \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..ddd407b --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,40 @@ +# EssentialsPlus - Entwicklungsrichtlinien & Build-Workflow + +## ⚠️ EXTREM WICHTIGER DEPLOYMENT- & RELEASE-WORKFLOW + +Bei **JEDEM** Update / Build müssen immer folgende Schritte ausgeführt werden: + +### 1. Lokales Bolt Launcher Deployment +- `./gradlew deploy` baut die Fat-Jar (`shadowJar`) und installiert sie atomar nach: + `~/.local/share/bolt-launcher/custom-runelite.jar` +- Zusätzlich wird die Jar immer nach `/home/hikyu/Downloads/RuneLite.jar` kopiert. + +### 2. Git Release Upload (`Runelite-EssentialsPlus`) +- **Remote**: `ssh://git@git.gapspace.de:2222/Hikyu/Runelite-EssentialsPlus.git` +- **Lokaler Release-Ordner**: `/home/hikyu/Documents/projects/Runelite-EssentialsPlus` +- **Direkter Download-Link**: `https://gapspace.de/Hikyu/Runelite-EssentialsPlus/raw/branch/master/RuneLite.jar` +- **WICHTIGE REGELN**: + 1. In diesem Repository darf **NUR** die einzelne Datei `RuneLite.jar` liegen (keine Ordner, keine Skripte, keine anderen Dateien). + 2. Es darf **keine Versions-Historie** auf dem Server akkumulieren: Alte Versionen werden bei jedem Update überschrieben. + 3. Der Befehl zum Aktualisieren des Release-Repos lautet: + ```bash + cp build/libs/bank-highlighter-1.0-SNAPSHOT-all.jar /home/hikyu/Documents/projects/Runelite-EssentialsPlus/RuneLite.jar + cd /home/hikyu/Documents/projects/Runelite-EssentialsPlus + git add RuneLite.jar + git commit --amend -m "RuneLite EssentialsPlus (neuestes Update)" --date=now + git push -f origin master + ``` + +--- + +## 🛠️ Plugin-Module Übersicht +- **Deterministischer Attack Timer** (`com.example.essentialsplus.helpers.combat.*`): + - 100% deterministische Berechnung von Waffengeschwindigkeit, Ranged Rapid-Modus (-1 Tick), Powered Staves (4 Ticks), Manual/Autocast Spells (5 Ticks, Harmonised 4 Ticks, Twinflame 6 Ticks) inkl. Casting-Sound-Erkennung. + - Spezielle Boss-Mechaniken: Tormented Demons, Blood Moon Set, Red Keris, Scurrius, Amoxliatl, ToA Warden Skulls, Eye of Ayak, Doom of Mokhaiotl, Royal Titans, Maggot King, Yama Shadow Crash. + - Essens-/Trankverzögerungen (Fast Foods 2 Ticks, Standard 3 Ticks, Vampyrium Tartare 4 Ticks). + - Design und Einstellungen in `AttackTimerOverlay.java` und `EssentialsPlusConfig.java` bleiben optisch immer unverändert. +- **Item Charges Helper** (`com.example.essentialsplus.helpers.ItemCharges*`): + - Lückenlose Ladungsanzeige & Persistenz für Barrows (Dharok etc. in %), Blowpipe (Darts & Scales getrennt/kombiniert), Coal Bag, Gem Bag, Herb Sack, Fish Barrel, Arclight/Emberlight, Ringe, Jewelry, Crystal Tools/Armor. +- **Wealth & Currency Store**: Vermögen, GE-Buy-Limits und Minigame-Währungen. +- **Loot & Combat Tracker**: Drop Tables, Sessions, DPS-Rechner. +- **Bank Highlighter**: Farbhervorhebung in Bank-Tabs. diff --git a/README.md b/README.md new file mode 100644 index 0000000..fc7d115 --- /dev/null +++ b/README.md @@ -0,0 +1,182 @@ +# RuneLite Example Plugin ("Hello World") + +Ein einsatzbereites RuneLite-Plugin-Beispielprojekt. Das Plugin sendet eine **"Hello World!"**-Nachricht in den Ingame-Chat, sobald du dich einloggst oder das Plugin aktivierst. + +--- + +## 📁 Projektstruktur + +``` +. +├── build.gradle # Gradle Build-Konfiguration +├── settings.gradle # Projektname +├── gradlew / gradlew.bat # Gradle Wrapper (keine manuelle Gradle-Installation nötig) +└── src + ├── main/java/com/example + │ ├── ExamplePlugin.java # Hauptklasse des Plugins + │ └── ExampleConfig.java # Einstellungs-Menü im RuneLite Config-Panel + └── test/java/com/example + └── ExamplePluginTest.java # Startet RuneLite mit dem geladenen Plugin +``` + +--- + +## 🚀 Wie starte / teste ich das Plugin lokal? + +Der Standard- und einfachste Weg in der RuneLite-Entwicklung ist der **integrierte Test-Client**. Dadurch musst du keine JAR-Dateien manuell kopieren oder den offiziellen Client patchen. + +### Option 1: Über das Terminal / CLI (am schnellsten) + +Führe einfach folgenden Befehl im Projektordner aus: + +```bash +./gradlew run +``` + +*Unter Windows:* +```cmd +gradlew.bat run +``` + +RuneLite startet im Entwicklermodus mit deinem geladenen Plugin. Sobald du dich mit deinem Account einloggst, erscheint im Chat: +> `Example Plugin: Hello World!` + +--- + +### Option 2: Über eine IDE (IntelliJ IDEA / VS Code / Eclipse) + +1. **IntelliJ IDEA öffnen** $\rightarrow$ `Open` $\rightarrow$ diesen Projektordner auswählen $\rightarrow$ als Gradle-Projekt importieren. +2. Zu [`ExamplePluginTest.java`](file:///home/hikyu/Documents/projects/orsrs-plugin-bank-highlighter/src/test/java/com/example/ExamplePluginTest.java) navigieren. +3. Den grünen **Play-Button ▶** neben `main()` anklicken (oder `Shift + F10`). +4. RuneLite startet sofort mit Hot-Reloading & Debugging-Möglichkeiten. + +--- + +## ⚙️ Wie funktioniert das "Hello World"? + +In [`ExamplePlugin.java`](file:///home/hikyu/Documents/projects/orsrs-plugin-bank-highlighter/src/main/java/com/example/ExamplePlugin.java): +1. **Beim Login:** Das Event `@Subscribe public void onGameStateChanged(GameStateChanged gameStateChanged)` fängt `GameState.LOGGED_IN` ab und sendet die Nachricht. +2. **Bei Plugin-Aktivierung:** In `startUp()` wird geprüft, ob du bereits eingeloggt bist (`client.getGameState() == GameState.LOGGED_IN`), und sendet ebenfalls die Nachricht. +3. **Anpassbar:** Die Nachricht kann im RuneLite Einstellungsmenü (unter **Example Plugin** in der Seitenleiste) konfiguriert werden. + +--- + +## 📦 Standalone-JAR bauen (ShadowJar) + +Falls du eine eigenständige JAR-Datei benötigst: + +```bash +./gradlew shadowJar +``` + +Die fertige JAR befindet sich anschließend in: +`build/libs/bank-highlighter-1.0-SNAPSHOT-all.jar` + +--- + +## ⚡ Installation in Bolt (lokal) + +Die Plugins (ExamplePlugin, LiveDashboardPlugin, EssentialsPlusPlugin) werden über +den Einstiegspunkt `ExamplePluginTest.main` direkt als Builtin-Externals in den +RuneLite-Client injiziert — es ist **kein** Bolt-Plugin-Manifest (RS3) nötig. + +### 1. Einmalige Bolt-Konfiguration + +In `~/.config/bolt-launcher/launcher.json` muss gesetzt sein: + +```json +"runelite_use_custom_jar": true, +"runelite_custom_jar": "/home/hikyu/.local/share/bolt-launcher/custom-runelite.jar", +"osrs_launch_command": "java -jar /home/hikyu/.local/share/bolt-launcher/custom-runelite.jar" +``` + +### 2. Bauen & installieren + +```bash +./gradlew deploy +``` + +Der Befehl baut den Shadow-Jar und kopiert ihn **atomar** nach +`~/.local/share/bolt-launcher/custom-runelite.jar`, so dass er auch bei laufendem +Bolt/RuneLite sicher ersetzt werden kann (build.gradle `deploy`). + +### 3. Automatische Neuinstallation bei jeder Änderung + +Lasse den Continuous-Build im Projektordner laufen — er überwacht die Quelldateien +und installiert automatisch neu, sobald etwas geändert wird: + +```bash +./gradlew deploy -t +``` + +*(Beenden mit `Ctrl+C`. Der Watcher kompiliert und kopiert die JAR bei jeder +Quellcode-Änderung selbstständig.)* + +### 4. Start in Bolt + +1. Bolt starten → **RuneLite** wählen → einloggen. +2. „Essentials Plus" erscheint mit einem Icon in der Seitenleiste. +3. Falls nicht sichtbar: RuneLite-Hamburger → **Configuration** → **External Plugins** → „Essentials Plus" aktivieren. + +--- + +## 🪟 Windows / Jagex Launcher (offizielles RuneLite) + +Auf Windows wird RuneLite über den **Jagex Launcher** gestartet. Dort lässt sich der +Bolt-Trick (eigenes Client-JAR mit `ExamplePluginTest.main`) **nicht** verwenden, weil +der Jagex Launcher immer die offizielle RuneLite-EXE startet. Stattdessen nutzt man das +offizielle **Side-Loading** von RuneLite: Beim Client-Start lädt RuneLite alle `.jar` +Dateien aus `~\.runelite\sideloaded-plugins\` nach – sofern der **Developer Mode** +aktiviert ist. `runelite-plugin.properties` wird dabei nicht gebraucht. + +### 1. Plugin-JAR bauen (klein, ohne Client) + +```cmd +gradlew.bat jar +``` + +Ergebnis: `build\libs\bank-highlighter-1.0-SNAPSHOT.jar` (enthält nur `com.example.*` +Klassen, keine `net.runelite.*` – der richtige Jar-Typ zum Side-Loaden). + +### 2. JAR in den Side-Load-Ordner kopieren + +Ordner anlegen (falls nicht vorhanden) und das JAR hineinkopieren: + +``` +%USERPROFILE%\.runelite\sideloaded-plugins\bank-highlighter.jar +``` + +*Beispiel via Explorer: `Start` → `%USERPROFILE%\.runelite\` eingeben → Ordner +`sideloaded-plugins` erstellen → JAR hineinlegen.* + +### 3. Developer Mode in RuneLite aktivieren + +1. RuneLite über den **Jagex Launcher** starten. +2. Schraubenschlüssel (Configuration) → ganz nach unten scrollen → **RuneLite**-Reiter. +3. **„Enable Developer Mode"** aktivieren (Hinweis bestätigen). +4. RuneLite vollständig schließen und erneut starten. + +### 4. Plugin aktivieren + +Nach dem Neustart erscheinen die Plugins in der Plugin-Liste (Plug-Icon) unter +**External Plugins** (unten). + +- **Essentials Plus** aktivieren. +- Weitere: **Example Plugin**, **Live Dashboard**. + +### Automatisch aktualisieren + +Sobald sich etwas im Quellcode ändert, im Projektordner laufen lassen: + +```cmd +gradlew.bat jar -t +``` + +Der Watcher kompiliert das schlanke JAR automatisch neu. Danach **RuneLite einmal +neu starten**, damit das neue JAR geladen wird (Side-Loaded-Plugins werden nur beim +Client-Start eingelesen – nicht live). + +> ⚠️ **Hinweis:** Nach einem OSRS-Game-Update muss das JAR frisch gebaut werden +> (`gradlew.bat jar` bzw. der Watcher), damit die API (`net.runelite.api.gameval`, …) +> zum laufenden Client-Rev passt. Läuft ein älteres JAR, können Plugins mit Fehlern +> abstürzen. diff --git a/Update-EssentialsPlus.bat b/Update-EssentialsPlus.bat new file mode 100644 index 0000000..6717b13 --- /dev/null +++ b/Update-EssentialsPlus.bat @@ -0,0 +1,94 @@ +@echo off +setlocal enabledelayedexpansion +title RuneLite EssentialsPlus Updater +color 0B + +echo. +echo ======================================================== +echo RuneLite EssentialsPlus Updater +echo ======================================================== +echo. + +set "SCRIPT_DIR=%~dp0" +set "TARGET_JAR=%SCRIPT_DIR%RuneLite.jar" +set "OLD_JAR=%SCRIPT_DIR%RuneLite.jar.old" +set "TMP_JAR=%SCRIPT_DIR%RuneLite.jar.tmp" +set "DOWNLOAD_URL=https://gapspace.de/Hikyu/Runelite-EssentialsPlus/raw/branch/master/RuneLite.jar" + +:: ----------------------------------------------------------- +:: Schritt 1: Bestehende Version sichern +:: ----------------------------------------------------------- +echo [1/3] Sichere bestehende Version... +if exist "%TARGET_JAR%" ( + if exist "%OLD_JAR%" del /f /q "%OLD_JAR%" 2>nul + move /y "%TARGET_JAR%" "%OLD_JAR%" >nul + if !errorlevel! equ 0 ( + echo - Alte Version gesichert als: RuneLite.jar.old + ) else ( + echo [!] FEHLER: Konnte RuneLite.jar nicht verschieben. + echo Bitte schliesse RuneLite / Bolt Launcher und versuche es erneut. + echo. + pause + exit /b 1 + ) +) else ( + echo - Keine vorherige RuneLite.jar gefunden (Neuinstallation). +) + +echo. + +:: ----------------------------------------------------------- +:: Schritt 2: Neueste Version herunterladen +:: ----------------------------------------------------------- +echo [2/3] Lade neueste Version herunter... +echo Quelle: %DOWNLOAD_URL% +echo. + +if exist "%TMP_JAR%" del /f /q "%TMP_JAR%" 2>nul + +where curl >nul 2>nul +if !errorlevel! equ 0 ( + curl -# -L -f -o "%TMP_JAR%" "%DOWNLOAD_URL%" +) else ( + echo (Verwende PowerShell fuer Download...) + powershell -Command "[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12; (New-Object Net.WebClient).DownloadFile('%DOWNLOAD_URL%', '%TMP_JAR%')" +) + +if not exist "%TMP_JAR%" ( + echo. + echo [!] FEHLER: Download fehlgeschlagen! + if exist "%OLD_JAR%" ( + echo Stelle alte Version wieder her... + move /y "%OLD_JAR%" "%TARGET_JAR%" >nul + ) + echo. + pause + exit /b 1 +) + +:: Datei fertigstellen +move /y "%TMP_JAR%" "%TARGET_JAR%" >nul + +echo. + +:: ----------------------------------------------------------- +:: Schritt 3: Fertigstellung +:: ----------------------------------------------------------- +echo [3/3] Aktualisierung erfolgreich abgeschlossen! +echo - Datei: RuneLite.jar +echo. + +:: Optional: Falls im Bolt Launcher Verzeichnis, auch custom-runelite.jar synchronisieren +if exist "%LOCALAPPDATA%\bolt-launcher" ( + copy /y "%TARGET_JAR%" "%LOCALAPPDATA%\bolt-launcher\custom-runelite.jar" >nul 2>nul +) +if exist "%APPDATA%\bolt-launcher" ( + copy /y "%TARGET_JAR%" "%APPDATA%\bolt-launcher\custom-runelite.jar" >nul 2>nul +) + +color 0A +echo ======================================================== +echo Bereit! Du kannst RuneLite jetzt starten. +echo ======================================================== +echo. +pause diff --git a/Update-EssentialsPlus.sh b/Update-EssentialsPlus.sh new file mode 100755 index 0000000..0b51e75 --- /dev/null +++ b/Update-EssentialsPlus.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash +set -e + +# Farben für die Ausgabe +CYAN='\033[0;36m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +RED='\033[0;31m' +NC='\033[0m' # No Color + +echo -e "\n${CYAN}========================================================${NC}" +echo -e "${CYAN} RuneLite EssentialsPlus Linux Updater ${NC}" +echo -e "${CYAN}========================================================${NC}\n" + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +TARGET_JAR="$SCRIPT_DIR/RuneLite.jar" +OLD_JAR="$SCRIPT_DIR/RuneLite.jar.old" +TMP_JAR="$SCRIPT_DIR/RuneLite.jar.tmp" +DOWNLOAD_URL="https://gapspace.de/Hikyu/Runelite-EssentialsPlus/raw/branch/master/RuneLite.jar" + +# 1. Bestehende Version sichern +echo -e "${YELLOW}[1/3] Sichere bestehende Version...${NC}" +if [ -f "$TARGET_JAR" ]; then + rm -f "$OLD_JAR" + mv "$TARGET_JAR" "$OLD_JAR" + echo -e " -> Alte Version gesichert als: ${CYAN}RuneLite.jar.old${NC}" +else + echo -e " -> Keine vorherige RuneLite.jar gefunden (Neuinstallation)." +fi +echo "" + +# 2. Neueste Version herunterladen +echo -e "${YELLOW}[2/3] Lade neueste Version herunter...${NC}" +echo -e " Quelle: ${CYAN}$DOWNLOAD_URL${NC}\n" + +rm -f "$TMP_JAR" +if ! curl -# -L -f -o "$TMP_JAR" "$DOWNLOAD_URL"; then + echo -e "\n${RED}[!] FEHLER: Download fehlgeschlagen!${NC}" + if [ -f "$OLD_JAR" ]; then + echo -e " Stelle alte Version wieder her..." + mv "$OLD_JAR" "$TARGET_JAR" + fi + exit 1 +fi + +mv "$TMP_JAR" "$TARGET_JAR" +echo "" + +# 3. Fertigstellung & optionaler Bolt-Launcher-Sync +echo -e "${YELLOW}[3/3] Aktualisierung erfolgreich abgeschlossen!${NC}" +echo -e " -> Datei: ${GREEN}$TARGET_JAR${NC}" + +BOLT_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/bolt-launcher" +if [ -d "$BOLT_DIR" ]; then + cp "$TARGET_JAR" "$BOLT_DIR/custom-runelite.jar" + echo -e " -> Bolt Launcher synchronisiert: ${GREEN}$BOLT_DIR/custom-runelite.jar${NC}" +fi + +echo -e "\n${GREEN}========================================================${NC}" +echo -e "${GREEN} Bereit! Du kannst RuneLite jetzt starten. ${NC}" +echo -e "${GREEN}========================================================${NC}\n" diff --git a/build.gradle b/build.gradle new file mode 100644 index 0000000..849f068 --- /dev/null +++ b/build.gradle @@ -0,0 +1,102 @@ +plugins { + id 'java' +} + +repositories { + mavenLocal() + maven { + url = 'https://repo.runelite.net' + content { + includeGroupByRegex("net\\.runelite.*") + } + } + mavenCentral() +} + +def runeLiteVersion = 'latest.release' +def pluginMainClass = 'com.example.ExamplePluginTest' + +dependencies { + compileOnly group: 'net.runelite', name: 'client', version: runeLiteVersion + + compileOnly 'org.projectlombok:lombok:1.18.30' + annotationProcessor 'org.projectlombok:lombok:1.18.30' + + testImplementation 'junit:junit:4.12' + testImplementation group: 'net.runelite', name: 'client', version: runeLiteVersion + testImplementation group: 'net.runelite', name: 'jshell', version: runeLiteVersion + testCompileOnly 'org.projectlombok:lombok:1.18.30' + testAnnotationProcessor 'org.projectlombok:lombok:1.18.30' +} + +group = 'com.example' +version = '1.0-SNAPSHOT' + +tasks.withType(JavaCompile).configureEach { + options.encoding = 'UTF-8' + options.release.set(11) +} + +tasks.register('run', JavaExec) { + classpath = sourceSets.test.runtimeClasspath + mainClass = pluginMainClass + + jvmArgs "-ea" + args "--developer-mode", "--debug" +} + +tasks.register('shadowJar', Jar) { + dependsOn configurations.testRuntimeClasspath + manifest { + attributes('Main-Class': pluginMainClass, 'Multi-Release': true) + } + + duplicatesStrategy = DuplicatesStrategy.EXCLUDE + from sourceSets.main.output + from sourceSets.test.output + from { + configurations.testRuntimeClasspath.collect { file -> + file.isDirectory() ? file : zipTree(file) + } + } + + exclude 'META-INF/INDEX.LIST' + exclude 'META-INF/*.SF' + exclude 'META-INF/*.DSA' + exclude 'META-INF/*.RSA' + exclude '**/module-info.class' + + group = BasePlugin.BUILD_GROUP + archiveClassifier.set('shadow') + archiveFileName.set("${rootProject.name}-${project.version}-all.jar") +} + +tasks.register('deploy') { + dependsOn 'shadowJar' + doLast { + def destDir = new File("${System.getProperty('user.home')}/.local/share/bolt-launcher") + destDir.mkdirs() + def srcFile = file("build/libs/${rootProject.name}-${project.version}-all.jar") + def targetFile = new File(destDir, "custom-runelite.jar") + def tmpFile = new File(destDir, "custom-runelite.jar.tmp") + + // Copy to temporary file first + tmpFile.bytes = srcFile.bytes + + // Atomic move (keeps open file descriptors valid on running Linux processes) + java.nio.file.Files.move( + tmpFile.toPath(), + targetFile.toPath(), + java.nio.file.StandardCopyOption.ATOMIC_MOVE, + java.nio.file.StandardCopyOption.REPLACE_EXISTING + ) + // Also copy to Downloads as documented in CLAUDE.md + def downloadsDir = new File("${System.getProperty('user.home')}/Downloads") + if (downloadsDir.exists()) { + new File(downloadsDir, "RuneLite.jar").bytes = srcFile.bytes + } + println "Plugin sicher und atomar nach ~/.local/share/bolt-launcher/custom-runelite.jar installiert!" + } +} + + diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..a4b76b9530d66f5e68d973ea569d8e19de379189 GIT binary patch literal 43583 zcma&N1CXTcmMvW9vTb(Rwr$&4wr$(C?dmSu>@vG-+vuvg^_??!{yS%8zW-#zn-LkA z5&1^$^{lnmUON?}LBF8_K|(?T0Ra(xUH{($5eN!MR#ZihR#HxkUPe+_R8Cn`RRs(P z_^*#_XlXmGv7!4;*Y%p4nw?{bNp@UZHv1?Um8r6)Fei3p@ClJn0ECfg1hkeuUU@Or zDaPa;U3fE=3L}DooL;8f;P0ipPt0Z~9P0)lbStMS)ag54=uL9ia-Lm3nh|@(Y?B`; zx_#arJIpXH!U{fbCbI^17}6Ri*H<>OLR%c|^mh8+)*h~K8Z!9)DPf zR2h?lbDZQ`p9P;&DQ4F0sur@TMa!Y}S8irn(%d-gi0*WxxCSk*A?3lGh=gcYN?FGl z7D=Js!i~0=u3rox^eO3i@$0=n{K1lPNU zwmfjRVmLOCRfe=seV&P*1Iq=^i`502keY8Uy-WNPwVNNtJFx?IwAyRPZo2Wo1+S(xF37LJZ~%i)kpFQ3Fw=mXfd@>%+)RpYQLnr}B~~zoof(JVm^^&f zxKV^+3D3$A1G;qh4gPVjhrC8e(VYUHv#dy^)(RoUFM?o%W-EHxufuWf(l*@-l+7vt z=l`qmR56K~F|v<^Pd*p~1_y^P0P^aPC##d8+HqX4IR1gu+7w#~TBFphJxF)T$2WEa zxa?H&6=Qe7d(#tha?_1uQys2KtHQ{)Qco)qwGjrdNL7thd^G5i8Os)CHqc>iOidS} z%nFEDdm=GXBw=yXe1W-ShHHFb?Cc70+$W~z_+}nAoHFYI1MV1wZegw*0y^tC*s%3h zhD3tN8b=Gv&rj}!SUM6|ajSPp*58KR7MPpI{oAJCtY~JECm)*m_x>AZEu>DFgUcby z1Qaw8lU4jZpQ_$;*7RME+gq1KySGG#Wql>aL~k9tLrSO()LWn*q&YxHEuzmwd1?aAtI zBJ>P=&$=l1efe1CDU;`Fd+_;&wI07?V0aAIgc(!{a z0Jg6Y=inXc3^n!U0Atk`iCFIQooHqcWhO(qrieUOW8X(x?(RD}iYDLMjSwffH2~tB z)oDgNBLB^AJBM1M^c5HdRx6fBfka`(LD-qrlh5jqH~);#nw|iyp)()xVYak3;Ybik z0j`(+69aK*B>)e_p%=wu8XC&9e{AO4c~O1U`5X9}?0mrd*m$_EUek{R?DNSh(=br# z#Q61gBzEpmy`$pA*6!87 zSDD+=@fTY7<4A?GLqpA?Pb2z$pbCc4B4zL{BeZ?F-8`s$?>*lXXtn*NC61>|*w7J* z$?!iB{6R-0=KFmyp1nnEmLsA-H0a6l+1uaH^g%c(p{iT&YFrbQ$&PRb8Up#X3@Zsk zD^^&LK~111%cqlP%!_gFNa^dTYT?rhkGl}5=fL{a`UViaXWI$k-UcHJwmaH1s=S$4 z%4)PdWJX;hh5UoK?6aWoyLxX&NhNRqKam7tcOkLh{%j3K^4Mgx1@i|Pi&}<^5>hs5 zm8?uOS>%)NzT(%PjVPGa?X%`N2TQCKbeH2l;cTnHiHppPSJ<7y-yEIiC!P*ikl&!B z%+?>VttCOQM@ShFguHVjxX^?mHX^hSaO_;pnyh^v9EumqSZTi+#f&_Vaija0Q-e*| z7ulQj6Fs*bbmsWp{`auM04gGwsYYdNNZcg|ph0OgD>7O}Asn7^Z=eI>`$2*v78;sj-}oMoEj&@)9+ycEOo92xSyY344^ z11Hb8^kdOvbf^GNAK++bYioknrpdN>+u8R?JxG=!2Kd9r=YWCOJYXYuM0cOq^FhEd zBg2puKy__7VT3-r*dG4c62Wgxi52EMCQ`bKgf*#*ou(D4-ZN$+mg&7$u!! z-^+Z%;-3IDwqZ|K=ah85OLwkO zKxNBh+4QHh)u9D?MFtpbl)us}9+V!D%w9jfAMYEb>%$A;u)rrI zuBudh;5PN}_6J_}l55P3l_)&RMlH{m!)ai-i$g)&*M`eN$XQMw{v^r@-125^RRCF0 z^2>|DxhQw(mtNEI2Kj(;KblC7x=JlK$@78`O~>V!`|1Lm-^JR$-5pUANAnb(5}B}JGjBsliK4& zk6y(;$e&h)lh2)L=bvZKbvh@>vLlreBdH8No2>$#%_Wp1U0N7Ank!6$dFSi#xzh|( zRi{Uw%-4W!{IXZ)fWx@XX6;&(m_F%c6~X8hx=BN1&q}*( zoaNjWabE{oUPb!Bt$eyd#$5j9rItB-h*5JiNi(v^e|XKAj*8(k<5-2$&ZBR5fF|JA z9&m4fbzNQnAU}r8ab>fFV%J0z5awe#UZ|bz?Ur)U9bCIKWEzi2%A+5CLqh?}K4JHi z4vtM;+uPsVz{Lfr;78W78gC;z*yTch~4YkLr&m-7%-xc ztw6Mh2d>_iO*$Rd8(-Cr1_V8EO1f*^@wRoSozS) zy1UoC@pruAaC8Z_7~_w4Q6n*&B0AjOmMWa;sIav&gu z|J5&|{=a@vR!~k-OjKEgPFCzcJ>#A1uL&7xTDn;{XBdeM}V=l3B8fE1--DHjSaxoSjNKEM9|U9#m2<3>n{Iuo`r3UZp;>GkT2YBNAh|b z^jTq-hJp(ebZh#Lk8hVBP%qXwv-@vbvoREX$TqRGTgEi$%_F9tZES@z8Bx}$#5eeG zk^UsLBH{bc2VBW)*EdS({yw=?qmevwi?BL6*=12k9zM5gJv1>y#ML4!)iiPzVaH9% zgSImetD@dam~e>{LvVh!phhzpW+iFvWpGT#CVE5TQ40n%F|p(sP5mXxna+Ev7PDwA zamaV4m*^~*xV+&p;W749xhb_X=$|LD;FHuB&JL5?*Y2-oIT(wYY2;73<^#46S~Gx| z^cez%V7x$81}UWqS13Gz80379Rj;6~WdiXWOSsdmzY39L;Hg3MH43o*y8ibNBBH`(av4|u;YPq%{R;IuYow<+GEsf@R?=@tT@!}?#>zIIn0CoyV!hq3mw zHj>OOjfJM3F{RG#6ujzo?y32m^tgSXf@v=J$ELdJ+=5j|=F-~hP$G&}tDZsZE?5rX ztGj`!S>)CFmdkccxM9eGIcGnS2AfK#gXwj%esuIBNJQP1WV~b~+D7PJTmWGTSDrR` zEAu4B8l>NPuhsk5a`rReSya2nfV1EK01+G!x8aBdTs3Io$u5!6n6KX%uv@DxAp3F@{4UYg4SWJtQ-W~0MDb|j-$lwVn znAm*Pl!?Ps&3wO=R115RWKb*JKoexo*)uhhHBncEDMSVa_PyA>k{Zm2(wMQ(5NM3# z)jkza|GoWEQo4^s*wE(gHz?Xsg4`}HUAcs42cM1-qq_=+=!Gk^y710j=66(cSWqUe zklbm8+zB_syQv5A2rj!Vbw8;|$@C!vfNmNV!yJIWDQ>{+2x zKjuFX`~~HKG~^6h5FntRpnnHt=D&rq0>IJ9#F0eM)Y-)GpRjiN7gkA8wvnG#K=q{q z9dBn8_~wm4J<3J_vl|9H{7q6u2A!cW{bp#r*-f{gOV^e=8S{nc1DxMHFwuM$;aVI^ zz6A*}m8N-&x8;aunp1w7_vtB*pa+OYBw=TMc6QK=mbA-|Cf* zvyh8D4LRJImooUaSb7t*fVfih<97Gf@VE0|z>NcBwBQze);Rh!k3K_sfunToZY;f2 z^HmC4KjHRVg+eKYj;PRN^|E0>Gj_zagfRbrki68I^#~6-HaHg3BUW%+clM1xQEdPYt_g<2K+z!$>*$9nQ>; zf9Bei{?zY^-e{q_*|W#2rJG`2fy@{%6u0i_VEWTq$*(ZN37|8lFFFt)nCG({r!q#9 z5VK_kkSJ3?zOH)OezMT{!YkCuSSn!K#-Rhl$uUM(bq*jY? zi1xbMVthJ`E>d>(f3)~fozjg^@eheMF6<)I`oeJYx4*+M&%c9VArn(OM-wp%M<-`x z7sLP1&3^%Nld9Dhm@$3f2}87!quhI@nwd@3~fZl_3LYW-B?Ia>ui`ELg z&Qfe!7m6ze=mZ`Ia9$z|ARSw|IdMpooY4YiPN8K z4B(ts3p%2i(Td=tgEHX z0UQ_>URBtG+-?0E;E7Ld^dyZ;jjw0}XZ(}-QzC6+NN=40oDb2^v!L1g9xRvE#@IBR zO!b-2N7wVfLV;mhEaXQ9XAU+>=XVA6f&T4Z-@AX!leJ8obP^P^wP0aICND?~w&NykJ#54x3_@r7IDMdRNy4Hh;h*!u(Ol(#0bJdwEo$5437-UBjQ+j=Ic>Q2z` zJNDf0yO6@mr6y1#n3)s(W|$iE_i8r@Gd@!DWDqZ7J&~gAm1#~maIGJ1sls^gxL9LLG_NhU!pTGty!TbhzQnu)I*S^54U6Yu%ZeCg`R>Q zhBv$n5j0v%O_j{QYWG!R9W?5_b&67KB$t}&e2LdMvd(PxN6Ir!H4>PNlerpBL>Zvyy!yw z-SOo8caEpDt(}|gKPBd$qND5#a5nju^O>V&;f890?yEOfkSG^HQVmEbM3Ugzu+UtH zC(INPDdraBN?P%kE;*Ae%Wto&sgw(crfZ#Qy(<4nk;S|hD3j{IQRI6Yq|f^basLY; z-HB&Je%Gg}Jt@={_C{L$!RM;$$|iD6vu#3w?v?*;&()uB|I-XqEKqZPS!reW9JkLewLb!70T7n`i!gNtb1%vN- zySZj{8-1>6E%H&=V}LM#xmt`J3XQoaD|@XygXjdZ1+P77-=;=eYpoEQ01B@L*a(uW zrZeZz?HJsw_4g0vhUgkg@VF8<-X$B8pOqCuWAl28uB|@r`19DTUQQsb^pfqB6QtiT z*`_UZ`fT}vtUY#%sq2{rchyfu*pCg;uec2$-$N_xgjZcoumE5vSI{+s@iLWoz^Mf; zuI8kDP{!XY6OP~q5}%1&L}CtfH^N<3o4L@J@zg1-mt{9L`s^z$Vgb|mr{@WiwAqKg zp#t-lhrU>F8o0s1q_9y`gQNf~Vb!F%70f}$>i7o4ho$`uciNf=xgJ>&!gSt0g;M>*x4-`U)ysFW&Vs^Vk6m%?iuWU+o&m(2Jm26Y(3%TL; zA7T)BP{WS!&xmxNw%J=$MPfn(9*^*TV;$JwRy8Zl*yUZi8jWYF>==j~&S|Xinsb%c z2?B+kpet*muEW7@AzjBA^wAJBY8i|#C{WtO_or&Nj2{=6JTTX05}|H>N2B|Wf!*3_ z7hW*j6p3TvpghEc6-wufFiY!%-GvOx*bZrhZu+7?iSrZL5q9}igiF^*R3%DE4aCHZ zqu>xS8LkW+Auv%z-<1Xs92u23R$nk@Pk}MU5!gT|c7vGlEA%G^2th&Q*zfg%-D^=f z&J_}jskj|Q;73NP4<4k*Y%pXPU2Thoqr+5uH1yEYM|VtBPW6lXaetokD0u z9qVek6Q&wk)tFbQ8(^HGf3Wp16gKmr>G;#G(HRBx?F`9AIRboK+;OfHaLJ(P>IP0w zyTbTkx_THEOs%Q&aPrxbZrJlio+hCC_HK<4%f3ZoSAyG7Dn`=X=&h@m*|UYO-4Hq0 z-Bq&+Ie!S##4A6OGoC~>ZW`Y5J)*ouaFl_e9GA*VSL!O_@xGiBw!AF}1{tB)z(w%c zS1Hmrb9OC8>0a_$BzeiN?rkPLc9%&;1CZW*4}CDDNr2gcl_3z+WC15&H1Zc2{o~i) z)LLW=WQ{?ricmC`G1GfJ0Yp4Dy~Ba;j6ZV4r{8xRs`13{dD!xXmr^Aga|C=iSmor% z8hi|pTXH)5Yf&v~exp3o+sY4B^^b*eYkkCYl*T{*=-0HniSA_1F53eCb{x~1k3*`W zr~};p1A`k{1DV9=UPnLDgz{aJH=-LQo<5%+Em!DNN252xwIf*wF_zS^!(XSm(9eoj z=*dXG&n0>)_)N5oc6v!>-bd(2ragD8O=M|wGW z!xJQS<)u70m&6OmrF0WSsr@I%T*c#Qo#Ha4d3COcX+9}hM5!7JIGF>7<~C(Ear^Sn zm^ZFkV6~Ula6+8S?oOROOA6$C&q&dp`>oR-2Ym3(HT@O7Sd5c~+kjrmM)YmgPH*tL zX+znN>`tv;5eOfX?h{AuX^LK~V#gPCu=)Tigtq9&?7Xh$qN|%A$?V*v=&-2F$zTUv z`C#WyIrChS5|Kgm_GeudCFf;)!WH7FI60j^0o#65o6`w*S7R@)88n$1nrgU(oU0M9 zx+EuMkC>(4j1;m6NoGqEkpJYJ?vc|B zOlwT3t&UgL!pX_P*6g36`ZXQ; z9~Cv}ANFnJGp(;ZhS(@FT;3e)0)Kp;h^x;$*xZn*k0U6-&FwI=uOGaODdrsp-!K$Ac32^c{+FhI-HkYd5v=`PGsg%6I`4d9Jy)uW0y%) zm&j^9WBAp*P8#kGJUhB!L?a%h$hJgQrx!6KCB_TRo%9{t0J7KW8!o1B!NC)VGLM5! zpZy5Jc{`r{1e(jd%jsG7k%I+m#CGS*BPA65ZVW~fLYw0dA-H_}O zrkGFL&P1PG9p2(%QiEWm6x;U-U&I#;Em$nx-_I^wtgw3xUPVVu zqSuKnx&dIT-XT+T10p;yjo1Y)z(x1fb8Dzfn8e yu?e%!_ptzGB|8GrCfu%p?(_ zQccdaaVK$5bz;*rnyK{_SQYM>;aES6Qs^lj9lEs6_J+%nIiuQC*fN;z8md>r_~Mfl zU%p5Dt_YT>gQqfr@`cR!$NWr~+`CZb%dn;WtzrAOI>P_JtsB76PYe*<%H(y>qx-`Kq!X_; z<{RpAqYhE=L1r*M)gNF3B8r(<%8mo*SR2hu zccLRZwGARt)Hlo1euqTyM>^!HK*!Q2P;4UYrysje@;(<|$&%vQekbn|0Ruu_Io(w4#%p6ld2Yp7tlA`Y$cciThP zKzNGIMPXX%&Ud0uQh!uQZz|FB`4KGD?3!ND?wQt6!n*f4EmCoJUh&b?;B{|lxs#F- z31~HQ`SF4x$&v00@(P+j1pAaj5!s`)b2RDBp*PB=2IB>oBF!*6vwr7Dp%zpAx*dPr zb@Zjq^XjN?O4QcZ*O+8>)|HlrR>oD*?WQl5ri3R#2?*W6iJ>>kH%KnnME&TT@ZzrHS$Q%LC?n|e>V+D+8D zYc4)QddFz7I8#}y#Wj6>4P%34dZH~OUDb?uP%-E zwjXM(?Sg~1!|wI(RVuxbu)-rH+O=igSho_pDCw(c6b=P zKk4ATlB?bj9+HHlh<_!&z0rx13K3ZrAR8W)!@Y}o`?a*JJsD+twZIv`W)@Y?Amu_u zz``@-e2X}27$i(2=9rvIu5uTUOVhzwu%mNazS|lZb&PT;XE2|B&W1>=B58#*!~D&) zfVmJGg8UdP*fx(>Cj^?yS^zH#o-$Q-*$SnK(ZVFkw+er=>N^7!)FtP3y~Xxnu^nzY zikgB>Nj0%;WOltWIob|}%lo?_C7<``a5hEkx&1ku$|)i>Rh6@3h*`slY=9U}(Ql_< zaNG*J8vb&@zpdhAvv`?{=zDedJ23TD&Zg__snRAH4eh~^oawdYi6A3w8<Ozh@Kw)#bdktM^GVb zrG08?0bG?|NG+w^&JvD*7LAbjED{_Zkc`3H!My>0u5Q}m!+6VokMLXxl`Mkd=g&Xx z-a>m*#G3SLlhbKB!)tnzfWOBV;u;ftU}S!NdD5+YtOjLg?X}dl>7m^gOpihrf1;PY zvll&>dIuUGs{Qnd- zwIR3oIrct8Va^Tm0t#(bJD7c$Z7DO9*7NnRZorrSm`b`cxz>OIC;jSE3DO8`hX955ui`s%||YQtt2 z5DNA&pG-V+4oI2s*x^>-$6J?p=I>C|9wZF8z;VjR??Icg?1w2v5Me+FgAeGGa8(3S z4vg*$>zC-WIVZtJ7}o9{D-7d>zCe|z#<9>CFve-OPAYsneTb^JH!Enaza#j}^mXy1 z+ULn^10+rWLF6j2>Ya@@Kq?26>AqK{A_| zQKb*~F1>sE*=d?A?W7N2j?L09_7n+HGi{VY;MoTGr_)G9)ot$p!-UY5zZ2Xtbm=t z@dpPSGwgH=QtIcEulQNI>S-#ifbnO5EWkI;$A|pxJd885oM+ zGZ0_0gDvG8q2xebj+fbCHYfAXuZStH2j~|d^sBAzo46(K8n59+T6rzBwK)^rfPT+B zyIFw)9YC-V^rhtK`!3jrhmW-sTmM+tPH+;nwjL#-SjQPUZ53L@A>y*rt(#M(qsiB2 zx6B)dI}6Wlsw%bJ8h|(lhkJVogQZA&n{?Vgs6gNSXzuZpEyu*xySy8ro07QZ7Vk1!3tJphN_5V7qOiyK8p z#@jcDD8nmtYi1^l8ml;AF<#IPK?!pqf9D4moYk>d99Im}Jtwj6c#+A;f)CQ*f-hZ< z=p_T86jog%!p)D&5g9taSwYi&eP z#JuEK%+NULWus;0w32-SYFku#i}d~+{Pkho&^{;RxzP&0!RCm3-9K6`>KZpnzS6?L z^H^V*s!8<>x8bomvD%rh>Zp3>Db%kyin;qtl+jAv8Oo~1g~mqGAC&Qi_wy|xEt2iz zWAJEfTV%cl2Cs<1L&DLRVVH05EDq`pH7Oh7sR`NNkL%wi}8n>IXcO40hp+J+sC!W?!krJf!GJNE8uj zg-y~Ns-<~D?yqbzVRB}G>0A^f0!^N7l=$m0OdZuqAOQqLc zX?AEGr1Ht+inZ-Qiwnl@Z0qukd__a!C*CKuGdy5#nD7VUBM^6OCpxCa2A(X;e0&V4 zM&WR8+wErQ7UIc6LY~Q9x%Sn*Tn>>P`^t&idaOEnOd(Ufw#>NoR^1QdhJ8s`h^|R_ zXX`c5*O~Xdvh%q;7L!_!ohf$NfEBmCde|#uVZvEo>OfEq%+Ns7&_f$OR9xsihRpBb z+cjk8LyDm@U{YN>+r46?nn{7Gh(;WhFw6GAxtcKD+YWV?uge>;+q#Xx4!GpRkVZYu zzsF}1)7$?%s9g9CH=Zs+B%M_)+~*j3L0&Q9u7!|+T`^O{xE6qvAP?XWv9_MrZKdo& z%IyU)$Q95AB4!#hT!_dA>4e@zjOBD*Y=XjtMm)V|+IXzjuM;(l+8aA5#Kaz_$rR6! zj>#&^DidYD$nUY(D$mH`9eb|dtV0b{S>H6FBfq>t5`;OxA4Nn{J(+XihF(stSche7$es&~N$epi&PDM_N`As;*9D^L==2Q7Z2zD+CiU(|+-kL*VG+&9!Yb3LgPy?A zm7Z&^qRG_JIxK7-FBzZI3Q<;{`DIxtc48k> zc|0dmX;Z=W$+)qE)~`yn6MdoJ4co;%!`ddy+FV538Y)j(vg}5*k(WK)KWZ3WaOG!8 z!syGn=s{H$odtpqFrT#JGM*utN7B((abXnpDM6w56nhw}OY}0TiTG1#f*VFZr+^-g zbP10`$LPq_;PvrA1XXlyx2uM^mrjTzX}w{yuLo-cOClE8MMk47T25G8M!9Z5ypOSV zAJUBGEg5L2fY)ZGJb^E34R2zJ?}Vf>{~gB!8=5Z) z9y$>5c)=;o0HeHHSuE4U)#vG&KF|I%-cF6f$~pdYJWk_dD}iOA>iA$O$+4%@>JU08 zS`ep)$XLPJ+n0_i@PkF#ri6T8?ZeAot$6JIYHm&P6EB=BiaNY|aA$W0I+nz*zkz_z zkEru!tj!QUffq%)8y0y`T&`fuus-1p>=^hnBiBqD^hXrPs`PY9tU3m0np~rISY09> z`P3s=-kt_cYcxWd{de@}TwSqg*xVhp;E9zCsnXo6z z?f&Sv^U7n4`xr=mXle94HzOdN!2kB~4=%)u&N!+2;z6UYKUDqi-s6AZ!haB;@&B`? z_TRX0%@suz^TRdCb?!vNJYPY8L_}&07uySH9%W^Tc&1pia6y1q#?*Drf}GjGbPjBS zbOPcUY#*$3sL2x4v_i*Y=N7E$mR}J%|GUI(>WEr+28+V z%v5{#e!UF*6~G&%;l*q*$V?&r$Pp^sE^i-0$+RH3ERUUdQ0>rAq2(2QAbG}$y{de( z>{qD~GGuOk559Y@%$?N^1ApVL_a704>8OD%8Y%8B;FCt%AoPu8*D1 zLB5X>b}Syz81pn;xnB}%0FnwazlWfUV)Z-~rZg6~b z6!9J$EcE&sEbzcy?CI~=boWA&eeIa%z(7SE^qgVLz??1Vbc1*aRvc%Mri)AJaAG!p z$X!_9Ds;Zz)f+;%s&dRcJt2==P{^j3bf0M=nJd&xwUGlUFn?H=2W(*2I2Gdu zv!gYCwM10aeus)`RIZSrCK=&oKaO_Ry~D1B5!y0R=%!i2*KfXGYX&gNv_u+n9wiR5 z*e$Zjju&ODRW3phN925%S(jL+bCHv6rZtc?!*`1TyYXT6%Ju=|X;6D@lq$8T zW{Y|e39ioPez(pBH%k)HzFITXHvnD6hw^lIoUMA;qAJ^CU?top1fo@s7xT13Fvn1H z6JWa-6+FJF#x>~+A;D~;VDs26>^oH0EI`IYT2iagy23?nyJ==i{g4%HrAf1-*v zK1)~@&(KkwR7TL}L(A@C_S0G;-GMDy=MJn2$FP5s<%wC)4jC5PXoxrQBFZ_k0P{{s@sz+gX`-!=T8rcB(=7vW}^K6oLWMmp(rwDh}b zwaGGd>yEy6fHv%jM$yJXo5oMAQ>c9j`**}F?MCry;T@47@r?&sKHgVe$MCqk#Z_3S z1GZI~nOEN*P~+UaFGnj{{Jo@16`(qVNtbU>O0Hf57-P>x8Jikp=`s8xWs^dAJ9lCQ z)GFm+=OV%AMVqVATtN@|vp61VVAHRn87}%PC^RAzJ%JngmZTasWBAWsoAqBU+8L8u z4A&Pe?fmTm0?mK-BL9t+{y7o(7jm+RpOhL9KnY#E&qu^}B6=K_dB}*VlSEiC9fn)+V=J;OnN)Ta5v66ic1rG+dGAJ1 z1%Zb_+!$=tQ~lxQrzv3x#CPb?CekEkA}0MYSgx$Jdd}q8+R=ma$|&1a#)TQ=l$1tQ z=tL9&_^vJ)Pk}EDO-va`UCT1m#Uty1{v^A3P~83_#v^ozH}6*9mIjIr;t3Uv%@VeW zGL6(CwCUp)Jq%G0bIG%?{_*Y#5IHf*5M@wPo6A{$Um++Co$wLC=J1aoG93&T7Ho}P z=mGEPP7GbvoG!uD$k(H3A$Z))+i{Hy?QHdk>3xSBXR0j!11O^mEe9RHmw!pvzv?Ua~2_l2Yh~_!s1qS`|0~0)YsbHSz8!mG)WiJE| z2f($6TQtt6L_f~ApQYQKSb=`053LgrQq7G@98#igV>y#i==-nEjQ!XNu9 z~;mE+gtj4IDDNQJ~JVk5Ux6&LCSFL!y=>79kE9=V}J7tD==Ga+IW zX)r7>VZ9dY=V&}DR))xUoV!u(Z|%3ciQi_2jl}3=$Agc(`RPb z8kEBpvY>1FGQ9W$n>Cq=DIpski};nE)`p3IUw1Oz0|wxll^)4dq3;CCY@RyJgFgc# zKouFh!`?Xuo{IMz^xi-h=StCis_M7yq$u) z?XHvw*HP0VgR+KR6wI)jEMX|ssqYvSf*_3W8zVTQzD?3>H!#>InzpSO)@SC8q*ii- z%%h}_#0{4JG;Jm`4zg};BPTGkYamx$Xo#O~lBirRY)q=5M45n{GCfV7h9qwyu1NxOMoP4)jjZMxmT|IQQh0U7C$EbnMN<3)Kk?fFHYq$d|ICu>KbY_hO zTZM+uKHe(cIZfEqyzyYSUBZa8;Fcut-GN!HSA9ius`ltNebF46ZX_BbZNU}}ZOm{M2&nANL9@0qvih15(|`S~z}m&h!u4x~(%MAO$jHRWNfuxWF#B)E&g3ghSQ9|> z(MFaLQj)NE0lowyjvg8z0#m6FIuKE9lDO~Glg}nSb7`~^&#(Lw{}GVOS>U)m8bF}x zVjbXljBm34Cs-yM6TVusr+3kYFjr28STT3g056y3cH5Tmge~ASxBj z%|yb>$eF;WgrcOZf569sDZOVwoo%8>XO>XQOX1OyN9I-SQgrm;U;+#3OI(zrWyow3 zk==|{lt2xrQ%FIXOTejR>;wv(Pb8u8}BUpx?yd(Abh6? zsoO3VYWkeLnF43&@*#MQ9-i-d0t*xN-UEyNKeyNMHw|A(k(_6QKO=nKMCxD(W(Yop zsRQ)QeL4X3Lxp^L%wzi2-WVSsf61dqliPUM7srDB?Wm6Lzn0&{*}|IsKQW;02(Y&| zaTKv|`U(pSzuvR6Rduu$wzK_W-Y-7>7s?G$)U}&uK;<>vU}^^ns@Z!p+9?St1s)dG zK%y6xkPyyS1$~&6v{kl?Md6gwM|>mt6Upm>oa8RLD^8T{0?HC!Z>;(Bob7el(DV6x zi`I)$&E&ngwFS@bi4^xFLAn`=fzTC;aimE^!cMI2n@Vo%Ae-ne`RF((&5y6xsjjAZ zVguVoQ?Z9uk$2ON;ersE%PU*xGO@T*;j1BO5#TuZKEf(mB7|g7pcEA=nYJ{s3vlbg zd4-DUlD{*6o%Gc^N!Nptgay>j6E5;3psI+C3Q!1ZIbeCubW%w4pq9)MSDyB{HLm|k zxv-{$$A*pS@csolri$Ge<4VZ}e~78JOL-EVyrbxKra^d{?|NnPp86!q>t<&IP07?Z z^>~IK^k#OEKgRH+LjllZXk7iA>2cfH6+(e&9ku5poo~6y{GC5>(bRK7hwjiurqAiZ zg*DmtgY}v83IjE&AbiWgMyFbaRUPZ{lYiz$U^&Zt2YjG<%m((&_JUbZcfJ22(>bi5 z!J?<7AySj0JZ&<-qXX;mcV!f~>G=sB0KnjWca4}vrtunD^1TrpfeS^4dvFr!65knK zZh`d;*VOkPs4*-9kL>$GP0`(M!j~B;#x?Ba~&s6CopvO86oM?-? zOw#dIRc;6A6T?B`Qp%^<U5 z19x(ywSH$_N+Io!6;e?`tWaM$`=Db!gzx|lQ${DG!zb1Zl&|{kX0y6xvO1o z220r<-oaS^^R2pEyY;=Qllqpmue|5yI~D|iI!IGt@iod{Opz@*ml^w2bNs)p`M(Io z|E;;m*Xpjd9l)4G#KaWfV(t8YUn@A;nK^#xgv=LtnArX|vWQVuw3}B${h+frU2>9^ z!l6)!Uo4`5k`<<;E(ido7M6lKTgWezNLq>U*=uz&s=cc$1%>VrAeOoUtA|T6gO4>UNqsdK=NF*8|~*sl&wI=x9-EGiq*aqV!(VVXA57 zw9*o6Ir8Lj1npUXvlevtn(_+^X5rzdR>#(}4YcB9O50q97%rW2me5_L=%ffYPUSRc z!vv?Kv>dH994Qi>U(a<0KF6NH5b16enCp+mw^Hb3Xs1^tThFpz!3QuN#}KBbww`(h z7GO)1olDqy6?T$()R7y%NYx*B0k_2IBiZ14&8|JPFxeMF{vW>HF-Vi3+ZOI=+qP}n zw(+!WcTd~4ZJX1!ZM&y!+uyt=&i!+~d(V%GjH;-NsEEv6nS1TERt|RHh!0>W4+4pp z1-*EzAM~i`+1f(VEHI8So`S`akPfPTfq*`l{Fz`hS%k#JS0cjT2mS0#QLGf=J?1`he3W*;m4)ce8*WFq1sdP=~$5RlH1EdWm|~dCvKOi4*I_96{^95p#B<(n!d?B z=o`0{t+&OMwKcxiBECznJcfH!fL(z3OvmxP#oWd48|mMjpE||zdiTBdWelj8&Qosv zZFp@&UgXuvJw5y=q6*28AtxZzo-UUpkRW%ne+Ylf!V-0+uQXBW=5S1o#6LXNtY5!I z%Rkz#(S8Pjz*P7bqB6L|M#Er{|QLae-Y{KA>`^} z@lPjeX>90X|34S-7}ZVXe{wEei1<{*e8T-Nbj8JmD4iwcE+Hg_zhkPVm#=@b$;)h6 z<<6y`nPa`f3I6`!28d@kdM{uJOgM%`EvlQ5B2bL)Sl=|y@YB3KeOzz=9cUW3clPAU z^sYc}xf9{4Oj?L5MOlYxR{+>w=vJjvbyO5}ptT(o6dR|ygO$)nVCvNGnq(6;bHlBd zl?w-|plD8spjDF03g5ip;W3Z z><0{BCq!Dw;h5~#1BuQilq*TwEu)qy50@+BE4bX28+7erX{BD4H)N+7U`AVEuREE8 z;X?~fyhF-x_sRfHIj~6f(+^@H)D=ngP;mwJjxhQUbUdzk8f94Ab%59-eRIq?ZKrwD z(BFI=)xrUlgu(b|hAysqK<}8bslmNNeD=#JW*}^~Nrswn^xw*nL@Tx!49bfJecV&KC2G4q5a!NSv)06A_5N3Y?veAz;Gv+@U3R% z)~UA8-0LvVE{}8LVDOHzp~2twReqf}ODIyXMM6=W>kL|OHcx9P%+aJGYi_Om)b!xe zF40Vntn0+VP>o<$AtP&JANjXBn7$}C@{+@3I@cqlwR2MdwGhVPxlTIcRVu@Ho-wO` z_~Or~IMG)A_`6-p)KPS@cT9mu9RGA>dVh5wY$NM9-^c@N=hcNaw4ITjm;iWSP^ZX| z)_XpaI61<+La+U&&%2a z0za$)-wZP@mwSELo#3!PGTt$uy0C(nTT@9NX*r3Ctw6J~7A(m#8fE)0RBd`TdKfAT zCf@$MAxjP`O(u9s@c0Fd@|}UQ6qp)O5Q5DPCeE6mSIh|Rj{$cAVIWsA=xPKVKxdhg zLzPZ`3CS+KIO;T}0Ip!fAUaNU>++ZJZRk@I(h<)RsJUhZ&Ru9*!4Ptn;gX^~4E8W^TSR&~3BAZc#HquXn)OW|TJ`CTahk+{qe`5+ixON^zA9IFd8)kc%*!AiLu z>`SFoZ5bW-%7}xZ>gpJcx_hpF$2l+533{gW{a7ce^B9sIdmLrI0)4yivZ^(Vh@-1q zFT!NQK$Iz^xu%|EOK=n>ug;(7J4OnS$;yWmq>A;hsD_0oAbLYhW^1Vdt9>;(JIYjf zdb+&f&D4@4AS?!*XpH>8egQvSVX`36jMd>$+RgI|pEg))^djhGSo&#lhS~9%NuWfX zDDH;3T*GzRT@5=7ibO>N-6_XPBYxno@mD_3I#rDD?iADxX`! zh*v8^i*JEMzyN#bGEBz7;UYXki*Xr(9xXax(_1qVW=Ml)kSuvK$coq2A(5ZGhs_pF z$*w}FbN6+QDseuB9=fdp_MTs)nQf!2SlROQ!gBJBCXD&@-VurqHj0wm@LWX-TDmS= z71M__vAok|@!qgi#H&H%Vg-((ZfxPAL8AI{x|VV!9)ZE}_l>iWk8UPTGHs*?u7RfP z5MC&=c6X;XlUzrz5q?(!eO@~* zoh2I*%J7dF!!_!vXoSIn5o|wj1#_>K*&CIn{qSaRc&iFVxt*^20ngCL;QonIS>I5^ zMw8HXm>W0PGd*}Ko)f|~dDd%;Wu_RWI_d;&2g6R3S63Uzjd7dn%Svu-OKpx*o|N>F zZg=-~qLb~VRLpv`k zWSdfHh@?dp=s_X`{yxOlxE$4iuyS;Z-x!*E6eqmEm*j2bE@=ZI0YZ5%Yj29!5+J$4h{s($nakA`xgbO8w zi=*r}PWz#lTL_DSAu1?f%-2OjD}NHXp4pXOsCW;DS@BC3h-q4_l`<))8WgzkdXg3! zs1WMt32kS2E#L0p_|x+x**TFV=gn`m9BWlzF{b%6j-odf4{7a4y4Uaef@YaeuPhU8 zHBvRqN^;$Jizy+ z=zW{E5<>2gp$pH{M@S*!sJVQU)b*J5*bX4h>5VJve#Q6ga}cQ&iL#=(u+KroWrxa%8&~p{WEUF0il=db;-$=A;&9M{Rq`ouZ5m%BHT6%st%saGsD6)fQgLN}x@d3q>FC;=f%O3Cyg=Ke@Gh`XW za@RajqOE9UB6eE=zhG%|dYS)IW)&y&Id2n7r)6p_)vlRP7NJL(x4UbhlcFXWT8?K=%s7;z?Vjts?y2+r|uk8Wt(DM*73^W%pAkZa1Jd zNoE)8FvQA>Z`eR5Z@Ig6kS5?0h;`Y&OL2D&xnnAUzQz{YSdh0k zB3exx%A2TyI)M*EM6htrxSlep!Kk(P(VP`$p0G~f$smld6W1r_Z+o?=IB@^weq>5VYsYZZR@` z&XJFxd5{|KPZmVOSxc@^%71C@;z}}WhbF9p!%yLj3j%YOlPL5s>7I3vj25 z@xmf=*z%Wb4;Va6SDk9cv|r*lhZ`(y_*M@>q;wrn)oQx%B(2A$9(74>;$zmQ!4fN; z>XurIk-7@wZys<+7XL@0Fhe-f%*=(weaQEdR9Eh6>Kl-EcI({qoZqyzziGwpg-GM#251sK_ z=3|kitS!j%;fpc@oWn65SEL73^N&t>Ix37xgs= zYG%eQDJc|rqHFia0!_sm7`@lvcv)gfy(+KXA@E{3t1DaZ$DijWAcA)E0@X?2ziJ{v z&KOYZ|DdkM{}t+@{@*6ge}m%xfjIxi%qh`=^2Rwz@w0cCvZ&Tc#UmCDbVwABrON^x zEBK43FO@weA8s7zggCOWhMvGGE`baZ62cC)VHyy!5Zbt%ieH+XN|OLbAFPZWyC6)p z4P3%8sq9HdS3=ih^0OOlqTPbKuzQ?lBEI{w^ReUO{V?@`ARsL|S*%yOS=Z%sF)>-y z(LAQdhgAcuF6LQjRYfdbD1g4o%tV4EiK&ElLB&^VZHbrV1K>tHTO{#XTo>)2UMm`2 z^t4s;vnMQgf-njU-RVBRw0P0-m#d-u`(kq7NL&2T)TjI_@iKuPAK-@oH(J8?%(e!0Ir$yG32@CGUPn5w4)+9@8c&pGx z+K3GKESI4*`tYlmMHt@br;jBWTei&(a=iYslc^c#RU3Q&sYp zSG){)V<(g7+8W!Wxeb5zJb4XE{I|&Y4UrFWr%LHkdQ;~XU zgy^dH-Z3lmY+0G~?DrC_S4@=>0oM8Isw%g(id10gWkoz2Q%7W$bFk@mIzTCcIB(K8 zc<5h&ZzCdT=9n-D>&a8vl+=ZF*`uTvQviG_bLde*k>{^)&0o*b05x$MO3gVLUx`xZ z43j+>!u?XV)Yp@MmG%Y`+COH2?nQcMrQ%k~6#O%PeD_WvFO~Kct za4XoCM_X!c5vhRkIdV=xUB3xI2NNStK*8_Zl!cFjOvp-AY=D;5{uXj}GV{LK1~IE2 z|KffUiBaStRr;10R~K2VVtf{TzM7FaPm;Y(zQjILn+tIPSrJh&EMf6evaBKIvi42-WYU9Vhj~3< zZSM-B;E`g_o8_XTM9IzEL=9Lb^SPhe(f(-`Yh=X6O7+6ALXnTcUFpI>ekl6v)ZQeNCg2 z^H|{SKXHU*%nBQ@I3It0m^h+6tvI@FS=MYS$ZpBaG7j#V@P2ZuYySbp@hA# ze(kc;P4i_-_UDP?%<6>%tTRih6VBgScKU^BV6Aoeg6Uh(W^#J^V$Xo^4#Ekp ztqQVK^g9gKMTHvV7nb64UU7p~!B?>Y0oFH5T7#BSW#YfSB@5PtE~#SCCg3p^o=NkMk$<8- z6PT*yIKGrvne7+y3}_!AC8NNeI?iTY(&nakN>>U-zT0wzZf-RuyZk^X9H-DT_*wk= z;&0}6LsGtfVa1q)CEUPlx#(ED@-?H<1_FrHU#z5^P3lEB|qsxEyn%FOpjx z3S?~gvoXy~L(Q{Jh6*i~=f%9kM1>RGjBzQh_SaIDfSU_9!<>*Pm>l)cJD@wlyxpBV z4Fmhc2q=R_wHCEK69<*wG%}mgD1=FHi4h!98B-*vMu4ZGW~%IrYSLGU{^TuseqVgV zLP<%wirIL`VLyJv9XG_p8w@Q4HzNt-o;U@Au{7%Ji;53!7V8Rv0^Lu^Vf*sL>R(;c zQG_ZuFl)Mh-xEIkGu}?_(HwkB2jS;HdPLSxVU&Jxy9*XRG~^HY(f0g8Q}iqnVmgjI zfd=``2&8GsycjR?M%(zMjn;tn9agcq;&rR!Hp z$B*gzHsQ~aXw8c|a(L^LW(|`yGc!qOnV(ZjU_Q-4z1&0;jG&vAKuNG=F|H?@m5^N@ zq{E!1n;)kNTJ>|Hb2ODt-7U~-MOIFo%9I)_@7fnX+eMMNh>)V$IXesJpBn|uo8f~#aOFytCT zf9&%MCLf8mp4kwHTcojWmM3LU=#|{3L>E}SKwOd?%{HogCZ_Z1BSA}P#O(%H$;z7XyJ^sjGX;j5 zrzp>|Ud;*&VAU3x#f{CKwY7Vc{%TKKqmB@oTHA9;>?!nvMA;8+Jh=cambHz#J18x~ zs!dF>$*AnsQ{{82r5Aw&^7eRCdvcgyxH?*DV5(I$qXh^zS>us*I66_MbL8y4d3ULj z{S(ipo+T3Ag!+5`NU2sc+@*m{_X|&p#O-SAqF&g_n7ObB82~$p%fXA5GLHMC+#qqL zdt`sJC&6C2)=juQ_!NeD>U8lDVpAOkW*khf7MCcs$A(wiIl#B9HM%~GtQ^}yBPjT@ z+E=|A!Z?A(rwzZ;T}o6pOVqHzTr*i;Wrc%&36kc@jXq~+w8kVrs;%=IFdACoLAcCAmhFNpbP8;s`zG|HC2Gv?I~w4ITy=g$`0qMQdkijLSOtX6xW%Z9Nw<;M- zMN`c7=$QxN00DiSjbVt9Mi6-pjv*j(_8PyV-il8Q-&TwBwH1gz1uoxs6~uU}PrgWB zIAE_I-a1EqlIaGQNbcp@iI8W1sm9fBBNOk(k&iLBe%MCo#?xI$%ZmGA?=)M9D=0t7 zc)Q0LnI)kCy{`jCGy9lYX%mUsDWwsY`;jE(;Us@gmWPqjmXL+Hu#^;k%eT>{nMtzj zsV`Iy6leTA8-PndszF;N^X@CJrTw5IIm!GPeu)H2#FQitR{1p;MasQVAG3*+=9FYK zw*k!HT(YQorfQj+1*mCV458(T5=fH`um$gS38hw(OqVMyunQ;rW5aPbF##A3fGH6h z@W)i9Uff?qz`YbK4c}JzQpuxuE3pcQO)%xBRZp{zJ^-*|oryTxJ-rR+MXJ)!f=+pp z10H|DdGd2exhi+hftcYbM0_}C0ZI-2vh+$fU1acsB-YXid7O|=9L!3e@$H*6?G*Zp z%qFB(sgl=FcC=E4CYGp4CN>=M8#5r!RU!u+FJVlH6=gI5xHVD&k;Ta*M28BsxfMV~ zLz+@6TxnfLhF@5=yQo^1&S}cmTN@m!7*c6z;}~*!hNBjuE>NLVl2EwN!F+)0$R1S! zR|lF%n!9fkZ@gPW|x|B={V6x3`=jS*$Pu0+5OWf?wnIy>Y1MbbGSncpKO0qE(qO=ts z!~@&!N`10S593pVQu4FzpOh!tvg}p%zCU(aV5=~K#bKi zHdJ1>tQSrhW%KOky;iW+O_n;`l9~omqM%sdxdLtI`TrJzN6BQz+7xOl*rM>xVI2~# z)7FJ^Dc{DC<%~VS?@WXzuOG$YPLC;>#vUJ^MmtbSL`_yXtNKa$Hk+l-c!aC7gn(Cg ze?YPYZ(2Jw{SF6MiO5(%_pTo7j@&DHNW`|lD`~{iH+_eSTS&OC*2WTT*a`?|9w1dh zh1nh@$a}T#WE5$7Od~NvSEU)T(W$p$s5fe^GpG+7fdJ9=enRT9$wEk+ZaB>G3$KQO zgq?-rZZnIv!p#>Ty~}c*Lb_jxJg$eGM*XwHUwuQ|o^}b3^T6Bxx{!?va8aC@-xK*H ztJBFvFfsSWu89%@b^l3-B~O!CXs)I6Y}y#0C0U0R0WG zybjroj$io0j}3%P7zADXOwHwafT#uu*zfM!oD$6aJx7+WL%t-@6^rD_a_M?S^>c;z zMK580bZXo1f*L$CuMeM4Mp!;P@}b~$cd(s5*q~FP+NHSq;nw3fbWyH)i2)-;gQl{S zZO!T}A}fC}vUdskGSq&{`oxt~0i?0xhr6I47_tBc`fqaSrMOzR4>0H^;A zF)hX1nfHs)%Zb-(YGX;=#2R6C{BG;k=?FfP?9{_uFLri~-~AJ;jw({4MU7e*d)?P@ zXX*GkNY9ItFjhwgAIWq7Y!ksbMzfqpG)IrqKx9q{zu%Mdl+{Dis#p9q`02pr1LG8R z@As?eG!>IoROgS!@J*to<27coFc1zpkh?w=)h9CbYe%^Q!Ui46Y*HO0mr% zEff-*$ndMNw}H2a5@BsGj5oFfd!T(F&0$<{GO!Qdd?McKkorh=5{EIjDTHU`So>8V zBA-fqVLb2;u7UhDV1xMI?y>fe3~4urv3%PX)lDw+HYa;HFkaLqi4c~VtCm&Ca+9C~ zge+67hp#R9`+Euq59WhHX&7~RlXn=--m8$iZ~~1C8cv^2(qO#X0?vl91gzUKBeR1J z^p4!!&7)3#@@X&2aF2-)1Ffcc^F8r|RtdL2X%HgN&XU-KH2SLCbpw?J5xJ*!F-ypZ zMG%AJ!Pr&}`LW?E!K~=(NJxuSVTRCGJ$2a*Ao=uUDSys!OFYu!Vs2IT;xQ6EubLIl z+?+nMGeQQhh~??0!s4iQ#gm3!BpMpnY?04kK375e((Uc7B3RMj;wE?BCoQGu=UlZt!EZ1Q*auI)dj3Jj{Ujgt zW5hd~-HWBLI_3HuO) zNrb^XzPsTIb=*a69wAAA3J6AAZZ1VsYbIG}a`=d6?PjM)3EPaDpW2YP$|GrBX{q*! z$KBHNif)OKMBCFP5>!1d=DK>8u+Upm-{hj5o|Wn$vh1&K!lVfDB&47lw$tJ?d5|=B z^(_9=(1T3Fte)z^>|3**n}mIX;mMN5v2F#l(q*CvU{Ga`@VMp#%rQkDBy7kYbmb-q z<5!4iuB#Q_lLZ8}h|hPODI^U6`gzLJre9u3k3c#%86IKI*^H-@I48Bi*@avYm4v!n0+v zWu{M{&F8#p9cx+gF0yTB_<2QUrjMPo9*7^-uP#~gGW~y3nfPAoV%amgr>PSyVAd@l)}8#X zR5zV6t*uKJZL}?NYvPVK6J0v4iVpwiN|>+t3aYiZSp;m0!(1`bHO}TEtWR1tY%BPB z(W!0DmXbZAsT$iC13p4f>u*ZAy@JoLAkJhzFf1#4;#1deO8#8d&89}en&z!W&A3++^1(;>0SB1*54d@y&9Pn;^IAf3GiXbfT`_>{R+Xv; zQvgL>+0#8-laO!j#-WB~(I>l0NCMt_;@Gp_f0#^c)t?&#Xh1-7RR0@zPyBz!U#0Av zT?}n({(p?p7!4S2ZBw)#KdCG)uPnZe+U|0{BW!m)9 zi_9$F?m<`2!`JNFv+w8MK_K)qJ^aO@7-Ig>cM4-r0bi=>?B_2mFNJ}aE3<+QCzRr*NA!QjHw# z`1OsvcoD0?%jq{*7b!l|L1+Tw0TTAM4XMq7*ntc-Ived>Sj_ZtS|uVdpfg1_I9knY z2{GM_j5sDC7(W&}#s{jqbybqJWyn?{PW*&cQIU|*v8YGOKKlGl@?c#TCnmnAkAzV- zmK={|1G90zz=YUvC}+fMqts0d4vgA%t6Jhjv?d;(Z}(Ep8fTZfHA9``fdUHkA+z3+ zhh{ohP%Bj?T~{i0sYCQ}uC#5BwN`skI7`|c%kqkyWIQ;!ysvA8H`b-t()n6>GJj6xlYDu~8qX{AFo$Cm3d|XFL=4uvc?Keb zzb0ZmMoXca6Mob>JqkNuoP>B2Z>D`Q(TvrG6m`j}-1rGP!g|qoL=$FVQYxJQjFn33lODt3Wb1j8VR zlR++vIT6^DtYxAv_hxupbLLN3e0%A%a+hWTKDV3!Fjr^cWJ{scsAdfhpI)`Bms^M6 zQG$waKgFr=c|p9Piug=fcJvZ1ThMnNhQvBAg-8~b1?6wL*WyqXhtj^g(Ke}mEfZVM zJuLNTUVh#WsE*a6uqiz`b#9ZYg3+2%=C(6AvZGc=u&<6??!slB1a9K)=VL zY9EL^mfyKnD zSJyYBc_>G;5RRnrNgzJz#Rkn3S1`mZgO`(r5;Hw6MveN(URf_XS-r58Cn80K)ArH4 z#Rrd~LG1W&@ttw85cjp8xV&>$b%nSXH_*W}7Ch2pg$$c0BdEo-HWRTZcxngIBJad> z;C>b{jIXjb_9Jis?NZJsdm^EG}e*pR&DAy0EaSGi3XWTa(>C%tz1n$u?5Fb z1qtl?;_yjYo)(gB^iQq?=jusF%kywm?CJP~zEHi0NbZ);$(H$w(Hy@{i>$wcVRD_X|w-~(0Z9BJyh zhNh;+eQ9BEIs;tPz%jSVnfCP!3L&9YtEP;svoj_bNzeGSQIAjd zBss@A;)R^WAu-37RQrM%{DfBNRx>v!G31Z}8-El9IOJlb_MSoMu2}GDYycNaf>uny z+8xykD-7ONCM!APry_Lw6-yT>5!tR}W;W`C)1>pxSs5o1z#j7%m=&=7O4hz+Lsqm` z*>{+xsabZPr&X=}G@obTb{nPTkccJX8w3CG7X+1+t{JcMabv~UNv+G?txRqXib~c^Mo}`q{$`;EBNJ;#F*{gvS12kV?AZ%O0SFB$^ zn+}!HbmEj}w{Vq(G)OGAzH}R~kS^;(-s&=ectz8vN!_)Yl$$U@HNTI-pV`LSj7Opu zTZ5zZ)-S_{GcEQPIQXLQ#oMS`HPu{`SQiAZ)m1at*Hy%3xma|>o`h%E%8BEbi9p0r zVjcsh<{NBKQ4eKlXU|}@XJ#@uQw*$4BxKn6#W~I4T<^f99~(=}a`&3(ur8R9t+|AQ zWkQx7l}wa48-jO@ft2h+7qn%SJtL%~890FG0s5g*kNbL3I&@brh&f6)TlM`K^(bhr zJWM6N6x3flOw$@|C@kPi7yP&SP?bzP-E|HSXQXG>7gk|R9BTj`e=4de9C6+H7H7n# z#GJeVs1mtHhLDmVO?LkYRQc`DVOJ_vdl8VUihO-j#t=0T3%Fc1f9F73ufJz*adn*p zc%&vi(4NqHu^R>sAT_0EDjVR8bc%wTz#$;%NU-kbDyL_dg0%TFafZwZ?5KZpcuaO54Z9hX zD$u>q!-9`U6-D`E#`W~fIfiIF5_m6{fvM)b1NG3xf4Auw;Go~Fu7cth#DlUn{@~yu z=B;RT*dp?bO}o%4x7k9v{r=Y@^YQ^UUm(Qmliw8brO^=NP+UOohLYiaEB3^DB56&V zK?4jV61B|1Uj_5fBKW;8LdwOFZKWp)g{B%7g1~DgO&N& z#lisxf?R~Z@?3E$Mms$$JK8oe@X`5m98V*aV6Ua}8Xs2#A!{x?IP|N(%nxsH?^c{& z@vY&R1QmQs83BW28qAmJfS7MYi=h(YK??@EhjL-t*5W!p z^gYX!Q6-vBqcv~ruw@oMaU&qp0Fb(dbVzm5xJN%0o_^@fWq$oa3X?9s%+b)x4w-q5Koe(@j6Ez7V@~NRFvd zfBH~)U5!ix3isg`6be__wBJp=1@yfsCMw1C@y+9WYD9_C%{Q~7^0AF2KFryfLlUP# zwrtJEcH)jm48!6tUcxiurAMaiD04C&tPe6DI0#aoqz#Bt0_7_*X*TsF7u*zv(iEfA z;$@?XVu~oX#1YXtceQL{dSneL&*nDug^OW$DSLF0M1Im|sSX8R26&)<0Fbh^*l6!5wfSu8MpMoh=2l z^^0Sr$UpZp*9oqa23fcCfm7`ya2<4wzJ`Axt7e4jJrRFVf?nY~2&tRL* zd;6_njcz01c>$IvN=?K}9ie%Z(BO@JG2J}fT#BJQ+f5LFSgup7i!xWRKw6)iITjZU z%l6hPZia>R!`aZjwCp}I zg)%20;}f+&@t;(%5;RHL>K_&7MH^S+7<|(SZH!u zznW|jz$uA`P9@ZWtJgv$EFp>)K&Gt+4C6#*khZQXS*S~6N%JDT$r`aJDs9|uXWdbg zBwho$phWx}x!qy8&}6y5Vr$G{yGSE*r$^r{}pw zVTZKvikRZ`J_IJrjc=X1uw?estdwm&bEahku&D04HD+0Bm~q#YGS6gp!KLf$A{%Qd z&&yX@Hp>~(wU{|(#U&Bf92+1i&Q*-S+=y=3pSZy$#8Uc$#7oiJUuO{cE6=tsPhwPe| zxQpK>`Dbka`V)$}e6_OXKLB%i76~4N*zA?X+PrhH<&)}prET;kel24kW%+9))G^JI zsq7L{P}^#QsZViX%KgxBvEugr>ZmFqe^oAg?{EI=&_O#e)F3V#rc z8$4}0Zr19qd3tE4#$3_f=Bbx9oV6VO!d3(R===i-7p=Vj`520w0D3W6lQfY48}!D* z&)lZMG;~er2qBoI2gsX+Ts-hnpS~NYRDtPd^FPzn!^&yxRy#CSz(b&E*tL|jIkq|l zf%>)7Dtu>jCf`-7R#*GhGn4FkYf;B$+9IxmqH|lf6$4irg{0ept__%)V*R_OK=T06 zyT_m-o@Kp6U{l5h>W1hGq*X#8*y@<;vsOFqEjTQXFEotR+{3}ODDnj;o0@!bB5x=N z394FojuGOtVKBlVRLtHp%EJv_G5q=AgF)SKyRN5=cGBjDWv4LDn$IL`*=~J7u&Dy5 zrMc83y+w^F&{?X(KOOAl-sWZDb{9X9#jrQtmrEXD?;h-}SYT7yM(X_6qksM=K_a;Z z3u0qT0TtaNvDER_8x*rxXw&C^|h{P1qxK|@pS7vdlZ#P z7PdB7MmC2}%sdzAxt>;WM1s0??`1983O4nFK|hVAbHcZ3x{PzytQLkCVk7hA!Lo` zEJH?4qw|}WH{dc4z%aB=0XqsFW?^p=X}4xnCJXK%c#ItOSjdSO`UXJyuc8bh^Cf}8 z@Ht|vXd^6{Fgai8*tmyRGmD_s_nv~r^Fy7j`Bu`6=G)5H$i7Q7lvQnmea&TGvJp9a|qOrUymZ$6G|Ly z#zOCg++$3iB$!6!>215A4!iryregKuUT344X)jQb3|9qY>c0LO{6Vby05n~VFzd?q zgGZv&FGlkiH*`fTurp>B8v&nSxNz)=5IF$=@rgND4d`!AaaX;_lK~)-U8la_Wa8i?NJC@BURO*sUW)E9oyv3RG^YGfN%BmxzjlT)bp*$<| zX3tt?EAy<&K+bhIuMs-g#=d1}N_?isY)6Ay$mDOKRh z4v1asEGWoAp=srraLW^h&_Uw|6O+r;wns=uwYm=JN4Q!quD8SQRSeEcGh|Eb5Jg8m zOT}u;N|x@aq)=&;wufCc^#)5U^VcZw;d_wwaoh9$p@Xrc{DD6GZUqZ ziC6OT^zSq@-lhbgR8B+e;7_Giv;DK5gn^$bs<6~SUadiosfewWDJu`XsBfOd1|p=q zE>m=zF}!lObA%ePey~gqU8S6h-^J2Y?>7)L2+%8kV}Gp=h`Xm_}rlm)SyUS=`=S7msKu zC|T!gPiI1rWGb1z$Md?0YJQ;%>uPLOXf1Z>N~`~JHJ!^@D5kSXQ4ugnFZ>^`zH8CAiZmp z6Ms|#2gcGsQ{{u7+Nb9sA?U>(0e$5V1|WVwY`Kn)rsnnZ4=1u=7u!4WexZD^IQ1Jk zfF#NLe>W$3m&C^ULjdw+5|)-BSHwpegdyt9NYC{3@QtMfd8GrIWDu`gd0nv-3LpGCh@wgBaG z176tikL!_NXM+Bv#7q^cyn9$XSeZR6#!B4JE@GVH zoobHZN_*RF#@_SVYKkQ_igme-Y5U}cV(hkR#k1c{bQNMji zU7aE`?dHyx=1`kOYZo_8U7?3-7vHOp`Qe%Z*i+FX!s?6huNp0iCEW-Z7E&jRWmUW_ z67j>)Ew!yq)hhG4o?^z}HWH-e=es#xJUhDRc4B51M4~E-l5VZ!&zQq`gWe`?}#b~7w1LH4Xa-UCT5LXkXQWheBa2YJYbyQ zl1pXR%b(KCXMO0OsXgl0P0Og<{(@&z1aokU-Pq`eQq*JYgt8xdFQ6S z6Z3IFSua8W&M#`~*L#r>Jfd6*BzJ?JFdBR#bDv$_0N!_5vnmo@!>vULcDm`MFU823 zpG9pqjqz^FE5zMDoGqhs5OMmC{Y3iVcl>F}5Rs24Y5B^mYQ;1T&ks@pIApHOdrzXF z-SdX}Hf{X;TaSxG_T$0~#RhqKISGKNK47}0*x&nRIPtmdwxc&QT3$8&!3fWu1eZ_P zJveQj^hJL#Sn!*4k`3}(d(aasl&7G0j0-*_2xtAnoX1@9+h zO#c>YQg60Z;o{Bi=3i7S`Ic+ZE>K{(u|#)9y}q*j8uKQ1^>+(BI}m%1v3$=4ojGBc zm+o1*!T&b}-lVvZqIUBc8V}QyFEgm#oyIuC{8WqUNV{Toz`oxhYpP!_p2oHHh5P@iB*NVo~2=GQm+8Yrkm2Xjc_VyHg1c0>+o~@>*Qzo zHVBJS>$$}$_4EniTI;b1WShX<5-p#TPB&!;lP!lBVBbLOOxh6FuYloD%m;n{r|;MU3!q4AVkua~fieeWu2 zQAQ$ue(IklX6+V;F1vCu-&V?I3d42FgWgsb_e^29ol}HYft?{SLf>DrmOp9o!t>I^ zY7fBCk+E8n_|apgM|-;^=#B?6RnFKlN`oR)`e$+;D=yO-(U^jV;rft^G_zl`n7qnM zL z*-Y4Phq+ZI1$j$F-f;`CD#|`-T~OM5Q>x}a>B~Gb3-+9i>Lfr|Ca6S^8g*{*?_5!x zH_N!SoRP=gX1?)q%>QTY!r77e2j9W(I!uAz{T`NdNmPBBUzi2{`XMB^zJGGwFWeA9 z{fk33#*9SO0)DjROug+(M)I-pKA!CX;IY(#gE!UxXVsa)X!UftIN98{pt#4MJHOhY zM$_l}-TJlxY?LS6Nuz1T<44m<4i^8k@D$zuCPrkmz@sdv+{ciyFJG2Zwy&%c7;atIeTdh!a(R^QXnu1Oq1b42*OQFWnyQ zWeQrdvP|w_idy53Wa<{QH^lFmEd+VlJkyiC>6B#s)F;w-{c;aKIm;Kp50HnA-o3lY z9B~F$gJ@yYE#g#X&3ADx&tO+P_@mnQTz9gv30_sTsaGXkfNYXY{$(>*PEN3QL>I!k zp)KibPhrfX3%Z$H6SY`rXGYS~143wZrG2;=FLj50+VM6soI~up_>fU(2Wl@{BRsMi zO%sL3x?2l1cXTF)k&moNsHfQrQ+wu(gBt{sk#CU=UhrvJIncy@tJX5klLjgMn>~h= zg|FR&;@eh|C7`>s_9c~0-{IAPV){l|Ts`i=)AW;d9&KPc3fMeoTS%8@V~D8*h;&(^>yjT84MM}=%#LS7shLAuuj(0VAYoozhWjq z4LEr?wUe2^WGwdTIgWBkDUJa>YP@5d9^Rs$kCXmMRxuF*YMVrn?0NFyPl}>`&dqZb z<5eqR=ZG3>n2{6v6BvJ`YBZeeTtB88TAY(x0a58EWyuf>+^|x8Qa6wA|1Nb_p|nA zWWa}|z8a)--Wj`LqyFk_a3gN2>5{Rl_wbW?#by7&i*^hRknK%jwIH6=dQ8*-_{*x0j^DUfMX0`|K@6C<|1cgZ~D(e5vBFFm;HTZF(!vT8=T$K+|F)x3kqzBV4-=p1V(lzi(s7jdu0>LD#N=$Lk#3HkG!a zIF<7>%B7sRNzJ66KrFV76J<2bdYhxll0y2^_rdG=I%AgW4~)1Nvz=$1UkE^J%BxLo z+lUci`UcU062os*=`-j4IfSQA{w@y|3}Vk?i;&SSdh8n+$iHA#%ERL{;EpXl6u&8@ zzg}?hkEOUOJt?ZL=pWZFJ19mI1@P=$U5*Im1e_8Z${JsM>Ov?nh8Z zP5QvI!{Jy@&BP48%P2{Jr_VgzW;P@7)M9n|lDT|Ep#}7C$&ud&6>C^5ZiwKIg2McPU(4jhM!BD@@L(Gd*Nu$ji(ljZ<{FIeW_1Mmf;76{LU z-ywN~=uNN)Xi6$<12A9y)K%X|(W0p|&>>4OXB?IiYr||WKDOJPxiSe01NSV-h24^L z_>m$;|C+q!Mj**-qQ$L-*++en(g|hw;M!^%_h-iDjFHLo-n3JpB;p?+o2;`*jpvJU zLY^lt)Un4joij^^)O(CKs@7E%*!w>!HA4Q?0}oBJ7Nr8NQ7QmY^4~jvf0-`%waOLn zdNjAPaC0_7c|RVhw)+71NWjRi!y>C+Bl;Z`NiL^zn2*0kmj5gyhCLCxts*cWCdRI| zjsd=sT5BVJc^$GxP~YF$-U{-?kW6r@^vHXB%{CqYzU@1>dzf#3SYedJG-Rm6^RB7s zGM5PR(yKPKR)>?~vpUIeTP7A1sc8-knnJk*9)3t^e%izbdm>Y=W{$wm(cy1RB-19i za#828DMBY+ps#7Y8^6t)=Ea@%Nkt)O6JCx|ybC;Ap}Z@Zw~*}3P>MZLPb4Enxz9Wf zssobT^(R@KuShj8>@!1M7tm|2%-pYYDxz-5`rCbaTCG5{;Uxm z*g=+H1X8{NUvFGzz~wXa%Eo};I;~`37*WrRU&K0dPSB$yk(Z*@K&+mFal^?c zurbqB-+|Kb5|sznT;?Pj!+kgFY1#Dr;_%A(GIQC{3ct|{*Bji%FNa6c-thbpBkA;U zURV!Dr&X{0J}iht#-Qp2=xzuh(fM>zRoiGrYl5ttw2#r34gC41CCOC31m~^UPTK@s z6;A@)7O7_%C)>bnAXerYuAHdE93>j2N}H${zEc6&SbZ|-fiG*-qtGuy-qDelH(|u$ zorf8_T6Zqe#Ub!+e3oSyrskt_HyW_^5lrWt#30l)tHk|j$@YyEkXUOV;6B51L;M@=NIWZXU;GrAa(LGxO%|im%7F<-6N;en0Cr zLH>l*y?pMwt`1*cH~LdBPFY_l;~`N!Clyfr;7w<^X;&(ZiVdF1S5e(+Q%60zgh)s4 zn2yj$+mE=miVERP(g8}G4<85^-5f@qxh2ec?n+$A_`?qN=iyT1?U@t?V6DM~BIlBB z>u~eXm-aE>R0sQy!-I4xtCNi!!qh?R1!kKf6BoH2GG{L4%PAz0{Sh6xpuyI%*~u)s z%rLuFl)uQUCBQAtMyN;%)zFMx4loh7uTfKeB2Xif`lN?2gq6NhWhfz0u5WP9J>=V2 zo{mLtSy&BA!mSzs&CrKWq^y40JF5a&GSXIi2= z{EYb59J4}VwikL4P=>+mc6{($FNE@e=VUwG+KV21;<@lrN`mnz5jYGASyvz7BOG_6(p^eTxD-4O#lROgon;R35=|nj#eHIfJBYPWG>H>`dHKCDZ3`R{-?HO0mE~(5_WYcFmp8sU?wr*UkAQiNDGc6T zA%}GOLXlOWqL?WwfHO8MB#8M8*~Y*gz;1rWWoVSXP&IbKxbQ8+s%4Jnt?kDsq7btI zCDr0PZ)b;B%!lu&CT#RJzm{l{2fq|BcY85`w~3LSK<><@(2EdzFLt9Y_`;WXL6x`0 zDoQ?=?I@Hbr;*VVll1Gmd8*%tiXggMK81a+T(5Gx6;eNb8=uYn z5BG-0g>pP21NPn>$ntBh>`*})Fl|38oC^9Qz>~MAazH%3Q~Qb!ALMf$srexgPZ2@&c~+hxRi1;}+)-06)!#Mq<6GhP z-Q?qmgo${aFBApb5p}$1OJKTClfi8%PpnczyVKkoHw7Ml9e7ikrF0d~UB}i3vizos zXW4DN$SiEV9{faLt5bHy2a>33K%7Td-n5C*N;f&ZqAg#2hIqEb(y<&f4u5BWJ>2^4 z414GosL=Aom#m&=x_v<0-fp1r%oVJ{T-(xnomNJ(Dryv zh?vj+%=II_nV+@NR+(!fZZVM&(W6{6%9cm+o+Z6}KqzLw{(>E86uA1`_K$HqINlb1 zKelh3-jr2I9V?ych`{hta9wQ2c9=MM`2cC{m6^MhlL2{DLv7C^j z$xXBCnDl_;l|bPGMX@*tV)B!c|4oZyftUlP*?$YU9C_eAsuVHJ58?)zpbr30P*C`T z7y#ao`uE-SOG(Pi+`$=e^mle~)pRrdwL5)N;o{gpW21of(QE#U6w%*C~`v-z0QqBML!!5EeYA5IQB0 z^l01c;L6E(iytN!LhL}wfwP7W9PNAkb+)Cst?qg#$n;z41O4&v+8-zPs+XNb-q zIeeBCh#ivnFLUCwfS;p{LC0O7tm+Sf9Jn)~b%uwP{%69;QC)Ok0t%*a5M+=;y8j=v z#!*pp$9@!x;UMIs4~hP#pnfVc!%-D<+wsG@R2+J&%73lK|2G!EQC)O05TCV=&3g)C!lT=czLpZ@Sa%TYuoE?v8T8`V;e$#Zf2_Nj6nvBgh1)2 GZ~q4|mN%#X literal 0 HcmV?d00001 diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..9036682 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,8 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionSha256Sum=682b4df7fe5accdca84a4d1ef6a3a6ab096b3efd5edf7de2bd8c758d95a93703 +distributionUrl=https\://services.gradle.org/distributions/gradle-8.10-all.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..f5feea6 --- /dev/null +++ b/gradlew @@ -0,0 +1,252 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s +' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..9b42019 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/item-charges.md b/item-charges.md new file mode 100644 index 0000000..957ab6d --- /dev/null +++ b/item-charges.md @@ -0,0 +1,136 @@ +# Item charges – Taskbeschreibung + +Implementiere einen **„Item charges"**-Helper in **Essentials Plus** (`com.example.essentialsplus.helpers`): Auf dem **Icon jedes Items** mit bekanntem Charge/Füllstand wird eine **Zahl** gerendert. Manche Items zeigen **mehrere Zahlen** (z. B. Toxic Blowpipe: Scales + Darts). Togglebar per Checkbox, anzeige in Inventar, Ausrüstung und (optional) Bank. + +**Beispiele:** +- Toxic Blowpipe (mit Scales) → `scales`-Zahl; mit geladenen Darts → zweiter Wert (Darts-Marker, s. Hinweise). +- Dizan's Quiver → zwei Zahlen: Ammo (`CHARGES_DIZANAS_QUIVER_QUANTITY`) + Blessing (`CHARGES_DIZANAS_QUIVER_BLESSING`). +- Trident/Shadow/Tome/Bowfa etc. → eine Zahl. +- **Lootingsack**: Geldwert (Coins) als Zahl auf dem Sack-Icon. +- **Coal Bag**: Füllstand via Chat-Tracking + Persistenz (kein Varbit!). + +**Format:** Ganzzahlen / Stack-Format (Geld: `QuantityFormatter.quantityToStackSize`, z. B. `1.2M`). Unbekannter Wert → `?`. + +## Datenquellen & Mechanik + +### 1. `CHARGES_*`-Varbits (Hauptquelle, charakterweit) + +Werte via `client.getVarbitValue(id)` (`net.runelite.api.gameval.VarbitID`). Mapping Item-Gruppe → Varbit (verifiziert, Release 1.12.39). Varbit aus der **ID-Variante** des Items ableiten (s. Quelle 2), nicht aus einem Katalog — zwei identische Waffen haben nur EINEN Varbit-Wert (OSRS-Grenze!). + +| Item-Gruppe (ItemID-Konstanten) | VarbitID-Konstante | +|---|---| +| `TOXIC_BLOWPIPE`, `TOXIC_BLOWPIPE_LOADED`, `TOXIC_BLOWPIPE_ORNAMENT`, `TOXIC_BLOWPIPE_LOADED_ORNAMENT` | `CHARGES_TOXIC_BLOWPIPE_QUANTITY` | +| `SERPENTINE_HELM`, `SERPENTINE_HELM_CHARGED`, `_CYAN`, `_CYAN_CHARGED`, `_RED`, `_RED_CHARGED` | `CHARGES_SERPENTINE_HELM_QUANTITY` | +| `WARPED_SCEPTRE`, `WARPED_SCEPTRE_UNCHARGED` | `CHARGES_WARPED_SCEPTRE_QUANTITY` | +| `TUMEKENS_SHADOW`, `TUMEKENS_SHADOW_UNCHARGED`, `TUMEKENS_HEKA`, `TUMEKENS_HEKA_UNCHARGED` | `CHARGES_TUMEKENS_SHADOW_QUANTITY` | +| `SANGUINESTI_STAFF`, `SANGUINESTI_STAFF_UNCHARGED`, `_OR`, `_UNCHARGED_OR` | `CHARGES_SANGUINESTI_STAFF_QUANTITY` | +| `TRIDENT_OF_THE_SEAS` (+ `_E`), jew. gecharge/un-gecharge-Varianten | `CHARGES_TRIDENT_OF_THE_SEAS_QUANTITY` bzw. `..._E_QUANTITY` | +| `TOXIC_STAFF_OF_THE_DEAD` (+ `_UNCHARGED`, `_OR`-Varianten) | `CHARGES_TOXIC_STAFF_OF_THE_DEAD_QUANTITY` | +| `BOW_OF_FAERDHINEN`, `BOW_OF_FAERDHINEN_INACTIVE`, `_INFINITE*` | `CHARGES_BOW_OF_FAERDHINEN_QUANTITY` (+ `CHARGES_BOW_OF_FAERDHINEN_CORRUPTION` für korrupte Variante) | +| `BLADE_OF_SAELDOR`, `BLADE_OF_SAELDOR_INACTIVE`, `_INFINITE` | `CHARGES_BLADE_OF_SAELDOR_QUANTITY` (+ `_CORRUPTION`) | +| `VENATOR_BOW`, `VENATOR_BOW_UNCHARGED`, `VENATOR_BOW_ORNAMENT(_UNCHARGED)` | `CHARGES_VENATOR_BOW_QUANTITY` | +| `TONALZTICS_OF_RALOS_CHARGED`, `_UNCHARGED` | `CHARGES_TONALZTICS_OF_RALOS_QUANTITY` | +| `ARCLIGHT` | `CHARGES_ARCLIGHT_QUANTITY` | +| `DIZANAS_QUIVER_CHARGED`, `_UNCHARGED`, `_INFINITE`, `_BROKEN`, Trouver-Varianten | `CHARGES_DIZANAS_QUIVER_QUANTITY` (Ammo) + `CHARGES_DIZANAS_QUIVER_BLESSING` (Blessing) — zwei Zahlen | +| `TOME_OF_FIRE`, `TOME_OF_FIRE_UNCHARGED` | `CHARGES_TOME_OF_FIRE_QUANTITY` | +| `TOME_OF_WATER`, `TOME_OF_WATER_UNCHARGED` | `CHARGES_TOME_OF_WATER_QUANTITY` | +| `TOME_OF_EARTH`, `TOME_OF_EARTH_UNCHARGED` | `CHARGES_TOME_OF_EARTH_QUANTITY` | +| `ASH_SANCTIFIER` | `CHARGES_ASH_SANCTIFIER_QUANTITY` | +| `BONECRUSHER` | `CHARGES_BONECRUSHER_QUANTITY` | +| `BRYOPHYTAS_STAFF` | `CHARGES_BRYOPHYTAS_STAFF_QUANTITY` | +| `CIRCLET_OF_WATER` | `CHARGES_CIRCLET_OF_WATER_QUANTITY` | +| `SOUL_BEARER` | `CHARGES_SOUL_BEARER_QUANTITY` | +| `CELESTIAL_RING`, `CELESTIAL_RING_CHARGED` | `CHARGES_CELESTIAL_RING_QUANTITY` | +| `WILD_CAVE_SCEPTRE_CHARGED`, `WILD_CAVE_SCEPTRE_UNCHARGED` | `CHARGES_WILDERNESS_WEAPON_QUANTITY` | +| `BRACELET_OF_ETHEREUM` | `CHARGES_BRACELET_OF_ETHEREUM_QUANTITY` | +| `RING_OF_SUFFERING` | `CHARGES_RING_OF_SUFFERING_QUANTITY` | +| `XERIC_TALISMAN`, `XERIC_TALISMAN_EMPTY` | `CHARGES_XERICS_TALISMAN_QUANTITY` | +| `RING_OF_SHADOWS`, `RING_OF_SHADOWS_UNCHARGED` | `CHARGES_RING_OF_SHADOWS_QUANTITY` | +| `ECHO_BOOTS` | `CHARGES_ECHO_BOOTS_QUANTITY` | +| `AMULET_OF_BLOOD_FURY` | `CHARGES_AMULET_OF_BLOOD_FURY_QUANTITY` | +| `GIANTSOUL_AMULET_CHARGED`, `_UNCHARGED` | `CHARGES_GIANTSOUL_AMULET_QUANTITY` | +| `EYE_OF_AYAK`, `EYE_OF_AYAK_UNCHARGED` | `CHARGES_EYE_OF_AYAK_QUANTITY` | +| `HORN_OF_PLENTY`, `_UNCHARGED`, `SAILORS_AMULET`, `_EMPTY`, `COWBELL_AMULET`, `_EMPTY`, `PENDANT_OF_ATES`, `_EMPTY` | jew. `CHARGES_*_QUANTITY`-Varbit | +| Crystal Armour/Tools (Crystal, `CRYSTAL_*` piece IDs) | `CHARGES_CRYSTAL_ARMOUR_QUANTITY`, `CHARGES_CRYSTAL_TOOLS_QUANTITY`, `CHARGES_CRYSTAL_WEAK_QUANTITY` | +| `ALCHEMIST_RING` | `CHARGES_ALCHEMIST_AMULET_QUANTITY` | + +Vollständige Liste ergänzen per `javap -constants net.runelite.api.gameval.VarbitID | grep 'CHARGES_.*_QUANTITY'` gegen das runelite-api-Jar (release 1.12.39; Pfad s. **Referenzen**). Das sind ~55 Konstanten; es kommen auch Solo-/Sailing-Charges vor (`CHARGES_PLANK_SACK*`, `CHARGES_BOWSTRING_SPOOL*`), nur relevante übernehmen. Stichproben-Hinweis: `DBTableID$Charges$Row` (in runelite-api `net.runelite.api.gameval.DBTableID`) listet dieselben Charge-Typen als DB-Rows (Referenz/Querschnitt, nicht zwingend zur Laufzeit nötig). + +Varianten-Ableitung für die Tabelle: Einträge in `net.runelite.api.gameval.ItemID` mit typischen Suffixen (`_CHARGED`, `_UNCHARGED`, `_INACTIVE`, `_EMPTY`, `_LOADED`, `_OR`, `_CYAN/_RED`, `_TROUVER`, `_INFINITE`, `_BROKEN`) — per `javap -constants ... ItemID | grep` verifizieren. + +### 2. Item-ID-Statusvarianten (Ladungs-Status) + +Viele Items wechseln die **Item-ID** bei leer/laden — mehrere exakte IDs auf dieselbe Charge abbilden bzw. „0"-Semantik: + +- `TOXIC_BLOWPIPE` (12924) vs `TOXIC_BLOWPIPE_LOADED` (12926): **Darts geladen: ja/nein** (zweiter Wert, Marker; die exakte **Dart-Anzahl ist NICHT in der API** — kein Varbit/Container). `DRYGORE_BLOWPIPE` vs `DRYGORE_BLOWPIPE_LOADED` analog. +- `BOW_OF_FAERDHINEN_INACTIVE` → 0; `BLADE_OF_SAELDOR_INACTIVE` → 0; `*_INFINITE` → „∞". +- Alle `*_UNCHARGED`/`*_EMPTY` → 0 bzw. kein Varbit abfragen. + +### 3. Container (Echtzeit; per GameTick gelesen) + +- **Lootingsack**: `client.getItemContainer(InventoryID.LOOTING_BAG)` (auch `LOOTING_BAG_OPEN`). Geldwert = Σ qty von `ItemID.COINS` im Container. (Plugin hat bereits `readContainer()`/WealthStore für Loot — hier eigenes Lesen im Overlay-Tick, da das Overlay nicht an den Container-Event gebunden ist.) +- **Dizan's Quiver Ammo**: `InventoryID.DIZANAS_QUIVER_AMMO` als Crosscheck/Geld-Anzeige zum Varbit-Wert möglich. +- **Coal/Gem/Herb sack**: kein Container, kein Varbit (verifiziert) → Quelle 4. + +### 4. Coal Bag — Chat-Tracking + Persistenz + +- Kein Varbit/Container in `VarbitID`/`InventoryID` (verifiziert; nur `ItemID.COAL_BAG`/`COAL_BAG_OPEN`). +- Füllstand (max **27**) ableiten aus **Chat-Nachrichten** + **letzten bekannten Wert pro Account** in `configManager` persistieren (key `coalBag.`, Wert int 0–27; `accountKey`-Konvention wie bestehende Persistenz im Plugin). +- Zu verarbeitende Muster (ChatMessage-Filter `Game`): Check-/Fill-Feedback „[…]`X` coal in your coal bag]" o. Ä. *exakt in-game verifizieren* (2024 wurde Feedback von Popups auf Chat umgestellt); zusätzlich Empty → 0; bei unbekannter Nachricht/Login ohne Check → **`?`** anzeigen statt falscher Zahl. +- `ChatMessage`-Events: Fehler-tolerant parsen (Regex), bei Update sofort speichern. Beim `LOGOUT` aktuellen Wert speichern. Optional Rechtsklick „Coal bag vergessen" → Wert nicht aus Chat-Nachrichten ableiten. + +## Neue Dateien + +### `helpers/ItemCharges.java` (`@Singleton`, Logik) + +- **Datenmodell**: `ChargeValue { String text; Color color; }` (+ optional 2. Wert). Lookup-API `List chargesFor(int itemId)`: + 1. exakte `itemId` in Tabelle (Load/Empty/Inactive-Varianten); dann + 2. `itemManager.canonicalize(itemId)` (Noten/Placeholder) erneut prüfen. +- **`CHARGE_MAP`**: `Map` statisch aufgebaut aus den ItemID-Konstanten (Quelle 1+2); `ChargeSource` = Varbit-Konstante bzw. `COAL_BAG`-Typ bzw. `LOOTING_BAG`-Container-Typ. +- **State**: Coal-Bag-Count pro Account + `Map`-Cache nur so weit nötig (Varbit-Lesen ist billig; kein harter Cache zwingend). +- **Events** (im Plugin registriert, Muster `TreeCooldowns`): + - `GameTick` → die zwei Bag-Varbits/Container aktualisieren. + - `VarbitChanged` → relevante Varbit-Id → Value merken. + - `ChatMessage` → Coal-Bag-Messages parsen (s. o.), speichern. + - `GameStateChanged` → `!= LOGGED_IN` → `reset()`, Coal-Bag-Count aus `configManager` laden bzw. auf „?" zurücksetzen. +- **Reset**: Coal-Bag-Cache auf `?` beim Logout (Persistenz bleibt). + +### `helpers/ItemChargesOverlay.java` (`WidgetItemOverlay`) + +- `showOnInventory(); showOnEquipment(); showOnBank();` — Bank über Config-Sub-Key steuerbar (s. unten). +- `renderItemOverlay(g, itemId, WidgetItem)`: + - `if (!config.itemCharges()) return;` + - `List` aus `chargesFor(itemId)`; leer → nichts rendern. + - Zahl unten-links am Icon (offizielles RuneLite-Muster): `bounds.x - 1, bounds.y + 15`, mit Schatten (`Fonts.RUNESCAPE_SMALL` bzw. `TimerTextSize`), Farbe aus `itemChargesColor`; **2. Wert** (Blowpipe-Darts, Quiver-Blessing) darüber (`bounds.y - 3`) in `itemChargesSecondaryColor`. + - Geld (Lootingsack): Stack-Size-Format; Low-Charge-Färbung ab `itemChargesLowAt` → `itemChargesLowColor`. +- Coins/Loot-Bag-Wert: aus `chargesFor(LOOTING_BAG_OPEN/LOOTING_BAG)`-Container-Lesung im Overlay-Tick (Falls Container null → kein Wert). + +## Zu ändernde Dateien + +- **`EssentialsPlusConfig.java`**: `@ConfigItem itemCharges` (bool, default `false`, position ~37). Optionen als `configManager`-Keys via `setHelperOption` (Muster wie treeCooldowns): + - `itemChargesWeapons` (bool, true) — Varbit-Charges an/aus + - `itemChargesBags` (bool, true) — Loot-Bag-Geld + Coal-Bag-Füllstand an/aus + - `itemChargesBank` (bool, false) — Overlay auch in Bank (Achtung Hinweise!) + - `itemChargesSize` (`TimerTextSize`, SMALL), `itemChargesColor` (Color, weiß), `itemChargesSecondaryColor` (Color, hellgrau `0xC0C0C0`), `itemChargesLowColor` (Color, rot `0xFF3333`), `itemChargesLowAt` (int, 0 = aus) +- **`EssentialsPlusPlugin.java`**: `@Inject ItemCharges` + `ItemChargesOverlay`; `startUp()`: `eventBus.register(itemCharges)`, `overlayManager.add(itemChargesOverlay)`; `shutDown()`: spiegeln + `itemCharges.reset()`. `isHelperOption()`: `case "itemCharges"` → `config.itemCharges()`. `setHelperOption`/`onConfigChanged`: Bestände für `itemcharges*`-Keys refresh (Muster solcher Bestands-Abschnitte). +- **`HelpersCard.java`**: neues `JCheckBox "Item charges"` (Tooltip: „Charge-Zahl auf Item-Icons (Blowpipe, Trident, Tome, …) + Loot-Bag-Geld & Coal-Bag-Füllstand. Rechtsklick für Optionen"). Rechtsklick-Menü analog `styleMenu`/`treeMenu`: Toggles `itemChargesWeapons`/`itemChargesBags`/`itemChargesBank`, Größe, Farben, Schwellenwert, Reset. `refresh()` erweitern. + +## Verifikation + +- `./gradlew compileJava` (bzw. `./gradlew deploy` für Bolt). +- Manuell: Blowpipe mit Scales (nur Scales-Zahl) → mit Darts laden (2. Zahl erscheint); Trident/Shadow/Sang/Tome (Zahl); Lootingsack mit Coins betreten (Geld auf Sack-Icon); Coal Bag füllen → Zahl steigt, Empty → 0, Relog → Wert bleibt; Bank öffnen mit `itemChargesBank` → Zahlen auch dort; beide Blowpipes (leer + geladen) im Inventar → korrekte per-ID-Darstellung. + +## Hinweise / Einschränkungen + +- `CHARGES_*`-Varbits sind **charakterweit, nicht pro Item-Exemplar**: zwei unterschiedlich geladene Tridents zeigen denselben Wert (OSRS-Systemgrenze, deckungsgleich mit dem In-Game-Charge-Overlay). +- **Bank-Anzeige** dementsprechend unzuverlässig für „unbenutzte" Kopien; default daher `itemChargesBank = false`. +- Coal Bag: nur Heuristik (Chat + Persistenz), kein API-Wert; nach Login unbekannt → `?`. +- Blowpipe: Dart-**Anzahl** nicht in der API → 2. Wert ist Darts-geladen-Marker, keine Zahl. +- Keine Chat-Popup-abhängigkeit (Popup = ist abgeschaltet hat), ausschließlich ChatMessage-Events verwenden. + +## Referenzen + +- `tree-cooldown.md` (gleiche Struktur), `reflect.txt`/Cachierte Jars für Konstanten: + - runelite-api: `/home/hikyu/.gradle/caches/modules-2/files-2.1/net.runelite/runelite-api/1.12.39//runelite-api-1.12.39.jar` → `net/runelite/api/gameval/{VarbitID,ItemID,InventoryID,DBTableID$Charges$Row}` + - client: `/home/hikyu/.gradle/caches/modules-2/files-2.1/net.runelite/client/1.12.39//client-1.12.39.jar` → `net.runelite.client.ui.overlay.WidgetItemOverlay`, `components.TextComponent`, `ui.FontManager` +- Konventionen: `helpers/TreeCooldowns.java` (Logik+Events), `helpers/TreeCooldownOverlay.java`/`ItemPriceOverlay.java` (Overlay-Zeichnung), `HelpersCard.java` (Checkbox+Menü), `EssentialsPlusPlugin.isHelperOption/setHelperOption/onConfigChanged`. \ No newline at end of file diff --git a/runelite-plugin.properties b/runelite-plugin.properties new file mode 100644 index 0000000..771ff8b --- /dev/null +++ b/runelite-plugin.properties @@ -0,0 +1,7 @@ +displayName=Essentials Plus +author=hikyu +description=Essentials Plus Plugin Suite +tags=essentials,plus,wealth +version=1.0.0 +plugins=com.example.essentialsplus.EssentialsPlusPlugin +build=standard \ No newline at end of file diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 0000000..f7c8fa2 --- /dev/null +++ b/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'bank-highlighter' diff --git a/skill-reset-hourly.md b/skill-reset-hourly.md new file mode 100644 index 0000000..f72fe26 --- /dev/null +++ b/skill-reset-hourly.md @@ -0,0 +1,94 @@ +# Skill "Reset Hourly" – Taskbeschreibung + +Implementiere ein **Rechtsklick-Feature „Reset Hourly"** in **Essentials Plus**. Es erscheint an zwei Stellen: + +1. **In-Game-XP-HUD** (`skills/SkillsHudOverlay.java`): Rechtsklick auf das Overlay zeigt den Menüeintrag „Reset Hourly". +2. **Skills-Karte** im Seitenpanel: auf der **Overview-Karte** als Eintrag im bestehenden Karten-Rechtsklick-Menü und im **Skills-Tab** als eigenes Rechtsklick-Popup auf der Karte. + +Klick setzt die **aktuell angezeigte XP-Zählung** zurück. Es wirkt **nur**, wenn der global aktive Zeitraum `Session` oder `Now` ist. Alle anderen Zeiträume (`1H`, `1D`, `2W`, `3M`, `All`) bleiben unberührt – insbesondere bleibt der **Gesamt-XP-Wert (`All`) erhalten**, weil er aus der ersten Sample-Spur abgeleitet wird. + +**Display-Sprache:** Deutsch (Menütext trotzdem exakt `Reset Hourly`). + +## Hintergrund / Datenmodell + +- `skills/SkillHistory.java` speichert **absolute** XP-Samples pro Skill mit Zeitstempel (`XpSample`, `List samples`), ein File pro Charakter (JSON in `essentials-plus/skills`). +- `ui/TimeFilter.java` + `ui/TimeRange.java` liefern den global geteilten Zeitraum: `CURRENT("Now")`, `SESSION("Sess")`, `HOUR("1H")`, `DAY`, `TWO_WEEKS`, `QUARTER`, `ALL`. +- Gewinne werden als `latest - at(start)` berechnet; der Start kommt aus `SkillHistory.start(filter)`, `starts(filter)` und `gained(filter)`. +- Es gibt **noch kein** Reset-/Baseline-Feld. Der Reset wird als **Zeitmarker** umgesetzt, nicht durch Löschen von Samples. + +## Neue Dateien + +### `skills/SkillHistory.java` (erweitern, keine neue Datei) + +- Zwei persistente Marker ergänzen (0 = nie zurückgesetzt): + - `public long resetSessionAt;` + - `public long resetNowAt;` +- `public synchronized void reset(TimeRange range)`: + - `SESSION` → `resetSessionAt = System.currentTimeMillis()` + - `CURRENT` → `resetNowAt = System.currentTimeMillis()` + - sonst → no-op + - `dirty = true` (Persistenz-Flag, existiert bereits) +- Startlogik **nur** für die betroffenen Ranges clampen: + - `starts(TimeFilter)`: bei `SESSION` für jeden Skill `Math.max(filter.start(), resetSessionAt)`; bei `CURRENT` pro Skill `Math.max(currentStart(i, now), resetNowAt)`, idle (`Long.MAX_VALUE`) bleibt idle. + - `start(TimeFilter)`: bei `SESSION` `Math.max(filter.start(), resetSessionAt)`; bei `CURRENT` nach `currentStart(-1, now)` clampen (Fallback `now` wenn idle). + - `gained(TimeFilter)`: Nicht-Current-Zweig von `gained(filter.start())` auf `gained(start(filter))` umstellen, damit der Session-Clamp greift. Current-Zweig nutzt bereits `starts(filter)`. +- Da `range(long)`, `levelUps(long)` und `activeMillis(long, int)` ihren Start aus `start(filter)` bekommen, ziehen Charts, Stats/„XP per hour" und Level-Ups automatisch mit. +- `normalize()` prüft nur `skills`/`samples`; die neuen Felder brauchen keine Migration (fehlend = 0 = nie zurückgesetzt). + +### `skills/SkillStore.java` (erweitern) + +- `public void resetHourly(String key, TimeRange range)`: History per `histories.get(key)` holen; wenn vorhanden `h.reset(range)` und `save(h)` aufrufen. +- Wird vom Plugin auf dem Executor aufgerufen (File-I/O), nicht auf dem Swing-/Client-Thread. + +## Zu ändernde Dateien + +### `skills/SkillsHudOverlay.java` + +- Im Konstruktor: `addMenuEntry(net.runelite.api.MenuAction.RUNELITE_OVERLAY, "Reset Hourly", "Skills")`. +- Die Render-Logik nutzt bereits `history.starts(filter)`/`gained(filter)` → greift den Reset automatisch, **kein** Render-Umbau nötig. +- Verifikation der API (RuneLite 1.12.39): `Overlay.addMenuEntry(MenuAction, option, target)` und die 4-arg-Variante mit `Consumer` existieren; `OverlayRenderer` injiziert den Callback in `MenuEntry.onClick(...)` **und** feuert `OverlayMenuClicked`. + +### `EssentialsPlusPlugin.java` + +- Import `net.runelite.client.events.OverlayMenuClicked` (+ `net.runelite.client.ui.overlay.Overlay` falls nötig). +- `public void resetSkillsHourly(String key)`: + - `if (key == null) return;` + - `executor.execute(() -> skillStore.resetHourly(key, timeFilter.getRange()));` + - danach Panel-Refresh: `EssentialsPlusPanel p = panel; if (p != null) p.refreshSkillsLater();` +- `@Subscribe public void onOverlayMenuClicked(OverlayMenuClicked event)`: + - wenn `event.getOverlay() == skillsHudOverlay` und `"Reset Hourly".equals(event.getEntry().getOption())` → `resetSkillsHourly(skillKey)`. +- `getSkillStore()`, `getSkillKey()`, `getTimeFilter()` sind vorhandene Lombok-Getter; `skillsHudOverlay` ist injiziert (`EssentialsPlusPlugin.java:232`). +- Alternativ den `Consumer` der 4-arg-`addMenuEntry` nutzen; dann ist kein `OverlayMenuClicked`-Subscriber nötig (beides funktioniert). Empfehlung: Subscriber, damit die Aktion im Plugin kapselt bleibt. + +### `ui/EssentialsPlusPanel.java` (Overview-Karte) + +- In `menu(String id)` im `case SKILLS` (ca. Zeile 560) einen Eintrag ergänzen: + - `JMenuItem reset = new JMenuItem("Reset Hourly");` + - Action: Character über `SkillsPanel.resolve(plugin.getSkillStore(), null, plugin.getSkillKey())` auflösen; wenn nicht null → `plugin.resetSkillsHourly(h.key);` und danach `refreshSkills();`. +- **Kein** zweites Popup auf der Overview-Karte installieren: `CardDrag` (`ui/CardDrag.java`) konsumiert dort bereits Rechtsklick und öffnet `menu(id)` – sonst gäbe es zwei Menüs. + +### `ui/SkillsPanel.java` (Skills-Tab) + +- Nach `private final SkillsCard card = new SkillsCard(null);` (Zeile 61) im Konstruktor einen `MouseAdapter` auf `card` installieren: + - bei `SwingUtilities.isRightMouseButton(e)` (oder `e.isPopupTrigger()`) ein `JPopupMenu` mit `JMenuItem "Reset Hourly"` bauen und `menu.show(card, e.getX(), e.getY())` zeigen. + - Action: `plugin.resetSkillsHourly(shownKey);` danach `refresh();` (bzw. `refreshLater()`). +- Im Skills-Tab ist **keine** `CardDrag` installiert, daher kollidiert das Popup nicht. + +## Verifikation + +- `./gradlew test` (bestehende Tests + neuer Test unten) und `./gradlew build`. +- Neuer Test in `src/test/java/com/example/essentialsplus/SkillHistoryTest.java` (Muster: `record` per Reflection, package-private): + - Samples über ca. eine Stunde anlegen, dann `h.reset(TimeRange.CURRENT)` bzw. `h.reset(TimeRange.SESSION)`. + - Erwartung: `gained(filter)` für den betroffenen Range startet neu (Baseline = Sample bei `resetAt`); `gained(TimeRange.ALL)` bleibt unverändert; `1H`/`1D` bleiben unverändert. +- Manuell: + - Filter auf **Sess** oder **Now** stellen → Rechtsklick aufs XP-HUD → „Reset Hourly" → `XP gained` zählt ab 0 neu; Gesamt-XP (`All`) unverändert. + - Filter auf `1H`/`1D`/`All` stellen → „Reset Hourly" ändert die Anzeige nicht. + - Overview-Skills-Karte rechtsklicken → „Reset Hourly"; Skills-Tab-Karte rechtsklicken → „Reset Hourly". + - Nach Reset Datei `essentials-plus/skills/.json` prüfen (`resetSessionAt`/`resetNowAt` gesetzt); Relog → Session wird ohnehin vom neuen Login übersteuert, `Now`-Marker bleibt gespeichert. + +## Hinweise / Einschränkungen + +- „Reset Hourly" wirkt bewusst **nur** auf `Session` und `Now`; bei `1H`/`1D`/`2W`/`3M`/`All` passiert nichts (`TimeRange`-Vergleich im `reset(...)`). +- Der Gesamtwert (`All`) bleibt in jedem Fall erhalten, da er aus der ersten Sample-Spur (`samples.get(0)`) berechnet wird und der Reset nur einen Start-Clamp setzt. +- Marker sind pro Charakter im `SkillHistory`-JSON persistiert; ein fehlendes Feld nach älterem Stand bedeutet 0 (nie zurückgesetzt) und ist damit abwärtskompatibel. +- `record(...)` bleibt unverändert: Es fügt weiterhin Samples hinzu; der Reset löscht keine Historie. diff --git a/src/main/java/com/example/ExampleConfig.java b/src/main/java/com/example/ExampleConfig.java new file mode 100644 index 0000000..146f1b9 --- /dev/null +++ b/src/main/java/com/example/ExampleConfig.java @@ -0,0 +1,41 @@ +package com.example; + +import net.runelite.client.config.Config; +import net.runelite.client.config.ConfigGroup; +import net.runelite.client.config.ConfigItem; +import net.runelite.client.config.ConfigSection; + +@ConfigGroup("example") +public interface ExampleConfig extends Config +{ + @ConfigSection( + name = "General Settings", + description = "Allgemeine Einstellungen für das Plugin", + position = 0 + ) + String generalSection = "generalSection"; + + @ConfigItem( + keyName = "greeting", + name = "Welcome Greeting", + description = "The message to show in game chat when logging in or enabling the plugin", + section = generalSection, + position = 1 + ) + default String greeting() + { + return "Hello World!"; + } + + @ConfigItem( + keyName = "showOnLogin", + name = "Show on Login", + description = "Whether to send the greeting message when logging in", + section = generalSection, + position = 2 + ) + default boolean showOnLogin() + { + return true; + } +} diff --git a/src/main/java/com/example/ExamplePlugin.java b/src/main/java/com/example/ExamplePlugin.java new file mode 100644 index 0000000..0790b09 --- /dev/null +++ b/src/main/java/com/example/ExamplePlugin.java @@ -0,0 +1,245 @@ +package com.example; + +import com.example.model.Profile; +import com.example.overlay.BankHighlighterOverlay; +import com.example.overlay.ScreenFlashOverlay; +import com.example.overlay.TileHighlighterOverlay; +import com.example.overlay.WorldObjectHighlighterOverlay; +import com.example.ui.ExamplePluginPanel; +import com.google.inject.Provides; +import java.awt.Color; +import java.awt.image.BufferedImage; +import javax.annotation.Nullable; +import javax.inject.Inject; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; +import net.runelite.api.ChatMessageType; +import net.runelite.api.Client; +import net.runelite.api.GameState; +import net.runelite.api.events.GameStateChanged; +import net.runelite.api.events.GameTick; +import net.runelite.api.events.ItemContainerChanged; +import net.runelite.client.callback.ClientThread; +import net.runelite.client.config.ConfigManager; +import net.runelite.client.eventbus.Subscribe; +import net.runelite.client.game.ItemManager; +import net.runelite.client.plugins.Plugin; +import net.runelite.client.plugins.PluginDescriptor; +import net.runelite.client.plugins.banktags.BankTagsService; +import net.runelite.client.ui.ClientToolbar; +import net.runelite.client.ui.NavigationButton; +import net.runelite.client.ui.overlay.OverlayManager; +import net.runelite.client.util.ColorUtil; +import net.runelite.client.util.ImageUtil; + +@Slf4j +@PluginDescriptor( + name = "Bank Highlighter / Auto-Profile", + description = "Aktiviert automatisch Profile, Item-Highlights, 3D-Objekte, Boden-Tiles und Pfade", + tags = {"bank", "highlighter", "profile", "auto", "herblore", "tiles", "path", "agility"} +) +public class ExamplePlugin extends Plugin implements ProfileManager.ProfileChangeListener +{ + @Getter + @Inject + private Client client; + + @Getter + @Inject + private ClientThread clientThread; + + @Getter + @Inject + private ItemManager itemManager; + + @Getter + @Nullable + @com.google.inject.Inject(optional = true) + private BankTagsService bankTagsService; + + @Inject + private ConfigManager configManager; + + @Inject + private ClientToolbar clientToolbar; + + @Inject + private OverlayManager overlayManager; + + @Inject + private ProfileManager profileManager; + + @Inject + private BankHighlighterOverlay bankHighlighterOverlay; + + @Inject + private WorldObjectHighlighterOverlay worldObjectHighlighterOverlay; + + @Inject + private TileHighlighterOverlay tileHighlighterOverlay; + + @Inject + private ScreenFlashOverlay screenFlashOverlay; + + private NavigationButton navButton; + private ExamplePluginPanel panel; + + @Override + protected void startUp() throws Exception + { + log.info("Bank Highlighter / Auto-Profile started!"); + + profileManager.addListener(this); + panel = new ExamplePluginPanel(this, profileManager); + + final BufferedImage icon = ImageUtil.loadImageResource(getClass(), "/com/example/icon.png"); + + navButton = NavigationButton.builder() + .tooltip("Bank Highlighter & Profile") + .icon(icon) + .priority(5) + .panel(panel) + .build(); + + clientToolbar.addNavigation(navButton); + overlayManager.add(bankHighlighterOverlay); + overlayManager.add(worldObjectHighlighterOverlay); + overlayManager.add(tileHighlighterOverlay); + overlayManager.add(screenFlashOverlay); + + clientThread.invokeLater(() -> + { + if (client.getGameState() == GameState.LOGGED_IN) + { + profileManager.evaluate(client, itemManager, bankTagsService); + panel.updateLiveInspector(client); + } + }); + } + + @Override + protected void shutDown() throws Exception + { + log.info("Bank Highlighter / Auto-Profile stopped!"); + profileManager.removeListener(this); + + overlayManager.remove(bankHighlighterOverlay); + overlayManager.remove(worldObjectHighlighterOverlay); + overlayManager.remove(tileHighlighterOverlay); + overlayManager.remove(screenFlashOverlay); + + if (navButton != null) + { + clientToolbar.removeNavigation(navButton); + navButton = null; + } + panel = null; + } + + @Subscribe + public void onGameTick(GameTick event) + { + if (client.getGameState() == GameState.LOGGED_IN) + { + screenFlashOverlay.decrementTick(); + profileManager.evaluate(client, itemManager, bankTagsService); + if (panel != null) + { + panel.updateLiveInspector(client); + } + } + } + + @Subscribe + public void onItemContainerChanged(ItemContainerChanged event) + { + if (client.getGameState() == GameState.LOGGED_IN) + { + profileManager.evaluate(client, itemManager, bankTagsService); + if (panel != null) + { + panel.updateLiveInspector(client); + } + } + } + + @Subscribe + public void onGameStateChanged(GameStateChanged gameStateChanged) + { + if (gameStateChanged.getGameState() == GameState.LOGGED_IN) + { + clientThread.invokeLater(() -> + { + profileManager.evaluate(client, itemManager, bankTagsService); + if (panel != null) + { + panel.updateLiveInspector(client); + } + }); + } + } + + @Override + public void onActiveProfileChanged(Profile oldProfile, Profile newProfile) + { + if (newProfile != null) + { + // 1. Chat Message + if (newProfile.isNotifyOnActivation()) + { + String msg = newProfile.getActivationMessage().replace("{name}", newProfile.getName()); + sendChatMessage(msg); + } + + // 2. Screen Flash + if (newProfile.isScreenFlash()) + { + Color color = ColorUtil.fromString(newProfile.getFlashColor()); + screenFlashOverlay.flash(color != null ? color : new Color(255, 152, 0), 2); + } + + // 3. Sound Effect + if (newProfile.isPlaySound()) + { + clientThread.invokeLater(() -> + { + if (client.getGameState() == GameState.LOGGED_IN) + { + client.playSoundEffect(newProfile.getSoundId()); + } + }); + } + } + } + + @Override + public void onProfileListUpdated() + { + } + + public void sendManualChatMessage(String message) + { + clientThread.invokeLater(() -> + { + if (client.getGameState() == GameState.LOGGED_IN) + { + sendChatMessage(message); + } + else + { + log.warn("Cannot send chat message - player is not logged in"); + } + }); + } + + public void sendChatMessage(String message) + { + client.addChatMessage(ChatMessageType.GAMEMESSAGE, "", "[Profile] " + message, null); + } + + @Provides + ExampleConfig provideConfig(ConfigManager configManager) + { + return configManager.getConfig(ExampleConfig.class); + } +} diff --git a/src/main/java/com/example/ProfileManager.java b/src/main/java/com/example/ProfileManager.java new file mode 100644 index 0000000..2c0c48a --- /dev/null +++ b/src/main/java/com/example/ProfileManager.java @@ -0,0 +1,483 @@ +package com.example; + +import com.example.model.Folder; +import com.example.model.Profile; +import com.google.gson.Gson; +import com.google.gson.reflect.TypeToken; +import java.lang.reflect.Type; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import javax.inject.Inject; +import javax.inject.Singleton; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; +import net.runelite.api.Client; +import net.runelite.client.config.ConfigManager; +import net.runelite.client.game.ItemManager; + +@Slf4j +@Singleton +public class ProfileManager +{ + private static final String CONFIG_GROUP = "bankhighlighter"; + private static final String PROFILES_KEY = "profiles"; + private static final String FOLDERS_KEY = "folders"; + + @Getter + private final List folders = new ArrayList<>(); + + @Getter + private final List profiles = new ArrayList<>(); + + @Getter + private Profile activeProfile = null; + + private final ConfigManager configManager; + private final Gson gson; + + public interface ProfileChangeListener + { + void onActiveProfileChanged(Profile oldProfile, Profile newProfile); + void onProfileListUpdated(); + } + + private final List listeners = new ArrayList<>(); + + @Inject + public ProfileManager(ConfigManager configManager, Gson gson) + { + this.configManager = configManager; + this.gson = gson; + loadProfiles(); + } + + public void addListener(ProfileChangeListener listener) + { + if (!listeners.contains(listener)) + { + listeners.add(listener); + } + } + + public void removeListener(ProfileChangeListener listener) + { + listeners.remove(listener); + } + + public void loadProfiles() + { + profiles.clear(); + folders.clear(); + + // Load Folders + String foldersJson = configManager.getConfiguration(CONFIG_GROUP, FOLDERS_KEY); + if (foldersJson != null && !foldersJson.trim().isEmpty()) + { + try + { + Type listType = new TypeToken>() {}.getType(); + List loaded = gson.fromJson(foldersJson, listType); + if (loaded != null) + { + folders.addAll(loaded); + } + } + catch (Exception e) + { + log.error("Fehler beim Laden der Ordner aus Config", e); + } + } + + // Load Profiles + String json = configManager.getConfiguration(CONFIG_GROUP, PROFILES_KEY); + if (json != null && !json.trim().isEmpty()) + { + try + { + Type listType = new TypeToken>() {}.getType(); + List loaded = gson.fromJson(json, listType); + if (loaded != null && !loaded.isEmpty()) + { + profiles.addAll(loaded); + } + } + catch (Exception e) + { + log.error("Fehler beim Laden der Profile aus Config", e); + } + } + + if (profiles.isEmpty()) + { + loadHerblorePrayerPotionWorkflow(); + } + + notifyListUpdated(); + } + + public void saveProfiles() + { + try + { + String json = gson.toJson(profiles); + configManager.setConfiguration(CONFIG_GROUP, PROFILES_KEY, json); + + String foldersJson = gson.toJson(folders); + configManager.setConfiguration(CONFIG_GROUP, FOLDERS_KEY, foldersJson); + + notifyListUpdated(); + } + catch (Exception e) + { + log.error("Fehler beim Speichern der Profile / Ordner", e); + } + } + + // Preset 1: Herblore Prayer Potions + public void loadHerblorePrayerPotionWorkflow() + { + Folder herbFolder = new Folder("Prayer Potions", "Prayer potion(4)"); + folders.add(herbFolder); + + Profile step2 = new Profile("2. Inv: Grimy säubern", "Grimy ranarr weed"); + step2.setFolderId(herbFolder.getId()); + step2.setRequiredInventory("Grimy ranarr weed"); + step2.setHighlightInBank(false); + step2.setHighlightInInventory(true); + step2.setHighlightItems("Grimy ranarr weed"); + step2.setHighlightColor("#4CAF50"); + step2.setShowTextLabel(true); + step2.setCustomTextLabel("Clean"); + step2.setActivationMessage("Schritt 2: Grimy Ranarr im Inventar säubern!"); + + Profile step3 = new Profile("3. Inv: Unf Potion mixen", "Ranarr weed"); + step3.setFolderId(herbFolder.getId()); + step3.setRequiredInventory("Ranarr weed, Vial of water"); + step3.setHighlightInBank(false); + step3.setHighlightInInventory(true); + step3.setHighlightItems("Ranarr weed, Vial of water"); + step3.setHighlightColor("#FF9800"); + step3.setShowTextLabel(true); + step3.setCustomTextLabel("Combine"); + step3.setActivationMessage("Schritt 3: Ranarr Weed & Vial of Water kombinieren!"); + + Profile step5 = new Profile("5. Inv: Potion fertigstellen", "Prayer potion(3)"); + step5.setFolderId(herbFolder.getId()); + step5.setRequiredInventory("Ranarr potion (unf), Snape grass"); + step5.setHighlightInBank(false); + step5.setHighlightInInventory(true); + step5.setHighlightItems("Ranarr potion (unf), Snape grass"); + step5.setHighlightColor("#9C27B0"); + step5.setShowTextLabel(true); + step5.setCustomTextLabel("Combine"); + step5.setActivationMessage("Schritt 5: Snape Grass & Unf Potion zur Prayer Potion mixen!"); + + Profile step4a = new Profile("4a. Welt: Bank öffnen", "Ranarr potion (unf)"); + step4a.setFolderId(herbFolder.getId()); + step4a.setRequireBankClosed(true); + step4a.setRequiredInventory("Ranarr potion (unf)"); + step4a.setEnableObjectHighlight(true); + step4a.setHighlightObjects("Bank, Bank booth, Bank chest, Banker, Grand Exchange booth"); + step4a.setObjectHighlightColor("#00E5FF"); + step4a.setShowObjectText(true); + step4a.setObjectCustomText("Bank öffnen"); + step4a.setActivationMessage("Schritt 4a: Klicke auf die Bank, um Snape Grass zu holen!"); + + Profile step6a = new Profile("6a. Welt: Bank öffnen", "Prayer potion(4)"); + step6a.setFolderId(herbFolder.getId()); + step6a.setRequireBankClosed(true); + step6a.setRequiredInventory("Prayer potion"); + step6a.setEnableObjectHighlight(true); + step6a.setHighlightObjects("Bank, Bank booth, Bank chest, Banker, Grand Exchange booth"); + step6a.setObjectHighlightColor("#00E5FF"); + step6a.setShowObjectText(true); + step6a.setObjectCustomText("Bank öffnen"); + step6a.setActivationMessage("Schritt 6a: Klicke auf die Bank, um fertige Potions einzulagern!"); + + Profile step6b = new Profile("6b. Bank: Potions deponieren", "Prayer potion(4)"); + step6b.setFolderId(herbFolder.getId()); + step6b.setRequireBankOpen(true); + step6b.setRequiredInventory("Prayer potion"); + step6b.setHighlightInBank(false); + step6b.setHighlightInInventory(true); + step6b.setHighlightItems("Prayer potion"); + step6b.setHighlightColor("#FF5722"); + step6b.setShowTextLabel(true); + step6b.setCustomTextLabel("Depot"); + step6b.setActivationMessage("Schritt 6b: Fertige Prayer Potions in die Bank einlagern!"); + + Profile step4b = new Profile("4b. Bank: Snape Grass nehmen", "Snape grass"); + step4b.setFolderId(herbFolder.getId()); + step4b.setRequireBankOpen(true); + step4b.setRequiredInventory("Ranarr potion (unf)"); + step4b.setHighlightInBank(true); + step4b.setHighlightInInventory(false); + step4b.setHighlightItems("Snape grass"); + step4b.setHighlightColor("#00E5FF"); + step4b.setShowTextLabel(true); + step4b.setCustomTextLabel("Nehmen"); + step4b.setActivationMessage("Schritt 4b: Snape Grass aus der Bank entnehmen!"); + + Profile step1 = new Profile("1. Bank: Zutaten nehmen", "Vial of water"); + step1.setFolderId(herbFolder.getId()); + step1.setRequireBankOpen(true); + step1.setBankTag("herblore"); + step1.setHighlightInBank(true); + step1.setHighlightInInventory(false); + step1.setHighlightItems("Vial of water, Grimy ranarr weed"); + step1.setHighlightColor("#00E5FF"); + step1.setShowTextLabel(true); + step1.setCustomTextLabel("Nehmen"); + step1.setActivationMessage("Schritt 1: Vial of Water & Grimy Ranarr aus der Bank nehmen!"); + + profiles.add(step2); + profiles.add(step3); + profiles.add(step5); + profiles.add(step4a); + profiles.add(step6a); + profiles.add(step6b); + profiles.add(step4b); + profiles.add(step1); + + saveProfiles(); + } + + // Preset 2: 🍳 Cooking (z. B. Raw Karambwan am Feuer/Range) + public void loadCookingKarambwanWorkflow() + { + Folder cookFolder = new Folder("Cooking: Karambwan", "Cooked karambwan"); + folders.add(cookFolder); + + Profile step1 = new Profile("1. Bank: Rohen Fisch nehmen", "Raw karambwan"); + step1.setFolderId(cookFolder.getId()); + step1.setRequireBankOpen(true); + step1.setHighlightInBank(true); + step1.setHighlightInInventory(false); + step1.setHighlightItems("Raw karambwan, Raw shark, Raw monkfish"); + step1.setHighlightColor("#00E5FF"); + step1.setShowTextLabel(true); + step1.setCustomTextLabel("Nehmen"); + step1.setActivationMessage("Schritt 1: Rohen Fisch aus der Bank entnehmen!"); + + Profile step2 = new Profile("2. Welt: Am Herd/Feuer braten", "Raw karambwan"); + step2.setFolderId(cookFolder.getId()); + step2.setRequireBankClosed(true); + step2.setRequiredInventory("Raw karambwan"); + step2.setEnableObjectHighlight(true); + step2.setHighlightObjects("Range, Stove, Fire, Cooking range, Clay oven"); + step2.setObjectHighlightColor("#FF5722"); + step2.setShowObjectText(true); + step2.setObjectCustomText("Braten"); + step2.setActivationMessage("Schritt 2: Klicke auf den Herd/das Feuer zum Braten!"); + + Profile step3 = new Profile("3. Welt: Zurück zur Bank", "Cooked karambwan"); + step3.setFolderId(cookFolder.getId()); + step3.setRequireBankClosed(true); + step3.setRequiredInventory("Cooked karambwan"); + step3.setEnableObjectHighlight(true); + step3.setHighlightObjects("Bank, Bank booth, Bank chest, Banker"); + step3.setObjectHighlightColor("#00E5FF"); + step3.setShowObjectText(true); + step3.setObjectCustomText("Bank öffnen"); + step3.setActivationMessage("Schritt 3: Gehe zur Bank, um den gebratenen Fisch einzulagern!"); + + Profile step4 = new Profile("4. Bank: Fisch deponieren", "Cooked karambwan"); + step4.setFolderId(cookFolder.getId()); + step4.setRequireBankOpen(true); + step4.setRequiredInventory("Cooked karambwan"); + step4.setHighlightInBank(false); + step4.setHighlightInInventory(true); + step4.setHighlightItems("Cooked karambwan, Burnt karambwan"); + step4.setHighlightColor("#4CAF50"); + step4.setShowTextLabel(true); + step4.setCustomTextLabel("Depot"); + step4.setActivationMessage("Schritt 4: Gebratenen Fisch in die Bank einlagern!"); + + profiles.add(step2); + profiles.add(step3); + profiles.add(step4); + profiles.add(step1); + + saveProfiles(); + } + + // Preset 3: 🏃 Agility Rooftop Course Guide (mit 3D Hindernissen + Boden-Tiles + Pfadlinien) + public void loadAgilitySeersWorkflow() + { + Folder agilFolder = new Folder("Agility: Seers Rooftop", "Graceful hood"); + folders.add(agilFolder); + + Profile start = new Profile("1. Start: Bank Wall hochklettern", "Graceful top"); + start.setFolderId(agilFolder.getId()); + start.setRegionId("10806, 10550"); + start.setEnableObjectHighlight(true); + start.setHighlightObjects("Wall, Climbing wall, Rough wall"); + start.setObjectHighlightColor("#2ECC71"); + start.setShowObjectText(true); + start.setObjectCustomText("Klettern"); + start.setEnableTileHighlight(true); + start.setHighlightTiles("2729, 3488, 0, Start-Kachel"); + start.setTileHighlightColor("#2ECC71"); + start.setRenderPathLine(true); + start.setActivationMessage("Agility: Starte den Seers' Rooftop Kurs an der Wand!"); + + Profile gap1 = new Profile("2. Rooftop: Lücke überspringen", "Graceful legs"); + gap1.setFolderId(agilFolder.getId()); + gap1.setEnableObjectHighlight(true); + gap1.setHighlightObjects("Gap, Tightrope, Edge"); + gap1.setObjectHighlightColor("#00E5FF"); + gap1.setShowObjectText(true); + gap1.setObjectCustomText("Springen"); + gap1.setActivationMessage("Agility: Nächstes Dach-Hindernis überqueren!"); + + profiles.add(gap1); + profiles.add(start); + + saveProfiles(); + } + + public Folder createFolder(String name, String iconItem) + { + Folder f = new Folder(name, iconItem); + folders.add(f); + saveProfiles(); + return f; + } + + public void deleteFolder(Folder folder) + { + if (folder == null) return; + for (Profile p : profiles) + { + if (folder.getId().equals(p.getFolderId())) + { + p.setFolderId(""); + } + } + folders.removeIf(f -> f.getId().equals(folder.getId())); + saveProfiles(); + } + + public Folder getFolderById(String id) + { + if (id == null || id.isEmpty()) return null; + for (Folder f : folders) + { + if (f.getId().equals(id)) return f; + } + return null; + } + + public Profile createProfile(String name) + { + Profile p = new Profile(name); + profiles.add(p); + saveProfiles(); + return p; + } + + public void deleteProfile(Profile profile) + { + if (profile == null) + { + return; + } + + if (activeProfile != null && activeProfile.getId().equals(profile.getId())) + { + setActiveProfile(null); + } + + profiles.removeIf(p -> p.getId().equals(profile.getId())); + saveProfiles(); + } + + public void updateProfile(Profile updated) + { + for (int i = 0; i < profiles.size(); i++) + { + if (profiles.get(i).getId().equals(updated.getId())) + { + profiles.set(i, updated); + break; + } + } + saveProfiles(); + } + + public void moveProfileUp(Profile profile) + { + int idx = profiles.indexOf(profile); + if (idx > 0) + { + Collections.swap(profiles, idx, idx - 1); + saveProfiles(); + } + } + + public void moveProfileDown(Profile profile) + { + int idx = profiles.indexOf(profile); + if (idx >= 0 && idx < profiles.size() - 1) + { + Collections.swap(profiles, idx, idx + 1); + saveProfiles(); + } + } + + public void evaluate(Client client, ItemManager itemManager, net.runelite.client.plugins.banktags.BankTagsService bankTagsService) + { + if (client == null) + { + return; + } + + Profile matched = null; + for (Profile p : profiles) + { + if (!p.isEnabled()) + { + continue; + } + + if (p.getFolderId() != null && !p.getFolderId().isEmpty()) + { + Folder f = getFolderById(p.getFolderId()); + if (f != null && !f.isEnabled()) + { + continue; + } + } + + if (p.matches(client, itemManager, bankTagsService)) + { + matched = p; + break; + } + } + + if (matched != activeProfile) + { + setActiveProfile(matched); + } + } + + private void setActiveProfile(Profile newActive) + { + Profile old = this.activeProfile; + this.activeProfile = newActive; + for (ProfileChangeListener listener : listeners) + { + listener.onActiveProfileChanged(old, newActive); + } + } + + private void notifyListUpdated() + { + for (ProfileChangeListener listener : listeners) + { + listener.onProfileListUpdated(); + } + } +} diff --git a/src/main/java/com/example/essentialsplus/EssentialsPlusConfig.java b/src/main/java/com/example/essentialsplus/EssentialsPlusConfig.java new file mode 100644 index 0000000..01b8e3f --- /dev/null +++ b/src/main/java/com/example/essentialsplus/EssentialsPlusConfig.java @@ -0,0 +1,587 @@ +package com.example.essentialsplus; + +import com.example.essentialsplus.helpers.TimerTextSize; +import java.awt.Color; +import net.runelite.client.config.Config; +import net.runelite.client.config.ConfigGroup; +import net.runelite.client.config.ConfigItem; +import net.runelite.client.config.Range; +import net.runelite.client.config.Units; + +@ConfigGroup(EssentialsPlusConfig.GROUP) +public interface EssentialsPlusConfig extends Config +{ + String GROUP = "essentialsplus"; + + @ConfigItem( + keyName = "snapshotSeconds", + name = "Min. Abstand Datenpunkte", + description = "Mindestabstand zwischen zwei gespeicherten Datenpunkten. Unveraenderte Werte werden nie doppelt gespeichert.", + position = 1 + ) + @Units(Units.SECONDS) + @Range(min = 10, max = 3600) + default int snapshotSeconds() + { + return 60; + } + + @ConfigItem( + keyName = "rawDays", + name = "Jede Aenderung behalten (Tage)", + description = "So lange wird jeder einzelne Datenpunkt gespeichert. Danach: stuendliche Werte (Open/Close/Min/Max/Durchschnitt).", + position = 2 + ) + @Units(" d") + @Range(min = 1, max = 90) + default int rawDays() + { + return 7; + } + + @ConfigItem( + keyName = "hourlyDays", + name = "Stuendliche Werte (Tage)", + description = "So lange bleiben stuendliche Werte erhalten. Aeltere Daten werden zu Tageswerten (Open/Close/Min/Max/Durchschnitt).", + position = 3 + ) + @Units(" d") + @Range(min = 1, max = 3650) + default int hourlyDays() + { + return 90; + } + + @ConfigItem( + keyName = "includeGe", + name = "GE-Angebote zaehlen", + description = "Gold und Items in laufenden Grand Exchange Angeboten mitzaehlen.", + position = 4 + ) + default boolean includeGe() + { + return true; + } + + @ConfigItem( + keyName = "revalueOffline", + name = "Offline-Charaktere neu bewerten", + description = "Bewertet ausgeloggte Charaktere stuendlich mit aktuellen GE-Preisen neu (Marktschwankungen im Verlauf).", + position = 5 + ) + default boolean revalueOffline() + { + return true; + } + + @ConfigItem( + keyName = "showGeBuyLimits", + name = "GE-Kauflimit (4h) im Interface", + description = "Zeigt im Grand Exchange direkt das 4-Stunden-Kauflimit, bereits gekaufte Menge und verbleibendes Limit an.", + position = 7 + ) + default boolean showGeBuyLimits() + { + return true; + } + + @ConfigItem( + keyName = "showSkillsHud", + name = "XP-HUD im Spiel", + description = "Zeigt ingame die gefarmte XP pro Skill im gewaehlten Zeitraum (1H, 1D, ...) an.", + position = 8 + ) + default boolean showSkillsHud() + { + return false; + } + + @ConfigItem( + keyName = "skillsHudMax", + name = "XP-HUD max. Skills", + description = "Maximale Anzahl Skills im XP-HUD (meiste XP zuerst).", + position = 9 + ) + @Range(min = 1, max = 24) + default int skillsHudMax() + { + return 8; + } + + @ConfigItem( + keyName = "skillsHudLevel", + name = "XP-HUD: Level", + description = "Zeigt das aktuelle Level pro Skill im XP-HUD.", + position = 10 + ) + default boolean skillsHudLevel() + { + return true; + } + + @ConfigItem( + keyName = "skillsHudPercent", + name = "XP-HUD: Fortschritt %", + description = "Zeigt den Fortschritt im aktuellen Level in Prozent.", + position = 11 + ) + default boolean skillsHudPercent() + { + return true; + } + + @ConfigItem( + keyName = "skillsHudXpLeft", + name = "XP-HUD: XP bis Level", + description = "Zeigt die verbleibende XP bis zum naechsten Level.", + position = 12 + ) + default boolean skillsHudXpLeft() + { + return false; + } + + @ConfigItem( + keyName = "skillsHudTimeToLevel", + name = "XP-HUD: Zeit bis Level", + description = "Geschaetzte Zeit bis zum naechsten Level (basierend auf XP/h im gewaehlten Zeitraum).", + position = 13 + ) + default boolean skillsHudTimeToLevel() + { + return false; + } + + @ConfigItem( + keyName = "skillsHudXpHour", + name = "XP-HUD: XP/h", + description = "Zeigt XP pro Stunde aktiver Trainingszeit im gewaehlten Zeitraum.", + position = 14 + ) + default boolean skillsHudXpHour() + { + return false; + } + + @ConfigItem( + keyName = "skillsHudProgressBar", + name = "XP-HUD: Fortschrittsbalken", + description = "Zeigt einen Balken mit dem Fortschritt zum naechsten Level.", + position = 15 + ) + default boolean skillsHudProgressBar() + { + return false; + } + + @ConfigItem( + keyName = "wikiMenuOption", + name = "Wiki im Rechtsklick-Menue", + description = "Fuegt beim Rechtsklick auf NPCs, Objekte und Items eine 'Wiki'-Option hinzu, die die Seite im Spiel oeffnet.", + position = 20 + ) + default boolean wikiMenuOption() + { + return true; + } + @ConfigItem( + keyName = "attackTimer", + name = "Attack Timer", + description = "Zeigt ueber dem eigenen Kopf, wie viele Ticks bis zum naechsten Angriff verbleiben (0 = Angriff).", + position = 30 + ) + default boolean attackTimer() + { + return false; + } + + @ConfigItem( + keyName = "enemyAttackTimer", + name = "Enemy Attack Timer", + description = "Zeigt ueber NPCs, die dich angreifen, die Ticks bis zu ihrem naechsten Angriff. Die Angriffsgeschwindigkeit wird aus ihren Angriffen gelernt (Standard 4).", + position = 31 + ) + default boolean enemyAttackTimer() + { + return false; + } + @ConfigItem( + keyName = "enemyAttackStyle", + name = "Enemy Attack Style", + description = "Zeigt ueber NPCs, die dich angreifen, womit sie zuletzt angegriffen haben (Nahkampf, Fernkampf, Magie).", + position = 32 + ) + default boolean enemyAttackStyle() + { + return false; + } + + @ConfigItem( + keyName = "itemPrices", + name = "Item Preise", + description = "Zeigt beim Hovern ueber Inventar-Items eine Zeile mit GE- und/oder HA-Preis.", + position = 33 + ) + default boolean itemPrices() + { + return false; + } + + @ConfigItem( + keyName = "itemPricesGe", + name = "Item Preise: GE", + description = "GE-Preis in der Hover-Zeile anzeigen.", + position = 34 + ) + default boolean itemPricesGe() + { + return true; + } + + @ConfigItem( + keyName = "itemPricesHa", + name = "Item Preise: HA", + description = "High-Alch-Wert in der Hover-Zeile anzeigen.", + position = 35 + ) + default boolean itemPricesHa() + { + return true; + } + + @ConfigItem( + keyName = "treeCooldowns", + name = "Tree Cooldowns", + description = "Zeigt Respawn-Countdown auf gefaellten Baeumen und verbleibende Hack-Zeit auf langlebigen Baeumen.", + position = 36 + ) + default boolean treeCooldowns() + { + return false; + } + + @ConfigItem( + keyName = "itemCharges", + name = "Item charges", + description = "Zeigt Ladungen und Füllstände direkt auf Item-Icons an (Blowpipe, Trident, Tome, Quiver, Loot-Bag, Coal-Bag).", + position = 37 + ) + default boolean itemCharges() + { + return false; + } + + @ConfigItem( + keyName = "enemyAttackStyleText", + name = "Enemy Attack Style: Text statt Icon", + description = "Zeigt 'Melee', 'Range' oder 'Mage' statt des Gebets-Icons.", + position = 60 + ) + default boolean enemyAttackStyleText() + { + return false; + } + + @ConfigItem( + keyName = "enemyAttackStyleSize", + name = "Enemy Attack Style: Groesse", + description = "Groesse von Icon bzw. Text.", + position = 61 + ) + default TimerTextSize enemyAttackStyleSize() + { + return TimerTextSize.NORMAL; + } + + @ConfigItem( + keyName = "enemyAttackStyleOffset", + name = "Enemy Attack Style: Hoehe", + description = "Abstand ueber dem Kopf, unabhaengig vom Enemy Attack Timer.", + position = 62 + ) + @Range(min = -100, max = 300) + default int enemyAttackStyleOffset() + { + return 60; + } + + @ConfigItem( + keyName = "attackTimerBar", + name = "Attack Timer: Tick-Balken", + description = "Zeigt unter der Zahl einen Balken mit einem Kaestchen pro Tick.", + position = 40 + ) + default boolean attackTimerBar() + { + return false; + } + + @ConfigItem( + keyName = "attackTimerSize", + name = "Attack Timer: Textgroesse", + description = "Groesse der Zahl.", + position = 41 + ) + default TimerTextSize attackTimerSize() + { + return TimerTextSize.NORMAL; + } + + @ConfigItem( + keyName = "attackTimerColor", + name = "Attack Timer: Farbe", + description = "Farbe der Zahl und des Balkens.", + position = 42 + ) + default Color attackTimerColor() + { + return Color.WHITE; + } + + @ConfigItem( + keyName = "attackTimerReadyColor", + name = "Attack Timer: Farbe bei 0", + description = "Farbe, wenn der Angriff faellig ist (0).", + position = 43 + ) + default Color attackTimerReadyColor() + { + return new Color(0x66FF66); + } + + @ConfigItem( + keyName = "attackTimerOffset", + name = "Attack Timer: Hoehe", + description = "Abstand ueber dem Kopf.", + position = 44 + ) + @Range(min = -100, max = 200) + default int attackTimerOffset() + { + return 30; + } + + @ConfigItem( + keyName = "enemyAttackTimerBar", + name = "Enemy Attack Timer: Tick-Balken", + description = "Zeigt unter der Zahl einen Balken mit einem Kaestchen pro Tick.", + position = 50 + ) + default boolean enemyAttackTimerBar() + { + return false; + } + + @ConfigItem( + keyName = "enemyAttackTimerSize", + name = "Enemy Attack Timer: Textgroesse", + description = "Groesse der Zahl.", + position = 51 + ) + default TimerTextSize enemyAttackTimerSize() + { + return TimerTextSize.NORMAL; + } + + @ConfigItem( + keyName = "enemyAttackTimerColor", + name = "Enemy Attack Timer: Farbe", + description = "Farbe der Zahl und des Balkens.", + position = 52 + ) + default Color enemyAttackTimerColor() + { + return new Color(0xFF7043); + } + + @ConfigItem( + keyName = "enemyAttackTimerReadyColor", + name = "Enemy Attack Timer: Farbe bei 0", + description = "Farbe, wenn der Angriff faellig ist (0).", + position = 53 + ) + default Color enemyAttackTimerReadyColor() + { + return new Color(0x66FF66); + } + + @ConfigItem( + keyName = "enemyAttackTimerOffset", + name = "Enemy Attack Timer: Hoehe", + description = "Abstand ueber dem Kopf.", + position = 54 + ) + @Range(min = -100, max = 200) + default int enemyAttackTimerOffset() + { + return 30; + } + + @ConfigItem( + keyName = "treeCooldownRespawn", + name = "Tree Cooldowns: Respawn", + description = "Respawn-Countdown ueber Baumstuempfen anzeigen.", + position = 70 + ) + default boolean treeCooldownRespawn() + { + return true; + } + + @ConfigItem( + keyName = "treeCooldownChop", + name = "Tree Cooldowns: Hackzeit", + description = "Verbleibende Hack-Zeit ueber Baeumen waehrend des Faellens anzeigen.", + position = 71 + ) + default boolean treeCooldownChop() + { + return true; + } + + @ConfigItem( + keyName = "treeCooldownBar", + name = "Tree Cooldowns: Fortschrittsbalken", + description = "Zeigt einen kleinen Fortschrittsbalken unter der Sekundenzahl.", + position = 72 + ) + default boolean treeCooldownBar() + { + return false; + } + + @ConfigItem( + keyName = "treeCooldownSize", + name = "Tree Cooldowns: Textgroesse", + description = "Groesse der Zahl.", + position = 73 + ) + default TimerTextSize treeCooldownSize() + { + return TimerTextSize.NORMAL; + } + + @ConfigItem( + keyName = "treeCooldownColor", + name = "Tree Cooldowns: Farbe", + description = "Farbe der Zahl.", + position = 74 + ) + default Color treeCooldownColor() + { + return new Color(0x8FBF3F); + } + + @ConfigItem( + keyName = "treeCooldownReadyColor", + name = "Tree Cooldowns: Farbe bei Bereit", + description = "Farbe kurz vor bzw. beim Respawn.", + position = 75 + ) + default Color treeCooldownReadyColor() + { + return new Color(0x66FF66); + } + + @ConfigItem( + keyName = "treeCooldownOffset", + name = "Tree Cooldowns: Hoehe", + description = "Abstand ueber dem Stumpf bzw. der Baum-Basis.", + position = 76 + ) + @Range(min = -100, max = 200) + default int treeCooldownOffset() + { + return 50; + } + + @ConfigItem( + keyName = "itemChargesWeapons", + name = "Item Charges: Waffen & Rüstung", + description = "Ladungen von Waffen und Rüstungsteilen anzeigen.", + position = 80 + ) + default boolean itemChargesWeapons() + { + return true; + } + + @ConfigItem( + keyName = "itemChargesBags", + name = "Item Charges: Beutel & Säcke", + description = "Geldwert im Looting Bag und Füllstand des Coal Bags anzeigen.", + position = 81 + ) + default boolean itemChargesBags() + { + return true; + } + + @ConfigItem( + keyName = "itemChargesBank", + name = "Item Charges: Bank", + description = "Ladungen auch auf Items im Bank-Interface anzeigen (Achtung: Varbits sind charakterweit).", + position = 82 + ) + default boolean itemChargesBank() + { + return true; + } + + @ConfigItem( + keyName = "itemChargesSize", + name = "Item Charges: Textgröße", + description = "Schriftgröße der Ladungsanzeige.", + position = 83 + ) + default TimerTextSize itemChargesSize() + { + return TimerTextSize.SMALL; + } + + @ConfigItem( + keyName = "itemChargesColor", + name = "Item Charges: Farbe", + description = "Farbe der primären Ladungszahl.", + position = 84 + ) + default Color itemChargesColor() + { + return Color.WHITE; + } + + @ConfigItem( + keyName = "itemChargesSecondaryColor", + name = "Item Charges: Sekundärfarbe", + description = "Farbe für Sekundärwerte (z. B. Quiver-Blessing, Darts-Marker).", + position = 85 + ) + default Color itemChargesSecondaryColor() + { + return new Color(0xC0C0C0); + } + + @ConfigItem( + keyName = "itemChargesLowColor", + name = "Item Charges: Warnfarbe", + description = "Farbe bei niedrigem Ladungsstand.", + position = 86 + ) + default Color itemChargesLowColor() + { + return new Color(0xFF3333); + } + + @ConfigItem( + keyName = "itemChargesLowAt", + name = "Item Charges: Niedrig ab", + description = "Schwellenwert für niedrige Ladungen (0 = deaktiviert).", + position = 87 + ) + @Range(min = 0, max = 10000) + default int itemChargesLowAt() + { + return 0; + } +} + diff --git a/src/main/java/com/example/essentialsplus/EssentialsPlusPlugin.java b/src/main/java/com/example/essentialsplus/EssentialsPlusPlugin.java new file mode 100644 index 0000000..2ac98b6 --- /dev/null +++ b/src/main/java/com/example/essentialsplus/EssentialsPlusPlugin.java @@ -0,0 +1,3526 @@ +package com.example.essentialsplus; + +import com.example.essentialsplus.data.AccountData; +import com.example.essentialsplus.data.ItemLedger; +import com.example.essentialsplus.data.ItemSnapshot; +import com.example.essentialsplus.data.Snapshot; +import com.example.essentialsplus.data.SourceValue; +import com.example.essentialsplus.data.TopItem; +import com.example.essentialsplus.skills.SkillHistory; +import com.example.essentialsplus.skills.SkillStore; +import com.example.essentialsplus.skills.SkillsHudOverlay; +import com.example.essentialsplus.ui.EssentialsPlusPanel; +import com.example.essentialsplus.ui.TimeFilter; +import com.example.essentialsplus.ui.WealthPanel; +import com.example.essentialsplus.wiki.WikiApi; +import com.example.essentialsplus.wiki.WikiOverlay; +import com.example.essentialsplus.wiki.WikiWindow; +import com.example.essentialsplus.wiki.WikiWindows; +import com.google.inject.Provides; +import java.awt.BasicStroke; +import java.awt.Color; +import java.awt.Graphics2D; +import java.awt.RenderingHints; +import java.awt.image.BufferedImage; +import net.runelite.client.util.ImageUtil; +import java.io.File; +import java.io.IOException; +import java.io.PrintWriter; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.time.Instant; +import java.time.LocalDate; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeMap; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; +import java.util.function.Consumer; +import javax.inject.Inject; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; +import net.runelite.api.Client; +import net.runelite.api.EnumComposition; +import net.runelite.api.GameState; +import net.runelite.api.Item; +import net.runelite.api.ItemComposition; +import net.runelite.api.ItemContainer; +import net.runelite.api.Player; +import net.runelite.api.events.GameStateChanged; +import net.runelite.api.events.GameTick; +import net.runelite.api.events.GrandExchangeOfferChanged; +import net.runelite.api.events.ItemContainerChanged; +import net.runelite.api.events.VarbitChanged; +import net.runelite.api.gameval.InterfaceID; +import net.runelite.api.gameval.InventoryID; +import net.runelite.api.gameval.ItemID; +import net.runelite.api.gameval.VarbitID; +import net.runelite.api.widgets.ComponentID; +import java.util.Iterator; +import net.runelite.client.callback.ClientThread; +import net.runelite.client.config.ConfigManager; +import net.runelite.client.config.RuneScapeProfileType; +import net.runelite.client.eventbus.Subscribe; +import net.runelite.client.events.ConfigChanged; +import net.runelite.client.events.OverlayMenuClicked; +import net.runelite.client.game.ItemManager; +import net.runelite.client.plugins.Plugin; +import net.runelite.client.plugins.PluginDescriptor; +import net.runelite.client.ui.ClientToolbar; +import net.runelite.client.ui.NavigationButton; +import com.example.essentialsplus.data.GeBuyLimit; +import com.example.essentialsplus.data.GeLimitInfo; +import com.example.essentialsplus.data.GeSlot; +import java.time.Duration; +import java.util.Locale; +import net.runelite.api.GrandExchangeOffer; +import net.runelite.api.GrandExchangeOfferState; +import net.runelite.api.VarPlayer; +import net.runelite.api.Varbits; +import net.runelite.api.events.ClientTick; +import net.runelite.api.events.ScriptCallbackEvent; +import net.runelite.api.gameval.InterfaceID; +import net.runelite.api.widgets.Widget; +import net.runelite.client.game.ItemStats; + +@Slf4j +@PluginDescriptor( + name = "Essentials Plus", + description = "Moderne Plugin-Suite & Übersicht (Wealth Tracker, Net Worth, Bank, GE limits und mehr)", + tags = {"essentials", "plus", "wiki", "wealth", "net worth", "bank", "gp", "graph", "coffer", "value", "history", "analysis"} +) +public class EssentialsPlusPlugin extends Plugin +{ + private static final int DOOM_REWARD_CONTAINER = 923; + private static final int RUNEPOUCH_RUNE_ENUM = 982; + private static final int[] RUNE_POUCH_TYPES = { + VarbitID.RUNE_POUCH_TYPE_1, VarbitID.RUNE_POUCH_TYPE_2, VarbitID.RUNE_POUCH_TYPE_3, + VarbitID.RUNE_POUCH_TYPE_4, VarbitID.RUNE_POUCH_TYPE_5, VarbitID.RUNE_POUCH_TYPE_6 + }; + private static final int[] RUNE_POUCH_QUANTITIES = { + VarbitID.RUNE_POUCH_QUANTITY_1, VarbitID.RUNE_POUCH_QUANTITY_2, VarbitID.RUNE_POUCH_QUANTITY_3, + VarbitID.RUNE_POUCH_QUANTITY_4, VarbitID.RUNE_POUCH_QUANTITY_5, VarbitID.RUNE_POUCH_QUANTITY_6 + }; + + /** Item containers the game only sends while/after they are opened; last contents are persisted. */ + private static final ContainerSource[] CONTAINERS = { + new ContainerSource(InventoryID.BANK, "bank", "Bank", WealthCategory.BANK), + new ContainerSource(InventoryID.INV, "inventory", "Inventory", WealthCategory.INVENTORY), + new ContainerSource(InventoryID.WORN, "equipment", "Equipment", WealthCategory.EQUIPMENT), + new ContainerSource(InventoryID.LOOTING_BAG, "lootingBag", "Looting bag", WealthCategory.STORAGE), + new ContainerSource(InventoryID.SEED_VAULT, "seedVault", "Seed vault", WealthCategory.STORAGE), + }; + + private static final Set WATCHED_VARBITS = new java.util.HashSet<>(Arrays.asList( + VarbitID.MISC_COFFERS, VarbitID.BLAST_FURNACE_COFFER, VarbitID.NZONE_CASH, + VarbitID.RUNE_POUCH_TYPE_1, VarbitID.RUNE_POUCH_TYPE_2, VarbitID.RUNE_POUCH_TYPE_3, + VarbitID.RUNE_POUCH_TYPE_4, VarbitID.RUNE_POUCH_TYPE_5, VarbitID.RUNE_POUCH_TYPE_6, + VarbitID.RUNE_POUCH_QUANTITY_1, VarbitID.RUNE_POUCH_QUANTITY_2, VarbitID.RUNE_POUCH_QUANTITY_3, + VarbitID.RUNE_POUCH_QUANTITY_4, VarbitID.RUNE_POUCH_QUANTITY_5, VarbitID.RUNE_POUCH_QUANTITY_6 + )); + + /** Blast Furnace: ores inside the furnace and bars waiting in the dispenser (varbit, item id) */ + private static final int[][] BLAST_FURNACE_ITEMS = { + {VarbitID.BLAST_FURNACE_TIN_ORE, ItemID.TIN_ORE}, + {VarbitID.BLAST_FURNACE_COPPER_ORE, ItemID.COPPER_ORE}, + {VarbitID.BLAST_FURNACE_IRON_ORE, ItemID.IRON_ORE}, + {VarbitID.BLAST_FURNACE_MITHRIL_ORE, ItemID.MITHRIL_ORE}, + {VarbitID.BLAST_FURNACE_ADAMANTITE_ORE, ItemID.ADAMANTITE_ORE}, + {VarbitID.BLAST_FURNACE_RUNITE_ORE, ItemID.RUNITE_ORE}, + {VarbitID.BLAST_FURNACE_GOLD_ORE, ItemID.GOLD_ORE}, + {VarbitID.BLAST_FURNACE_SILVER_ORE, ItemID.SILVER_ORE}, + {VarbitID.BLAST_FURNACE_COAL, ItemID.COAL}, + {VarbitID.BLAST_FURNACE_LEAD_ORE, ItemID.LEAD_ORE}, + {VarbitID.BLAST_FURNACE_NICKEL_ORE, ItemID.NICKEL_ORE}, + {VarbitID.BLAST_FURNACE_BRONZE_BARS, ItemID.BRONZE_BAR}, + {VarbitID.BLAST_FURNACE_IRON_BARS, ItemID.IRON_BAR}, + {VarbitID.BLAST_FURNACE_STEEL_BARS, ItemID.STEEL_BAR}, + {VarbitID.BLAST_FURNACE_MITHRIL_BARS, ItemID.MITHRIL_BAR}, + {VarbitID.BLAST_FURNACE_ADAMANTITE_BARS, ItemID.ADAMANTITE_BAR}, + {VarbitID.BLAST_FURNACE_RUNITE_BARS, ItemID.RUNITE_BAR}, + {VarbitID.BLAST_FURNACE_GOLD_BARS, ItemID.GOLD_BAR}, + {VarbitID.BLAST_FURNACE_SILVER_BARS, ItemID.SILVER_BAR}, + {VarbitID.BLAST_FURNACE_LEAD_BARS, ItemID.LEAD_BAR}, + {VarbitID.BLAST_FURNACE_CUPRONICKEL_BARS, ItemID.CUPRONICKEL_BAR} + }; + + private static final Set COFFER_VARBITS = Set.of(VarbitID.MISC_COFFERS, VarbitID.BLAST_FURNACE_COFFER, VarbitID.NZONE_CASH); + private static final Set BLAST_FURNACE_VARBITS = new java.util.HashSet<>(); + private static final int BLAST_FURNACE_REGION = 7757; + + static + { + for (int[] bf : BLAST_FURNACE_ITEMS) + { + WATCHED_VARBITS.add(bf[0]); + BLAST_FURNACE_VARBITS.add(bf[0]); + } + } + + /** quiet ticks before a normal change (inventory while skilling, GE collect) is valued */ + private static final int COMPUTE_MIN_TICKS = 2; + /** a burst of changes is valued after this many ticks even if it keeps changing */ + private static final int COMPUTE_MAX_DELAY_TICKS = 5; + /** periodic re-valuation (prices) when nothing happens */ + private static final int COMPUTE_IDLE_TICKS = 100; + private static final long SAVE_INTERVAL_MS = 60_000; + private static final long LEDGER_SAVE_INTERVAL_MS = 5 * 60_000; + private static final long HOUR_MS = 3_600_000; + private static final long OFFLINE_GRACE_MS = 10 * 60_000; + private static final int TOP_ITEMS = 60; + + @Inject + private Client client; + + @Inject + private ClientThread clientThread; + + @Inject + private ClientToolbar clientToolbar; + + @Inject + @Getter + private ItemManager itemManager; + + @Inject + private EssentialsPlusConfig config; + + @Inject + @Getter + private WealthStore store; + + @Inject + @Getter + private com.example.essentialsplus.loot.LootTrackerStore lootTrackerStore; + + @Inject + @Getter + private com.example.essentialsplus.loot.combat.CombatTracker combatTracker; + + @Inject + private net.runelite.client.game.LootManager lootManager; + + @Inject + @Getter + private SkillStore skillStore; + + /** Level goals per skill, set from the skills tab. */ + @Getter + private com.example.essentialsplus.skills.SkillGoals skillGoals; + + @Inject + private com.example.essentialsplus.currency.CurrencyStore currencyStore; + + /** Minigame points, marks of grace and other tokens of the logged-in character. */ + @Getter + private com.example.essentialsplus.currency.CurrencyTracker currencyTracker; + + @Inject + @Getter + private net.runelite.client.game.SkillIconManager skillIconManager; + + @Inject + private net.runelite.client.ui.overlay.OverlayManager overlayManager; + + @Inject + private SkillsHudOverlay skillsHudOverlay; + + @Inject + private com.example.essentialsplus.helpers.AttackTimers attackTimers; + + @Inject + private com.example.essentialsplus.helpers.AttackStyles attackStyles; + + @Inject + private com.example.essentialsplus.helpers.AttackTimerOverlay attackTimerOverlay; + + @Inject + private com.example.essentialsplus.helpers.ItemPriceOverlay itemPriceOverlay; + + @Inject + private com.example.essentialsplus.helpers.TreeCooldowns treeCooldowns; + + @Inject + private com.example.essentialsplus.helpers.TreeCooldownOverlay treeCooldownOverlay; + + @Inject + private com.example.essentialsplus.helpers.ItemCharges itemCharges; + + @Inject + private com.example.essentialsplus.helpers.ItemChargesOverlay itemChargesOverlay; + + @Inject + private net.runelite.client.eventbus.EventBus eventBus; + + @Inject + private net.runelite.client.ui.components.colorpicker.ColorPickerManager colorPickerManager; + + @Inject + private okhttp3.OkHttpClient okHttpClient; + + @Inject + private com.google.gson.Gson gson; + + @Inject + private net.runelite.client.input.MouseManager mouseManager; + + @Inject + @Getter + private net.runelite.client.game.SpriteManager spriteManager; + + @Getter + private WikiApi wikiApi; + + /** Worn gear, bank gear planner and the live target of the fights view. */ + @Getter + private com.example.essentialsplus.loot.gear.FightService fightService; + + /** Collection log pages and the items the logged-in account has obtained. */ + @Getter + private com.example.essentialsplus.collectionlog.CollectionLogService collectionLog; + + /** Achievement diary and combat achievement progress of the logged-in account. */ + @Getter + private com.example.essentialsplus.progress.ProgressService progress; + + /** Wiki page loads, searches and thumbnails; kept off the shared executor because image downloads can take seconds. */ + @Getter + private java.util.concurrent.ExecutorService wikiExecutor; + + private WikiWindows wikiWindows; + private WikiOverlay wikiOverlay; + + /** Global time range of all views; persisted in the config. */ + @Getter + private TimeFilter timeFilter; + + /** Account key of the logged-in character for skill tracking (null when logged out). */ + @Getter + private volatile String skillKey; + private boolean xpDirty; + private ScheduledFuture skillSaveTask; + + private String pendingCrateName; + private int pendingCrateTick; + private Map invSnapshotBeforeOpen; + + @Inject + private ScheduledExecutorService executor; + + @Inject + private ConfigManager configManager; + + /** timestamp for a pinned item snapshot (session start), 0 = none */ + private volatile long pinLedgerAt; + /** true until the next LOGGED_IN is a fresh login (hops and reconnects keep the session) */ + private boolean awaitingLogin = true; + /** the real bank was not visible yet this session - the saved one may be outdated (played elsewhere, closed client) */ + private boolean bankSeenThisSession; + + private EssentialsPlusPanel panel; + private NavigationButton navButton; + private ScheduledFuture reloadTask; + private ScheduledFuture revalueTask; + + @Getter + private volatile String activeKey; + private boolean dirty = true; + private int ticksSinceCompute; + private int quietTicks; + /** ticks since the oldest change that is not valued yet */ + private int dirtyTicks; + /** value on the next tick (bank opened/closed, gear changed) */ + private boolean urgent; + /** sources that only change on specific events are read then and cached in between */ + private boolean pouchDirty = true; + private boolean coffersDirty = true; + private boolean furnaceDirty = true; + private final long[] cofferCache = new long[3]; + private Map furnaceCache = new HashMap<>(); + private int ticksSinceLogin = -1; + private boolean bankUpdated; + private volatile boolean bankOpen; + private volatile boolean justOpenedBank; + private Map lastInvItems; + /** + * Container contents captured when they change. The bank container is gone once the bank is closed, so a + * withdrawal right before closing would otherwise be valued with the old bank (items counted twice). + */ + private final Map> seenContainers = new java.util.concurrent.ConcurrentHashMap<>(); + private Map lastWornItems; + private final GrandExchangeTracker geTracker = new GrandExchangeTracker(); + private long lastSave; + private long lastLedgerSave; + private boolean unsaved; + private final Map lastRevalue = new HashMap<>(); + + @Provides + EssentialsPlusConfig provideConfig(ConfigManager configManager) + { + return configManager.getConfig(EssentialsPlusConfig.class); + } + + @Override + protected void startUp() + { + timeFilter = new TimeFilter(TimeFilter.parse(configManager.getConfiguration(EssentialsPlusConfig.GROUP, "timeRange")), + r -> configManager.setConfiguration(EssentialsPlusConfig.GROUP, "timeRange", r.name())); + awaitingLogin = client.getGameState() != GameState.LOGGED_IN; + if (!awaitingLogin) + { + pinLedgerAt = timeFilter.getSessionStart(); + } + skillStore.load(); + skillGoals = new com.example.essentialsplus.skills.SkillGoals(configManager); + overlayManager.add(skillsHudOverlay); + eventBus.register(attackTimers); + eventBus.register(attackStyles); + eventBus.register(treeCooldowns); + eventBus.register(itemCharges); + overlayManager.add(attackTimerOverlay); + overlayManager.add(itemPriceOverlay); + overlayManager.add(treeCooldownOverlay); + overlayManager.add(itemChargesOverlay); + currencyStore.load(); + currencyTracker = new com.example.essentialsplus.currency.CurrencyTracker(client, currencyStore); + skillSaveTask = executor.scheduleWithFixedDelay(() -> + { + skillStore.saveDirty(); + currencyStore.saveDirty(); + }, 60, 60, TimeUnit.SECONDS); + startWiki(); + collectionLog = new com.example.essentialsplus.collectionlog.CollectionLogService(client, configManager); + progress = new com.example.essentialsplus.progress.ProgressService(client, okHttpClient, wikiExecutor); + + com.example.essentialsplus.loot.gear.MonsterService monsters = new com.example.essentialsplus.loot.gear.MonsterService( + okHttpClient, gson, wikiExecutor, () -> fightService.onMonsterLoaded()); + fightService = new com.example.essentialsplus.loot.gear.FightService(client, itemManager, combatTracker, attackStyles, + monsters, wikiExecutor, () -> + { + AccountData acc = activeKey != null ? store.get(activeKey) : null; + return acc != null ? acc.containers.get("bank") : null; + }); + + panel = new EssentialsPlusPanel(this); + BufferedImage icon = loadIcon(); + navButton = NavigationButton.builder() + .tooltip("Essentials Plus") + .icon(icon) + .priority(7) + .panel(panel) + .build(); + clientToolbar.addNavigation(navButton); + + executor.execute(() -> + { + store.reloadExternal(activeKey); + refreshPanel(); + }); + + reloadTask = executor.scheduleWithFixedDelay(() -> + { + if (store.reloadExternal(activeKey)) + { + refreshPanel(); + } + }, 30, 30, TimeUnit.SECONDS); + + revalueTask = executor.scheduleWithFixedDelay(() -> clientThread.invokeLater(this::revalueOffline), 20, 600, TimeUnit.SECONDS); + + lootTrackerStore.load(); + lootTrackerStore.addChangeListener(this::refreshPanel); + + dirty = true; + if (client.getGameState() == GameState.LOGGED_IN) + { + if (ticksSinceLogin < 0) + { + ticksSinceLogin = 0; + } + clientThread.invokeLater(this::syncGeOffers); + } + } + + @Override + protected void shutDown() + { + stopWiki(); + fightService = null; + lootTrackerStore.save(); + overlayManager.remove(skillsHudOverlay); + overlayManager.remove(attackTimerOverlay); + overlayManager.remove(itemPriceOverlay); + overlayManager.remove(treeCooldownOverlay); + overlayManager.remove(itemChargesOverlay); + eventBus.unregister(attackTimers); + eventBus.unregister(attackStyles); + eventBus.unregister(treeCooldowns); + eventBus.unregister(itemCharges); + attackTimers.reset(); + attackStyles.reset(); + treeCooldowns.reset(); + itemCharges.reset(); + if (skillSaveTask != null) + { + skillSaveTask.cancel(false); + skillSaveTask = null; + } + skillStore.saveDirty(); + currencyStore.saveDirty(); + skillKey = null; + if (reloadTask != null) + { + reloadTask.cancel(false); + reloadTask = null; + } + if (revalueTask != null) + { + revalueTask.cancel(false); + revalueTask = null; + } + AccountData acc = store.get(activeKey); + if (acc != null) + { + recordFinal(acc); + WealthStore.compact(acc, config.rawDays(), config.hourlyDays(), System.currentTimeMillis()); + store.save(acc); + ItemLedger ledger = store.ledger(acc.key, true); + if (ledger.dirty) + { + store.saveLedger(acc.key, ledger); + } + } + activeKey = null; + bankOpen = false; + lastInvItems = null; + lastWornItems = null; + clientToolbar.removeNavigation(navButton); + if (panel != null) + { + panel.dispose(); + } + panel = null; + navButton = null; + } + + private void refreshPanel() + { + EssentialsPlusPanel p = panel; + if (p != null) + { + p.refreshLater(); + } + } + + // ---------------------------------------------------------------- wiki + + private static final String WIKI_BOUNDS = "wikiBounds"; + private static final String WIKI_RECENT = "wikiRecent"; + private static final int WIKI_RECENT_MAX = 10; + + private void startWiki() + { + java.util.concurrent.atomic.AtomicInteger threads = new java.util.concurrent.atomic.AtomicInteger(); + wikiExecutor = java.util.concurrent.Executors.newFixedThreadPool(3, r -> + { + Thread t = new Thread(r, "essentials-wiki-" + threads.incrementAndGet()); + t.setDaemon(true); + return t; + }); + wikiApi = new WikiApi(okHttpClient, gson); + wikiWindows = new WikiWindows(wikiApi, wikiExecutor, client::getCanvasWidth, client::getCanvasHeight, + loadWikiBounds(), this::saveWikiBounds, this::addWikiRecent); + wikiOverlay = new WikiOverlay(spriteManager, wikiWindows); + overlayManager.add(wikiOverlay); + mouseManager.registerMouseListener(wikiWindows); + mouseManager.registerMouseWheelListener(wikiWindows); + } + + private void stopWiki() + { + if (wikiWindows != null) + { + mouseManager.unregisterMouseListener(wikiWindows); + mouseManager.unregisterMouseWheelListener(wikiWindows); + wikiWindows.closeAll(); + wikiWindows = null; + } + if (wikiOverlay != null) + { + overlayManager.remove(wikiOverlay); + wikiOverlay = null; + } + if (wikiExecutor != null) + { + wikiExecutor.shutdownNow(); + wikiExecutor = null; + } + } + + /** Opens a wiki page in the front in-game wiki window (a new one if none is open). */ + public void openWikiPage(String title) + { + WikiWindows w = wikiWindows; + if (w != null) + { + w.open(title, null); + } + } + + /** Opens a wiki page in an additional in-game window. */ + public void openWikiPageNewWindow(String title) + { + WikiWindows w = wikiWindows; + if (w != null) + { + w.openNew(title, null); + } + } + + /** Layout and display options of the overview cards ("overview."), null when unset. */ + public String getOverviewSetting(String name) + { + return configManager.getConfiguration(EssentialsPlusConfig.GROUP, "overview." + name); + } + + public void setOverviewSetting(String name, String value) + { + if (value == null || value.isEmpty()) + { + configManager.unsetConfiguration(EssentialsPlusConfig.GROUP, "overview." + name); + } + else + { + configManager.setConfiguration(EssentialsPlusConfig.GROUP, "overview." + name, value); + } + } + + /** In-game helper switches (attack timers) by config key. */ + public boolean isHelperOption(String key) + { + switch (key) + { + case "attackTimer": + return config.attackTimer(); + case "enemyAttackTimer": + return config.enemyAttackTimer(); + case "enemyAttackStyle": + return config.enemyAttackStyle(); + case "itemPrices": + return config.itemPrices(); + case "treeCooldowns": + return config.treeCooldowns(); + case "itemCharges": + return config.itemCharges(); + default: + return false; + } + } + + /** Sets a helper config value; null restores the default. */ + public void setHelperOption(String key, Object value) + { + if (value == null) + { + configManager.unsetConfiguration(EssentialsPlusConfig.GROUP, key); + } + else + { + configManager.setConfiguration(EssentialsPlusConfig.GROUP, key, value); + } + } + + /** Typed config value with the interface default applied (for the helper option menus). */ + public T getHelperValue(String key, Class type, T def) + { + T v = configManager.getConfiguration(EssentialsPlusConfig.GROUP, key, type); + return v == null ? def : v; + } + + public com.example.essentialsplus.helpers.ItemCharges getItemCharges() + { + return itemCharges; + } + + public net.runelite.client.ui.components.colorpicker.ColorPickerManager getColorPickerManager() + { + return colorPickerManager; + } + + public boolean isWikiMenuEnabled() + { + return config.wikiMenuOption(); + } + + public void setWikiMenuEnabled(boolean enabled) + { + configManager.setConfiguration(EssentialsPlusConfig.GROUP, "wikiMenuOption", enabled); + } + + /** Adds a "Wiki" entry for every examinable thing (npc, object, ground or inventory item) at the bottom of the menu. */ + @Subscribe + public void onMenuOpened(net.runelite.api.events.MenuOpened event) + { + if (!config.wikiMenuOption() || wikiWindows == null) + { + return; + } + net.runelite.api.Menu menu = client.getMenu(); + net.runelite.api.MenuEntry[] entries = menu.getMenuEntries(); + List targets = new ArrayList<>(); + Set seen = new java.util.HashSet<>(); + for (int i = entries.length - 1; i >= 0; i--) + { + net.runelite.api.MenuEntry entry = entries[i]; + WikiTarget target = widgetWikiTarget(entry); + if (target == null && "Examine".equalsIgnoreCase(net.runelite.client.util.Text.removeTags(entry.getOption()))) + { + target = wikiTarget(entry); + } + if (target != null && seen.add(target.type == null ? "page:" + target.name : target.type + ":" + target.id)) + { + targets.add(target); + } + } + // insert directly above "Cancel"; each insert lands below the previous one, keeping the menu order + int index = Math.min(1, entries.length); + for (WikiTarget target : targets) + { + menu.createMenuEntry(index) + .setOption("Wiki") + .setTarget(target.menuTarget) + .setType(net.runelite.api.MenuAction.RUNELITE) + .onClick(e -> lookupWiki(target)); + } + } + + private static final class WikiTarget + { + final String type; + final int id; + final String name; + final String menuTarget; + + WikiTarget(String type, int id, String name, String menuTarget) + { + this.type = type; + this.id = id; + this.name = name; + this.menuTarget = menuTarget; + } + } + + private WikiTarget wikiTarget(net.runelite.api.MenuEntry entry) + { + String name = null; + String type = null; + int id = -1; + switch (entry.getType()) + { + case EXAMINE_NPC: + { + net.runelite.api.NPC npc = entry.getNpc(); + if (npc != null) + { + net.runelite.api.NPCComposition comp = npc.getTransformedComposition(); + if (comp == null) + { + comp = npc.getComposition(); + } + if (comp != null) + { + type = "npc"; + id = comp.getId(); + name = comp.getName(); + } + } + break; + } + case EXAMINE_OBJECT: + { + net.runelite.api.ObjectComposition comp = client.getObjectDefinition(entry.getIdentifier()); + if (comp != null && comp.getImpostorIds() != null && comp.getImpostor() != null) + { + comp = comp.getImpostor(); + } + if (comp != null) + { + type = "object"; + id = comp.getId(); + name = comp.getName(); + } + break; + } + case EXAMINE_ITEM_GROUND: + type = "item"; + id = entry.getIdentifier(); + break; + default: + if (entry.getItemId() > 0) + { + type = "item"; + id = entry.getItemId(); + } + break; + } + if (type == null || id < 0) + { + return null; + } + if ("item".equals(type)) + { + id = itemManager.canonicalize(id); + ItemComposition comp = itemManager.getItemComposition(id); + name = comp == null ? null : comp.getName(); + } + name = name == null ? null : net.runelite.client.util.Text.removeTags(name).trim(); + if (name == null || name.isEmpty() || name.equalsIgnoreCase("null")) + { + return null; + } + return new WikiTarget(type, id, name, entry.getTarget()); + } + + /** Stats tab children 1..24, in widget order. */ + private static final String[] STAT_SKILLS = { + "Attack", "Strength", "Defence", "Ranged", "Prayer", "Magic", "Runecraft", "Construction", + "Hitpoints", "Agility", "Herblore", "Thieving", "Crafting", "Fletching", "Slayer", "Hunter", + "Mining", "Smithing", "Fishing", "Cooking", "Firemaking", "Woodcutting", "Farming", "Sailing" + }; + + /** Equipment tab slot widgets mapped to their worn container slot. */ + private static final Map WORN_SLOT_WIDGETS = new HashMap<>(); + + static + { + int[][] slots = { + {InterfaceID.Wornitems.SLOT0, 0}, {InterfaceID.Wornitems.SLOT1, 1}, {InterfaceID.Wornitems.SLOT2, 2}, + {InterfaceID.Wornitems.SLOT3, 3}, {InterfaceID.Wornitems.SLOT4, 4}, {InterfaceID.Wornitems.SLOT5, 5}, + {InterfaceID.Wornitems.SLOT7, 7}, {InterfaceID.Wornitems.SLOT9, 9}, {InterfaceID.Wornitems.SLOT10, 10}, + {InterfaceID.Wornitems.SLOT12, 12}, {InterfaceID.Wornitems.SLOT13, 13} + }; + for (int[] s : slots) + { + WORN_SLOT_WIDGETS.put(s[0], s[1]); + } + } + + /** Wiki targets for side-panel interfaces without an "Examine" option: prayers, equipment, combat options, skills. */ + private WikiTarget widgetWikiTarget(net.runelite.api.MenuEntry entry) + { + if (entry.getWidget() == null) + { + return null; + } + int component = entry.getParam1(); + int group = net.runelite.api.widgets.WidgetUtil.componentToInterface(component); + String option = net.runelite.client.util.Text.removeTags(entry.getOption()).trim(); + String target = net.runelite.client.util.Text.removeTags(entry.getTarget()).trim(); + switch (group) + { + case InterfaceID.PRAYERBOOK: + { + if (component < InterfaceID.Prayerbook.PRAYER1 || component > InterfaceID.Prayerbook.PRAYER30) + { + return null; + } + String name = target.isEmpty() ? net.runelite.client.util.Text.removeTags(entry.getWidget().getName()).trim() : target; + return name.isEmpty() ? null : new WikiTarget(null, -1, name, "" + name + ""); + } + case InterfaceID.WORNITEMS: + { + int itemId = entry.getItemId(); + Integer slot = WORN_SLOT_WIDGETS.get(component); + if (itemId <= 0 && slot != null) + { + ItemContainer worn = client.getItemContainer(InventoryID.WORN); + Item item = worn == null ? null : worn.getItem(slot); + itemId = item == null ? -1 : item.getId(); + } + if (itemId <= 0) + { + return null; + } + itemId = itemManager.canonicalize(itemId); + ItemComposition comp = itemManager.getItemComposition(itemId); + String name = comp == null ? null : net.runelite.client.util.Text.removeTags(comp.getName()).trim(); + if (name == null || name.isEmpty() || name.equalsIgnoreCase("null")) + { + return null; + } + return new WikiTarget("item", itemId, name, "" + name + ""); + } + case InterfaceID.COMBAT_INTERFACE: + { + String page; + if (component == InterfaceID.CombatInterface.RETALIATE) + { + page = "Auto Retaliate"; + } + else if (component == InterfaceID.CombatInterface.SP_ATTACKBAR || component == InterfaceID.CombatInterface.SPECIAL_ATTACK) + { + page = "Special attacks"; + } + else + { + page = "Combat Options"; + } + String label = target.isEmpty() ? option : target; + return new WikiTarget(null, -1, page, "" + (label.isEmpty() ? page : label) + ""); + } + case InterfaceID.STATS: + { + int child = net.runelite.api.widgets.WidgetUtil.componentToId(component); + String page = child >= 1 && child <= STAT_SKILLS.length ? STAT_SKILLS[child - 1] + : component == InterfaceID.Stats.TOTAL ? "Skills" : null; + return page == null ? null : new WikiTarget(null, -1, page, "" + page + ""); + } + default: + return null; + } + } + + private void lookupWiki(WikiTarget target) + { + if (target.type == null) + { + WikiWindows w = wikiWindows; + if (w != null) + { + w.open(target.name, null); + } + return; + } + java.util.concurrent.ExecutorService ex = wikiExecutor; + if (ex == null) + { + return; + } + ex.execute(() -> + { + String title = target.name; + String anchor = null; + try + { + WikiWindow.Target resolved = WikiWindow.parseWikiUrl(wikiApi.lookup(target.type, target.id, target.name)); + if (resolved != null) + { + title = resolved.title; + anchor = resolved.anchor; + } + } + catch (Exception e) + { + log.debug("wiki lookup for {} failed", target.name, e); + } + WikiWindows w = wikiWindows; + if (w != null) + { + w.open(title, anchor); + } + }); + } + + /** Recently opened wiki pages, newest first. */ + public List getWikiRecent() + { + String raw = configManager.getConfiguration(EssentialsPlusConfig.GROUP, WIKI_RECENT); + List out = new ArrayList<>(); + if (raw != null) + { + for (String t : raw.split("\\|")) + { + if (!t.trim().isEmpty()) + { + out.add(t.trim()); + } + } + } + return out; + } + + private void addWikiRecent(String title) + { + List recent = getWikiRecent(); + recent.removeIf(t -> t.equalsIgnoreCase(title)); + recent.add(0, title); + while (recent.size() > WIKI_RECENT_MAX) + { + recent.remove(recent.size() - 1); + } + configManager.setConfiguration(EssentialsPlusConfig.GROUP, WIKI_RECENT, String.join("|", recent)); + EssentialsPlusPanel p = panel; + if (p != null) + { + p.refreshWikiLater(); + } + } + + private java.awt.Rectangle loadWikiBounds() + { + String raw = configManager.getConfiguration(EssentialsPlusConfig.GROUP, WIKI_BOUNDS); + if (raw != null) + { + String[] parts = raw.split(","); + try + { + if (parts.length == 4) + { + return new java.awt.Rectangle(Integer.parseInt(parts[0]), Integer.parseInt(parts[1]), + Integer.parseInt(parts[2]), Integer.parseInt(parts[3])); + } + } + catch (NumberFormatException ignored) + { + // fall back to the default position + } + } + return null; + } + + private void saveWikiBounds(java.awt.Rectangle r) + { + configManager.setConfiguration(EssentialsPlusConfig.GROUP, WIKI_BOUNDS, r.x + "," + r.y + "," + r.width + "," + r.height); + } + + public static String accountKey(long accountHash, RuneScapeProfileType type) + { + String base = String.valueOf(accountHash); + return type == null || type == RuneScapeProfileType.STANDARD ? base : base + "-" + type.name().toLowerCase(); + } + + // ---------------------------------------------------------------- events + + @Subscribe + public void onItemContainerChanged(ItemContainerChanged event) + { + combatTracker.onItemContainerChanged(event); + if (fightService != null) + { + fightService.onContainerChanged(); + } + int containerId = event.getContainerId(); + + // Check if a reward crate / casket was just opened from inventory + if (containerId == net.runelite.api.InventoryID.INVENTORY.getId() && pendingCrateName != null) + { + if (client.getTickCount() - pendingCrateTick <= 4) + { + ItemContainer inv = event.getItemContainer(); + Map newInv = new HashMap<>(); + if (inv != null) + { + for (Item item : inv.getItems()) + { + if (item != null && item.getId() > 0 && item.getQuantity() > 0) + { + newInv.merge(item.getId(), item.getQuantity(), Integer::sum); + } + } + } + + List gained = new ArrayList<>(); + if (invSnapshotBeforeOpen != null) + { + for (Map.Entry entry : newInv.entrySet()) + { + int id = entry.getKey(); + int currentQty = entry.getValue(); + int prevQty = invSnapshotBeforeOpen.getOrDefault(id, 0); + if (currentQty > prevQty) + { + gained.add(new net.runelite.client.game.ItemStack(id, currentQty - prevQty)); + } + } + } + + if (!gained.isEmpty()) + { + lootTrackerStore.addLoot(pendingCrateName, "EVENT", gained, 1, itemManager, client.getTickCount()); + } + } + pendingCrateName = null; + invSnapshotBeforeOpen = null; + } + + checkChestContainers(event); + trackBankAndContainers(containerId, event.getItemContainer()); + + for (ContainerSource c : CONTAINERS) + { + if (c.id == containerId) + { + ItemContainer changed = event.getItemContainer(); + if (changed != null) + { + Map items = readContainer(changed); + seenContainers.put(c.key, items); + AccountData acc = activeAccount(); + if (acc != null) + { + acc.containers.put(c.key, items); + if (c.category == WealthCategory.BANK) + { + acc.bankKnown = true; + bankUpdated = true; + bankSeenThisSession = true; + if (acc.bankPending != null && !acc.bankPending.isEmpty()) + { + acc.bankPending = new HashMap<>(); + } + } + } + } + if (c.category == WealthCategory.BANK) + { + bankUpdated = true; + bankSeenThisSession = true; + } + if (c.category == WealthCategory.EQUIPMENT) + { + markUrgent(); + } + else + { + markDirty(); + } + return; + } + } + } + + private AccountData activeAccount() + { + if (activeKey != null) + { + AccountData acc = store.get(activeKey); + if (acc != null) + { + return acc; + } + } + if (client.getGameState() != GameState.LOGGED_IN) + { + return null; + } + Player player = client.getLocalPlayer(); + long hash = client.getAccountHash(); + if (player == null || player.getName() == null || hash == -1) + { + return null; + } + String name = player.getName(); + RuneScapeProfileType type = RuneScapeProfileType.getCurrent(client); + String key = accountKey(hash, type); + return store.getOrCreate(key, name); + } + + private boolean isBankOpen() + { + if (bankOpen) + { + return true; + } + net.runelite.api.widgets.Widget w = client.getWidget(ComponentID.BANK_CONTAINER); + if (w != null && !w.isHidden()) + { + bankOpen = true; + return true; + } + net.runelite.api.widgets.Widget itemContainer = client.getWidget(ComponentID.BANK_ITEM_CONTAINER); + if (itemContainer != null && !itemContainer.isHidden()) + { + bankOpen = true; + return true; + } + return isWidgetOpen(InterfaceID.BANKMAIN) + || isWidgetOpen(InterfaceID.BANKSIDE); + } + + void deductFromBank(Map bank, int itemId, long quantity) + { + deductFromBank(bank, itemId, quantity, itemManager != null ? itemManager::canonicalize : id -> id); + } + + static void deductFromBank(Map bank, int itemId, long quantity, java.util.function.IntUnaryOperator canonicalize) + { + if (bank == null || quantity <= 0) + { + return; + } + int canonical = canonicalize.applyAsInt(itemId); + + Long current = bank.get(itemId); + if (current != null) + { + if (current <= quantity) + { + bank.remove(itemId); + quantity -= current; + } + else + { + bank.put(itemId, current - quantity); + return; + } + } + + if (quantity <= 0) + { + return; + } + + for (Iterator> it = bank.entrySet().iterator(); it.hasNext(); ) + { + Map.Entry entry = it.next(); + if (canonicalize.applyAsInt(entry.getKey()) == canonical) + { + long available = entry.getValue(); + if (available <= quantity) + { + it.remove(); + quantity -= available; + } + else + { + entry.setValue(available - quantity); + quantity = 0; + break; + } + if (quantity <= 0) + { + break; + } + } + } + } + + void addToBank(Map bank, int itemId, long quantity) + { + addToBank(bank, itemId, quantity, itemManager != null ? itemManager::canonicalize : id -> id); + } + + static void addToBank(Map bank, int itemId, long quantity, java.util.function.IntUnaryOperator canonicalize) + { + if (bank == null || quantity <= 0) + { + return; + } + int canonical = canonicalize.applyAsInt(itemId); + + if (bank.containsKey(itemId)) + { + bank.merge(itemId, quantity, Long::sum); + return; + } + + for (Map.Entry entry : bank.entrySet()) + { + if (canonicalize.applyAsInt(entry.getKey()) == canonical) + { + entry.setValue(entry.getValue() + quantity); + return; + } + } + + bank.merge(canonical, quantity, Long::sum); + } + + /** + * Tracks transfers between Inventory/Equipment and Bank while the bank is open, + * as well as deposits into deposit boxes, ensuring bank and inventory remain strictly symmetrical. + */ + private void trackBankAndContainers(int containerId, ItemContainer container) + { + boolean inv = containerId == InventoryID.INV; + if (!inv && containerId != InventoryID.WORN) + { + return; + } + Map now = container == null ? Map.of() : readContainer(container); + Map before = inv ? lastInvItems : lastWornItems; + if (inv) + { + lastInvItems = now; + } + else + { + lastWornItems = now; + } + AccountData acc = activeAccount(); + if (acc == null) + { + return; + } + if (before == null) + { + Map saved = acc.containers.get(inv ? "inventory" : "equipment"); + before = saved != null ? saved : Map.of(); + } + + // 1. Transfers between inventory/equipment and main bank while bank is open + if (isBankOpen()) + { + ItemContainer liveBank = client.getItemContainer(InventoryID.BANK); + if (liveBank != null) + { + // the live bank is authoritative; booking the difference as well would count transfers twice + // when the bank update arrives before the inventory update + Map bank = readContainer(liveBank); + acc.containers.put("bank", bank); + acc.bankKnown = true; + seenContainers.put("bank", bank); + unsaved = true; + markUrgent(); + return; + } + Map bank = acc.containers.get("bank"); + if (bank == null) + { + bank = new HashMap<>(); + acc.containers.put("bank", bank); + } + boolean bankChanged = false; + + // Gained in inventory/equipment => withdrawn from bank + for (Map.Entry e : now.entrySet()) + { + int id = e.getKey(); + long currentQty = e.getValue(); + long prevQty = before.getOrDefault(id, 0L); + if (currentQty > prevQty) + { + long withdrawn = currentQty - prevQty; + deductFromBank(bank, id, withdrawn); + bankChanged = true; + log.info("Wealth tracker: Withdrew item {} x{} from bank (into {})", id, withdrawn, inv ? "INV" : "WORN"); + } + } + + // Lost in inventory/equipment => deposited into bank + for (Map.Entry e : before.entrySet()) + { + int id = e.getKey(); + long prevQty = e.getValue(); + long currentQty = now.getOrDefault(id, 0L); + if (prevQty > currentQty) + { + long deposited = prevQty - currentQty; + addToBank(bank, id, deposited); + bankChanged = true; + log.info("Wealth tracker: Deposited item {} x{} into bank (from {})", id, deposited, inv ? "INV" : "WORN"); + } + } + + if (bankChanged) + { + acc.containers.put("bank", bank); + seenContainers.put("bank", new HashMap<>(bank)); + unsaved = true; + markUrgent(); + } + return; + } + + // 2. Deposit boxes (bank interface not open) + if (isWidgetOpen(InterfaceID.BANK_DEPOSITBOX) + || isWidgetOpen(InterfaceID.BANK_DEPOSIT_IMP) + || isWidgetOpen(InterfaceID.SEED_VAULT_DEPOSIT) + || isWidgetOpen(InterfaceID.SHARED_BANK) + || isWidgetOpen(InterfaceID.SHARED_BANK_SIDE)) + { + Map pending = new HashMap<>(acc.bankPending); + boolean changed = false; + for (Map.Entry e : before.entrySet()) + { + long gone = e.getValue() - now.getOrDefault(e.getKey(), 0L); + if (gone > 0) + { + pending.merge(itemManager.canonicalize(e.getKey()), gone, Long::sum); + changed = true; + } + } + if (changed) + { + acc.bankPending = pending; + unsaved = true; + markUrgent(); + } + } + } + + private boolean isWidgetOpen(int group) + { + net.runelite.api.widgets.Widget w = client.getWidget(group, 0); + return w != null && !w.isHidden(); + } + + private void checkChestContainers(ItemContainerChanged event) + { + int cid = event.getContainerId(); + String chestName = null; + if (cid == net.runelite.api.InventoryID.BARROWS_REWARD.getId()) + { + chestName = "Barrows Chest"; + } + else if (cid == net.runelite.api.InventoryID.CHAMBERS_OF_XERIC_CHEST.getId()) + { + chestName = "Chambers of Xeric"; + } + else if (cid == net.runelite.api.InventoryID.THEATRE_OF_BLOOD_CHEST.getId()) + { + chestName = "Theatre of Blood"; + } + else if (cid == net.runelite.api.InventoryID.TOA_REWARD_CHEST.getId()) + { + chestName = "Tombs of Amascut"; + } + else if (cid == net.runelite.api.InventoryID.LUNAR_CHEST.getId()) + { + chestName = "Lunar Chest"; + } + else if (cid == net.runelite.api.InventoryID.FORTIS_COLOSSEUM_REWARD_CHEST.getId()) + { + chestName = "Fortis Colosseum"; + } + else if (cid == net.runelite.api.InventoryID.WILDERNESS_LOOT_CHEST.getId()) + { + chestName = "Wilderness Loot Chest"; + } + + if (chestName != null) + { + ItemContainer cont = event.getItemContainer(); + if (cont != null) + { + List items = new ArrayList<>(); + for (Item item : cont.getItems()) + { + if (item != null && item.getId() > 0 && item.getQuantity() > 0) + { + items.add(new net.runelite.client.game.ItemStack(item.getId(), item.getQuantity())); + } + } + if (!items.isEmpty()) + { + lootTrackerStore.addLoot(chestName, "CHEST", items, 1, itemManager, client.getTickCount()); + } + } + } + } + + @Subscribe + public void onMenuOptionClicked(net.runelite.api.events.MenuOptionClicked event) + { + String option = event.getMenuOption(); + if (option != null && (option.equalsIgnoreCase("Open") || option.equalsIgnoreCase("Search") || option.equalsIgnoreCase("Loot"))) + { + int itemId = event.getItemId(); + if (itemId > 0) + { + ItemComposition comp = itemManager.getItemComposition(itemId); + if (comp != null && comp.getName() != null) + { + String name = comp.getName(); + if (isRewardCrateOrChest(name)) + { + pendingCrateName = name; + pendingCrateTick = client.getTickCount(); + ItemContainer inv = client.getItemContainer(net.runelite.api.InventoryID.INVENTORY); + invSnapshotBeforeOpen = new HashMap<>(); + if (inv != null) + { + for (Item item : inv.getItems()) + { + if (item != null && item.getId() > 0 && item.getQuantity() > 0) + { + invSnapshotBeforeOpen.merge(item.getId(), item.getQuantity(), Integer::sum); + } + } + } + } + } + } + } + } + + private static boolean isRewardCrateOrChest(String name) + { + if (name == null) + { + return false; + } + String lower = name.toLowerCase(Locale.ENGLISH); + return lower.contains("supply crate") + || lower.contains("casket") + || lower.contains("spoils of war") + || lower.contains("bag full of gems") + || lower.contains("seed pack") + || lower.contains("bird nest") + || lower.contains("hallowed sack") + || lower.contains("ore pack") + || lower.contains("lockbox") + || lower.contains("reward pool"); + } + + @Subscribe + public void onHitsplatApplied(net.runelite.api.events.HitsplatApplied event) + { + combatTracker.onHitsplatApplied(event); + } + + @Subscribe + public void onAnimationChanged(net.runelite.api.events.AnimationChanged event) + { + combatTracker.onAnimationChanged(event); + } + + @Subscribe + public void onProjectileMoved(net.runelite.api.events.ProjectileMoved event) + { + combatTracker.onProjectileMoved(event); + } + + @Subscribe + public void onActorDeath(net.runelite.api.events.ActorDeath event) + { + combatTracker.onActorDeath(event); + } + + @Subscribe + public void onNpcDespawned(net.runelite.api.events.NpcDespawned event) + { + combatTracker.onNpcDespawned(event); + } + + @Subscribe + public void onServerNpcLoot(net.runelite.client.events.ServerNpcLoot event) + { + net.runelite.api.NPCComposition comp = event.getComposition(); + if (comp != null && comp.getName() != null && event.getItems() != null && !event.getItems().isEmpty()) + { + String rawName = net.runelite.client.util.Text.removeTags(comp.getName()); + String name = com.example.essentialsplus.loot.combat.CombatTracker.getCanonicalEncounterName(rawName); + long dropVal = calculateDropValue(event.getItems()); + lootTrackerStore.addLoot(name, "NPC", event.getItems(), 1, itemManager, client.getTickCount()); + combatTracker.onLootReceivedForEnemy(name, dropVal); + } + } + + @Subscribe + public void onVarbitChanged(VarbitChanged event) + { + int id = event.getVarbitId(); + if (!WATCHED_VARBITS.contains(id)) + { + return; + } + if (COFFER_VARBITS.contains(id)) + { + coffersDirty = true; + } + else if (BLAST_FURNACE_VARBITS.contains(id)) + { + // outside the Blast Furnace these read 0 - keep the last known contents + if (!atBlastFurnace()) + { + return; + } + furnaceDirty = true; + } + else + { + pouchDirty = true; + } + markDirty(); + } + + @Subscribe + public void onGrandExchangeOfferChanged(GrandExchangeOfferChanged event) + { + markDirty(); + checkGeOfferChanged(event.getSlot(), event.getOffer()); + } + + @Subscribe + public void onNpcLootReceived(net.runelite.client.events.NpcLootReceived event) + { + net.runelite.api.NPC npc = event.getNpc(); + if (npc != null && npc.getName() != null && event.getItems() != null && !event.getItems().isEmpty()) + { + String rawName = net.runelite.client.util.Text.removeTags(npc.getName()); + String name = com.example.essentialsplus.loot.combat.CombatTracker.getCanonicalEncounterName(rawName); + long dropVal = calculateDropValue(event.getItems()); + lootTrackerStore.addLoot(name, "NPC", event.getItems(), 1, itemManager, client.getTickCount()); + combatTracker.onLootReceivedForEnemy(name, dropVal); + } + } + + @Subscribe + public void onPlayerLootReceived(net.runelite.client.events.PlayerLootReceived event) + { + net.runelite.api.Player player = event.getPlayer(); + if (player != null && player.getName() != null && event.getItems() != null && !event.getItems().isEmpty()) + { + String name = net.runelite.client.util.Text.removeTags(player.getName()); + long dropVal = calculateDropValue(event.getItems()); + lootTrackerStore.addLoot(player.getName(), "PLAYER", event.getItems(), 1, itemManager, client.getTickCount()); + combatTracker.onLootReceivedForEnemy(name, dropVal); + } + } + + @Subscribe + public void onScriptPreFired(net.runelite.api.events.ScriptPreFired event) + { + collectionLog.onScriptPreFired(event); + if (event.getScriptId() == 7928) + { + int delve = -1; + try + { + net.runelite.api.widgets.Widget widget = client.getWidget(60227586); + if (widget != null) + { + net.runelite.api.widgets.Widget child = widget.getChild(1); + if (child != null && child.getText() != null) + { + String text = child.getText(); + // e.g. "Delve 4" or "Level 4" + String[] parts = text.split(" "); + if (parts.length > 1) + { + delve = Integer.parseInt(parts[1].trim()); + combatTracker.setDoomDelve(delve); + } + } + } + } + catch (Exception e) + { + log.debug("Failed parsing delve from script 7928", e); + } + recordDoomReward(delve); + } + } + + /** + * Doom of Mokhaiotl rewards come from a reward interface, not an NPC drop. RuneLite's own + * loot tracker only posts LootReceived for it when that plugin is enabled, so read the + * reward container ourselves. If both fire, LootTrackerStore dedups by tick + items. + */ + private void recordDoomReward(int delve) + { + net.runelite.api.ItemContainer container = client.getItemContainer(DOOM_REWARD_CONTAINER); + if (container == null) + { + return; + } + // mirror LootTrackerPlugin#toItemStacks so the dedup hash matches its LootReceived + java.util.List items = new java.util.ArrayList<>(); + for (net.runelite.api.Item item : container.getItems()) + { + if (item.getId() > -1) + { + items.add(new net.runelite.client.game.ItemStack(item.getId(), item.getQuantity())); + } + } + if (items.isEmpty()) + { + return; + } + String name = com.example.essentialsplus.loot.combat.CombatTracker.DOOM_OF_MOKHAIOTL; + lootTrackerStore.addLoot(name, "EVENT", items, -1, itemManager, client.getTickCount()); + combatTracker.onLootReceivedForEnemy(name, calculateDropValue(items), Math.max(0, delve)); + } + + @Subscribe + public void onChatMessage(net.runelite.api.events.ChatMessage event) + { + collectionLog.onChatMessage(event); + String msg = event.getMessage(); + if (msg != null && (msg.contains("Delve") || msg.contains("delve"))) + { + java.util.regex.Matcher m = java.util.regex.Pattern.compile("(?i)delve\\s*(\\d+)").matcher(msg); + if (m.find()) + { + try + { + int delve = Integer.parseInt(m.group(1)); + combatTracker.setDoomDelve(delve); + } + catch (Exception ignored) + { + } + } + } + } + + @Subscribe + public void onLootReceived(net.runelite.client.plugins.loottracker.LootReceived event) + { + if (event.getName() != null && event.getItems() != null && !event.getItems().isEmpty()) + { + String name = com.example.essentialsplus.loot.combat.CombatTracker.getCanonicalEncounterName(event.getName()); + String typeName = event.getType() != null ? event.getType().name() : "EVENT"; + int amount = event.getAmount(); + long dropVal = calculateDropValue(event.getItems()); + int delve = 0; + if (event.getMetadata() instanceof Number) + { + delve = ((Number) event.getMetadata()).intValue(); + } + lootTrackerStore.addLoot(name, typeName, event.getItems(), amount, itemManager, client.getTickCount()); + combatTracker.onLootReceivedForEnemy(name, dropVal, delve); + } + } + + private long calculateDropValue(java.util.Collection items) + { + if (items == null) + { + return 0; + } + long total = 0; + for (net.runelite.client.game.ItemStack item : items) + { + if (item != null && item.getId() > 0 && item.getQuantity() > 0) + { + total += (long) itemManager.getItemPrice(item.getId()) * item.getQuantity(); + } + } + return total; + } + + /** Changes often arrive split over a few ticks (e.g. GE collect) - wait until things are quiet. */ + private void markDirty() + { + if (!dirty) + { + dirtyTicks = 0; + } + dirty = true; + quietTicks = 0; + } + + /** Changes the player expects to see right away (bank closed, item equipped). */ + private void markUrgent() + { + markDirty(); + urgent = true; + } + + /** Re-reads every cached source on the next valuation (login, character switch). */ + private void invalidateSources() + { + pouchDirty = true; + coffersDirty = true; + furnaceDirty = true; + } + + @Subscribe + public void onWidgetLoaded(net.runelite.api.events.WidgetLoaded event) + { + if (event.getGroupId() == InterfaceID.BANKMAIN) + { + bankOpen = true; + justOpenedBank = true; + markUrgent(); + log.info("Wealth tracker: Bank opened (WidgetLoaded 12)"); + } + else if (event.getGroupId() == InterfaceID.BANK_DEPOSITBOX) + { + markUrgent(); + } + } + + @Subscribe + public void onWidgetClosed(net.runelite.api.events.WidgetClosed event) + { + if (event.getGroupId() == InterfaceID.BANKMAIN) + { + bankOpen = false; + justOpenedBank = false; + // save right away: closing the client does not always save, and an outdated bank breaks the next session + lastSave = 0; + markUrgent(); + log.info("Wealth tracker: Bank closed (WidgetClosed 12)"); + } + else if (event.getGroupId() == InterfaceID.BANK_DEPOSITBOX) + { + lastSave = 0; + markUrgent(); + } + } + + private boolean atBlastFurnace() + { + Player player = client.getLocalPlayer(); + return player != null && player.getWorldLocation() != null && player.getWorldLocation().getRegionID() == BLAST_FURNACE_REGION; + } + + @Subscribe + public void onConfigChanged(ConfigChanged event) + { + if (EssentialsPlusConfig.GROUP.equals(event.getGroup())) + { + dirty = true; + EssentialsPlusPanel p = panel; + if (p != null && "showSkillsHud".equals(event.getKey())) + { + p.refreshSkillsLater(); + } + if (p != null && (event.getKey().toLowerCase().contains("attacktimer") || event.getKey().toLowerCase().contains("attackstyle") || event.getKey().toLowerCase().contains("treecooldown") || event.getKey().toLowerCase().contains("itemcharge"))) + { + p.refreshHelpersLater(); + } + } + } + + @Subscribe + public void onGameStateChanged(GameStateChanged event) + { + GameState state = event.getGameState(); + if (state == GameState.LOGIN_SCREEN || state == GameState.LOGIN_SCREEN_AUTHENTICATOR) + { + awaitingLogin = true; + seenContainers.clear(); + bankOpen = false; + lastInvItems = null; + lastWornItems = null; + } + if (state == GameState.LOGGED_IN && awaitingLogin) + { + awaitingLogin = false; + bankSeenThisSession = false; + // a fresh login (not a hop or loading screen) starts the "Sess" range, with the exact item state pinned + long now = System.currentTimeMillis(); + timeFilter.setSessionStart(now); + pinLedgerAt = now; + } + if (state == GameState.LOGGED_IN) + { + invalidateSources(); + markUrgent(); + if (ticksSinceLogin < 0) + { + ticksSinceLogin = 0; + } + } + else if (state == GameState.LOGGING_IN || state == GameState.HOPPING || state == GameState.LOGIN_SCREEN + || state == GameState.CONNECTION_LOST) + { + ticksSinceLogin = -1; + geTracker.reset(); + } + if (state == GameState.LOGGED_IN) + { + xpDirty = true; + } + if (state == GameState.LOGIN_SCREEN) + { + collectionLog.reset(); + progress.reset(); + currencyTracker.reset(); + executor.execute(currencyStore::saveDirty); + } + if (state == GameState.LOGIN_SCREEN && skillKey != null) + { + skillKey = null; + executor.execute(skillStore::saveDirty); + } + if (state == GameState.LOGIN_SCREEN && activeKey != null) + { + AccountData acc = store.get(activeKey); + String key = activeKey; + activeKey = null; + if (acc != null) + { + recordFinal(acc); + saveAsync(acc); + saveLedgerAsync(key); + } + refreshPanel(); + } + } + + @Subscribe + public void onGameTick(GameTick event) + { + collectionLog.onGameTick(); + progress.onGameTick(); + combatTracker.onGameTick(event); + if (fightService != null) + { + fightService.onGameTick(); + } + trackCurrencies(); + if (xpDirty && ticksSinceLogin >= 3) + { + recordXp(); + } + ticksSinceCompute++; + quietTicks++; + dirtyTicks++; + if (ticksSinceLogin >= 0) + { + ticksSinceLogin++; + if (ticksSinceLogin == 5) + { + syncGeOffers(); + } + } + boolean due = dirty && (urgent || quietTicks >= COMPUTE_MIN_TICKS || dirtyTicks >= COMPUTE_MAX_DELAY_TICKS); + if (due || ticksSinceCompute >= COMPUTE_IDLE_TICKS) + { + dirty = false; + urgent = false; + ticksSinceCompute = 0; + compute(); + } + } + + private void trackCurrencies() + { + Player player = client.getLocalPlayer(); + long hash = client.getAccountHash(); + if (client.getGameState() != GameState.LOGGED_IN || player == null || player.getName() == null || hash == -1) + { + return; + } + currencyTracker.onGameTick(accountKey(hash, RuneScapeProfileType.getCurrent(client)), player.getName(), ticksSinceLogin); + } + + @Subscribe + public void onStatChanged(net.runelite.api.events.StatChanged event) + { + xpDirty = true; + } + + private void recordXp() + { + Player player = client.getLocalPlayer(); + long hash = client.getAccountHash(); + if (client.getGameState() != GameState.LOGGED_IN || player == null || player.getName() == null || hash == -1) + { + return; + } + // hitpoints start at level 10: anything less means stats are not loaded yet + if (client.getSkillExperience(net.runelite.api.Skill.HITPOINTS) < 1154) + { + return; + } + xpDirty = false; + int[] xp = new int[SkillHistory.SKILLS.length]; + for (int i = 0; i < xp.length; i++) + { + xp[i] = client.getSkillExperience(SkillHistory.SKILLS[i]); + } + String key = accountKey(hash, RuneScapeProfileType.getCurrent(client)); + boolean switched = !key.equals(skillKey); + skillKey = key; + if (skillStore.record(key, player.getName(), xp, System.currentTimeMillis()) || switched) + { + EssentialsPlusPanel p = panel; + if (p != null) + { + p.refreshSkillsLater(); + } + } + } + + public void resetSkillsHourly(String key) + { + if (key == null) + { + return; + } + executor.execute(() -> skillStore.resetHourly(key, timeFilter.getRange())); + EssentialsPlusPanel p = panel; + if (p != null) + { + p.refreshSkillsLater(); + } + } + + public void resetWealthHourly(String key) + { + long now = System.currentTimeMillis(); + com.example.essentialsplus.ui.TimeRange range = timeFilter.getRange(); + EssentialsPlusPanel p = panel; + com.example.essentialsplus.ui.WealthPanel wp = p != null ? p.getWealthPanel() : null; + boolean isSession = (wp != null && wp.isSessionView()) || range == com.example.essentialsplus.ui.TimeRange.SESSION; + boolean isCurrent = timeFilter.isCurrent() && !isSession; + if (!isSession && !isCurrent) + { + return; + } + if (wp != null) + { + wp.resetHourly(isSession, now); + } + if (key == null || "ALL".equals(key)) + { + for (AccountData a : store.all()) + { + a.resetHourly(isSession); + } + } + else + { + AccountData a = store.get(key); + if (a != null) + { + a.resetHourly(isSession); + } + } + AccountData cur = store.get(activeKey); + if (cur != null) + { + saveAsync(cur); + } + if (p != null) + { + p.refreshWealthLater(); + } + } + + @Subscribe + public void onOverlayMenuClicked(OverlayMenuClicked event) + { + if (event.getOverlay() == skillsHudOverlay && "Reset Hourly".equals(event.getEntry().getOption())) + { + resetSkillsHourly(skillKey); + } + } + + public boolean isSkillsHudEnabled() + { + return config.showSkillsHud(); + } + + public void setSkillsHudEnabled(boolean enabled) + { + configManager.setConfiguration(EssentialsPlusConfig.GROUP, "showSkillsHud", enabled); + } + + /** Boolean HUD options by config key, read with the config defaults applied. */ + public boolean isHudOption(String key) + { + switch (key) + { + case "skillsHudLevel": + return config.skillsHudLevel(); + case "skillsHudPercent": + return config.skillsHudPercent(); + case "skillsHudXpLeft": + return config.skillsHudXpLeft(); + case "skillsHudTimeToLevel": + return config.skillsHudTimeToLevel(); + case "skillsHudXpHour": + return config.skillsHudXpHour(); + case "skillsHudProgressBar": + return config.skillsHudProgressBar(); + default: + return false; + } + } + + public void setHudOption(String key, boolean enabled) + { + configManager.setConfiguration(EssentialsPlusConfig.GROUP, key, enabled); + } + + /** Start of the current session (0 = none). */ + public long getSessionStart() + { + String v = configManager.getConfiguration(EssentialsPlusConfig.GROUP, "sessionStart"); + try + { + return v == null ? 0 : Long.parseLong(v); + } + catch (NumberFormatException e) + { + return 0; + } + } + + /** Starts a new session now: views "since session" compare against this moment. */ + public void startSession() + { + long now = System.currentTimeMillis(); + configManager.setConfiguration(EssentialsPlusConfig.GROUP, "sessionStart", String.valueOf(now)); + pinLedgerAt = now; + markDirty(); + } + + /** Called from the panel's refresh button. */ + public void requestUpdate() + { + dirty = true; + executor.execute(() -> + { + store.reloadExternal(activeKey); + refreshPanel(); + }); + } + + public void deleteAccount(String key) + { + clientThread.invokeLater(() -> + { + store.delete(key); + if (key.equals(activeKey)) + { + dirty = true; + } + refreshPanel(); + }); + } + + // ---------------------------------------------------------------- calculation + + private boolean pricesLoaded() + { + return itemManager.getItemPrice(ItemID.ABYSSAL_WHIP) > 0; + } + + private void compute() + { + if (client.getGameState() != GameState.LOGGED_IN) + { + return; + } + Player player = client.getLocalPlayer(); + long hash = client.getAccountHash(); + if (player == null || player.getName() == null || hash == -1 || !pricesLoaded()) + { + dirty = true; + return; + } + + String name = player.getName(); + RuneScapeProfileType type = RuneScapeProfileType.getCurrent(client); + String key = accountKey(hash, type); + + if (!key.equals(activeKey)) + { + AccountData previous = store.get(activeKey); + if (previous != null) + { + recordFinal(previous); + saveAsync(previous); + saveLedgerAsync(previous.key); + } + activeKey = key; + invalidateSources(); + furnaceCache = new HashMap<>(); + store.refreshFromDisk(key); + geTracker.reset(); + } + + AccountData acc = store.getOrCreate(key, name); + acc.name = name; + acc.accountHash = hash; + acc.profile = type == null ? null : type.name(); + long now = System.currentTimeMillis(); + + // Synchronize bankOpen with actual widget visibility + if (bankOpen) + { + net.runelite.api.widgets.Widget w = client.getWidget(ComponentID.BANK_CONTAINER); + net.runelite.api.widgets.Widget ic = client.getWidget(ComponentID.BANK_ITEM_CONTAINER); + boolean widgetVisible = (w != null && !w.isHidden()) || (ic != null && !ic.isHidden()); + if (!widgetVisible && !isWidgetOpen(InterfaceID.BANKMAIN)) + { + bankOpen = false; + justOpenedBank = false; + } + } + + for (ContainerSource c : CONTAINERS) + { + ItemContainer container = client.getItemContainer(c.id); + // the contents from the last change event are the newest; the client may keep an outdated bank after closing + Map items = seenContainers.remove(c.key); + if (items == null && container != null) + { + if (c.category == WealthCategory.BANK) + { + // For bank, only read from client container if bank is not in memory yet, + // or if the bank interface was just opened this frame to sync the initial live state + if (!acc.containers.containsKey("bank") || acc.containers.get("bank").isEmpty() || justOpenedBank) + { + Map candidate = readContainer(container); + Map existingBank = acc.containers.get("bank"); + if (existingBank != null && !existingBank.isEmpty() && candidate.size() < existingBank.size() / 2) + { + log.debug("Wealth tracker: Bank container streaming ({} items vs {} known), deferring sync", + candidate.size(), existingBank.size()); + } + else + { + items = candidate; + justOpenedBank = false; + log.info("Wealth tracker: Synced live bank container ({} items)", items.size()); + } + } + } + else + { + items = readContainer(container); + } + } + if (items == null && container == null && ticksSinceLogin >= 10 + && (c.category == WealthCategory.INVENTORY || c.category == WealthCategory.EQUIPMENT)) + { + // the game sends nothing for an empty inventory / no gear - don't keep an old saved state + items = new HashMap<>(); + } + if (items != null) + { + if (c.category == WealthCategory.BANK) + { + bankSeenThisSession = true; + acc.bankKnown = true; + } + acc.containers.put(c.key, items); + } + } + if (bankUpdated && client.getItemContainer(InventoryID.BANK) != null) + { + // the real bank was seen again, items booked while it was not visible are included now + acc.bankPending = new HashMap<>(); + } + bankUpdated = false; + + Map ge = Map.of(); + boolean offersLive = ticksSinceLogin >= 5; + if (config.includeGe()) + { + if (!offersLive) + { + // GE offers of this login are not known yet - keep showing the last value + dirty = true; + return; + } + ge = geTracker.update(acc, client, itemManager::canonicalize, true); + } + + Valuation val = new Valuation(); + valueContainers(acc, val); + + // Rune pouch contents live in varbits, not in a container + Map pouch = acc.containers.get("runePouch"); + if (pouchDirty || pouch == null) + { + pouchDirty = false; + pouch = readRunePouch(); + acc.containers.put("runePouch", pouch); + } + if (!pouch.isEmpty()) + { + val.add("Rune pouch", WealthCategory.STORAGE, pouch); + } + + // read at login and while at the Blast Furnace only + if (furnaceDirty && (atBlastFurnace() || ticksSinceLogin <= 10)) + { + furnaceDirty = false; + Map read = new HashMap<>(); + for (int[] bf : BLAST_FURNACE_ITEMS) + { + int qty = client.getVarbitValue(bf[0]); + if (qty > 0) + { + read.merge(bf[1], (long) qty, Long::sum); + } + } + furnaceCache = read; + } + Map furnace = furnaceCache; + if (!furnace.isEmpty()) + { + val.add("Blast Furnace", WealthCategory.STORAGE, furnace); + } + + if (coffersDirty) + { + coffersDirty = false; + cofferCache[0] = client.getVarbitValue(VarbitID.MISC_COFFERS); + cofferCache[1] = client.getVarbitValue(VarbitID.BLAST_FURNACE_COFFER); + cofferCache[2] = client.getVarbitValue(VarbitID.NZONE_CASH); + } + val.addGp("Miscellania coffer", WealthCategory.COFFERS, cofferCache[0]); + val.addGp("Blast Furnace coffer", WealthCategory.COFFERS, cofferCache[1]); + val.addGp("Nightmare Zone coffer", WealthCategory.COFFERS, cofferCache[2]); + + if (!ge.isEmpty()) + { + val.add("GE offers", WealthCategory.GRAND_EXCHANGE, ge); + } + + if (!Arrays.equals(acc.current, val.values)) + { + unsaved = true; + } + // no timeline points until the GE offers of this login are known + apply(acc, val, now, config.snapshotSeconds() * 1000L, true); + // moving tokens between bank and inventory is not earning them + boolean itemsSync = ticksSinceLogin < 10 || isBankOpen() || isWidgetOpen(InterfaceID.BANK_DEPOSITBOX) + || isWidgetOpen(InterfaceID.BANK_DEPOSIT_IMP) || isWidgetOpen(InterfaceID.SEED_VAULT_DEPOSIT); + currencyTracker.onItems(key, name, acc.containers, acc.bankPending, itemsSync, itemManager::canonicalize); + + if (unsaved && now - lastSave >= SAVE_INTERVAL_MS) + { + saveAsync(acc); + } + if (now - lastLedgerSave >= LEDGER_SAVE_INTERVAL_MS) + { + saveLedgerAsync(key); + } + + refreshPanel(); + } + + /** Stores a valuation into the account: current state, top items, timeline point and item snapshot. */ + private void apply(AccountData acc, Valuation val, long now, long minGap, boolean record) + { + acc.current = val.values; + acc.sources = val.sources; + acc.topItems = topItems(val); + acc.lastUpdated = now; + + if (!acc.bankKnown || !record) + { + return; + } + if (acc.key.equals(activeKey) && ticksSinceLogin < 10) + { + // inventory / equipment may still hold the last session's state: pinning the session start on them + // would show the difference to the live state as activity + return; + } + + ItemLedger ledger = store.ledger(acc.key, true); + acc.market += marketEffect(ledger.last(), val); + + Snapshot last = acc.lastSnapshot(); + long valTotal = val.total(); + + // Check if the previous snapshot(s) in history were an isolated transient spike that now reverted + if (last != null && acc.history.size() >= 2) + { + Snapshot prev = acc.history.get(acc.history.size() - 2); + if (AccountData.isTransientSpike(prev.t, prev.total(), last.t, last.total(), now, valTotal)) + { + acc.history.remove(acc.history.size() - 1); + last = acc.lastSnapshot(); + unsaved = true; + } + else if (acc.history.size() >= 3) + { + Snapshot prev2 = acc.history.get(acc.history.size() - 3); + if (AccountData.isDoubleTransientSpike(prev2.t, prev2.total(), prev.t, prev.total(), last.t, last.total(), now, valTotal)) + { + acc.history.remove(acc.history.size() - 1); + acc.history.remove(acc.history.size() - 1); + last = acc.lastSnapshot(); + unsaved = true; + } + else if (acc.history.size() >= 4) + { + Snapshot prev3 = acc.history.get(acc.history.size() - 4); + if (AccountData.isTripleTransientSpike(prev3.t, prev3.total(), prev2.t, prev2.total(), prev.t, prev.total(), last.t, last.total(), now, valTotal)) + { + acc.history.remove(acc.history.size() - 1); + acc.history.remove(acc.history.size() - 1); + acc.history.remove(acc.history.size() - 1); + last = acc.lastSnapshot(); + unsaved = true; + } + } + } + } + + boolean differs = last == null || !last.sameValues(val.values); + if (differs && (last == null || now - last.t >= minGap)) + { + Snapshot point = new Snapshot(now, val.values.clone()); + point.m = acc.market; + acc.history.add(point); + unsaved = true; + } + + long pin = pinLedgerAt; + if (pin > 0 && acc.key.equals(activeKey)) + { + // exact item state at the session start so "what changed" can compare against it + pinLedgerAt = 0; + ItemSnapshot pinned = val.itemSnapshot(pin); + pinned.k = true; + ledger.snapshots.add(pinned); + ledger.dirty = true; + return; + } + ItemSnapshot snap = val.itemSnapshot(now); + ItemSnapshot prev = ledger.last(); + if (prev == null || !prev.sameAs(snap)) + { + boolean sameHour = prev != null && !Boolean.TRUE.equals(prev.k) && now / HOUR_MS == prev.t / HOUR_MS; + if (sameHour && ledger.snapshots.size() > 1) + { + ledger.snapshots.set(ledger.snapshots.size() - 1, snap); + } + else + { + ledger.snapshots.add(snap); + } + ledger.dirty = true; + } + } + + /** Value change of the items held at the previous valuation that comes only from their price changing. */ + private static long marketEffect(ItemSnapshot prev, Valuation val) + { + if (prev == null || prev.ids == null) + { + return 0; + } + long effect = 0; + for (int i = 0; i < prev.ids.length; i++) + { + Integer price = val.prices.get(prev.ids[i]); + if (price != null && price > 0 && prev.p[i] > 0) + { + effect += prev.q[i] * (long) (price - prev.p[i]); + } + } + return effect; + } + + private void valueContainers(AccountData acc, Valuation val) + { + for (ContainerSource c : CONTAINERS) + { + Map items = acc.containers.get(c.key); + if (c.category == WealthCategory.BANK && items != null && !acc.bankPending.isEmpty()) + { + Map withPending = new HashMap<>(items); + acc.bankPending.forEach((id, q) -> withPending.merge(id, q, Long::sum)); + items = withPending; + } + if (items != null) + { + val.add(c.label, c.category, items); + } + } + } + + /** + * Re-prices characters that are not logged in (in any client) with current GE prices, + * so market movements show up in their timeline too. + */ + private void revalueOffline() + { + if (!config.revalueOffline() || client.getGameState().ordinal() < GameState.LOGIN_SCREEN.ordinal() || !pricesLoaded()) + { + return; + } + long now = System.currentTimeMillis(); + boolean any = false; + for (AccountData acc : store.all()) + { + if (acc.key.equals(activeKey) || acc.containers.isEmpty() + || now - acc.lastUpdated < OFFLINE_GRACE_MS + || now - lastRevalue.getOrDefault(acc.key, 0L) < HOUR_MS) + { + continue; + } + lastRevalue.put(acc.key, now); + + Valuation val = new Valuation(); + valueContainers(acc, val); + // keep non-container sources (rune pouch, coffers, GE) at their last known value + Set containerLabels = new java.util.HashSet<>(); + for (ContainerSource c : CONTAINERS) + { + containerLabels.add(c.label); + } + for (SourceValue s : acc.sources) + { + if (!containerLabels.contains(s.name) && s.category < WealthCategory.COUNT) + { + val.addGp(s.name, WealthCategory.values()[s.category], s.value); + } + } + + long lastUpdated = acc.lastUpdated; + apply(acc, val, now, HOUR_MS, true); + acc.lastUpdated = lastUpdated; + WealthStore.compact(acc, config.rawDays(), config.hourlyDays(), now); + AccountData ref = acc; + executor.execute(() -> + { + store.save(ref); + ItemLedger ledger = store.ledger(ref.key, false); + WealthStore.compact(ledger, now); + store.saveLedger(ref.key, ledger); + }); + any = true; + } + if (any) + { + refreshPanel(); + } + } + + /** Writes the latest known value so the timeline ends exactly where the session ended. */ + private void recordFinal(AccountData acc) + { + if (!acc.bankKnown || acc.current == null) + { + return; + } + Snapshot last = acc.lastSnapshot(); + if (last == null || !last.sameValues(acc.current)) + { + Snapshot point = new Snapshot(System.currentTimeMillis(), acc.current.clone()); + point.m = acc.market; + acc.history.add(point); + } + } + + private void saveAsync(AccountData acc) + { + long now = System.currentTimeMillis(); + WealthStore.compact(acc, config.rawDays(), config.hourlyDays(), now); + lastSave = now; + unsaved = false; + executor.execute(() -> store.save(acc)); + } + + private void saveLedgerAsync(String key) + { + long now = System.currentTimeMillis(); + lastLedgerSave = now; + ItemLedger ledger = store.ledger(key, true); + WealthStore.compact(ledger, now); + if (ledger.dirty) + { + executor.execute(() -> store.saveLedger(key, ledger)); + } + } + + + + private Map readContainer(ItemContainer container) + { + Map items = new HashMap<>(); + for (Item item : container.getItems()) + { + if (item == null || item.getId() <= 0 || item.getQuantity() <= 0 || isPlaceholder(item.getId())) + { + continue; + } + items.merge(item.getId(), (long) item.getQuantity(), Long::sum); + } + return items; + } + + /** Bank placeholders canonicalize to the real item, so they would be valued as if the item was still banked. */ + private boolean isPlaceholder(int itemId) + { + ItemComposition comp = itemManager.getItemComposition(itemId); + return comp != null && comp.getPlaceholderTemplateId() != -1; + } + + private Map readRunePouch() + { + Map items = new HashMap<>(); + EnumComposition runes = client.getEnum(RUNEPOUCH_RUNE_ENUM); + for (int i = 0; i < RUNE_POUCH_TYPES.length; i++) + { + int type = client.getVarbitValue(RUNE_POUCH_TYPES[i]); + int qty = client.getVarbitValue(RUNE_POUCH_QUANTITIES[i]); + if (type <= 0 || qty <= 0 || runes == null) + { + continue; + } + int itemId = runes.getIntValue(type); + if (itemId > 0) + { + items.merge(itemId, (long) qty, Long::sum); + } + } + return items; + } + + private int price(int canonicalId, Map priceCache) + { + return priceCache.computeIfAbsent(canonicalId, id -> + { + if (id == ItemID.COINS) + { + return 1; + } + if (id == ItemID.PLATINUM) + { + return 1000; + } + return Math.max(0, itemManager.getItemPrice(id)); + }); + } + + private List topItems(Valuation val) + { + List list = new ArrayList<>(); + for (Map.Entry e : val.merged.entrySet()) + { + long value = price(e.getKey(), val.prices) * e.getValue(); + if (value > 0) + { + list.add(new TopItem(e.getKey(), null, e.getValue(), value)); + } + } + list.sort((a, b) -> Long.compare(b.value, a.value)); + if (list.size() > TOP_ITEMS) + { + list = new ArrayList<>(list.subList(0, TOP_ITEMS)); + } + for (TopItem item : list) + { + item.name = itemName(item.id); + } + return list; + } + + private String itemName(int id) + { + ItemComposition comp = itemManager.getItemComposition(id); + return comp != null ? comp.getName() : "Item " + id; + } + + /** + * Collects the priced items of one view ("bank", "inventory", "equipment", "lootingBag", "seedVault", + * "runePouch", "storage", "ge", "coffers", "all") over the given characters, sorted by value. + * Runs on the client thread, calls back on the Swing thread. + */ + public void loadItems(List accounts, String view, Consumer> callback) + { + clientThread.invokeLater(() -> + { + Map merged = new HashMap<>(); + List rows = new ArrayList<>(); + for (AccountData acc : accounts) + { + for (String key : viewContainers(view)) + { + if (key.equals("ge")) + { + if (acc.geSlots != null) + { + for (com.example.essentialsplus.data.GeSlot slot : acc.geSlots) + { + if (slot != null) + { + merged.merge(itemManager.canonicalize(slot.item), slot.escrowItems + slot.collectItems, Long::sum); + merged.merge(ItemID.COINS, slot.escrowCoins + slot.collectCoins, Long::sum); + } + } + } + continue; + } + if (key.equals("coffers")) + { + for (SourceValue sv : acc.sources) + { + if (sv.category == WealthCategory.COFFERS.ordinal() && sv.value > 0) + { + rows.add(new com.example.essentialsplus.data.ItemRow(ItemID.COINS, (accounts.size() > 1 ? acc.name + ": " : "") + sv.name, sv.value, 1)); + } + } + continue; + } + Map items = acc.containers.get(key); + if (items != null) + { + items.forEach((id, q) -> + { + if (!isPlaceholder(id)) + { + merged.merge(itemManager.canonicalize(id), q, Long::sum); + } + }); + } + if (key.equals("bank")) + { + acc.bankPending.forEach((id, q) -> merged.merge(itemManager.canonicalize(id), q, Long::sum)); + } + } + } + Map prices = new HashMap<>(); + merged.forEach((id, q) -> + { + if (q > 0) + { + rows.add(new com.example.essentialsplus.data.ItemRow(id, itemName(id), q, price(id, prices))); + } + }); + rows.sort((a, b) -> b.value != a.value ? Long.compare(b.value, a.value) : Long.compare(b.qty, a.qty)); + javax.swing.SwingUtilities.invokeLater(() -> callback.accept(rows)); + }); + } + + /** Grand Exchange slots of the given characters with item names and current GE prices. */ + public void loadGe(List accounts, Consumer> callback) + { + clientThread.invokeLater(() -> + { + List rows = new ArrayList<>(); + Map prices = new HashMap<>(); + for (AccountData acc : accounts) + { + com.example.essentialsplus.data.GeSlot[] slots = acc.geSlots; + for (int i = 0; i < 8; i++) + { + com.example.essentialsplus.data.GeSlot s = slots != null && i < slots.length ? slots[i] : null; + if (s == null) + { + rows.add(new com.example.essentialsplus.data.GeRow(acc.name, i, null, null, 0)); + } + else + { + int id = itemManager.canonicalize(s.item); + GeLimitInfo limitInfo = null; + if (s.buy) + { + ItemStats stats = itemManager.getItemStats(id); + int geLimit = stats != null ? stats.getGeLimit() : 0; + limitInfo = getBuyLimitInfo(acc, id, geLimit); + } + rows.add(new com.example.essentialsplus.data.GeRow(acc.name, i, s, itemName(id), price(id, prices), limitInfo)); + } + } + } + javax.swing.SwingUtilities.invokeLater(() -> callback.accept(rows)); + }); + } + + private static List viewContainers(String view) + { + switch (view) + { + case "storage": + return List.of("lootingBag", "seedVault", "runePouch"); + case "all": + return List.of("bank", "inventory", "equipment", "lootingBag", "seedVault", "runePouch", "ge"); + default: + return List.of(view); + } + } + + /** Resolves item names on the client thread, then calls back on the Swing thread. */ + public void resolveItemNames(java.util.Collection ids, Consumer> callback) + { + clientThread.invokeLater(() -> + { + Map names = new HashMap<>(); + for (int id : ids) + { + names.put(id, itemName(id)); + } + javax.swing.SwingUtilities.invokeLater(() -> callback.accept(names)); + }); + } + + // ---------------------------------------------------------------- GE buy limit tracking + + private void syncGeOffers() + { + if (client.getGameState() != GameState.LOGGED_IN || activeKey == null) + { + return; + } + GrandExchangeOffer[] offers = client.getGrandExchangeOffers(); + if (offers == null) + { + return; + } + for (int i = 0; i < offers.length; i++) + { + checkGeOfferChanged(i, offers[i]); + } + } + + private void checkGeOfferChanged(int slot, GrandExchangeOffer offer) + { + if (slot < 0 || slot >= 8) + { + return; + } + AccountData acc = store.get(activeKey); + if (acc == null) + { + return; + } + if (acc.geBuyLimitSlots == null || acc.geBuyLimitSlots.length != 8) + { + acc.geBuyLimitSlots = new AccountData.GeBuyLimitSlot[8]; + } + + if (offer == null || offer.getItemId() <= 0 || offer.getState() == GrandExchangeOfferState.EMPTY) + { + if (client.getGameState() == GameState.LOGGED_IN) + { + acc.geBuyLimitSlots[slot] = null; + } + return; + } + + GrandExchangeOfferState state = offer.getState(); + boolean buy = state == GrandExchangeOfferState.BUYING || state == GrandExchangeOfferState.BOUGHT + || state == GrandExchangeOfferState.CANCELLED_BUY; + if (!buy) + { + acc.geBuyLimitSlots[slot] = null; + return; + } + + int canonicalId = itemManager.canonicalize(offer.getItemId()); + AccountData.GeBuyLimitSlot prev = acc.geBuyLimitSlots[slot]; + AccountData.GeBuyLimitSlot current = new AccountData.GeBuyLimitSlot( + offer.getItemId(), + offer.getPrice(), + offer.getTotalQuantity(), + offer.getQuantitySold(), + offer.getSpent() + ); + + boolean sameOffer = current.isSameOffer(prev) && current.quantity >= prev.quantity; + int quantityDelta = sameOffer ? (current.quantity - prev.quantity) : current.quantity; + + if (quantityDelta > 0) + { + long now = System.currentTimeMillis(); + acc.recordBuy(canonicalId, quantityDelta, now); + GeBuyLimit limit = acc.geBuyLimits != null ? acc.geBuyLimits.get(canonicalId) : null; + if (limit != null && limit.windowStart > 0) + { + try + { + configManager.setRSProfileConfiguration("grandexchange", String.valueOf(canonicalId), + Instant.ofEpochMilli(limit.windowStart + AccountData.FOUR_HOURS_MS)); + } + catch (Exception ignored) + { + } + } + } + acc.geBuyLimitSlots[slot] = current; + } + + @Subscribe + public void onClientTick(ClientTick event) + { + updateGeBuyLimitWidgets(); + } + + @Subscribe + public void onScriptCallbackEvent(ScriptCallbackEvent event) + { + if (!config.showGeBuyLimits()) + { + return; + } + if ("geBuyExamineText".equals(event.getEventName())) + { + Object[] objectStack = client.getObjectStack(); + int objectStackSize = client.getObjectStackSize(); + if (objectStack == null || objectStackSize < 1) + { + return; + } + int itemId = client.getVarpValue(VarPlayer.CURRENT_GE_ITEM); + if (itemId <= 0) + { + return; + } + itemId = itemManager.canonicalize(itemId); + ItemStats stats = itemManager.getItemStats(itemId); + int geLimit = stats != null ? stats.getGeLimit() : 0; + AccountData acc = store.get(activeKey); + GeLimitInfo info = getBuyLimitInfo(acc, itemId, geLimit); + if (info != null && (info.limit > 0 || info.bought > 0)) + { + String current = (String) objectStack[objectStackSize - 1]; + if (current == null) + { + current = ""; + } + String limitLine = formatBuyLimitLine(info); + String base = stripBuyLimitLine(current); + String updated = base.isEmpty() ? limitLine : base + "
" + limitLine; + objectStack[objectStackSize - 1] = updated; + } + } + } + + private void updateGeBuyLimitWidgets() + { + if (!config.showGeBuyLimits() || client.getGameState() != GameState.LOGGED_IN) + { + return; + } + Widget geUniverse = client.getWidget(InterfaceID.GE_OFFERS, 0); + if (geUniverse == null || geUniverse.isHidden()) + { + return; + } + + AccountData acc = store.get(activeKey); + + // 1. DETAILS screen (child 15: DETAILS container, child 16: DETAILS_DESC) + Widget details = client.getWidget(InterfaceID.GE_OFFERS, 15); + Widget detailsDesc = client.getWidget(InterfaceID.GE_OFFERS, 16); + if (details != null && !details.isHidden() && detailsDesc != null && !detailsDesc.isHidden()) + { + updateDetailsWidget(acc, details, detailsDesc); + } + + // 2. SETUP screen (child 26: SETUP container, child 27: SETUP_DESC) + Widget setup = client.getWidget(InterfaceID.GE_OFFERS, 26); + Widget setupDesc = client.getWidget(InterfaceID.GE_OFFERS, 27); + if (setup != null && !setup.isHidden() && setupDesc != null && !setupDesc.isHidden()) + { + updateSetupWidget(acc, setup, setupDesc); + } + } + + private void updateDetailsWidget(AccountData acc, Widget details, Widget detailsDesc) + { + if (!isGeDetailsBuyOffer(details)) + { + return; + } + int itemId = getGeDetailsItemId(details); + if (itemId <= 0) + { + return; + } + itemId = itemManager.canonicalize(itemId); + ItemStats stats = itemManager.getItemStats(itemId); + int geLimit = stats != null ? stats.getGeLimit() : 0; + GeLimitInfo info = getBuyLimitInfo(acc, itemId, geLimit); + if (info == null || (info.limit <= 0 && info.bought <= 0)) + { + return; + } + + String limitLine = formatBuyLimitLine(info); + String current = detailsDesc.getText(); + if (current == null) + { + current = ""; + } + String base = stripBuyLimitLine(current); + String updated = base.isEmpty() ? limitLine : base + "
" + limitLine; + if (!updated.equals(current)) + { + detailsDesc.setText(updated); + if (detailsDesc.getOriginalHeight() < 38) + { + detailsDesc.setOriginalHeight(38); + detailsDesc.revalidate(); + } + } + } + + private void updateSetupWidget(AccountData acc, Widget setup, Widget setupDesc) + { + int offerType = client.getVarbitValue(Varbits.GE_OFFER_CREATION_TYPE); + if (offerType != 0 && !hasWidgetWithText(setup, "Buy")) + { + return; + } + int varpItem = client.getVarpValue(VarPlayer.CURRENT_GE_ITEM); + int itemId = varpItem > 0 ? varpItem : findItemIdInWidget(setup); + if (itemId <= 0) + { + return; + } + itemId = itemManager.canonicalize(itemId); + ItemStats stats = itemManager.getItemStats(itemId); + int geLimit = stats != null ? stats.getGeLimit() : 0; + GeLimitInfo info = getBuyLimitInfo(acc, itemId, geLimit); + if (info == null || (info.limit <= 0 && info.bought <= 0)) + { + return; + } + + String limitLine = formatBuyLimitLine(info); + String current = setupDesc.getText(); + if (current == null) + { + current = ""; + } + String base = stripBuyLimitLine(current); + String updated = base.isEmpty() ? limitLine : base + "
" + limitLine; + if (!updated.equals(current)) + { + setupDesc.setText(updated); + if (setupDesc.getOriginalHeight() < 38) + { + setupDesc.setOriginalHeight(38); + setupDesc.revalidate(); + } + } + } + + private boolean isGeDetailsBuyOffer(Widget details) + { + if (details == null) + { + return true; + } + if (hasWidgetWithText(details, "Buy offer")) + { + return true; + } + if (hasWidgetWithText(details, "Sell offer")) + { + return false; + } + int id = getGeDetailsItemId(details); + if (id > 0) + { + GrandExchangeOffer[] offers = client.getGrandExchangeOffers(); + if (offers != null) + { + for (GrandExchangeOffer o : offers) + { + if (o != null && itemManager.canonicalize(o.getItemId()) == id) + { + GrandExchangeOfferState state = o.getState(); + return state == GrandExchangeOfferState.BUYING || state == GrandExchangeOfferState.BOUGHT + || state == GrandExchangeOfferState.CANCELLED_BUY; + } + } + } + } + return true; + } + + private int getGeDetailsItemId(Widget details) + { + int varp = client.getVarpValue(VarPlayer.CURRENT_GE_ITEM); + if (varp > 0) + { + return varp; + } + for (int c = 15; c <= 25; c++) + { + Widget w = client.getWidget(InterfaceID.GE_OFFERS, c); + if (w != null) + { + int id = findItemIdInWidget(w); + if (id > 0) + { + return id; + } + } + } + if (details != null) + { + int id = findItemIdInWidget(details); + if (id > 0) + { + return id; + } + GrandExchangeOffer[] offers = client.getGrandExchangeOffers(); + if (offers != null) + { + for (GrandExchangeOffer o : offers) + { + if (o != null && o.getItemId() > 0) + { + ItemComposition comp = itemManager.getItemComposition(o.getItemId()); + if (comp != null && hasWidgetWithText(details, comp.getName())) + { + return o.getItemId(); + } + } + } + } + } + return -1; + } + + private static int findItemIdInWidget(Widget root) + { + if (root == null) + { + return -1; + } + if (root.getItemId() > 0) + { + return root.getItemId(); + } + Widget[] children = root.getChildren(); + if (children != null) + { + for (Widget c : children) + { + int id = findItemIdInWidget(c); + if (id > 0) + { + return id; + } + } + } + Widget[] dynamic = root.getDynamicChildren(); + if (dynamic != null) + { + for (Widget c : dynamic) + { + int id = findItemIdInWidget(c); + if (id > 0) + { + return id; + } + } + } + Widget[] nested = root.getNestedChildren(); + if (nested != null) + { + for (Widget c : nested) + { + int id = findItemIdInWidget(c); + if (id > 0) + { + return id; + } + } + } + Widget[] stat = root.getStaticChildren(); + if (stat != null) + { + for (Widget c : stat) + { + int id = findItemIdInWidget(c); + if (id > 0) + { + return id; + } + } + } + return -1; + } + + private static boolean hasWidgetWithText(Widget root, String textToFind) + { + if (root == null || textToFind == null) + { + return false; + } + String txt = root.getText(); + if (txt != null && txt.toLowerCase().contains(textToFind.toLowerCase())) + { + return true; + } + Widget[] children = root.getChildren(); + if (children != null) + { + for (Widget c : children) + { + if (hasWidgetWithText(c, textToFind)) + { + return true; + } + } + } + Widget[] dynamic = root.getDynamicChildren(); + if (dynamic != null) + { + for (Widget c : dynamic) + { + if (hasWidgetWithText(c, textToFind)) + { + return true; + } + } + } + Widget[] nested = root.getNestedChildren(); + if (nested != null) + { + for (Widget c : nested) + { + if (hasWidgetWithText(c, textToFind)) + { + return true; + } + } + } + Widget[] stat = root.getStaticChildren(); + if (stat != null) + { + for (Widget c : stat) + { + if (hasWidgetWithText(c, textToFind)) + { + return true; + } + } + } + return false; + } + + public GeLimitInfo getBuyLimitInfo(AccountData acc, int itemId, int itemGeLimit) + { + long now = System.currentTimeMillis(); + if (acc != null) + { + GeLimitInfo info = acc.getBuyLimitInfo(itemId, itemGeLimit, now); + if (info.windowActive) + { + return info; + } + } + try + { + Instant rlReset = configManager.getRSProfileConfiguration("grandexchange", String.valueOf(itemId), Instant.class); + if (rlReset != null && rlReset.isAfter(Instant.now())) + { + long resetInMs = Duration.between(Instant.now(), rlReset).toMillis(); + int bought = acc != null && acc.geBuyLimits != null && acc.geBuyLimits.containsKey(itemId) + ? acc.geBuyLimits.get(itemId).bought : 0; + if (bought > 0) + { + int remaining = Math.max(0, itemGeLimit > 0 ? itemGeLimit - bought : 0); + return new GeLimitInfo(itemId, itemGeLimit, bought, remaining, resetInMs, true); + } + } + } + catch (Exception ignored) + { + } + return new GeLimitInfo(itemId, itemGeLimit, 0, itemGeLimit, 0, false); + } + + public static String formatBuyLimitLine(GeLimitInfo info) + { + if (info == null) + { + return ""; + } + if (info.limit > 0) + { + String formattedBought = String.format(Locale.US, "%,d", info.bought); + String formattedLimit = String.format(Locale.US, "%,d", info.limit); + String formattedRemaining = String.format(Locale.US, "%,d", info.remaining); + + if (info.remaining <= 0 && info.windowActive) + { + String time = info.resetInMs > 0 ? " - reset in " + formatDuration(info.resetInMs) + "" : ""; + return "Buy limit (4h): " + formattedLimit + " / " + formattedLimit + " (0 left)" + time; + } + else if (info.windowActive && info.resetInMs > 0) + { + return "Buy limit (4h): " + formattedBought + " / " + + formattedLimit + " (" + formattedRemaining + " left) - reset in " + + formatDuration(info.resetInMs) + ""; + } + else + { + return "Buy limit (4h): 0 / " + + formattedLimit + " (" + formattedLimit + " left)"; + } + } + else if (info.bought > 0) + { + String formattedBought = String.format(Locale.US, "%,d", info.bought); + String time = info.resetInMs > 0 ? " - reset in " + formatDuration(info.resetInMs) + "" : ""; + return "Bought in 4h: " + formattedBought + "" + time; + } + return ""; + } + + public static String formatDuration(long ms) + { + long m = Math.max(0, ms) / 60_000; + if (m < 60) + { + return m + "m"; + } + long h = m / 60; + if (h < 24) + { + return h + "h " + (m % 60) + "m"; + } + return (h / 24) + "d " + (h % 24) + "h"; + } + + public static String stripBuyLimitLine(String text) + { + if (text == null || text.isEmpty()) + { + return ""; + } + String[] lines = text.split("
"); + StringBuilder sb = new StringBuilder(); + for (String line : lines) + { + String lower = line.toLowerCase(); + if (lower.contains("buy limit") || lower.contains("bought in 4h")) + { + continue; + } + if (sb.length() > 0) + { + sb.append("
"); + } + sb.append(line); + } + return sb.toString(); + } + + // ---------------------------------------------------------------- export + + /** Writes timeline and item history CSVs of the given characters to essentials-plus/export. */ + public void exportCsv(List accounts, Runnable done) + { + clientThread.invokeLater(() -> + { + java.util.Set ids = new java.util.TreeSet<>(); + Map ledgers = new HashMap<>(); + for (AccountData a : accounts) + { + ItemLedger l = store.ledger(a.key, a.key.equals(activeKey)); + ledgers.put(a.key, l); + for (ItemSnapshot s : l.snapshots) + { + for (int id : s.ids) + { + ids.add(id); + } + } + } + Map names = new HashMap<>(); + for (int id : ids) + { + names.put(id, itemName(id)); + } + executor.execute(() -> + { + try + { + writeExport(accounts, ledgers, names); + } + catch (IOException e) + { + log.warn("Wealth tracker: export failed", e); + } + if (done != null) + { + javax.swing.SwingUtilities.invokeLater(done); + } + }); + }); + } + + private static void writeExport(List accounts, Map ledgers, Map names) throws IOException + { + File dir = new File(WealthStore.DIR, "export"); + Files.createDirectories(dir.toPath()); + DateTimeFormatter iso = DateTimeFormatter.ISO_OFFSET_DATE_TIME; + ZoneId zone = ZoneId.systemDefault(); + String date = LocalDate.now().toString(); + + for (AccountData a : accounts) + { + String base = WealthStore.fileBase(a.name == null ? a.key : a.name) + "-" + date; + + try (PrintWriter w = new PrintWriter(Files.newBufferedWriter(new File(dir, base + "-timeline.csv").toPath(), StandardCharsets.UTF_8))) + { + StringBuilder header = new StringBuilder("time,epoch_ms,resolution"); + for (WealthCategory c : WealthCategory.values()) + { + header.append(',').append(c.name().toLowerCase()); + } + header.append(",total,open,close,low,high"); + w.println(header); + for (Snapshot s : a.history) + { + StringBuilder row = new StringBuilder(); + row.append(Instant.ofEpochMilli(s.t).atZone(zone).format(iso)).append(',').append(s.t).append(',') + .append(s.g == null ? "raw" : s.g.equals(Snapshot.HOUR) ? "hour" : "day"); + for (WealthCategory c : WealthCategory.values()) + { + row.append(',').append(s.get(c)); + } + row.append(',').append(s.total()).append(',').append(s.open()).append(',').append(s.close()) + .append(',').append(s.low()).append(',').append(s.high()); + w.println(row); + } + } + + ItemLedger ledger = ledgers.get(a.key); + if (ledger != null && !ledger.snapshots.isEmpty()) + { + try (PrintWriter w = new PrintWriter(Files.newBufferedWriter(new File(dir, base + "-items.csv").toPath(), StandardCharsets.UTF_8))) + { + w.println("time,epoch_ms,item_id,item_name,quantity,price,value"); + for (ItemSnapshot s : ledger.snapshots) + { + String time = Instant.ofEpochMilli(s.t).atZone(zone).format(iso); + for (int i = 0; i < s.ids.length; i++) + { + String n = names.getOrDefault(s.ids[i], "").replace("\"", "\"\""); + w.println(time + ',' + s.t + ',' + s.ids[i] + ",\"" + n + "\"," + s.q[i] + ',' + s.p[i] + ',' + (s.q[i] * s.p[i])); + } + } + } + } + } + } + + // ---------------------------------------------------------------- helpers + + /** Accumulates values per category/source plus merged canonical holdings with prices. */ + private final class Valuation + { + final long[] values = new long[WealthCategory.COUNT]; + final List sources = new ArrayList<>(); + final Map merged = new HashMap<>(); + final Map prices = new HashMap<>(); + + long total() + { + long sum = 0; + for (long v : values) + { + sum += v; + } + return sum; + } + + void add(String label, WealthCategory category, Map items) + { + long total = 0; + for (Map.Entry e : items.entrySet()) + { + if (isPlaceholder(e.getKey())) + { + // saved banks from older versions may still contain placeholders + continue; + } + int id = itemManager.canonicalize(e.getKey()); + long qty = e.getValue(); + total += price(id, prices) * qty; + merged.merge(id, qty, Long::sum); + } + values[category.ordinal()] += total; + sources.add(new SourceValue(label, category.ordinal(), total)); + } + + void addGp(String label, WealthCategory category, long gp) + { + if (gp > 0) + { + values[category.ordinal()] += gp; + sources.add(new SourceValue(label, category.ordinal(), gp)); + } + } + + ItemSnapshot itemSnapshot(long t) + { + TreeMap sorted = new TreeMap<>(merged); + int[] ids = new int[sorted.size()]; + long[] q = new long[sorted.size()]; + int[] p = new int[sorted.size()]; + int i = 0; + for (Map.Entry e : sorted.entrySet()) + { + ids[i] = e.getKey(); + q[i] = e.getValue(); + p[i] = price(e.getKey(), prices); + i++; + } + return new ItemSnapshot(t, ids, q, p); + } + } + + private static BufferedImage loadIcon() + { + try + { + BufferedImage img = ImageUtil.loadImageResource(EssentialsPlusPlugin.class, "/com/example/essentialsplus/icon.png"); + if (img != null) + { + return img; + } + } + catch (Exception ignored) + { + } + return createIcon(); + } + + private static BufferedImage createIcon() + { + BufferedImage img = new BufferedImage(16, 16, BufferedImage.TYPE_INT_ARGB); + Graphics2D g = img.createGraphics(); + g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); + + // Background dark rounded rect + g.setColor(new Color(35, 35, 42)); + g.fillRoundRect(0, 0, 16, 16, 4, 4); + + // Border in warm orange + g.setColor(new Color(225, 145, 20)); + g.drawRoundRect(0, 0, 15, 15, 4, 4); + + // E letter in white + g.setColor(Color.WHITE); + // Vertical bar + g.fillRect(3, 4, 2, 8); + // Top bar + g.fillRect(5, 4, 3, 2); + // Middle bar + g.fillRect(5, 7, 2, 2); + // Bottom bar + g.fillRect(5, 10, 3, 2); + + // + symbol in orange + g.setColor(new Color(255, 175, 40)); + // Vertical plus + g.fillRect(11, 5, 2, 6); + // Horizontal plus + g.fillRect(9, 7, 6, 2); + + g.dispose(); + return img; + } + + private static final class ContainerSource + { + final int id; + final String key; + final String label; + final WealthCategory category; + + ContainerSource(int id, String key, String label, WealthCategory category) + { + this.id = id; + this.key = key; + this.label = label; + this.category = category; + } + } +} diff --git a/src/main/java/com/example/essentialsplus/GrandExchangeTracker.java b/src/main/java/com/example/essentialsplus/GrandExchangeTracker.java new file mode 100644 index 0000000..ddec402 --- /dev/null +++ b/src/main/java/com/example/essentialsplus/GrandExchangeTracker.java @@ -0,0 +1,318 @@ +package com.example.essentialsplus; + +import com.example.essentialsplus.data.AccountData; +import com.example.essentialsplus.data.GeSlot; +import java.util.HashMap; +import java.util.Map; +import java.util.function.IntUnaryOperator; +import net.runelite.api.Client; +import net.runelite.api.GrandExchangeOffer; +import net.runelite.api.GrandExchangeOfferState; +import net.runelite.api.Item; +import net.runelite.api.ItemContainer; +import net.runelite.api.gameval.InterfaceID; +import net.runelite.api.gameval.InventoryID; +import net.runelite.api.gameval.ItemID; +import net.runelite.api.widgets.Widget; + +/** + * Values Grand Exchange offers without double counting. + *

+ * A slot holds two things: the escrow of the part that has not traded yet and the collection box + * (bought items / received coins). The client does not say how much of a partially completed offer was + * already collected, so collections are detected: whenever the offer item or coins show up in inventory + * or bank while a GE / bank interface is open, that amount is treated as collected. If a slot empties and + * the items did not show up anywhere visible they went to the bank unseen and are booked there. + * The game's collection box containers are used directly when the client has them. + */ +final class GrandExchangeTracker +{ + private static final int SLOTS = 8; + private static final int[] COLLECT_CONTAINERS = { + InventoryID.TRADINGPOST_SELL_0, InventoryID.TRADINGPOST_SELL_1, InventoryID.TRADINGPOST_SELL_2, + InventoryID.TRADINGPOST_SELL_3, InventoryID.TRADINGPOST_SELL_4, InventoryID.TRADINGPOST_SELL_5, + InventoryID.GE_COLLECT_6, InventoryID.GE_COLLECT_7 + }; + + /** how long a visible coin loss may be matched to a new buy offer */ + private static final long COIN_LOSS_WINDOW_MS = 15_000L; + + /** inventory + visible bank holdings (canonical ids) at the previous update */ + private Map previousHoldings; + /** coins that recently left inventory / visible bank and were not matched to a buy offer yet */ + private long recentCoinLoss; + private long recentCoinLossAt; + + void reset() + { + previousHoldings = null; + recentCoinLoss = 0; + } + + /** + * @param live false while the offers of a fresh login have not arrived yet - persisted values are used then + * @return canonical item id -> quantity held by the Grand Exchange + */ + Map update(AccountData acc, Client client, IntUnaryOperator canonicalize, boolean live) + { + boolean migrate = acc.geSlots == null || acc.geSlots.length != SLOTS; + + Map holdings = new HashMap<>(); + addCanonical(holdings, acc.containers.get("inventory"), canonicalize); + addCanonical(holdings, acc.containers.get("bank"), canonicalize); + Map gains = new HashMap<>(); + long updateTime = System.currentTimeMillis(); + if (updateTime - recentCoinLossAt > COIN_LOSS_WINDOW_MS) + { + recentCoinLoss = 0; + } + if (previousHoldings != null) + { + long coinLoss = previousHoldings.getOrDefault(ItemID.COINS, 0L) - holdings.getOrDefault(ItemID.COINS, 0L); + if (coinLoss > 0) + { + recentCoinLoss += coinLoss; + recentCoinLossAt = updateTime; + } + for (Map.Entry e : holdings.entrySet()) + { + long d = e.getValue() - previousHoldings.getOrDefault(e.getKey(), 0L); + if (d > 0) + { + gains.put(e.getKey(), d); + } + } + } + boolean haveBaseline = previousHoldings != null; + previousHoldings = holdings; + + GrandExchangeOffer[] offers = client.getGrandExchangeOffers(); + Map out = new HashMap<>(); + if (!live || offers == null || offers.length < SLOTS || (!haveBaseline && !migrate)) + { + // keep the last known state + if (!migrate) + { + for (GeSlot s : acc.geSlots) + { + addSlot(out, s); + } + } + return out; + } + if (migrate) + { + acc.geSlots = new GeSlot[SLOTS]; + } + boolean seeded = migrate; + + boolean collecting = isOpen(client, InterfaceID.GE_OFFERS) || isOpen(client, InterfaceID.GE_COLLECT) || isOpen(client, InterfaceID.BANKMAIN); + + for (int i = 0; i < SLOTS; i++) + { + GrandExchangeOffer o = offers[i]; + GeSlot s = acc.geSlots[i]; + GrandExchangeOfferState state = o == null ? null : o.getState(); + + if (state == null || state == GrandExchangeOfferState.EMPTY || o.getItemId() <= 0) + { + if (s != null && !migrate) + { + // slot cleared: whatever was still collectable was collected now + int item = canonicalize.applyAsInt(s.item); + long items = s.collectItems - consume(gains, item, s.collectItems); + long coins = s.collectCoins - consume(gains, ItemID.COINS, s.collectCoins); + bookToBank(acc, item, items); + bookToBank(acc, ItemID.COINS, coins); + } + acc.geSlots[i] = null; + continue; + } + + boolean buy = state == GrandExchangeOfferState.BUYING || state == GrandExchangeOfferState.BOUGHT + || state == GrandExchangeOfferState.CANCELLED_BUY; + boolean cancelled = state == GrandExchangeOfferState.CANCELLED_BUY || state == GrandExchangeOfferState.CANCELLED_SELL; + long total = o.getTotalQuantity(); + long price = o.getPrice(); + long done = o.getQuantitySold(); + long spent = o.getSpent(); + + // what the slot has produced so far (collectable if nothing was taken out) + long grossItems = buy ? done : (cancelled ? total - done : 0); + long grossCoins = buy ? Math.max(0, done * price - spent) + (cancelled ? (total - done) * price : 0) : spent; + long escrowItems = !buy && !cancelled ? total - done : 0; + long escrowCoins = buy && !cancelled ? (total - done) * price : 0; + + if (s == null || s.item != o.getItemId() || s.buy != buy || s.total != total || s.price != price) + { + GeSlot fresh = new GeSlot(); + fresh.item = o.getItemId(); + fresh.buy = buy; + fresh.total = total; + fresh.price = price; + long now = System.currentTimeMillis(); + fresh.trackedSince = now; + fresh.placedAt = migrate ? 0 : now; + fresh.done = migrate ? done : 0; + fresh.trackedDone = fresh.done; + if (migrate && acc.geSeedCollected != null) + { + // first run with this tracker: take over what is known to be collected already + long seed = acc.geSeedCollected.getOrDefault(fresh.item, 0L); + fresh.collectedItems = Math.min(grossItems, seed); + if (seed > 0) + { + acc.geSeedCollected.put(fresh.item, seed - fresh.collectedItems); + } + } + if (!migrate && buy) + { + // A buy offer can be paid with coins from the bank while the bank is not visible. Whatever did not + // leave inventory / visible bank came from there - otherwise the stale bank and the offer both count it. + long cost = total * price; + long seen = Math.min(cost, recentCoinLoss); + recentCoinLoss -= seen; + bookToBank(acc, ItemID.COINS, -(cost - seen)); + } + s = fresh; + acc.geSlots[i] = s; + } + + int item = canonicalize.applyAsInt(s.item); + // items / coins of this slot showing up in inventory or bank were collected + if (collecting) + { + s.collectedItems += consume(gains, item, Math.max(0, grossItems - s.collectedItems)); + s.collectedCoins += consume(gains, ItemID.COINS, Math.max(0, grossCoins - s.collectedCoins)); + } + long collectItems = Math.max(0, grossItems - s.collectedItems); + long collectCoins = Math.max(0, grossCoins - s.collectedCoins); + ItemContainer box = client.getItemContainer(COLLECT_CONTAINERS[i]); + if (box != null) + { + long boxItems = 0; + long boxCoins = 0; + for (Item it : box.getItems()) + { + if (it == null || it.getId() <= 0 || it.getQuantity() <= 0) + { + continue; + } + if (it.getId() == ItemID.COINS) + { + boxCoins += it.getQuantity(); + } + else if (canonicalize.applyAsInt(it.getId()) == item) + { + boxItems += it.getQuantity(); + } + } + // The client can keep an outdated box after collecting and may fill it a tick after the offer, so the box + // only counts when it shrank since the last update: that part went to the bank without being seen. + long goneItems = Math.min(collectItems - boxItems, s.collectItems - boxItems); + if (goneItems > 0) + { + bookToBank(acc, item, goneItems); + s.collectedItems += goneItems; + collectItems -= goneItems; + } + long goneCoins = Math.min(collectCoins - boxCoins, s.collectCoins - boxCoins); + if (goneCoins > 0) + { + bookToBank(acc, ItemID.COINS, goneCoins); + s.collectedCoins += goneCoins; + collectCoins -= goneCoins; + } + } + + long now = System.currentTimeMillis(); + if (s.trackedSince == 0) + { + s.trackedSince = now; + s.done = done; + s.trackedDone = done; + } + if (done != s.done) + { + s.lastTradeAt = now; + } + s.done = done; + s.spent = spent; + s.state = state.name(); + s.escrowItems = escrowItems; + s.escrowCoins = escrowCoins; + s.collectItems = collectItems; + s.collectCoins = collectCoins; + addSlot(out, s); + } + if (seeded) + { + acc.geSeedCollected = null; + } + return out; + } + + private static void addSlot(Map out, GeSlot s) + { + if (s == null) + { + return; + } + long items = s.escrowItems + s.collectItems; + long coins = s.escrowCoins + s.collectCoins; + if (items > 0) + { + out.merge(s.item, items, Long::sum); + } + if (coins > 0) + { + out.merge(ItemID.COINS, coins, Long::sum); + } + } + + /** Takes up to {@code max} of a visible gain and returns how much was taken. */ + private static long consume(Map gains, int item, long max) + { + if (max <= 0) + { + return 0; + } + long g = gains.getOrDefault(item, 0L); + long take = Math.min(g, max); + if (take > 0) + { + gains.put(item, g - take); + } + return take; + } + + /** Books items that went to the bank while the bank was not visible. */ + private static void bookToBank(AccountData acc, int item, long qty) + { + if (qty == 0) + { + return; + } + Map pending = new HashMap<>(acc.bankPending); + pending.merge(item, qty, Long::sum); + acc.bankPending = pending; + } + + private static void addCanonical(Map into, Map items, IntUnaryOperator canonicalize) + { + if (items == null) + { + return; + } + for (Map.Entry e : items.entrySet()) + { + into.merge(canonicalize.applyAsInt(e.getKey()), e.getValue(), Long::sum); + } + } + + private static boolean isOpen(Client client, int group) + { + Widget w = client.getWidget(group, 0); + return w != null && !w.isHidden(); + } +} diff --git a/src/main/java/com/example/essentialsplus/WealthCategory.java b/src/main/java/com/example/essentialsplus/WealthCategory.java new file mode 100644 index 0000000..318f13d --- /dev/null +++ b/src/main/java/com/example/essentialsplus/WealthCategory.java @@ -0,0 +1,27 @@ +package com.example.essentialsplus; + +import java.awt.Color; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +/** + * Wealth buckets. Ordinal is used as index into persisted value arrays - only append new entries. + */ +@Getter +@RequiredArgsConstructor +public enum WealthCategory +{ + BANK("Bank", new Color(232, 163, 61)), + INVENTORY("Inventory", new Color(90, 176, 232)), + EQUIPMENT("Equipment", new Color(165, 123, 224)), + STORAGE("Storage", new Color(77, 201, 166)), + COFFERS("Coffers", new Color(232, 112, 122)), + GRAND_EXCHANGE("Grand Exchange", new Color(201, 214, 90)), + /** Totals imported from other plugins without a breakdown. */ + IMPORTED("Imported (total only)", new Color(120, 120, 128)); + + public static final int COUNT = values().length; + + private final String label; + private final Color color; +} diff --git a/src/main/java/com/example/essentialsplus/WealthStore.java b/src/main/java/com/example/essentialsplus/WealthStore.java new file mode 100644 index 0000000..c10cdba --- /dev/null +++ b/src/main/java/com/example/essentialsplus/WealthStore.java @@ -0,0 +1,475 @@ +package com.example.essentialsplus; + +import com.example.essentialsplus.data.AccountData; +import com.example.essentialsplus.data.ItemLedger; +import com.example.essentialsplus.data.ItemSnapshot; +import com.example.essentialsplus.data.Snapshot; +import com.google.gson.Gson; +import java.io.File; +import java.io.IOException; +import java.io.Reader; +import java.io.Writer; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.StandardCopyOption; +import java.time.DayOfWeek; +import java.time.Instant; +import java.time.LocalDate; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.time.temporal.ChronoUnit; +import java.time.temporal.TemporalAdjusters; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CopyOnWriteArrayList; +import javax.inject.Inject; +import javax.inject.Singleton; +import lombok.extern.slf4j.Slf4j; +import net.runelite.client.RuneLite; + +/** + * Loads and saves the files in ~/.runelite/essentials-plus: + *

    + *
  • {@code .json} - state + wealth timeline
  • + *
  • {@code .items.json} - item holdings over time
  • + *
+ * Several RuneLite clients may run at once, so files of characters that are not logged in + * in this client are re-read when they change on disk. + */ +@Slf4j +@Singleton +public class WealthStore +{ + public static final File DIR = new File(RuneLite.RUNELITE_DIR, "essentials-plus"); + private static final String LEDGER_SUFFIX = ".items.json"; + + private final Gson gson; + private final Map accounts = new ConcurrentHashMap<>(); + private final Map ledgers = new ConcurrentHashMap<>(); + + @Inject + WealthStore(Gson gson) + { + this.gson = gson.newBuilder().disableHtmlEscaping().create(); + } + + // ------------------------------------------------------------------ accounts + + public List all() + { + List list = new ArrayList<>(accounts.values()); + list.sort(Comparator.comparingLong((AccountData a) -> -a.currentTotal()) + .thenComparing(a -> a.name == null ? "" : a.name, String.CASE_INSENSITIVE_ORDER)); + return list; + } + + public AccountData get(String key) + { + return key == null ? null : accounts.get(key); + } + + public AccountData getOrCreate(String key, String name) + { + return accounts.computeIfAbsent(key, k -> + { + AccountData a = new AccountData(); + a.key = k; + a.name = name; + return a; + }); + } + + public void put(AccountData account) + { + accounts.put(account.key, account); + } + + + + /** + * Re-reads account files changed on disk, skipping the character logged in here (this client owns it). + * + * @return true if anything changed + */ + public boolean reloadExternal(String activeKey) + { + File[] files = DIR.listFiles((d, n) -> n.endsWith(".json") && !n.endsWith(LEDGER_SUFFIX)); + if (files == null) + { + return false; + } + + boolean changed = false; + Set seen = new HashSet<>(); + for (File f : files) + { + AccountData existing = findByFile(f); + if (existing != null) + { + seen.add(existing.key); + if (existing.key.equals(activeKey) || existing.fileModified >= f.lastModified()) + { + continue; + } + } + + AccountData loaded = read(f, AccountData.class); + if (loaded == null || loaded.key == null || loaded.key.equals(activeKey)) + { + continue; + } + loaded.normalize(); + loaded.fileModified = f.lastModified(); + accounts.put(loaded.key, loaded); + seen.add(loaded.key); + changed = true; + } + + // files deleted by another client + for (String key : new ArrayList<>(accounts.keySet())) + { + AccountData a = accounts.get(key); + if (a != null && !seen.contains(key) && !key.equals(activeKey) && a.fileModified > 0) + { + accounts.remove(key); + ledgers.remove(key); + changed = true; + } + } + return changed; + } + + /** Replaces the in-memory copy with the file if the file is newer (used when a character logs in). */ + public AccountData refreshFromDisk(String key) + { + File f = fileFor(key); + AccountData mem = accounts.get(key); + if (f.exists() && (mem == null || mem.fileModified < f.lastModified())) + { + AccountData loaded = read(f, AccountData.class); + if (loaded != null) + { + loaded.key = key; + loaded.normalize(); + loaded.fileModified = f.lastModified(); + accounts.put(key, loaded); + ledgers.remove(key); + return loaded; + } + } + return mem; + } + + private AccountData findByFile(File f) + { + for (AccountData a : accounts.values()) + { + if (fileFor(a.key).getName().equals(f.getName())) + { + return a; + } + } + return null; + } + + public void save(AccountData account) + { + if (account == null || account.key == null) + { + return; + } + File target = fileFor(account.key); + if (write(target, account)) + { + account.fileModified = target.lastModified(); + } + } + + public void delete(String key) + { + accounts.remove(key); + ledgers.remove(key); + for (File f : new File[]{fileFor(key), ledgerFileFor(key)}) + { + if (f.exists() && !f.delete()) + { + log.warn("Wealth tracker: could not delete {}", f); + } + } + } + + // ------------------------------------------------------------------ item ledgers + + /** Item history of a character, loaded lazily and re-read when another client changed it. */ + public ItemLedger ledger(String key, boolean owned) + { + ItemLedger mem = ledgers.get(key); + File f = ledgerFileFor(key); + if (mem != null && (owned || !f.exists() || mem.fileModified >= f.lastModified())) + { + return mem; + } + ItemLedger loaded = f.exists() ? read(f, ItemLedger.class) : null; + if (loaded == null) + { + loaded = mem != null ? mem : new ItemLedger(); + } + else + { + loaded.normalize(); + loaded.fileModified = f.lastModified(); + } + ledgers.put(key, loaded); + return loaded; + } + + public void saveLedger(String key, ItemLedger ledger) + { + File target = ledgerFileFor(key); + if (write(target, ledger)) + { + ledger.fileModified = target.lastModified(); + ledger.dirty = false; + } + } + + // ------------------------------------------------------------------ io + + private T read(File f, Class type) + { + try (Reader r = Files.newBufferedReader(f.toPath(), StandardCharsets.UTF_8)) + { + return gson.fromJson(r, type); + } + catch (Exception e) + { + log.warn("Wealth tracker: could not read {}", f, e); + return null; + } + } + + private synchronized boolean write(File target, Object data) + { + try + { + Files.createDirectories(DIR.toPath()); + File tmp = new File(DIR, target.getName() + ".tmp"); + try (Writer w = Files.newBufferedWriter(tmp.toPath(), StandardCharsets.UTF_8)) + { + gson.toJson(data, w); + } + Files.move(tmp.toPath(), target.toPath(), StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE); + return true; + } + catch (IOException e) + { + log.warn("Wealth tracker: could not save {}", target, e); + return false; + } + } + + static String fileBase(String key) + { + return key.replaceAll("[^A-Za-z0-9 _()\\-]", "_"); + } + + static File fileFor(String key) + { + return new File(DIR, fileBase(key) + ".json"); + } + + static File ledgerFileFor(String key) + { + return new File(DIR, fileBase(key) + LEDGER_SUFFIX); + } + + // ------------------------------------------------------------------ compaction + + /** + * Keeps the timeline useful for analysis while bounding its size: + *
    + *
  • younger than {@code rawDays}: every change (identical consecutive points dropped)
  • + *
  • younger than {@code hourlyDays}: one point per hour
  • + *
  • older: one point per day
  • + *
+ * Buckets keep open, close, low, high and the time-weighted average breakdown. + * Must be called on the thread that appends to the history (client thread). + */ + public static void compact(AccountData account, int rawDays, int hourlyDays, long now) + { + List src = AccountData.sanitizeHistory(account.history); + if (src.size() < 2) + { + if (src.size() != account.history.size()) + { + account.history = new CopyOnWriteArrayList<>(src); + } + return; + } + + ZoneId zone = ZoneId.systemDefault(); + ZonedDateTime nowZ = Instant.ofEpochMilli(now).atZone(zone); + long rawCutoff = nowZ.minusDays(rawDays).truncatedTo(ChronoUnit.HOURS).toInstant().toEpochMilli(); + long hourlyCutoff = nowZ.minusDays(Math.max(rawDays, hourlyDays)).truncatedTo(ChronoUnit.DAYS).toInstant().toEpochMilli(); + + List out = new ArrayList<>(src.size()); + List bucket = new ArrayList<>(); + long bucketStart = Long.MIN_VALUE; + String bucketG = null; + Snapshot prevRaw = null; + + for (Snapshot s : src) + { + if (s.t >= rawCutoff) + { + flush(bucket, bucketStart, bucketG, zone, out); + bucketStart = Long.MIN_VALUE; + if (!s.isAggregate() && prevRaw != null && prevRaw.sameValues(s.v)) + { + continue; + } + out.add(s); + prevRaw = s.isAggregate() ? null : s; + continue; + } + + String g = s.t >= hourlyCutoff ? Snapshot.HOUR : Snapshot.DAY; + ZonedDateTime z = Instant.ofEpochMilli(s.t).atZone(zone); + long bs = (g.equals(Snapshot.HOUR) ? z.truncatedTo(ChronoUnit.HOURS) : z.truncatedTo(ChronoUnit.DAYS)).toInstant().toEpochMilli(); + if (bs != bucketStart || !g.equals(bucketG)) + { + flush(bucket, bucketStart, bucketG, zone, out); + bucketStart = bs; + bucketG = g; + } + bucket.add(s); + } + flush(bucket, bucketStart, bucketG, zone, out); + + if (out.size() != src.size()) + { + account.history = new CopyOnWriteArrayList<>(out); + } + } + + private static void flush(List bucket, long start, String g, ZoneId zone, List out) + { + if (bucket.isEmpty()) + { + return; + } + if (bucket.size() == 1 && g.equals(bucket.get(0).g)) + { + out.add(bucket.get(0)); + bucket.clear(); + return; + } + + long end = g.equals(Snapshot.HOUR) + ? start + Ui_HOUR + : Instant.ofEpochMilli(start).atZone(zone).plusDays(1).toInstant().toEpochMilli(); + + double[] sum = new double[WealthCategory.COUNT]; + double weight = 0; + long lo = Long.MAX_VALUE; + long hi = Long.MIN_VALUE; + for (int i = 0; i < bucket.size(); i++) + { + Snapshot s = bucket.get(i); + long from = Math.max(s.t, start); + long to = i + 1 < bucket.size() ? bucket.get(i + 1).t : end; + double w = Math.max(1, Math.min(to, end) - from); + for (WealthCategory c : WealthCategory.values()) + { + sum[c.ordinal()] += s.get(c) * w; + } + weight += w; + lo = Math.min(lo, s.low()); + hi = Math.max(hi, s.high()); + } + + long[] avg = new long[WealthCategory.COUNT]; + for (int i = 0; i < avg.length; i++) + { + avg[i] = Math.round(sum[i] / weight); + } + + Snapshot agg = new Snapshot(start + (end - start) / 2, avg); + agg.o = bucket.get(0).open(); + agg.c = bucket.get(bucket.size() - 1).close(); + agg.lo = lo; + agg.hi = hi; + agg.g = g; + agg.m = bucket.get(bucket.size() - 1).m; + out.add(agg); + bucket.clear(); + } + + private static final long Ui_HOUR = 3_600_000L; + + /** + * Item history resolution: hourly for 2 days, daily for 90 days, weekly afterwards. + * Identical consecutive snapshots are dropped. + */ + public static void compact(ItemLedger ledger, long now) + { + List src = ledger.snapshots; + if (src.size() < 2) + { + return; + } + ZoneId zone = ZoneId.systemDefault(); + List out = new ArrayList<>(src.size()); + long prevBucket = Long.MIN_VALUE; + for (ItemSnapshot s : src) + { + long age = now - s.t; + ZonedDateTime z = Instant.ofEpochMilli(s.t).atZone(zone); + long bucket; + if (age < 2 * 86_400_000L) + { + bucket = z.truncatedTo(ChronoUnit.HOURS).toInstant().toEpochMilli(); + } + else if (age < 90 * 86_400_000L) + { + bucket = z.truncatedTo(ChronoUnit.DAYS).toInstant().toEpochMilli() + 1; + } + else + { + LocalDate monday = z.toLocalDate().with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)); + bucket = monday.atStartOfDay(zone).toInstant().toEpochMilli() + 2; + } + + if (Boolean.TRUE.equals(s.k) && age < 2 * 86_400_000L) + { + out.add(s); + prevBucket = Long.MIN_VALUE; + continue; + } + if (!out.isEmpty() && bucket == prevBucket && !Boolean.TRUE.equals(out.get(out.size() - 1).k)) + { + // keep the latest state of each bucket + out.set(out.size() - 1, s); + } + else if (!out.isEmpty() && s.sameAs(out.get(out.size() - 1))) + { + continue; + } + else + { + out.add(s); + } + prevBucket = bucket; + } + if (out.size() != src.size()) + { + ledger.snapshots = new CopyOnWriteArrayList<>(out); + ledger.dirty = true; + } + } +} diff --git a/src/main/java/com/example/essentialsplus/collectionlog/CollectionLogService.java b/src/main/java/com/example/essentialsplus/collectionlog/CollectionLogService.java new file mode 100644 index 0000000..907585a --- /dev/null +++ b/src/main/java/com/example/essentialsplus/collectionlog/CollectionLogService.java @@ -0,0 +1,515 @@ +package com.example.essentialsplus.collectionlog; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.stream.Collectors; +import lombok.extern.slf4j.Slf4j; +import net.runelite.api.ChatMessageType; +import net.runelite.api.Client; +import net.runelite.api.EnumComposition; +import net.runelite.api.GameState; +import net.runelite.api.Item; +import net.runelite.api.ItemContainer; +import net.runelite.api.StructComposition; +import net.runelite.api.events.ChatMessage; +import net.runelite.api.gameval.InterfaceID; +import net.runelite.api.widgets.Widget; +import net.runelite.client.config.ConfigManager; +import net.runelite.client.util.Text; + +/** + * Collection log pages and items (read from the game cache) plus which items the logged-in account has + * obtained. Obtained items are learned whenever a collection log page is open and from the + * "New item added to your collection log" chat message, and stored per RuneScape profile. + */ +@Slf4j +public class CollectionLogService +{ + private static final String GROUP = com.example.essentialsplus.EssentialsPlusConfig.GROUP; + private static final String KEY = "clogObtained"; + private static final String KEY_FULL_SYNC = "clogFullSync"; + /** runs once per log item (item id, count) when the log's search is opened */ + private static final int SCRIPT_SEARCH_ITEM = 4100; + + /** tab structs (Bosses, Raids, Clues, Minigames, Other) */ + private static final int[] TAB_STRUCTS = {471, 472, 473, 474, 475}; + private static final String[] TAB_NAMES = {"Bosses", "Raids", "Clues", "Minigames", "Other"}; + private static final int PARAM_TAB_PAGES = 683; + private static final int PARAM_PAGE_NAME = 689; + private static final int PARAM_PAGE_ITEMS = 690; + /** server-sent inventory holding the obtained count of every collection log item */ + private static final int CLOG_INVENTORY = 620; + /** obj -> obj enum the log script uses to remap page items before looking them up in the inventory */ + private static final int ENUM_ITEM_REMAP = 3721; + /** unobtained items are drawn with this transparency */ + private static final int UNOBTAINED_OPACITY = 175; + private static final String NEW_ITEM = "New item added to your collection log:"; + + /** A collection log page, e.g. "Zulrah", with its item ids in log order. */ + public static final class Page + { + public final String name; + public final List itemIds; + + Page(String name, List itemIds) + { + this.name = name; + this.itemIds = itemIds; + } + } + + /** A collection log tab, e.g. "Bosses", with its pages in log order. */ + public static final class Tab + { + public final String name; + public final List pages; + + Tab(String name, List pages) + { + this.name = name; + this.pages = pages; + } + } + + private final Client client; + private final ConfigManager configManager; + private final List listeners = new CopyOnWriteArrayList<>(); + + private volatile List pages = Collections.emptyList(); + private volatile List tabs = Collections.emptyList(); + private volatile Map itemNames = Collections.emptyMap(); + private final Set obtained = ConcurrentHashMap.newKeySet(); + private volatile String profileKey; + private volatile boolean synced; + /** when all items were last read via the log search, 0 = never */ + private volatile long fullSyncAt; + /** items reported by the search script this tick: id -> count */ + private final Map searchItems = new java.util.HashMap<>(); + private int searchTick = -1; + private int lastWidgetHash; + private int lastInventoryHash; + + public CollectionLogService(Client client, ConfigManager configManager) + { + this.client = client; + this.configManager = configManager; + } + + public void addListener(Runnable r) + { + listeners.add(r); + } + + public void removeListener(Runnable r) + { + listeners.remove(r); + } + + /** Client thread. Loads the page list once and the obtained items when the profile changes. */ + public void onGameTick() + { + if (client.getGameState() != GameState.LOGGED_IN) + { + return; + } + if (pages.isEmpty()) + { + loadPages(); + } + String key = configManager.getRSProfileKey(); + if (key != null && !key.equals(profileKey)) + { + profileKey = key; + loadObtained(); + log.info("collection log: profile {} has {} stored obtained items", key, obtained.size()); + notifyListeners(); + } + finishSearchSync(); + scanInventory(); + scanOpenPage(); + } + + /** Client thread. The log search reports every item with its count: a complete, exact sync. */ + public void onScriptPreFired(net.runelite.api.events.ScriptPreFired event) + { + if (event.getScriptId() != SCRIPT_SEARCH_ITEM || event.getScriptEvent() == null) + { + return; + } + Object[] args = event.getScriptEvent().getArguments(); + if (args == null || args.length < 3 || !(args[1] instanceof Integer) || !(args[2] instanceof Integer)) + { + return; + } + searchItems.merge((Integer) args[1], (Integer) args[2], Math::max); + searchTick = client.getTickCount(); + } + + private void finishSearchSync() + { + if (searchItems.isEmpty() || client.getTickCount() == searchTick || itemNames.isEmpty()) + { + return; + } + Map seen = new java.util.HashMap<>(searchItems); + searchItems.clear(); + EnumComposition remap = client.getEnum(ENUM_ITEM_REMAP); + Set have = new HashSet<>(); + int known = 0; + for (int id : itemNames.keySet()) + { + int mapped = remap.getIntValue(id); + boolean inSearch = seen.containsKey(id) || mapped > 0 && seen.containsKey(mapped); + known += inSearch ? 1 : 0; + if (seen.getOrDefault(id, 0) > 0 || mapped > 0 && seen.getOrDefault(mapped, 0) > 0) + { + have.add(id); + } + } + boolean complete = known >= itemNames.size() * 9 / 10; + log.info("collection log search: {} items reported, {} log items matched, {} obtained, complete={}", seen.size(), known, have.size(), complete); + if (complete) + { + // the search lists every item, so this replaces what was guessed from pages before + obtained.clear(); + fullSyncAt = System.currentTimeMillis(); + if (profileKey != null) + { + configManager.setRSProfileConfiguration(GROUP, KEY_FULL_SYNC, fullSyncAt); + } + client.addChatMessage(ChatMessageType.GAMEMESSAGE, "", String.format( + "Essentials Plus: collection log synced - %d / %d items.", have.size(), itemNames.size()), null); + } + obtained.addAll(have); + synced = true; + saveObtained(); + notifyListeners(); + } + + /** When the log was last fully synced through its search, 0 = never. */ + public long getFullSyncAt() + { + return fullSyncAt; + } + + /** Client thread. */ + public void onChatMessage(ChatMessage event) + { + if (event.getType() != ChatMessageType.GAMEMESSAGE && event.getType() != ChatMessageType.SPAM) + { + return; + } + String msg = Text.removeTags(event.getMessage()); + if (!msg.startsWith(NEW_ITEM)) + { + return; + } + String name = msg.substring(NEW_ITEM.length()).trim(); + boolean changed = false; + for (Map.Entry e : itemNames.entrySet()) + { + if (e.getValue().equalsIgnoreCase(name)) + { + changed |= obtained.add(e.getKey()); + } + } + if (changed) + { + saveObtained(); + notifyListeners(); + } + } + + public void reset() + { + profileKey = null; + obtained.clear(); + synced = false; + fullSyncAt = 0; + searchItems.clear(); + } + + /** True once any collection log data is known for the current profile. */ + public boolean isSynced() + { + return synced; + } + + public boolean isLoaded() + { + return !pages.isEmpty(); + } + + public boolean isObtained(int itemId) + { + return obtained.contains(itemId); + } + + public List getTabs() + { + return tabs; + } + + /** Number of distinct items in the log. */ + public int totalItems() + { + return itemNames.size(); + } + + /** Number of distinct log items the account has obtained. */ + public int obtainedItems() + { + int n = 0; + for (int id : itemNames.keySet()) + { + if (obtained.contains(id)) + { + n++; + } + } + return n; + } + + /** Obtained items of a page or tab. */ + public int obtainedCount(List itemIds) + { + int n = 0; + for (int id : itemIds) + { + if (obtained.contains(id)) + { + n++; + } + } + return n; + } + + public String itemName(int itemId) + { + return itemNames.getOrDefault(itemId, "Item " + itemId); + } + + /** All item ids in the log with the given name (some items appear under several ids). */ + public Set idsByName(String name) + { + Set out = new HashSet<>(); + for (Map.Entry e : itemNames.entrySet()) + { + if (e.getValue().equalsIgnoreCase(name)) + { + out.add(e.getKey()); + } + } + return out; + } + + /** The log page of a loot source: exact name, else a page starting with it ("Barrows" -> "Barrows Chests"). */ + public Page findPage(String source) + { + if (source == null) + { + return null; + } + String s = source.trim().toLowerCase(Locale.ROOT); + for (Page p : pages) + { + if (p.name.toLowerCase(Locale.ROOT).equals(s)) + { + return p; + } + } + for (Page p : pages) + { + if (!p.name.equalsIgnoreCase("All Pets") && p.name.toLowerCase(Locale.ROOT).startsWith(s)) + { + return p; + } + } + return null; + } + + private void loadPages() + { + try + { + List out = new ArrayList<>(); + List tabList = new ArrayList<>(); + Map names = new LinkedHashMap<>(); + for (int t = 0; t < TAB_STRUCTS.length; t++) + { + int tabStart = out.size(); + StructComposition tab = client.getStructComposition(TAB_STRUCTS[t]); + EnumComposition pageEnum = client.getEnum(tab.getIntValue(PARAM_TAB_PAGES)); + for (int pageStructId : pageEnum.getIntVals()) + { + StructComposition page = client.getStructComposition(pageStructId); + EnumComposition items = client.getEnum(page.getIntValue(PARAM_PAGE_ITEMS)); + List ids = new ArrayList<>(); + for (int id : items.getIntVals()) + { + ids.add(id); + names.computeIfAbsent(id, i -> client.getItemDefinition(i).getName()); + } + out.add(new Page(page.getStringValue(PARAM_PAGE_NAME), Collections.unmodifiableList(ids))); + } + tabList.add(new Tab(TAB_NAMES[t], Collections.unmodifiableList(new ArrayList<>(out.subList(tabStart, out.size()))))); + } + itemNames = names; + tabs = Collections.unmodifiableList(tabList); + pages = Collections.unmodifiableList(out); + log.debug("collection log: {} pages, {} items", out.size(), names.size()); + notifyListeners(); + } + catch (Exception e) + { + log.warn("could not read collection log structure", e); + } + } + + /** Reads the collection log inventory, which the server sends while the log is open. */ + private void scanInventory() + { + ItemContainer inv = client.getItemContainer(CLOG_INVENTORY); + if (inv == null || itemNames.isEmpty()) + { + return; + } + Item[] items = inv.getItems(); + int hash = java.util.Arrays.hashCode(items); + if (items.length == 0 || hash == lastInventoryHash) + { + return; + } + lastInventoryHash = hash; + + Set have = new HashSet<>(); + for (Item it : items) + { + if (it.getId() > 0 && it.getQuantity() > 0) + { + have.add(it.getId()); + } + } + log.info("collection log inventory: {} slots, {} obtained items", items.length, have.size()); + EnumComposition remap = client.getEnum(ENUM_ITEM_REMAP); + boolean changed = !synced; + for (int id : itemNames.keySet()) + { + int mapped = remap.getIntValue(id); + // add only: the inventory may hold just the open page, and log items are never lost + if (have.contains(id) || (mapped > 0 && have.contains(mapped))) + { + changed |= obtained.add(id); + } + } + synced = true; + if (changed) + { + saveObtained(); + notifyListeners(); + } + } + + /** Fallback: items drawn fully opaque on the open log page are obtained. */ + private void scanOpenPage() + { + List children = new ArrayList<>(); + for (int component : new int[]{InterfaceID.Collection.ITEMS, InterfaceID.Collection.ITEMS_CONTENTS}) + { + Widget w = client.getWidget(component); + if (w == null || w.isHidden() || w.getDynamicChildren() == null) + { + continue; + } + for (Widget c : w.getDynamicChildren()) + { + if (c != null && c.getItemId() > 0) + { + children.add(c); + } + } + } + if (children.isEmpty()) + { + lastWidgetHash = 0; + return; + } + int hash = 1; + for (Widget c : children) + { + hash = 31 * hash + c.getItemId() * 7 + c.getOpacity(); + } + if (hash == lastWidgetHash) + { + return; + } + lastWidgetHash = hash; + log.info("collection log page widgets: {} items", children.size()); + + boolean changed = !synced; + for (Widget c : children) + { + int id = c.getItemId(); + if (c.getOpacity() != UNOBTAINED_OPACITY) + { + changed |= obtained.add(id); + } + } + synced = true; + if (changed) + { + saveObtained(); + notifyListeners(); + } + } + + private void loadObtained() + { + obtained.clear(); + Long full = configManager.getRSProfileConfiguration(GROUP, KEY_FULL_SYNC, Long.class); + fullSyncAt = full == null ? 0 : full; + String raw = configManager.getRSProfileConfiguration(GROUP, KEY); + synced = raw != null; + if (raw == null || raw.isEmpty()) + { + return; + } + for (String s : raw.split(",")) + { + try + { + obtained.add(Integer.parseInt(s.trim())); + } + catch (NumberFormatException ignored) + { + } + } + } + + private void saveObtained() + { + if (profileKey == null) + { + return; + } + synced = true; + String raw = obtained.stream().sorted().map(String::valueOf).collect(Collectors.joining(",")); + configManager.setRSProfileConfiguration(GROUP, KEY, raw); + } + + private void notifyListeners() + { + for (Runnable r : listeners) + { + r.run(); + } + } +} diff --git a/src/main/java/com/example/essentialsplus/currency/Currency.java b/src/main/java/com/example/essentialsplus/currency/Currency.java new file mode 100644 index 0000000..7e9634f --- /dev/null +++ b/src/main/java/com/example/essentialsplus/currency/Currency.java @@ -0,0 +1,77 @@ +package com.example.essentialsplus.currency; + +import net.runelite.api.gameval.ItemID; +import net.runelite.api.gameval.VarPlayerID; +import net.runelite.api.gameval.VarbitID; + +/** Points, marks and tokens earned from minigames and activities. */ +public enum Currency +{ + PEST_CONTROL("Pest Control points", Source.WIDGET, -1, ItemID.PEST_VOID_KNIGHT_TOP), + SLAYER("Slayer points", Source.VARBIT, VarbitID.SLAYER_POINTS, ItemID.SLAYER_HELM), + NMZ("Nightmare Zone points", Source.VARP, VarPlayerID.NZONE_REWARDPOINTS, ItemID.NZONE4DOSEOVERLOADPOTION), + TITHE_FARM("Tithe Farm points", Source.VARBIT, VarbitID.HOSIDIUS_TITHE_REWARDPOINTS, ItemID.HOSIDIUS_TITHE_FRUIT_A), + TEMPOROSS("Reward permits", Source.VARBIT, VarbitID.TEMPOROSS_REWARDPERMITS, ItemID.SPIRIT_FLAKES), + SOUL_WARS("Zeal", Source.VARP, VarPlayerID.SOUL_WARS_ZEAL, ItemID.SOUL_FRAGMENT), + GIANTS_FOUNDRY("Foundry reputation", Source.VARP, VarPlayerID.GIANTS_FOUNDRY_REWARD_SHOP_POINTS, ItemID.DOUBLE_AMMO_MOULD), + COLOSSEUM("Colosseum glory", Source.VARP, VarPlayerID.COLOSSEUM_GLORY, -1), + MIXOLOGY_MOX("Mox resin", Source.VARP, VarPlayerID.MIXOLOGY_MOX_POINTS, ItemID.MM_MOX_PASTE), + MIXOLOGY_AGA("Aga resin", Source.VARP, VarPlayerID.MIXOLOGY_AGA_POINTS, ItemID.MM_AGA_PASTE), + MIXOLOGY_LYE("Lye resin", Source.VARP, VarPlayerID.MIXOLOGY_LYE_POINTS, ItemID.MM_LYE_PASTE), + COMBAT_ACHIEVEMENTS("Combat achievement points", Source.VARBIT, VarbitID.CA_POINTS, -1), + BOUNTY_HUNTER("Bounty Hunter points", Source.VARP, VarPlayerID.BH_2023_POINTS, ItemID.BH_EMBLEM), + PVP_ARENA("PvP Arena points", Source.VARBIT, VarbitID.PVPA_POINTS_CURRENCY, -1), + + MARKS_OF_GRACE("Marks of grace", Source.ITEM, ItemID.GRACE, ItemID.GRACE), + TOKKUL("Tokkul", Source.ITEM, ItemID.TZHAAR_TOKEN, ItemID.TZHAAR_TOKEN), + GOLDEN_NUGGETS("Golden nuggets", Source.ITEM, ItemID.MOTHERLODE_NUGGET, ItemID.MOTHERLODE_NUGGET), + HALLOWED_MARKS("Hallowed marks", Source.ITEM, ItemID.HALLOWED_MARK, ItemID.HALLOWED_MARK), + ABYSSAL_PEARLS("Abyssal pearls", Source.ITEM, ItemID.ABYSSAL_PEARL, ItemID.ABYSSAL_PEARL), + STARDUST("Stardust", Source.ITEM, ItemID.STAR_DUST, ItemID.STAR_DUST), + MOLCH_PEARLS("Molch pearls", Source.ITEM, ItemID.AERIAL_FISHING_PEARL, ItemID.AERIAL_FISHING_PEARL), + NUMULITE("Numulite", Source.ITEM, ItemID.FOSSIL_NUMULITE, ItemID.FOSSIL_NUMULITE), + SPIRIT_FLAKES("Spirit flakes", Source.ITEM, ItemID.SPIRIT_FLAKES, ItemID.SPIRIT_FLAKES), + ANIMA_BARK("Anima-infused bark", Source.ITEM, ItemID.FORESTRY_CURRENCY, ItemID.FORESTRY_CURRENCY), + CRYSTAL_SHARDS("Crystal shards", Source.ITEM, ItemID.PRIF_CRYSTAL_SHARD, ItemID.PRIF_CRYSTAL_SHARD), + CASTLE_WARS("Castle wars tickets", Source.ITEM, ItemID.CASTLEWARS_TICKET, ItemID.CASTLEWARS_TICKET), + AGILITY_ARENA("Agility arena tickets", Source.ITEM, ItemID.AGILITYARENA_TICKET, ItemID.AGILITYARENA_TICKET), + ECTO_TOKENS("Ecto-tokens", Source.ITEM, ItemID.ECTOTOKEN, ItemID.ECTOTOKEN), + WARRIOR_GUILD("Warrior guild tokens", Source.ITEM, ItemID.WARGUILD_TOKENS, ItemID.WARGUILD_TOKENS), + PIECES_OF_EIGHT("Pieces of eight", Source.ITEM, ItemID.BREW_PIECE_OF_EIGHT, ItemID.BREW_PIECE_OF_EIGHT), + MINNOWS("Minnows", Source.ITEM, ItemID.MINNOW, ItemID.MINNOW); + + public enum Source + { + VARBIT, VARP, ITEM, WIDGET + } + + public final String label; + public final Source source; + /** varbit / varp / item id */ + public final int id; + /** item shown as icon, -1 = letter badge */ + public final int icon; + + Currency(String label, Source source, int id, int icon) + { + this.label = label; + this.source = source; + this.id = id; + this.icon = icon; + } + + public static Currency byName(String name) + { + if (name != null) + { + for (Currency c : values()) + { + if (c.name().equals(name)) + { + return c; + } + } + } + return null; + } +} diff --git a/src/main/java/com/example/essentialsplus/currency/CurrencyHistory.java b/src/main/java/com/example/essentialsplus/currency/CurrencyHistory.java new file mode 100644 index 0000000..a651141 --- /dev/null +++ b/src/main/java/com/example/essentialsplus/currency/CurrencyHistory.java @@ -0,0 +1,170 @@ +package com.example.essentialsplus.currency; + +import com.example.essentialsplus.ui.TimeFilter; +import com.example.essentialsplus.ui.TimeRange; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** Point / token amounts of one character over time. */ +public class CurrencyHistory +{ + /** One observed amount. */ + public static class Sample + { + public long t; + /** amount held */ + public long v; + /** change counted as earned (> 0) or spent (< 0); 0 for a sync (value changed while not watched) */ + public long g; + + public Sample() + { + } + + Sample(long t, long v, long g) + { + this.t = t; + this.v = v; + this.g = g; + } + } + + public String key; + public String name; + /** currency enum name -> samples, oldest first */ + public Map> series = new HashMap<>(); + /** currency with the latest earned change and when that happened */ + public String lastActive; + public long lastActiveAt; + + public transient boolean dirty; + + public CurrencyHistory() + { + } + + public CurrencyHistory(String key, String name) + { + this.key = key; + this.name = name; + } + + void normalize() + { + if (series == null) + { + series = new HashMap<>(); + } + series.values().removeIf(l -> l == null || l.isEmpty()); + } + + public synchronized List samples(Currency c) + { + List s = series.get(c.name()); + return s == null ? new ArrayList<>() : new ArrayList<>(s); + } + + public synchronized boolean has(Currency c) + { + List s = series.get(c.name()); + return s != null && !s.isEmpty(); + } + + /** @return the latest known amount, -1 if never seen */ + public synchronized long amount(Currency c) + { + List s = series.get(c.name()); + return s == null || s.isEmpty() ? -1 : s.get(s.size() - 1).v; + } + + /** Time of the latest earned change, 0 if none. */ + public synchronized long lastGain(Currency c) + { + List s = series.get(c.name()); + if (s != null) + { + for (int i = s.size() - 1; i >= 0; i--) + { + if (s.get(i).g > 0) + { + return s.get(i).t; + } + } + } + return 0; + } + + /** + * Stores an observed amount. + * + * @param sync the change happened while it could not be watched (login, bank open): not counted as earned + * @return the change counted as earned or spent, 0 when nothing was counted + */ + synchronized long record(Currency c, long value, long now, boolean sync) + { + List s = series.computeIfAbsent(c.name(), k -> new ArrayList<>()); + Sample last = s.isEmpty() ? null : s.get(s.size() - 1); + if (last != null && last.v == value) + { + return 0; + } + long delta = last == null || sync ? 0 : value - last.v; + s.add(new Sample(now, value, delta)); + dirty = true; + if (delta > 0) + { + lastActive = c.name(); + lastActiveAt = now; + } + return delta; + } + + /** Earned (positive) and spent (negative) in the range of the filter. */ + public synchronized long[] change(Currency c, TimeFilter filter, long now) + { + List s = series.get(c.name()); + long[] out = new long[2]; + if (s == null || s.isEmpty()) + { + return out; + } + long start = filter.getRange() == TimeRange.CURRENT + ? TimeFilter.streakStart(s.size(), i -> s.get(i).t, i -> s.get(i).g > 0, now) + : filter.start(); + for (int i = s.size() - 1; i >= 0 && s.get(i).t >= start; i--) + { + long g = s.get(i).g; + if (g > 0) + { + out[0] += g; + } + else + { + out[1] += g; + } + } + return out; + } + + /** Earned per bucket over the range, oldest first (for a small bar chart). */ + public synchronized long[] earnedBuckets(Currency c, long start, long end, int buckets) + { + long[] out = new long[buckets]; + List s = series.get(c.name()); + if (s == null || end <= start) + { + return out; + } + for (Sample x : s) + { + if (x.g > 0 && x.t >= start && x.t <= end) + { + int b = (int) Math.min(buckets - 1, (x.t - start) * buckets / (end - start)); + out[b] += x.g; + } + } + return out; + } +} diff --git a/src/main/java/com/example/essentialsplus/currency/CurrencyStore.java b/src/main/java/com/example/essentialsplus/currency/CurrencyStore.java new file mode 100644 index 0000000..385f866 --- /dev/null +++ b/src/main/java/com/example/essentialsplus/currency/CurrencyStore.java @@ -0,0 +1,147 @@ +package com.example.essentialsplus.currency; + +import com.example.essentialsplus.WealthStore; +import com.google.gson.Gson; +import java.io.File; +import java.io.Reader; +import java.io.Writer; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.StandardCopyOption; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import javax.inject.Inject; +import javax.inject.Singleton; +import lombok.extern.slf4j.Slf4j; + +/** Loads and saves {@link CurrencyHistory} files (one per character) in {@code essentials-plus/currencies}. */ +@Slf4j +@Singleton +public class CurrencyStore +{ + public static final File DIR = new File(WealthStore.DIR, "currencies"); + + private static final long HOUR = 3_600_000L; + private static final long DAY = 24 * HOUR; + + private final Gson gson; + private final Map histories = new ConcurrentHashMap<>(); + + @Inject + public CurrencyStore(Gson gson) + { + this.gson = gson.newBuilder().disableHtmlEscaping().create(); + } + + public void load() + { + File[] files = DIR.listFiles((d, n) -> n.endsWith(".json")); + if (files == null) + { + return; + } + for (File f : files) + { + try (Reader reader = Files.newBufferedReader(f.toPath(), StandardCharsets.UTF_8)) + { + CurrencyHistory h = gson.fromJson(reader, CurrencyHistory.class); + if (h != null && h.key != null) + { + h.normalize(); + histories.putIfAbsent(h.key, h); + } + } + catch (Exception e) + { + log.warn("Currencies: could not read {}", f, e); + } + } + } + + public CurrencyHistory get(String key) + { + return key == null ? null : histories.get(key); + } + + CurrencyHistory getOrCreate(String key, String name) + { + CurrencyHistory h = histories.computeIfAbsent(key, k -> new CurrencyHistory(k, name)); + h.name = name; + return h; + } + + public void saveDirty() + { + for (CurrencyHistory h : histories.values()) + { + if (h.dirty) + { + save(h); + } + } + } + + private synchronized void save(CurrencyHistory h) + { + String json; + synchronized (h) + { + long now = System.currentTimeMillis(); + for (Map.Entry> e : h.series.entrySet()) + { + e.setValue(compact(e.getValue(), now)); + } + json = gson.toJson(h); + h.dirty = false; + } + try + { + Files.createDirectories(DIR.toPath()); + File target = new File(DIR, h.key.replaceAll("[^A-Za-z0-9 _()\\-]", "_") + ".json"); + File tmp = new File(DIR, target.getName() + ".tmp"); + try (Writer w = Files.newBufferedWriter(tmp.toPath(), StandardCharsets.UTF_8)) + { + w.write(json); + } + Files.move(tmp.toPath(), target.toPath(), StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); + } + catch (Exception e) + { + h.dirty = true; + log.warn("Currencies: could not save {}", h.key, e); + } + } + + /** + * Samples older than 7 days are merged per hour, older than 90 days per day: the last amount is kept + * and earned / spent are summed separately. The first sample stays as baseline. + */ + static List compact(List in, long now) + { + if (in.size() < 3) + { + return in; + } + List out = new ArrayList<>(in.size()); + out.add(in.get(0)); + for (int i = 1; i < in.size(); i++) + { + CurrencyHistory.Sample s = in.get(i); + long age = now - s.t; + long size = age > 90 * DAY ? DAY : age > 7 * DAY ? HOUR : 0; + CurrencyHistory.Sample prev = out.get(out.size() - 1); + // only merge changes of the same sign, so earned and spent stay apart + if (size > 0 && out.size() > 1 && prev.t / size == s.t / size && Long.signum(prev.g) * Long.signum(s.g) >= 0) + { + out.set(out.size() - 1, new CurrencyHistory.Sample(s.t, s.v, prev.g + s.g)); + } + else + { + out.add(s); + } + } + return out; + } +} diff --git a/src/main/java/com/example/essentialsplus/currency/CurrencyTracker.java b/src/main/java/com/example/essentialsplus/currency/CurrencyTracker.java new file mode 100644 index 0000000..e0a8959 --- /dev/null +++ b/src/main/java/com/example/essentialsplus/currency/CurrencyTracker.java @@ -0,0 +1,269 @@ +package com.example.essentialsplus.currency; + +import java.util.EnumSet; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.function.IntUnaryOperator; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import net.runelite.api.Client; +import net.runelite.api.gameval.InterfaceID; +import net.runelite.api.widgets.Widget; + +/** + * Reads point varbits / varps every tick and token items after each wealth valuation, records the changes and + * works out what the player is doing right now. Client thread only, except the getters. + */ +public class CurrencyTracker +{ + /** a currency stays "what you are doing" this long after it was last earned */ + public static final long ACTIVE_WINDOW = 20 * 60_000L; + /** varps read 0 or stale values right after login: changes until then are synced, not earned */ + private static final int SETTLE_TICKS = 10; + private static final String[] ITEM_CONTAINERS = {"bank", "inventory", "equipment", "lootingBag", "seedVault"}; + private static final Pattern NUMBER = Pattern.compile("(\\d[\\d,]*)"); + private static final Map ITEMS = new HashMap<>(); + + static + { + for (Currency c : Currency.values()) + { + if (c.source == Currency.Source.ITEM) + { + ITEMS.put(c.id, c); + } + } + } + + private final Client client; + private final CurrencyStore store; + private final List listeners = new CopyOnWriteArrayList<>(); + /** currencies read at least once since login; the first reading is a sync */ + private final Set seen = EnumSet.noneOf(Currency.class); + + private volatile String key; + /** Pest Control lander / game overlay is on screen */ + private volatile boolean inPestControl; + + public CurrencyTracker(Client client, CurrencyStore store) + { + this.client = client; + this.store = store; + } + + public void addListener(Runnable r) + { + listeners.add(r); + } + + public void removeListener(Runnable r) + { + listeners.remove(r); + } + + public String getKey() + { + return key; + } + + public CurrencyHistory history() + { + return store.get(key); + } + + /** Logged out: the next login starts with a sync again. */ + public void reset() + { + seen.clear(); + key = null; + inPestControl = false; + notifyListeners(); + } + + public void onGameTick(String accountKey, String name, int ticksSinceLogin) + { + if (accountKey == null || ticksSinceLogin < 3) + { + return; + } + if (!accountKey.equals(key)) + { + seen.clear(); + key = accountKey; + notifyListeners(); + } + CurrencyHistory h = store.getOrCreate(accountKey, name); + long now = System.currentTimeMillis(); + boolean settled = ticksSinceLogin >= SETTLE_TICKS; + boolean changed = false; + for (Currency c : Currency.values()) + { + long value; + switch (c.source) + { + case VARBIT: + value = client.getVarbitValue(c.id); + break; + case VARP: + value = client.getVarpValue(c.id); + break; + default: + continue; + } + changed |= record(h, c, value, now, !settled); + } + + Widget lander = visible(InterfaceID.PestLanderOverlay.PEST_LANDER_OVER_POINTS); + boolean pest = lander != null || visible(InterfaceID.PestStatusOverlay.PEST_STATUS_OVER_TIME) != null; + if (pest != inPestControl) + { + inPestControl = pest; + changed = true; + } + long points = parse(lander); + if (points < 0) + { + points = parse(visible(InterfaceID.PestRewardshop.TOTAL)); + } + if (points >= 0) + { + changed |= record(h, Currency.PEST_CONTROL, points, now, false); + } + if (changed) + { + notifyListeners(); + } + } + + /** + * Counts token items over all known containers of the character. + * + * @param sync items moved in a way that is not earning or spending (bank open, login) + */ + public void onItems(String accountKey, String name, Map> containers, Map bankPending, + boolean sync, IntUnaryOperator canonicalize) + { + if (accountKey == null || containers == null || !accountKey.equals(key)) + { + return; + } + Map totals = new HashMap<>(); + for (String k : ITEM_CONTAINERS) + { + count(containers.get(k), canonicalize, totals); + } + count(bankPending, canonicalize, totals); + CurrencyHistory h = store.getOrCreate(accountKey, name); + long now = System.currentTimeMillis(); + boolean changed = false; + for (Currency c : ITEMS.values()) + { + long value = totals.getOrDefault(c, 0L); + // never seen and none held: nothing to show + if (value == 0 && !h.has(c)) + { + continue; + } + changed |= record(h, c, value, now, sync); + } + if (changed) + { + notifyListeners(); + } + } + + private static void count(Map items, IntUnaryOperator canonicalize, Map totals) + { + if (items == null) + { + return; + } + for (Map.Entry e : items.entrySet()) + { + Currency c = ITEMS.get(e.getKey()); + if (c == null) + { + c = ITEMS.get(canonicalize.applyAsInt(e.getKey())); + } + if (c != null) + { + totals.merge(c, e.getValue(), Long::sum); + } + } + } + + private boolean record(CurrencyHistory h, Currency c, long value, long now, boolean sync) + { + if (value < 0) + { + return false; + } + // a currency never held is not worth a row + if (value == 0 && !h.has(c)) + { + seen.add(c); + return false; + } + boolean first = seen.add(c); + long before = h.amount(c); + h.record(c, value, now, sync || first); + return before != value; + } + + /** + * What the player is doing right now: Pest Control while its overlay is shown, otherwise the currency + * earned last within {@link #ACTIVE_WINDOW}; null when idle. + */ + public Currency active() + { + if (inPestControl) + { + return Currency.PEST_CONTROL; + } + CurrencyHistory h = history(); + if (h == null || h.lastActive == null || System.currentTimeMillis() - h.lastActiveAt > ACTIVE_WINDOW) + { + return null; + } + return Currency.byName(h.lastActive); + } + + private Widget visible(int component) + { + Widget w = client.getWidget(component); + return w == null || w.isHidden() ? null : w; + } + + private static long parse(Widget w) + { + if (w == null || w.getText() == null) + { + return -1; + } + Matcher m = NUMBER.matcher(w.getText().replaceAll("<[^>]*>", "")); + long last = -1; + while (m.find()) + { + try + { + last = Long.parseLong(m.group(1).replace(",", "")); + } + catch (NumberFormatException ignored) + { + // keep the previous number + } + } + return last; + } + + private void notifyListeners() + { + for (Runnable r : listeners) + { + r.run(); + } + } +} diff --git a/src/main/java/com/example/essentialsplus/currency/ui/CurrencyCard.java b/src/main/java/com/example/essentialsplus/currency/ui/CurrencyCard.java new file mode 100644 index 0000000..557a229 --- /dev/null +++ b/src/main/java/com/example/essentialsplus/currency/ui/CurrencyCard.java @@ -0,0 +1,217 @@ +package com.example.essentialsplus.currency.ui; + +import com.example.essentialsplus.currency.Currency; +import com.example.essentialsplus.ui.Ui; +import java.awt.Cursor; +import java.awt.Dimension; +import java.awt.Font; +import java.awt.FontMetrics; +import java.awt.Graphics; +import java.awt.Graphics2D; +import java.awt.event.MouseAdapter; +import java.awt.event.MouseEvent; +import java.text.NumberFormat; +import java.util.Locale; +import javax.swing.JComponent; +import javax.swing.SwingUtilities; +import net.runelite.client.ui.FontManager; +import net.runelite.client.ui.PluginPanel; + +/** Overview entry: the points / marks of what is being played right now, big. */ +public class CurrencyCard extends JComponent +{ + private static final int ICON = 36; + + private final CurrencyIcons icons; + private Currency currency; + private long amount; + private long earned; + private String rangeLabel = ""; + private boolean now; + private long lastAt; + private int tracked; + private boolean hover; + private boolean showTracked = true; + private boolean pinned; + + CurrencyCard(CurrencyIcons icons, Runnable onClick) + { + this.icons = icons; + setCursor(Cursor.getPredefinedCursor(Cursor.HAND_CURSOR)); + addMouseListener(new MouseAdapter() + { + @Override + public void mouseEntered(MouseEvent e) + { + hover = true; + repaint(); + } + + @Override + public void mouseExited(MouseEvent e) + { + hover = false; + repaint(); + } + + @Override + public void mousePressed(MouseEvent e) + { + if (SwingUtilities.isLeftMouseButton(e)) + { + onClick.run(); + } + } + }); + } + + /** + * @param currency shown currency, null when nothing is tracked yet + * @param now it is being played right now (otherwise it is the one played last) + */ + void update(Currency currency, long amount, long earned, String rangeLabel, boolean now, long lastAt, int tracked) + { + this.currency = currency; + this.amount = amount; + this.earned = earned; + this.rangeLabel = rangeLabel; + this.now = now; + this.lastAt = lastAt; + this.tracked = tracked; + repaint(); + } + + void setOptions(boolean showTracked, boolean pinned) + { + this.showTracked = showTracked; + this.pinned = pinned; + repaint(); + } + + static String number(long v) + { + return v >= 10_000_000 ? Ui.gp(v) : NumberFormat.getIntegerInstance(Locale.US).format(v); + } + + static String ago(long t) + { + long d = System.currentTimeMillis() - t; + if (t <= 0) + { + return "never"; + } + if (d < Ui.MINUTE) + { + return "just now"; + } + if (d < Ui.HOUR) + { + return d / Ui.MINUTE + "m ago"; + } + if (d < Ui.DAY) + { + return d / Ui.HOUR + "h ago"; + } + return d / Ui.DAY + "d ago"; + } + + @Override + public Dimension getPreferredSize() + { + return new Dimension(PluginPanel.PANEL_WIDTH - 16, 104); + } + + @Override + public Dimension getMinimumSize() + { + return new Dimension(10, 96); + } + + @Override + protected void paintComponent(Graphics g0) + { + Graphics2D g = Ui.prepare(g0); + int w = getWidth(); + int h = getHeight(); + g.setColor(hover ? Ui.CARD_HOVER : Ui.CARD); + g.fillRoundRect(0, 0, w, h, 8, 8); + + int pad = 10; + Font bold = FontManager.getRunescapeBoldFont(); + Font small = FontManager.getRunescapeSmallFont(); + + g.setFont(bold); + FontMetrics fm = g.getFontMetrics(); + int head = 6 + fm.getAscent(); + g.setColor(Ui.ACCENT); + String title = "Points & Marks"; + g.drawString(title, pad, head); + g.setFont(bold.deriveFont(14f)); + g.setColor(hover ? Ui.ACCENT : Ui.FAINT); + Ui.drawRight(g, "›", w - pad, head); + + g.setFont(small); + FontMetrics sfm = g.getFontMetrics(); + int subRight = w - pad - 14; + if (currency != null) + { + String sub = now ? "● Playing now" : pinned ? "Pinned" : "Last: " + ago(lastAt); + g.setColor(now ? Ui.UP : Ui.MUTED); + if (subRight - sfm.stringWidth(sub) > pad + fm.stringWidth(title) + 8) + { + Ui.drawRight(g, sub, subRight, head); + } + } + + if (currency == null) + { + g.setFont(FontManager.getRunescapeFont()); + g.setColor(Ui.TEXT); + g.drawString("Nothing tracked yet", pad, head + 22); + g.setFont(small); + g.setColor(Ui.FAINT); + g.drawString("Play a minigame or collect marks,", pad, head + 40); + g.drawString("tokens or points to start", pad, head + 54); + g.dispose(); + return; + } + + int top = head + 8; + icons.paint(g, currency, pad - 2, top + 4, ICON, this); + int tx = pad + ICON + 8; + + g.setFont(bold.deriveFont(26f)); + FontMetrics bfm = g.getFontMetrics(); + int bigBase = top + bfm.getAscent() - 2; + g.setColor(Ui.GOLD); + g.drawString(number(amount), tx, bigBase); + + g.setFont(small); + int labelBase = bigBase + sfm.getAscent() + 3; + g.setColor(Ui.TEXT); + g.drawString(currency.label, tx, labelBase); + + int earnBase = labelBase + sfm.getAscent() + 6; + String gain = "+" + number(earned); + g.setFont(bold); + FontMetrics gfm = g.getFontMetrics(); + g.setColor(earned > 0 ? Ui.UP : Ui.FAINT); + g.drawString(gain, tx, earnBase); + g.setFont(small); + g.setColor(Ui.FAINT); + int afterGain = tx + gfm.stringWidth(gain); + String earnedText = Ui.ellipsize(sfm, " earned " + rangeLabel, w - pad - afterGain); + g.drawString(earnedText, afterGain, earnBase); + if (showTracked && tracked > 1) + { + // in the label line, right of the name - never over the earned text + String t = tracked + " tracked"; + if (w - pad - sfm.stringWidth(t) > tx + sfm.stringWidth(currency.label) + 8) + { + Ui.drawRight(g, t, w - pad, labelBase); + } + } + Ui.paintDragOutline(g, this); + g.dispose(); + } +} diff --git a/src/main/java/com/example/essentialsplus/currency/ui/CurrencyIcons.java b/src/main/java/com/example/essentialsplus/currency/ui/CurrencyIcons.java new file mode 100644 index 0000000..7e3d9a5 --- /dev/null +++ b/src/main/java/com/example/essentialsplus/currency/ui/CurrencyIcons.java @@ -0,0 +1,57 @@ +package com.example.essentialsplus.currency.ui; + +import com.example.essentialsplus.currency.Currency; +import com.example.essentialsplus.ui.Ui; +import java.awt.Component; +import java.awt.Font; +import java.awt.FontMetrics; +import java.awt.Graphics2D; +import java.util.EnumMap; +import java.util.Map; +import net.runelite.client.game.ItemManager; +import net.runelite.client.ui.FontManager; +import net.runelite.client.util.AsyncBufferedImage; + +/** Item icons of the currencies, with a letter badge for those without one (Swing thread only). */ +final class CurrencyIcons +{ + private final ItemManager itemManager; + private final Map icons = new EnumMap<>(Currency.class); + + CurrencyIcons(ItemManager itemManager) + { + this.itemManager = itemManager; + } + + void paint(Graphics2D g, Currency c, int x, int y, int size, Component repaintTarget) + { + if (c.icon > 0) + { + AsyncBufferedImage img = icons.get(c); + if (img == null) + { + img = itemManager.getImage(c.icon); + icons.put(c, img); + img.onLoaded(repaintTarget::repaint); + } + int w = img.getWidth(); + int h = img.getHeight(); + if (w > 0 && h > 0) + { + double scale = Math.min(1.0, Math.min((double) size / w, (double) size / h)); + int dw = (int) Math.round(w * scale); + int dh = (int) Math.round(h * scale); + g.drawImage(img, x + (size - dw) / 2, y + (size - dh) / 2, dw, dh, null); + } + return; + } + g.setColor(Ui.alpha(Ui.ACCENT, 50)); + g.fillOval(x + 2, y + 2, size - 4, size - 4); + Font f = FontManager.getRunescapeBoldFont(); + g.setFont(f); + FontMetrics fm = g.getFontMetrics(); + String s = c.label.substring(0, 1); + g.setColor(Ui.ACCENT); + g.drawString(s, x + (size - fm.stringWidth(s)) / 2, y + (size + fm.getAscent()) / 2 - 2); + } +} diff --git a/src/main/java/com/example/essentialsplus/currency/ui/CurrencyPanel.java b/src/main/java/com/example/essentialsplus/currency/ui/CurrencyPanel.java new file mode 100644 index 0000000..bde16e9 --- /dev/null +++ b/src/main/java/com/example/essentialsplus/currency/ui/CurrencyPanel.java @@ -0,0 +1,353 @@ +package com.example.essentialsplus.currency.ui; + +import com.example.essentialsplus.currency.Currency; +import com.example.essentialsplus.currency.CurrencyHistory; +import com.example.essentialsplus.currency.CurrencyTracker; +import com.example.essentialsplus.ui.TimeFilter; +import com.example.essentialsplus.ui.Ui; +import java.awt.BorderLayout; +import java.awt.Cursor; +import java.awt.Dimension; +import java.awt.Font; +import java.awt.FontMetrics; +import java.awt.Graphics; +import java.awt.Graphics2D; +import java.util.ArrayList; +import java.util.List; +import javax.swing.BorderFactory; +import javax.swing.JButton; +import javax.swing.JComponent; +import javax.swing.JLabel; +import javax.swing.JPanel; +import javax.swing.JScrollPane; +import javax.swing.ScrollPaneConstants; +import javax.swing.SwingUtilities; +import net.runelite.client.game.ItemManager; +import net.runelite.client.ui.DynamicGridLayout; +import net.runelite.client.ui.FontManager; +import net.runelite.client.ui.PluginPanel; + +/** Points & Marks tab: every tracked minigame point, mark and token with the amount earned in the range. */ +public class CurrencyPanel extends JPanel +{ + private final CurrencyTracker tracker; + private final TimeFilter filter; + private final CurrencyIcons icons; + private final JPanel list = new JPanel(new DynamicGridLayout(0, 1, 0, 4)); + private final CurrencyCard hero; + private CurrencyCard overviewCard; + /** currency always shown on the overview card, null = what is being played */ + private Currency overviewPin; + private boolean overviewShowTracked = true; + + public CurrencyPanel(CurrencyTracker tracker, ItemManager itemManager, TimeFilter filter, Runnable onBack) + { + this.tracker = tracker; + this.filter = filter; + this.icons = new CurrencyIcons(itemManager); + this.hero = new CurrencyCard(icons, () -> { }); + hero.setCursor(Cursor.getDefaultCursor()); + + setLayout(new BorderLayout(0, 6)); + setBackground(Ui.BG); + setBorder(BorderFactory.createEmptyBorder(10, 8, 8, 8)); + + JPanel backRow = new JPanel(new BorderLayout(6, 0)); + backRow.setOpaque(false); + JButton backBtn = new JButton("← Overview"); + backBtn.setFont(FontManager.getRunescapeSmallFont()); + backBtn.setForeground(Ui.ACCENT); + backBtn.setFocusPainted(false); + backBtn.setMargin(new java.awt.Insets(2, 2, 2, 2)); + backBtn.setCursor(Cursor.getPredefinedCursor(Cursor.HAND_CURSOR)); + backBtn.addActionListener(e -> onBack.run()); + backRow.add(backBtn, BorderLayout.WEST); + JLabel title = new JLabel("Points & Marks"); + title.setFont(FontManager.getRunescapeBoldFont()); + title.setForeground(Ui.ACCENT); + title.setHorizontalAlignment(JLabel.RIGHT); + backRow.add(title, BorderLayout.CENTER); + add(backRow, BorderLayout.NORTH); + + list.setOpaque(false); + JPanel listWrap = new JPanel(new BorderLayout()); + listWrap.setOpaque(false); + listWrap.add(list, BorderLayout.NORTH); + JScrollPane scroll = new JScrollPane(listWrap); + scroll.setBorder(null); + scroll.setOpaque(false); + scroll.getViewport().setOpaque(false); + scroll.setHorizontalScrollBarPolicy(ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER); + scroll.getVerticalScrollBar().setUnitIncrement(16); + scroll.getVerticalScrollBar().setPreferredSize(new Dimension(8, 0)); + add(scroll, BorderLayout.CENTER); + refresh(); + } + + public void setOverviewOptions(Currency pin, boolean showTracked) + { + overviewPin = pin; + overviewShowTracked = showTracked; + refresh(); + } + + /** Currencies with data for the logged-in character, in enum order. */ + public List tracked() + { + List out = new ArrayList<>(); + CurrencyHistory h = tracker.history(); + if (h != null) + { + for (Currency c : Currency.values()) + { + if (h.has(c)) + { + out.add(c); + } + } + } + return out; + } + + /** Card for the overview, kept up to date by {@link #refresh}. */ + public CurrencyCard createOverviewCard(Runnable onClick) + { + overviewCard = new CurrencyCard(icons, onClick); + refresh(); + return overviewCard; + } + + /** Swing thread. */ + public void refresh() + { + CurrencyHistory h = tracker.history(); + long now = System.currentTimeMillis(); + String range = filter.inLabel(); + + List shown = new ArrayList<>(); + if (h != null) + { + for (Currency c : Currency.values()) + { + if (h.has(c)) + { + shown.add(c); + } + } + } + Currency active = tracker.active(); + Currency featured = active; + if (featured == null && h != null) + { + featured = Currency.byName(h.lastActive); + if (featured == null) + { + long best = -1; + for (Currency c : shown) + { + List s = h.samples(c); + long t = s.get(s.size() - 1).t; + if (t > best) + { + best = t; + featured = c; + } + } + } + } + for (CurrencyCard card : new CurrencyCard[]{hero, overviewCard}) + { + if (card == null) + { + continue; + } + Currency c = featured; + boolean pinned = card == overviewCard && overviewPin != null && h != null && h.has(overviewPin); + if (pinned) + { + c = overviewPin; + } + card.setOptions(card != overviewCard || overviewShowTracked, pinned && c != active); + if (c == null || h == null) + { + card.update(null, 0, 0, range, false, 0, 0); + } + else + { + card.update(c, Math.max(0, h.amount(c)), h.change(c, filter, now)[0], range, + c == active, h.lastGain(c), shown.size()); + } + } + + list.removeAll(); + list.add(hero); + if (h == null || shown.isEmpty()) + { + list.add(hint(h == null + ? "Log in to start tracking." + : "Nothing tracked yet. Points show up once they change or are read in-game.")); + } + else + { + // earned in the range first, then most recently earned + shown.sort((a, b) -> + { + int byActive = Boolean.compare(b == active, a == active); + if (byActive != 0) + { + return byActive; + } + return Long.compare(h.lastGain(b), h.lastGain(a)); + }); + addSection("POINTS", shown, h, active, now, false); + addSection("MARKS & TOKENS", shown, h, active, now, true); + } + list.add(hint("Pest Control points are read while the lander or reward shop is open. " + + "Items count bank, inventory, gear, looting bag and seed vault.")); + list.revalidate(); + list.repaint(); + } + + private void addSection(String name, List shown, CurrencyHistory h, Currency active, long now, boolean items) + { + boolean header = false; + for (Currency c : shown) + { + if ((c.source == Currency.Source.ITEM) != items) + { + continue; + } + if (!header) + { + list.add(header(name)); + header = true; + } + long[] change = h.change(c, filter, now); + list.add(new Row(c, h.amount(c), change[0], change[1], h.lastGain(c), c == active)); + } + } + + private static JLabel header(String text) + { + JLabel l = new JLabel(text); + l.setFont(FontManager.getRunescapeSmallFont()); + l.setForeground(Ui.MUTED); + l.setBorder(BorderFactory.createEmptyBorder(6, 2, 0, 2)); + return l; + } + + private static JLabel hint(String text) + { + JLabel l = new JLabel("" + text + ""); + l.setFont(FontManager.getRunescapeSmallFont()); + l.setForeground(Ui.FAINT); + l.setBorder(BorderFactory.createEmptyBorder(6, 4, 6, 4)); + return l; + } + + /** One currency: icon, name, amount, earned / spent in the range. */ + private final class Row extends JComponent + { + private static final int ICON = 28; + private final Currency currency; + private final long amount; + private final long earned; + private final long spent; + private final long lastGain; + private final boolean active; + + Row(Currency currency, long amount, long earned, long spent, long lastGain, boolean active) + { + this.currency = currency; + this.amount = amount; + this.earned = earned; + this.spent = spent; + this.lastGain = lastGain; + this.active = active; + setToolTipText(currency.label + ": " + CurrencyCard.number(amount) + + (lastGain > 0 ? " - last earned " + CurrencyCard.ago(lastGain) : "")); + } + + @Override + public Dimension getPreferredSize() + { + return new Dimension(PluginPanel.PANEL_WIDTH - 24, 44); + } + + @Override + protected void paintComponent(Graphics g0) + { + Graphics2D g = Ui.prepare(g0); + int w = getWidth(); + int h = getHeight(); + g.setColor(active ? Ui.CARD_SELECTED : Ui.CARD); + g.fillRoundRect(0, 0, w, h, 6, 6); + if (active) + { + g.setColor(Ui.UP); + g.fillRoundRect(0, 6, 3, h - 12, 3, 3); + } + int pad = 8; + icons.paint(g, currency, pad - 2, (h - ICON) / 2, ICON, this); + int tx = pad + ICON + 6; + + Font bold = FontManager.getRunescapeBoldFont(); + Font small = FontManager.getRunescapeSmallFont(); + g.setFont(bold); + FontMetrics bfm = g.getFontMetrics(); + String value = CurrencyCard.number(Math.max(0, amount)); + int line1 = 5 + bfm.getAscent(); + g.setColor(Ui.GOLD); + Ui.drawRight(g, value, w - pad, line1); + + g.setFont(FontManager.getRunescapeFont()); + FontMetrics nfm = g.getFontMetrics(); + g.setColor(Ui.TEXT); + int nameMax = w - pad - bfm.stringWidth(value) - 8 - tx; + g.drawString(clip(nfm, currency.label, nameMax), tx, line1); + + g.setFont(small); + FontMetrics sfm = g.getFontMetrics(); + int line2 = line1 + sfm.getAscent() + 6; + String plus = "+" + CurrencyCard.number(earned); + g.setColor(earned > 0 ? Ui.UP : Ui.FAINT); + g.drawString(plus, tx, line2); + int x = tx + sfm.stringWidth(plus); + if (spent < 0) + { + String minus = " -" + CurrencyCard.number(-spent); + g.setColor(Ui.DOWN); + g.drawString(minus, x, line2); + x += sfm.stringWidth(minus); + } + g.setColor(Ui.FAINT); + g.drawString(" " + filter.getLabel(), x, line2); + g.setColor(active ? Ui.UP : Ui.FAINT); + Ui.drawRight(g, active ? "now" : lastGain > 0 ? CurrencyCard.ago(lastGain) : "", w - pad, line2); + g.dispose(); + } + } + + private static String clip(FontMetrics fm, String s, int max) + { + if (fm.stringWidth(s) <= max) + { + return s; + } + for (int i = s.length() - 1; i > 0; i--) + { + String t = s.substring(0, i) + ".."; + if (fm.stringWidth(t) <= max) + { + return t; + } + } + return ""; + } + + /** Coalesced refresh from any thread. */ + public void refreshLater() + { + SwingUtilities.invokeLater(this::refresh); + } +} diff --git a/src/main/java/com/example/essentialsplus/data/AccountData.java b/src/main/java/com/example/essentialsplus/data/AccountData.java new file mode 100644 index 0000000..6592052 --- /dev/null +++ b/src/main/java/com/example/essentialsplus/data/AccountData.java @@ -0,0 +1,462 @@ +package com.example.essentialsplus.data; + +import com.example.essentialsplus.WealthCategory; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CopyOnWriteArrayList; + +/** + * Persisted state of one character. Collections are replaced wholesale (never mutated in place, + * except history which is copy-on-write) so the UI and the save thread can read them safely. + */ +public class AccountData +{ + public int schema = 3; + public String key; + public long accountHash; + public String profile; + public String name; + public long lastUpdated; + public boolean bankKnown; + public long[] current; + /** running sum of value changes caused only by GE price movements (held quantity x price change) */ + public long market; + + /** Last known item stacks per source ("bank", "inventory", ...): item id -> quantity. */ + public Map> containers = new ConcurrentHashMap<>(); + public List sources = new ArrayList<>(); + public List topItems = new ArrayList<>(); + public List history = new CopyOnWriteArrayList<>(); + + /** Grand Exchange slot state (see GrandExchangeTracker) */ + public GeSlot[] geSlots; + /** Items known to be collected already when GE tracking starts for this character (item id -> qty). */ + public Map geSeedCollected; + /** Items that went to the bank while it was not visible; dropped when the bank is seen again. */ + public Map bankPending = new java.util.HashMap<>(); + /** 4-hour Grand Exchange buy limit tracker: canonical item id -> GeBuyLimit */ + public Map geBuyLimits = new ConcurrentHashMap<>(); + /** Snapshot of Grand Exchange slots for 4h buy limit tracking, modeled after LimitKeeper */ + public GeBuyLimitSlot[] geBuyLimitSlots = new GeBuyLimitSlot[8]; + + /** Timestamp of last right-click Reset Hourly in Wealth Tracker for 'Now' */ + public long resetHourlyNowAt; + /** Timestamp of last right-click Reset Hourly in Wealth Tracker for 'Session' */ + public long resetHourlySessionAt; + + public static class GeBuyLimitSlot + { + public int itemId; + public int price; + public int total; + public int quantity; + public int spent; + + public GeBuyLimitSlot() + { + } + + public GeBuyLimitSlot(int itemId, int price, int total, int quantity, int spent) + { + this.itemId = itemId; + this.price = price; + this.total = total; + this.quantity = quantity; + this.spent = spent; + } + + public boolean isSameOffer(GeBuyLimitSlot other) + { + return other != null && other.itemId == itemId && other.price == price && other.total == total; + } + } + + public transient long fileModified; + + public long currentTotal() + { + long sum = 0; + if (current != null) + { + for (long x : current) + { + sum += x; + } + } + return sum; + } + + public Snapshot lastSnapshot() + { + List h = history; + return h.isEmpty() ? null : h.get(h.size() - 1); + } + + public void normalize() + { + containers = containers == null ? new ConcurrentHashMap<>() : new ConcurrentHashMap<>(containers); + sources = sources == null ? new ArrayList<>() : sources; + topItems = topItems == null ? new ArrayList<>() : topItems; + bankPending = bankPending == null ? new java.util.HashMap<>() : bankPending; + geBuyLimits = geBuyLimits == null ? new ConcurrentHashMap<>() : new ConcurrentHashMap<>(geBuyLimits); + if (geBuyLimitSlots == null || geBuyLimitSlots.length != 8) + { + geBuyLimitSlots = geBuyLimitSlots == null ? new GeBuyLimitSlot[8] : java.util.Arrays.copyOf(geBuyLimitSlots, 8); + } + if (schema < 3) + { + clearBuyLimits(); + schema = 3; + } + List h = history == null ? new ArrayList<>() : history; + h.removeIf(s -> s == null); + h.forEach(Snapshot::normalize); + h.sort((a, b) -> Long.compare(a.t, b.t)); + h = sanitizeHistory(h); + history = new CopyOnWriteArrayList<>(h); + if (current != null && current.length < WealthCategory.COUNT) + { + current = java.util.Arrays.copyOf(current, WealthCategory.COUNT); + } + } + + public static final long MAX_SPIKE_WINDOW_MS = 5 * 60 * 1000L; + public static final long MIN_SPIKE_ABS_GP = 1_000_000L; + public static final double MIN_SPIKE_REL_RATIO = 0.08; + + /** + * Checks if a single snapshot at (tSpike, vSpike) represents a transient outlier spike between + * (tBefore, vBefore) and (tAfter, vAfter) that jumped away and quickly returned to baseline. + */ + public static boolean isTransientSpike(long tBefore, long vBefore, + long tSpike, long vSpike, + long tAfter, long vAfter) + { + long legBefore = tSpike - tBefore; + long legAfter = tAfter - tSpike; + if (legBefore <= 0 || legAfter <= 0) + { + return false; + } + + // The spike itself must be transient: + // Either the entire span is within MAX_SPIKE_WINDOW_MS (continuous session), + // OR across a session/logout gap (where one leg is long), the spike only persisted for + // a brief moment (Math.min(legBefore, legAfter) <= MAX_SPIKE_WINDOW_MS). + boolean transientDuration = (tAfter - tBefore <= MAX_SPIKE_WINDOW_MS) + || (Math.min(legBefore, legAfter) <= MAX_SPIKE_WINDOW_MS); + if (!transientDuration) + { + return false; + } + + long d1 = vSpike - vBefore; + long d2 = vAfter - vSpike; + + // Must be opposite directions (a jump away and a return) + if ((d1 > 0 && d2 >= 0) || (d1 < 0 && d2 <= 0) || d1 == 0 || d2 == 0) + { + return false; + } + + long absD1 = Math.abs(d1); + long absD2 = Math.abs(d2); + long spikeMag = Math.min(absD1, absD2); + long baseDelta = Math.abs(vAfter - vBefore); + + long baseWealth = Math.max(10_000L, Math.min(Math.abs(vBefore), Math.abs(vAfter))); + + // Magnitude check: must be at least 1M GP or at least 8% of baseline wealth + boolean significantSpike = spikeMag >= MIN_SPIKE_ABS_GP || (double) spikeMag / baseWealth >= MIN_SPIKE_REL_RATIO; + if (!significantSpike) + { + return false; + } + + // Baseline consistency check: after the spike, the value returned close to the starting baseline. + return baseDelta <= 0.35 * spikeMag + || baseDelta <= (long) (0.05 * Math.max(Math.abs(vBefore), Math.abs(vAfter)) + 500_000L); + } + + /** + * Checks if two consecutive snapshots represent a 2-point transient outlier spike between + * (tBefore, vBefore) and (tAfter, vAfter). + */ + public static boolean isDoubleTransientSpike(long tBefore, long vBefore, + long tSpike1, long vSpike1, + long tSpike2, long vSpike2, + long tAfter, long vAfter) + { + long legBefore = tSpike1 - tBefore; + long legGlitch = tSpike2 - tSpike1; + long legAfter = tAfter - tSpike2; + if (legBefore <= 0 || legGlitch <= 0 || legAfter <= 0) + { + return false; + } + + if (legGlitch > MAX_SPIKE_WINDOW_MS) + { + return false; + } + + boolean transientDuration = (tAfter - tBefore <= MAX_SPIKE_WINDOW_MS) + || (Math.min(legBefore, legAfter) + legGlitch <= MAX_SPIKE_WINDOW_MS); + if (!transientDuration) + { + return false; + } + + long d1_1 = vSpike1 - vBefore; + long d1_2 = vSpike2 - vBefore; + long d2_1 = vAfter - vSpike1; + long d2_2 = vAfter - vSpike2; + + // Both points must deviate in the same direction from baseline and return + boolean bothPositive = d1_1 > 0 && d1_2 > 0 && d2_1 < 0 && d2_2 < 0; + boolean bothNegative = d1_1 < 0 && d1_2 < 0 && d2_1 > 0 && d2_2 > 0; + if (!bothPositive && !bothNegative) + { + return false; + } + + long spikeMag = Math.min( + Math.min(Math.abs(d1_1), Math.abs(d1_2)), + Math.min(Math.abs(d2_1), Math.abs(d2_2)) + ); + long baseDelta = Math.abs(vAfter - vBefore); + long baseWealth = Math.max(10_000L, Math.min(Math.abs(vBefore), Math.abs(vAfter))); + + boolean significantSpike = spikeMag >= MIN_SPIKE_ABS_GP || (double) spikeMag / baseWealth >= MIN_SPIKE_REL_RATIO; + if (!significantSpike) + { + return false; + } + + return baseDelta <= 0.35 * spikeMag + || baseDelta <= (long) (0.05 * Math.max(Math.abs(vBefore), Math.abs(vAfter)) + 500_000L); + } + + /** + * Checks if three consecutive snapshots represent a 3-point transient outlier spike between + * (tBefore, vBefore) and (tAfter, vAfter). + */ + public static boolean isTripleTransientSpike(long tBefore, long vBefore, + long tSpike1, long vSpike1, + long tSpike2, long vSpike2, + long tSpike3, long vSpike3, + long tAfter, long vAfter) + { + long legBefore = tSpike1 - tBefore; + long legGlitch1 = tSpike2 - tSpike1; + long legGlitch2 = tSpike3 - tSpike2; + long totalGlitch = tSpike3 - tSpike1; + long legAfter = tAfter - tSpike3; + if (legBefore <= 0 || legGlitch1 <= 0 || legGlitch2 <= 0 || legAfter <= 0) + { + return false; + } + + if (totalGlitch > MAX_SPIKE_WINDOW_MS) + { + return false; + } + + boolean transientDuration = (tAfter - tBefore <= MAX_SPIKE_WINDOW_MS) + || (Math.min(legBefore, legAfter) + totalGlitch <= MAX_SPIKE_WINDOW_MS); + if (!transientDuration) + { + return false; + } + + long d1_1 = vSpike1 - vBefore; + long d1_2 = vSpike2 - vBefore; + long d1_3 = vSpike3 - vBefore; + long d2_1 = vAfter - vSpike1; + long d2_2 = vAfter - vSpike2; + long d2_3 = vAfter - vSpike3; + + boolean allPositive = d1_1 > 0 && d1_2 > 0 && d1_3 > 0 && d2_1 < 0 && d2_2 < 0 && d2_3 < 0; + boolean allNegative = d1_1 < 0 && d1_2 < 0 && d1_3 < 0 && d2_1 > 0 && d2_2 > 0 && d2_3 > 0; + if (!allPositive && !allNegative) + { + return false; + } + + long spikeMag = Math.min( + Math.min(Math.abs(d1_1), Math.min(Math.abs(d1_2), Math.abs(d1_3))), + Math.min(Math.abs(d2_1), Math.min(Math.abs(d2_2), Math.abs(d2_3))) + ); + long baseDelta = Math.abs(vAfter - vBefore); + long baseWealth = Math.max(10_000L, Math.min(Math.abs(vBefore), Math.abs(vAfter))); + + boolean significantSpike = spikeMag >= MIN_SPIKE_ABS_GP || (double) spikeMag / baseWealth >= MIN_SPIKE_REL_RATIO; + if (!significantSpike) + { + return false; + } + + return baseDelta <= 0.35 * spikeMag + || baseDelta <= (long) (0.05 * Math.max(Math.abs(vBefore), Math.abs(vAfter)) + 500_000L); + } + + /** + * Filters out momentary outlier spikes from the timeline (e.g. 1-minute spikes caused by + * transient container reloading, temporary GE duplication, or pricing glitches). + */ + public static List sanitizeHistory(List snapshots) + { + if (snapshots == null || snapshots.size() < 3) + { + return snapshots == null ? new ArrayList<>() : new ArrayList<>(snapshots); + } + + List list = new ArrayList<>(snapshots); + boolean changed = true; + int passes = 0; + while (changed && passes < 5) + { + changed = false; + passes++; + + // Pass 1: check single-point spikes + for (int i = 1; i < list.size() - 1; i++) + { + Snapshot before = list.get(i - 1); + Snapshot cur = list.get(i); + Snapshot after = list.get(i + 1); + + if (isTransientSpike(before.t, before.total(), cur.t, cur.total(), after.t, after.total())) + { + list.remove(i); + changed = true; + i--; + } + } + + // Pass 2: check two-point spikes + for (int i = 1; i < list.size() - 2; i++) + { + Snapshot before = list.get(i - 1); + Snapshot s1 = list.get(i); + Snapshot s2 = list.get(i + 1); + Snapshot after = list.get(i + 2); + + if (isDoubleTransientSpike(before.t, before.total(), s1.t, s1.total(), s2.t, s2.total(), after.t, after.total())) + { + list.remove(i + 1); + list.remove(i); + changed = true; + i--; + } + } + + // Pass 3: check three-point spikes + for (int i = 1; i < list.size() - 3; i++) + { + Snapshot before = list.get(i - 1); + Snapshot s1 = list.get(i); + Snapshot s2 = list.get(i + 1); + Snapshot s3 = list.get(i + 2); + Snapshot after = list.get(i + 3); + + if (isTripleTransientSpike(before.t, before.total(), s1.t, s1.total(), s2.t, s2.total(), s3.t, s3.total(), after.t, after.total())) + { + list.remove(i + 2); + list.remove(i + 1); + list.remove(i); + changed = true; + i--; + } + } + } + return list; + } + + public static final long FOUR_HOURS_MS = 4 * 3600 * 1000L; + + /** + * Records a purchase of an item, managing the 4-hour window rollover. + */ + public void recordBuy(int itemId, int quantity, long now) + { + if (quantity <= 0 || itemId <= 0) + { + return; + } + if (geBuyLimits == null) + { + geBuyLimits = new ConcurrentHashMap<>(); + } + GeBuyLimit limit = geBuyLimits.computeIfAbsent(itemId, id -> new GeBuyLimit(id, 0, 0, 0)); + synchronized (limit) + { + if (limit.windowStart == 0 || now - limit.windowStart >= FOUR_HOURS_MS) + { + limit.windowStart = now; + limit.bought = quantity; + } + else + { + limit.bought += quantity; + } + limit.lastTradeTime = now; + } + } + + /** + * Computes the current 4-hour buy limit status for an item. + */ + public GeLimitInfo getBuyLimitInfo(int itemId, int itemGeLimit, long now) + { + GeBuyLimit limit = geBuyLimits != null ? geBuyLimits.get(itemId) : null; + if (limit == null || limit.windowStart == 0) + { + return new GeLimitInfo(itemId, itemGeLimit, 0, itemGeLimit, 0, false); + } + synchronized (limit) + { + long elapsed = now - limit.windowStart; + if (elapsed >= FOUR_HOURS_MS) + { + // Expired: full limit restored + return new GeLimitInfo(itemId, itemGeLimit, 0, itemGeLimit, 0, false); + } + else + { + int bought = limit.bought; + int remaining = Math.max(0, itemGeLimit > 0 ? itemGeLimit - bought : 0); + long resetInMs = FOUR_HOURS_MS - elapsed; + return new GeLimitInfo(itemId, itemGeLimit, bought, remaining, resetInMs, true); + } + } + } + + /** + * Resets all tracked 4-hour buy limits and slot tracking for this character. + */ + public void clearBuyLimits() + { + if (geBuyLimits != null) + { + geBuyLimits.clear(); + } + geBuyLimitSlots = new GeBuyLimitSlot[8]; + } + + public void resetHourly(boolean isSession) + { + long now = System.currentTimeMillis(); + if (isSession) + { + resetHourlySessionAt = now; + } + else + { + resetHourlyNowAt = now; + } + } +} + diff --git a/src/main/java/com/example/essentialsplus/data/GeBuyLimit.java b/src/main/java/com/example/essentialsplus/data/GeBuyLimit.java new file mode 100644 index 0000000..21c35e0 --- /dev/null +++ b/src/main/java/com/example/essentialsplus/data/GeBuyLimit.java @@ -0,0 +1,24 @@ +package com.example.essentialsplus.data; + +/** + * Tracks purchases of a specific item within the rolling 4-hour Grand Exchange buy limit window. + */ +public class GeBuyLimit +{ + public int itemId; + public int bought; + public long windowStart; + public long lastTradeTime; + + public GeBuyLimit() + { + } + + public GeBuyLimit(int itemId, int bought, long windowStart, long lastTradeTime) + { + this.itemId = itemId; + this.bought = bought; + this.windowStart = windowStart; + this.lastTradeTime = lastTradeTime; + } +} diff --git a/src/main/java/com/example/essentialsplus/data/GeLimitInfo.java b/src/main/java/com/example/essentialsplus/data/GeLimitInfo.java new file mode 100644 index 0000000..e738b0d --- /dev/null +++ b/src/main/java/com/example/essentialsplus/data/GeLimitInfo.java @@ -0,0 +1,24 @@ +package com.example.essentialsplus.data; + +/** + * Calculated status of an item's 4-hour Grand Exchange buy limit. + */ +public class GeLimitInfo +{ + public final int itemId; + public final int limit; + public final int bought; + public final int remaining; + public final long resetInMs; + public final boolean windowActive; + + public GeLimitInfo(int itemId, int limit, int bought, int remaining, long resetInMs, boolean windowActive) + { + this.itemId = itemId; + this.limit = limit; + this.bought = bought; + this.remaining = remaining; + this.resetInMs = resetInMs; + this.windowActive = windowActive; + } +} diff --git a/src/main/java/com/example/essentialsplus/data/GeRow.java b/src/main/java/com/example/essentialsplus/data/GeRow.java new file mode 100644 index 0000000..da8fe28 --- /dev/null +++ b/src/main/java/com/example/essentialsplus/data/GeRow.java @@ -0,0 +1,28 @@ +package com.example.essentialsplus.data; + +/** One Grand Exchange slot prepared for display. */ +public class GeRow +{ + public final String character; + public final int slot; + /** null = empty slot */ + public final GeSlot offer; + public final String name; + public final int marketPrice; + public final GeLimitInfo limitInfo; + + public GeRow(String character, int slot, GeSlot offer, String name, int marketPrice) + { + this(character, slot, offer, name, marketPrice, null); + } + + public GeRow(String character, int slot, GeSlot offer, String name, int marketPrice, GeLimitInfo limitInfo) + { + this.character = character; + this.slot = slot; + this.offer = offer; + this.name = name; + this.marketPrice = marketPrice; + this.limitInfo = limitInfo; + } +} diff --git a/src/main/java/com/example/essentialsplus/data/GeSlot.java b/src/main/java/com/example/essentialsplus/data/GeSlot.java new file mode 100644 index 0000000..587e433 --- /dev/null +++ b/src/main/java/com/example/essentialsplus/data/GeSlot.java @@ -0,0 +1,30 @@ +package com.example.essentialsplus.data; + +/** Last known state of one Grand Exchange slot, persisted so collections can be told apart from gains. */ +public class GeSlot +{ + public int item; + public boolean buy; + public long total; + public long price; + + /** items / coins already taken out of the collection box (fallback tracking) */ + public long collectedItems; + public long collectedCoins; + + /** value held by the slot at the last update */ + public long escrowItems; + public long escrowCoins; + public long collectItems; + public long collectCoins; + + /** live offer details */ + public long done; + public long spent; + public String state; + /** when the offer was placed (0 = before tracking started, see trackedSince) */ + public long placedAt; + public long trackedSince; + public long trackedDone; + public long lastTradeAt; +} diff --git a/src/main/java/com/example/essentialsplus/data/ItemLedger.java b/src/main/java/com/example/essentialsplus/data/ItemLedger.java new file mode 100644 index 0000000..c050605 --- /dev/null +++ b/src/main/java/com/example/essentialsplus/data/ItemLedger.java @@ -0,0 +1,48 @@ +package com.example.essentialsplus.data; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; + +/** Item holdings over time, stored separately from the wealth timeline (<key>.items.json). */ +public class ItemLedger +{ + public List snapshots = new CopyOnWriteArrayList<>(); + + public transient long fileModified; + public transient boolean dirty; + + public ItemSnapshot last() + { + List s = snapshots; + return s.isEmpty() ? null : s.get(s.size() - 1); + } + + /** Latest snapshot taken at or before {@code t}, or the first one after it if none exists. */ + public ItemSnapshot at(long t) + { + List s = snapshots; + ItemSnapshot best = null; + for (ItemSnapshot x : s) + { + if (x.t <= t) + { + best = x; + } + else + { + return best != null ? best : x; + } + } + return best; + } + + public void normalize() + { + List s = snapshots == null ? new ArrayList<>() : new ArrayList<>(snapshots); + s.removeIf(x -> x == null || x.ids == null || x.q == null || x.p == null + || x.ids.length != x.q.length || x.ids.length != x.p.length); + s.sort((a, b) -> Long.compare(a.t, b.t)); + snapshots = new CopyOnWriteArrayList<>(s); + } +} diff --git a/src/main/java/com/example/essentialsplus/data/ItemRow.java b/src/main/java/com/example/essentialsplus/data/ItemRow.java new file mode 100644 index 0000000..87208a2 --- /dev/null +++ b/src/main/java/com/example/essentialsplus/data/ItemRow.java @@ -0,0 +1,20 @@ +package com.example.essentialsplus.data; + +/** One priced item stack for the item viewer. */ +public class ItemRow +{ + public final int id; + public final String name; + public final long qty; + public final long price; + public final long value; + + public ItemRow(int id, String name, long qty, long price) + { + this.id = id; + this.name = name; + this.qty = qty; + this.price = price; + this.value = qty * price; + } +} diff --git a/src/main/java/com/example/essentialsplus/data/ItemSnapshot.java b/src/main/java/com/example/essentialsplus/data/ItemSnapshot.java new file mode 100644 index 0000000..4a96152 --- /dev/null +++ b/src/main/java/com/example/essentialsplus/data/ItemSnapshot.java @@ -0,0 +1,44 @@ +package com.example.essentialsplus.data; + +import java.util.Arrays; + +/** + * Holdings of a character at one moment: canonical item id, quantity and the GE price at that time. + * Allows splitting wealth changes into "market" (price) and "activity" (quantity) effects. + */ +public class ItemSnapshot +{ + public long t; + public int[] ids; + public long[] q; + public int[] p; + /** pinned (session start) - kept by compaction */ + public Boolean k; + + public ItemSnapshot() + { + } + + public ItemSnapshot(long t, int[] ids, long[] q, int[] p) + { + this.t = t; + this.ids = ids; + this.q = q; + this.p = p; + } + + public boolean sameAs(ItemSnapshot other) + { + return other != null && Arrays.equals(ids, other.ids) && Arrays.equals(q, other.q) && Arrays.equals(p, other.p); + } + + public boolean sameQuantities(ItemSnapshot other) + { + return other != null && Arrays.equals(ids, other.ids) && Arrays.equals(q, other.q); + } + + public int indexOf(int id) + { + return ids == null ? -1 : Arrays.binarySearch(ids, id); + } +} diff --git a/src/main/java/com/example/essentialsplus/data/Snapshot.java b/src/main/java/com/example/essentialsplus/data/Snapshot.java new file mode 100644 index 0000000..c8e62d3 --- /dev/null +++ b/src/main/java/com/example/essentialsplus/data/Snapshot.java @@ -0,0 +1,148 @@ +package com.example.essentialsplus.data; + +import com.example.essentialsplus.WealthCategory; +import java.util.Arrays; + +/** + * One point on the wealth timeline. + *

+ * Raw points only have {@code t} and {@code v}. Aggregated points (hourly / daily buckets of older data) + * additionally hold open, close, low and high of the total and store the time-weighted average + * breakdown in {@code v}. + */ +public class Snapshot +{ + public static final String HOUR = "h"; + public static final String DAY = "d"; + + public long t; + public long[] v; + /** open / close / low / high total - only set on aggregates */ + public Long o; + public Long c; + public Long lo; + public Long hi; + /** granularity of an aggregate: "h" or "d" */ + public String g; + /** cumulative value change from GE price movements up to this point ({@link AccountData#market}); null on old data */ + public Long m; + + public Snapshot() + { + } + + public Snapshot(long t, long[] v) + { + this.t = t; + this.v = v; + } + + public long total() + { + long sum = 0; + if (v != null) + { + for (long x : v) + { + sum += x; + } + } + return sum; + } + + public long get(WealthCategory category) + { + int i = category.ordinal(); + return v != null && i < v.length ? v[i] : 0; + } + + public long low() + { + return lo != null ? lo : total(); + } + + public long high() + { + return hi != null ? hi : total(); + } + + public long open() + { + return o != null ? o : total(); + } + + public long close() + { + return c != null ? c : total(); + } + + public boolean isAggregate() + { + return g != null || lo != null; + } + + /** Pads arrays loaded from older files to the current category count. */ + public void normalize() + { + if (v == null) + { + v = new long[WealthCategory.COUNT]; + } + else if (v.length < WealthCategory.COUNT) + { + v = Arrays.copyOf(v, WealthCategory.COUNT); + } + if (lo != null && g == null) + { + g = DAY; + } + } + + public long market() + { + return m != null ? m : 0; + } + + /** Copy with {@code delta} removed from the total, spread over the categories by their share. */ + public Snapshot shifted(long delta) + { + long[] nv = v == null ? new long[0] : v.clone(); + if (delta != 0 && nv.length > 0) + { + long base = 0; + for (long x : nv) + { + base += Math.abs(x); + } + long left = delta; + int largest = 0; + for (int i = 0; i < nv.length; i++) + { + if (Math.abs(nv[i]) > Math.abs(nv[largest])) + { + largest = i; + } + if (base > 0) + { + long part = Math.round((double) delta * Math.abs(nv[i]) / base); + nv[i] -= part; + left -= part; + } + } + nv[largest] -= left; + } + Snapshot s = new Snapshot(t, nv); + s.g = g; + s.m = m; + s.o = o == null ? null : o - delta; + s.c = c == null ? null : c - delta; + s.lo = lo == null ? null : lo - delta; + s.hi = hi == null ? null : hi - delta; + return s; + } + + public boolean sameValues(long[] other) + { + return Arrays.equals(v, other); + } +} diff --git a/src/main/java/com/example/essentialsplus/data/SourceValue.java b/src/main/java/com/example/essentialsplus/data/SourceValue.java new file mode 100644 index 0000000..a1430ae --- /dev/null +++ b/src/main/java/com/example/essentialsplus/data/SourceValue.java @@ -0,0 +1,20 @@ +package com.example.essentialsplus.data; + +/** Value of a single wealth source (e.g. "Looting bag") inside a category. */ +public class SourceValue +{ + public String name; + public int category; + public long value; + + public SourceValue() + { + } + + public SourceValue(String name, int category, long value) + { + this.name = name; + this.category = category; + this.value = value; + } +} diff --git a/src/main/java/com/example/essentialsplus/data/TopItem.java b/src/main/java/com/example/essentialsplus/data/TopItem.java new file mode 100644 index 0000000..2c097e3 --- /dev/null +++ b/src/main/java/com/example/essentialsplus/data/TopItem.java @@ -0,0 +1,21 @@ +package com.example.essentialsplus.data; + +public class TopItem +{ + public int id; + public String name; + public long qty; + public long value; + + public TopItem() + { + } + + public TopItem(int id, String name, long qty, long value) + { + this.id = id; + this.name = name; + this.qty = qty; + this.value = value; + } +} diff --git a/src/main/java/com/example/essentialsplus/helpers/AttackStyles.java b/src/main/java/com/example/essentialsplus/helpers/AttackStyles.java new file mode 100644 index 0000000..2c5ecb2 --- /dev/null +++ b/src/main/java/com/example/essentialsplus/helpers/AttackStyles.java @@ -0,0 +1,256 @@ +package com.example.essentialsplus.helpers; + +import com.example.essentialsplus.EssentialsPlusConfig; +import java.lang.reflect.Field; +import java.lang.reflect.Modifier; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.Set; +import java.util.WeakHashMap; +import javax.inject.Inject; +import javax.inject.Singleton; +import net.runelite.api.Actor; +import net.runelite.api.Client; +import net.runelite.api.GameState; +import net.runelite.api.NPC; +import net.runelite.api.Player; +import net.runelite.api.Projectile; +import net.runelite.api.events.AnimationChanged; +import net.runelite.api.events.GameStateChanged; +import net.runelite.api.events.GameTick; +import net.runelite.api.events.NpcDespawned; +import net.runelite.api.events.ProjectileMoved; +import net.runelite.api.gameval.AnimationID; +import net.runelite.api.gameval.SpotanimID; +import net.runelite.client.eventbus.Subscribe; + +/** + * Which style each NPC attacking the local player used last. The style comes from the projectile (by its cache name, + * e.g. {@code CRYSTAL_HUNLLEF_RANGE_TRAVEL}), else the attack animation name, else melee when the NPC attacked from + * melee distance without a projectile. + */ +@Singleton +public class AttackStyles +{ + /** How long a style stays shown after the NPC's last attack. */ + private static final int SHOW_TICKS = 12; + /** Projectiles whose names do not say the style. */ + private static final Map PROJECTILE_OVERRIDES = Map.of( + SpotanimID.CRYSTAL_HUNLLEF_PRAYER_TRAVEL, CombatStyle.MAGIC, + SpotanimID.CRYSTAL_HUNLLEF_PRAYER_TRAVEL_HM, CombatStyle.MAGIC, + SpotanimID.ECHO_HUNLLEF_ANTIPRAYER_TRAVEL, CombatStyle.MAGIC); + + private final Client client; + private final EssentialsPlusConfig config; + + private Map projectileStyles; + private Map animationStyles; + + private final Map styles = new HashMap<>(); + /** Attack animations without a known style: melee if no projectile follows by the next tick. */ + private final Map unresolved = new HashMap<>(); + private final Set seenProjectiles = Collections.newSetFromMap(new WeakHashMap<>()); + + private static final class Shown + { + final CombatStyle style; + final int tick; + + Shown(CombatStyle style, int tick) + { + this.style = style; + this.tick = tick; + } + } + + @Inject + AttackStyles(Client client, EssentialsPlusConfig config) + { + this.client = client; + this.config = config; + } + + /** Last attack style of every NPC currently attacking the local player. */ + public Map current() + { + Map out = new HashMap<>(); + Player me = client.getLocalPlayer(); + int tick = client.getTickCount(); + for (Map.Entry e : styles.entrySet()) + { + NPC npc = e.getKey(); + if (me != null && !npc.isDead() && npc.getInteracting() == me && tick - e.getValue().tick <= SHOW_TICKS) + { + out.put(npc, e.getValue().style); + } + } + return out; + } + + @Subscribe + public void onAnimationChanged(AnimationChanged event) + { + if (!config.enemyAttackStyle() || !(event.getActor() instanceof NPC)) + { + return; + } + NPC npc = (NPC) event.getActor(); + int anim = npc.getAnimation(); + Player me = client.getLocalPlayer(); + if (anim == -1 || me == null || npc.getInteracting() != me || npc.isDead()) + { + return; + } + CombatStyle style = animationStyles().get(anim); + if (style != null) + { + set(npc, style); + } + else + { + unresolved.putIfAbsent(npc, client.getTickCount()); + } + } + + @Subscribe + public void onProjectileMoved(ProjectileMoved event) + { + if (!config.enemyAttackStyle()) + { + return; + } + Projectile p = event.getProjectile(); + if (!seenProjectiles.add(p)) + { + return; + } + Player me = client.getLocalPlayer(); + Actor target = p.getTargetActor(); + if (me == null || target != null && target != me) + { + return; + } + NPC source = p.getSourceActor() instanceof NPC ? (NPC) p.getSourceActor() : null; + if (source == null) + { + // no source: the NPC that just animated at us + for (NPC npc : unresolved.keySet()) + { + source = npc; + break; + } + } + if (source == null || source.getInteracting() != me) + { + return; + } + unresolved.remove(source); + CombatStyle style = PROJECTILE_OVERRIDES.getOrDefault(p.getId(), projectileStyles().get(p.getId())); + if (style != null) + { + set(source, style); + } + } + + @Subscribe + public void onGameTick(GameTick event) + { + int tick = client.getTickCount(); + Player me = client.getLocalPlayer(); + unresolved.entrySet().removeIf(e -> + { + if (tick - e.getValue() < 1) + { + return false; + } + NPC npc = e.getKey(); + if (me != null && npc.getWorldArea() != null && me.getWorldArea() != null + && npc.getWorldArea().isInMeleeDistance(me.getWorldArea())) + { + set(npc, CombatStyle.MELEE); + } + return true; + }); + styles.values().removeIf(s -> tick - s.tick > SHOW_TICKS); + } + + @Subscribe + public void onNpcDespawned(NpcDespawned event) + { + styles.remove(event.getNpc()); + unresolved.remove(event.getNpc()); + } + + @Subscribe + public void onGameStateChanged(GameStateChanged event) + { + if (event.getGameState() != GameState.LOGGED_IN) + { + reset(); + } + } + + public void reset() + { + styles.clear(); + unresolved.clear(); + seenProjectiles.clear(); + } + + private void set(NPC npc, CombatStyle style) + { + styles.put(npc, new Shown(style, client.getTickCount())); + } + + private Map projectileStyles() + { + if (projectileStyles == null) + { + projectileStyles = stylesByName(SpotanimID.class); + } + return projectileStyles; + } + + private Map animationStyles() + { + if (animationStyles == null) + { + animationStyles = stylesByName(AnimationID.class); + } + return animationStyles; + } + + /** Id -> style from the constant names of a gameval class; ids whose names disagree are left out. */ + static Map stylesByName(Class gameval) + { + Map out = new HashMap<>(); + Set conflicts = new java.util.HashSet<>(); + for (Field f : gameval.getFields()) + { + if (f.getType() != int.class || !Modifier.isStatic(f.getModifiers())) + { + continue; + } + CombatStyle style = CombatStyle.fromName(f.getName()); + if (style == null) + { + continue; + } + try + { + int id = f.getInt(null); + CombatStyle prev = out.putIfAbsent(id, style); + if (prev != null && prev != style) + { + conflicts.add(id); + } + } + catch (IllegalAccessException ignored) + { + } + } + out.keySet().removeAll(conflicts); + return out; + } +} diff --git a/src/main/java/com/example/essentialsplus/helpers/AttackTimerOverlay.java b/src/main/java/com/example/essentialsplus/helpers/AttackTimerOverlay.java new file mode 100644 index 0000000..ead6023 --- /dev/null +++ b/src/main/java/com/example/essentialsplus/helpers/AttackTimerOverlay.java @@ -0,0 +1,221 @@ +package com.example.essentialsplus.helpers; + +import com.example.essentialsplus.EssentialsPlusConfig; +import java.awt.BasicStroke; +import java.awt.Color; +import java.awt.Dimension; +import java.awt.FontMetrics; +import java.awt.Graphics2D; +import java.awt.RenderingHints; +import java.awt.image.BufferedImage; +import java.util.EnumMap; +import java.util.HashMap; +import java.util.LinkedHashSet; +import java.util.Map; +import java.util.Set; +import javax.inject.Inject; +import net.runelite.api.Actor; +import net.runelite.api.Client; +import net.runelite.api.NPC; +import net.runelite.api.Player; +import net.runelite.api.Point; +import net.runelite.client.game.SpriteManager; +import net.runelite.client.ui.overlay.Overlay; +import net.runelite.client.ui.overlay.OverlayLayer; +import net.runelite.client.ui.overlay.OverlayPosition; +import net.runelite.client.util.ImageUtil; + +/** + * Countdown above the player's (and attacking NPCs') head, optionally with one pip per tick of the attack speed, + * and the style attacking NPCs used last (prayer icon or text) left of their number. + */ +public class AttackTimerOverlay extends Overlay +{ + private static final int PIP = 8; + private static final int GAP = 2; + /** Height of an overhead prayer icon; the player's own timer moves above it. */ + private static final int OVERHEAD_SHIFT = 26; + private static final Color EMPTY = new Color(0, 0, 0, 150); + private static final Color OUTLINE = new Color(0, 0, 0, 220); + + private final Client client; + private final EssentialsPlusConfig config; + private final AttackTimers timers; + private final AttackStyles styles; + private final SpriteManager spriteManager; + /** Scaled prayer icons by size, then style. */ + private final Map> icons = new HashMap<>(); + + /** Look of one timer kind, read from the config once per frame. */ + private static final class Style + { + final boolean bar; + final TimerTextSize size; + final Color color; + final Color ready; + final int offset; + + Style(boolean bar, TimerTextSize size, Color color, Color ready, int offset) + { + this.bar = bar; + this.size = size; + this.color = color; + this.ready = ready; + this.offset = offset; + } + } + + @Inject + AttackTimerOverlay(Client client, EssentialsPlusConfig config, AttackTimers timers, AttackStyles styles, SpriteManager spriteManager) + { + this.client = client; + this.config = config; + this.timers = timers; + this.styles = styles; + this.spriteManager = spriteManager; + setPosition(OverlayPosition.DYNAMIC); + setLayer(OverlayLayer.ABOVE_SCENE); + } + + @Override + public Dimension render(Graphics2D g) + { + boolean styleOn = config.enemyAttackStyle(); + if (!config.attackTimer() && !config.enemyAttackTimer() && !styleOn) + { + return null; + } + g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); + timers.onRender(); + if (config.attackTimer()) + { + Player me = client.getLocalPlayer(); + AttackTimers.Timer t = timers.playerTimer(); + if (me != null && t != null) + { + Style style = new Style(config.attackTimerBar(), config.attackTimerSize(), config.attackTimerColor(), + config.attackTimerReadyColor(), config.attackTimerOffset()); + draw(g, me, t, style, null); + } + } + Map npcTimers = config.enemyAttackTimer() ? timers.npcTimers() : Map.of(); + Map npcStyles = styleOn ? styles.current() : Map.of(); + if (!npcTimers.isEmpty() || !npcStyles.isEmpty()) + { + Style style = new Style(config.enemyAttackTimerBar(), config.enemyAttackTimerSize(), config.enemyAttackTimerColor(), + config.enemyAttackTimerReadyColor(), config.enemyAttackTimerOffset()); + Set npcs = new LinkedHashSet<>(npcTimers.keySet()); + npcs.addAll(npcStyles.keySet()); + for (NPC npc : npcs) + { + draw(g, npc, npcTimers.get(npc), style, npcStyles.get(npc)); + } + } + return null; + } + + private void draw(Graphics2D g, Actor actor, AttackTimers.Timer t, Style style, CombatStyle attackStyle) + { + if (t != null) + { + drawTimer(g, actor, t, style); + } + if (attackStyle != null) + { + drawStyle(g, actor, attackStyle); + } + } + + private void drawTimer(Graphics2D g, Actor actor, AttackTimers.Timer t, Style style) + { + Point anchor = actor.getCanvasTextLocation(g, "", actor.getLogicalHeight() + style.offset); + if (anchor == null) + { + return; + } + int y = anchor.getY(); + if (actor instanceof Player && ((Player) actor).getOverheadIcon() != null) + { + y -= OVERHEAD_SHIFT; + } + boolean ready = t.remaining == 0; + + if (style.bar) + { + int width = t.speed * PIP + (t.speed - 1) * GAP; + int x0 = anchor.getX() - width / 2; + int barY = y - PIP; + // the pip of the running tick drains smoothly; later ticks stay full + double fill = t.remaining - t.tickProgress; + g.setStroke(new BasicStroke(1f)); + for (int i = 0; i < t.speed; i++) + { + int x = x0 + i * (PIP + GAP); + g.setColor(EMPTY); + g.fillRect(x, barY, PIP, PIP); + double part = Math.max(0, Math.min(1, fill - i)); + if (part > 0) + { + g.setColor(style.color); + g.fillRect(x, barY, (int) Math.round(PIP * part), PIP); + } + g.setColor(ready ? style.ready : OUTLINE); + g.drawRect(x, barY, PIP, PIP); + } + y = barY - 3; + } + + g.setFont(style.size.font()); + String text = String.valueOf(t.remaining); + int x = anchor.getX() - g.getFontMetrics().stringWidth(text) / 2; + shadowed(g, text, x, y, ready ? style.ready : style.color); + } + + /** Attack style with its own size and height, centered above the head. */ + private void drawStyle(Graphics2D g, Actor actor, CombatStyle attackStyle) + { + Point anchor = actor.getCanvasTextLocation(g, "", actor.getLogicalHeight() + config.enemyAttackStyleOffset()); + if (anchor == null) + { + return; + } + TimerTextSize size = config.enemyAttackStyleSize(); + if (config.enemyAttackStyleText()) + { + g.setFont(size.font()); + int x = anchor.getX() - g.getFontMetrics().stringWidth(attackStyle.label) / 2; + shadowed(g, attackStyle.label, x, anchor.getY(), attackStyle.color); + return; + } + BufferedImage icon = icon(attackStyle, size.iconSize()); + if (icon != null) + { + g.drawImage(icon, anchor.getX() - icon.getWidth() / 2, anchor.getY() - icon.getHeight(), null); + } + } + + private static void shadowed(Graphics2D g, String text, int x, int y, Color color) + { + g.setColor(Color.BLACK); + g.drawString(text, x + 1, y + 1); + g.setColor(color); + g.drawString(text, x, y); + } + + private BufferedImage icon(CombatStyle style, int size) + { + Map bySize = icons.computeIfAbsent(size, k -> new EnumMap<>(CombatStyle.class)); + BufferedImage img = bySize.get(style); + if (img == null) + { + BufferedImage sprite = spriteManager.getSprite(style.prayerSprite, 0); + if (sprite == null) + { + return null; + } + img = ImageUtil.resizeImage(sprite, size, size); + bySize.put(style, img); + } + return img; + } +} diff --git a/src/main/java/com/example/essentialsplus/helpers/AttackTimers.java b/src/main/java/com/example/essentialsplus/helpers/AttackTimers.java new file mode 100644 index 0000000..aff32c8 --- /dev/null +++ b/src/main/java/com/example/essentialsplus/helpers/AttackTimers.java @@ -0,0 +1,565 @@ +package com.example.essentialsplus.helpers; + +import com.example.essentialsplus.EssentialsPlusConfig; +import com.example.essentialsplus.helpers.combat.AnimationData; +import com.example.essentialsplus.helpers.combat.AttackSpeed; +import com.example.essentialsplus.helpers.combat.Attacking; +import com.example.essentialsplus.helpers.combat.CombatUtils; +import com.example.essentialsplus.helpers.combat.Spellbook; +import com.example.essentialsplus.helpers.combat.variablespeed.VariableSpeed; +import com.example.essentialsplus.helpers.combat.variablespeed.state.TickCount; +import java.util.HashMap; +import java.util.Map; +import java.util.Set; +import java.util.regex.Pattern; +import javax.inject.Inject; +import javax.inject.Singleton; +import lombok.extern.slf4j.Slf4j; +import net.runelite.api.Actor; +import net.runelite.api.Client; +import net.runelite.api.GameState; +import net.runelite.api.NPC; +import net.runelite.api.Player; +import net.runelite.api.events.AnimationChanged; +import net.runelite.api.events.ChatMessage; +import net.runelite.api.events.FakeXpDrop; +import net.runelite.api.events.GameStateChanged; +import net.runelite.api.events.GameTick; +import net.runelite.api.events.HitsplatApplied; +import net.runelite.api.events.InteractingChanged; +import net.runelite.api.events.NpcDespawned; +import net.runelite.api.events.NpcSpawned; +import net.runelite.api.events.SoundEffectPlayed; +import net.runelite.api.events.StatChanged; +import net.runelite.api.events.VarbitChanged; +import net.runelite.api.gameval.VarPlayerID; +import net.runelite.api.gameval.VarbitID; +import net.runelite.client.eventbus.Subscribe; +import net.runelite.client.game.ItemManager; +import net.runelite.client.game.NPCManager; + +/** + * Deterministic attack timer for the local player, incorporating weapon speeds, attack styles, + * manual casting, powered staves, boss mechanics, and food delays. + * NPC attack speeds are learned from intervals between their verified attack animations. + */ +@Slf4j +@Singleton +public class AttackTimers +{ + public enum AttackState + { + NOT_ATTACKING, + DELAYED_FIRST_TICK, + DELAYED + } + + static final int DEFAULT_SPEED = 4; + private static final int MAX_SPEED = 10; + private static final int TRUSTED_SAMPLES = 3; + private static final long TICK_NANOS = 600_000_000L; + private static final int CONFIRM_TICKS = 4; + private static final int UI_HIDE_DEBOUNCE_TICKS_MAX = 1; + + private static final int SLOW_FOOD_ATTACK_DELAY_TICKS = 4; + private static final int DEFAULT_FOOD_ATTACK_DELAY_TICKS = 3; + private static final int FAST_EAT_ATTACK_DELAY_TICKS = 2; + + private static final String GENERIC_EAT = "You eat"; + private static final String VAMPYRIUM_EAT = "Your stomach doesn't like it... but it heals some health"; + private static final String BARBARIAN_POTIONS = "You drink the lumpy potion"; + private static final String JUG_OF_WINE = "You drink the wine"; + + private static final Pattern EAT_MESSAGE = Pattern.compile( + "^(" + GENERIC_EAT + "|" + BARBARIAN_POTIONS + "|" + JUG_OF_WINE + "|" + VAMPYRIUM_EAT + ")", + Pattern.MULTILINE | Pattern.CASE_INSENSITIVE); + + private static final Pattern SLOW_FOOD = Pattern.compile("^(" + VAMPYRIUM_EAT + ")", + Pattern.MULTILINE | Pattern.CASE_INSENSITIVE); + + private static final String FAST_GNOME_FOOD = "worm hole|tangled toads legs|veg ball|chocolate bomb|worm crunchies|toad crunchies|" + + "choc chip crunchies|spicy crunchies|fruit batta|cheese and tomato batta|toad batta|vegetable batta|worm batta"; + private static final String FAST_FOOD = "karambwan|halibut"; + private static final Pattern FAST_EAT = Pattern.compile("(" + FAST_FOOD + "|" + FAST_GNOME_FOOD + ")", + Pattern.CASE_INSENSITIVE); + + private static final Set NON_ATTACK_ANIMS = Set.of(829, 424, 1156, 836); + + private final Client client; + private final ItemManager itemManager; + private final NPCManager npcManager; + private final EssentialsPlusConfig config; + + private final TickCount tc = new TickCount(); + private final VariableSpeed variableSpeed = new VariableSpeed(tc); + private final AttackSpeed attackSpeed = new AttackSpeed(tc, variableSpeed); + + public AttackState attackState = AttackState.NOT_ATTACKING; + public AttackState renderedState = AttackState.NOT_ATTACKING; + + public int tickPeriod = 0; + public int attackDelayHoldoffTicks = 0; + private int uiHideDebounceTickCount = 0; + public int pendingEatDelayTicks = 0; + + private Spellbook currentSpellBook = Spellbook.STANDARD; + private int lastUsedWeaponId = -1; + private Actor lastTarget = null; + + private volatile long lastTickNanos = System.nanoTime(); + + // NPC Attack Timers state + private final Map npcAttackTicks = new HashMap<>(); + private final Map npcPrevTicks = new HashMap<>(); + private final Map npcSpeeds = new HashMap<>(); + private final Map pendingNpcAnims = new HashMap<>(); + private final Map animOutcomes = new HashMap<>(); + private final java.util.List animChecks = new java.util.ArrayList<>(); + + private static final class AnimCheck + { + final long key; + final int tick; + boolean confirmed; + + AnimCheck(long key, int tick) + { + this.key = key; + this.tick = tick; + } + } + + @Inject + public AttackTimers(Client client, ItemManager itemManager, NPCManager npcManager, EssentialsPlusConfig config) + { + this.client = client; + this.itemManager = itemManager; + this.npcManager = npcManager; + this.config = config; + } + + public static final class Timer + { + public final int remaining; + public final int speed; + public final double tickProgress; + + public Timer(int remaining, int speed, double tickProgress) + { + this.remaining = remaining; + this.speed = speed; + this.tickProgress = tickProgress; + } + } + + public Timer playerTimer() + { + if (attackState == AttackState.NOT_ATTACKING && uiHideDebounceTickCount <= 0) + { + return null; + } + int remaining = 1 + attackDelayHoldoffTicks; + if (remaining < 0) + { + return null; + } + int speed = Math.max(1, tickPeriod); + double progress = (remaining == 0) ? 0 : tickProgress(); + return new Timer(remaining, speed, progress); + } + + private double tickProgress() + { + return Math.min(1, Math.max(0, (System.nanoTime() - lastTickNanos) / (double) TICK_NANOS)); + } + + public void onRender() + { + final int delta = variableSpeed.shadowCrash.onRender(client, attackDelayHoldoffTicks, attackSpeed.isUsingMagic()); + if (delta != 0) + { + attackDelayHoldoffTicks += delta; + if (attackDelayHoldoffTicks < 0) + { + attackState = AttackState.NOT_ATTACKING; + } + } + if (variableSpeed.maggotKing.onRender(client, itemManager) + || variableSpeed.doomOfMokhaiotl.onRender(client, itemManager, currentSpellBook)) + { + performAttack(); + } + checkForLateWeaponSwaps(); + renderedState = attackState; + } + + private void setAttackDelay() + { + Player localPlayer = client.getLocalPlayer(); + final AnimationData curAnimation = localPlayer != null ? AnimationData.fromId(localPlayer.getAnimation()) : null; + attackDelayHoldoffTicks = attackSpeed.compute(client, curAnimation, currentSpellBook, itemManager); + lastUsedWeaponId = CombatUtils.getWeaponId(client); + } + + private void performAttack() + { + attackState = AttackState.DELAYED_FIRST_TICK; + setAttackDelay(); + tickPeriod = attackDelayHoldoffTicks; + uiHideDebounceTickCount = UI_HIDE_DEBOUNCE_TICKS_MAX; + Player localPlayer = client.getLocalPlayer(); + lastTarget = localPlayer != null ? localPlayer.getInteracting() : null; + } + + private void checkForLateWeaponSwaps() + { + final boolean weaponMismatch = CombatUtils.getWeaponId(client) != lastUsedWeaponId; + if (inPreAttackWindow() && weaponMismatch) + { + performAttack(); + } + } + + private boolean inPreAttackWindow() + { + return attackState == AttackState.DELAYED_FIRST_TICK && renderedState != attackState; + } + + private void applyAndClearEats() + { + int pendingEats = pendingEatDelayTicks; + attackDelayHoldoffTicks += pendingEats; + pendingEatDelayTicks -= pendingEats; + } + + @Subscribe + public void onVarbitChanged(final VarbitChanged varbitChanged) + { + if (varbitChanged.getVarbitId() == VarbitID.SPELLBOOK) + { + currentSpellBook = Spellbook.fromVarbit(varbitChanged.getValue()); + } + if (varbitChanged.getVarpId() == VarPlayerID.SA_ENERGY) + { + attackSpeed.varbitSpecialAttackChanged(varbitChanged.getValue()); + } + } + + @Subscribe + public void onSoundEffectPlayed(final SoundEffectPlayed event) + { + attackSpeed.onSoundEffectPlayed(client, event); + } + + @Subscribe + public void onFakeXpDrop(final FakeXpDrop event) + { + if (attackSpeed.onXpDrop(event) && inPreAttackWindow()) + { + performAttack(); + } + } + + @Subscribe + public void onStatChanged(final StatChanged event) + { + if (attackSpeed.onXpDrop(event) && inPreAttackWindow()) + { + performAttack(); + } + } + + @Subscribe + public void onNpcSpawned(final NpcSpawned npcSpawned) + { + variableSpeed.onNpcSpawned(client, npcSpawned); + } + + @Subscribe + public void onNpcDespawned(final NpcDespawned npcDespawned) + { + variableSpeed.onNpcDespawned(client, npcDespawned); + npcAttackTicks.remove(npcDespawned.getNpc()); + npcPrevTicks.remove(npcDespawned.getNpc()); + } + + @Subscribe + public void onChatMessage(final ChatMessage event) + { + final String message = event.getMessage(); + if (message != null && EAT_MESSAGE.matcher(message).find()) + { + int delay; + if (FAST_EAT.matcher(message).find()) + { + delay = FAST_EAT_ATTACK_DELAY_TICKS; + } + else if (SLOW_FOOD.matcher(message).find()) + { + delay = SLOW_FOOD_ATTACK_DELAY_TICKS; + } + else + { + delay = DEFAULT_FOOD_ATTACK_DELAY_TICKS; + } + pendingEatDelayTicks += delay; + } + variableSpeed.onChatMessage(client, event); + if (message != null) + { + pendingEatDelayTicks += variableSpeed.maggotKing.onChatMessage(client, message); + } + } + + @Subscribe + public void onInteractingChanged(InteractingChanged interactingChanged) + { + Actor source = interactingChanged.getSource(); + Actor target = interactingChanged.getTarget(); + Player p = client.getLocalPlayer(); + + if (source != null && source.equals(p) && target instanceof NPC) + { + if (attackState == AttackState.NOT_ATTACKING && Attacking.isPlayerAttacking(client, npcManager)) + { + performAttack(); + } + } + applyAndClearEats(); + } + + @Subscribe + public void onGameTick(GameTick tick) + { + lastTickNanos = System.nanoTime(); + variableSpeed.onGameTick(client, tick); + final boolean isAttacking = Attacking.isPlayerAttacking(client, npcManager); + + switch (attackState) + { + case NOT_ATTACKING: + if (isAttacking) + { + performAttack(); + } + else + { + uiHideDebounceTickCount = Math.max(-20, uiHideDebounceTickCount - 1); + } + break; + case DELAYED_FIRST_TICK: + attackState = AttackState.DELAYED; + // fallthrough + case DELAYED: + if (attackDelayHoldoffTicks <= 0) + { + if (isAttacking) + { + performAttack(); + } + else + { + attackState = AttackState.NOT_ATTACKING; + } + } + break; + } + + applyAndClearEats(); + attackDelayHoldoffTicks = Math.max(-20, attackDelayHoldoffTicks - 1); + attackSpeed.onTick(); + + // Update NPC attack timer learning + updateNpcTimers(); + } + + private void updateNpcTimers() + { + int tick = client.getTickCount(); + animChecks.removeIf(c -> + { + if (!c.confirmed && tick - c.tick <= CONFIRM_TICKS) + { + return false; + } + animOutcomes.computeIfAbsent(c.key, k -> new int[2])[c.confirmed ? 0 : 1]++; + return true; + }); + for (Map.Entry pending : pendingNpcAnims.entrySet()) + { + NPC npc = pending.getKey(); + int animTick = pending.getValue()[0]; + long animKey = (long) npc.getId() << 32 | pending.getValue()[1]; + animChecks.add(new AnimCheck(animKey, animTick)); + int[] outcome = animOutcomes.get(animKey); + if (outcome != null && outcome[1] >= 2 && outcome[1] > outcome[0] * 2) + { + continue; + } + SpeedSamples samples = npcSpeeds.computeIfAbsent(npc.getId(), id -> new SpeedSamples()); + Integer last = npcAttackTicks.get(npc); + if (last != null) + { + int gap = animTick - last; + if (gap <= 0) + { + continue; + } + if (samples.count() >= TRUSTED_SAMPLES && gap < samples.speed()) + { + continue; + } + samples.add(gap); + npcPrevTicks.put(npc, last); + } + npcAttackTicks.put(npc, animTick); + } + pendingNpcAnims.clear(); + npcAttackTicks.values().removeIf(t -> tick - t > MAX_SPEED + 2); + npcPrevTicks.keySet().retainAll(npcAttackTicks.keySet()); + } + + @Subscribe + public void onAnimationChanged(AnimationChanged event) + { + if (!config.enemyAttackTimer()) + { + return; + } + Actor actor = event.getActor(); + int anim = actor.getAnimation(); + if (anim == -1 || NON_ATTACK_ANIMS.contains(anim)) + { + return; + } + Player me = client.getLocalPlayer(); + if (actor instanceof NPC && me != null && actor.getInteracting() == me && !actor.isDead()) + { + pendingNpcAnims.putIfAbsent((NPC) actor, new int[]{client.getTickCount(), anim}); + } + } + + @Subscribe + public void onHitsplatApplied(HitsplatApplied event) + { + if (event.getActor() == client.getLocalPlayer() && !event.getHitsplat().isMine()) + { + for (AnimCheck c : animChecks) + { + if (!c.confirmed) + { + c.confirmed = true; + break; + } + } + } + } + + @Subscribe + public void onGameStateChanged(GameStateChanged event) + { + if (event.getGameState() != GameState.LOGGED_IN) + { + reset(); + } + } + + public void reset() + { + attackState = AttackState.NOT_ATTACKING; + renderedState = AttackState.NOT_ATTACKING; + tickPeriod = 0; + attackDelayHoldoffTicks = 0; + uiHideDebounceTickCount = 0; + pendingEatDelayTicks = 0; + lastUsedWeaponId = -1; + lastTarget = null; + tc.reset(); + attackSpeed.reset(); + npcAttackTicks.clear(); + npcPrevTicks.clear(); + pendingNpcAnims.clear(); + animChecks.clear(); + } + + public Map npcTimers() + { + Map out = new HashMap<>(); + Player me = client.getLocalPlayer(); + for (Map.Entry e : npcAttackTicks.entrySet()) + { + NPC npc = e.getKey(); + if (npc.isDead() || me == null || npc.getInteracting() != me) + { + continue; + } + int attackTick = e.getValue(); + if (attackTick >= client.getTickCount()) + { + Integer prev = npcPrevTicks.get(npc); + attackTick = prev == null ? -1 : prev; + } + Timer t = npcTimer(attackTick, npcSpeed(npc.getId())); + if (t != null) + { + out.put(npc, t); + } + } + return out; + } + + private Timer npcTimer(int attackTick, int speed) + { + if (attackTick < 0) + { + return null; + } + int remaining = speed - (client.getTickCount() - attackTick - 1); + if (remaining < 0) + { + return null; + } + return new Timer(remaining, speed, remaining == 0 ? 0 : tickProgress()); + } + + int npcSpeed(int npcId) + { + SpeedSamples s = npcSpeeds.get(npcId); + return s == null ? DEFAULT_SPEED : s.speed(); + } + + static final class SpeedSamples + { + private final int[] counts = new int[MAX_SPEED + 1]; + private int total; + + void add(int gap) + { + if (gap >= 1 && gap <= MAX_SPEED) + { + counts[gap]++; + total++; + } + } + + int count() + { + return total; + } + + int speed() + { + int best = DEFAULT_SPEED; + int bestCount = 0; + for (int gap = 1; gap <= MAX_SPEED; gap++) + { + if (counts[gap] > bestCount) + { + best = gap; + bestCount = counts[gap]; + } + } + return best; + } + } +} diff --git a/src/main/java/com/example/essentialsplus/helpers/CombatStyle.java b/src/main/java/com/example/essentialsplus/helpers/CombatStyle.java new file mode 100644 index 0000000..8d5449e --- /dev/null +++ b/src/main/java/com/example/essentialsplus/helpers/CombatStyle.java @@ -0,0 +1,63 @@ +package com.example.essentialsplus.helpers; + +import java.awt.Color; +import net.runelite.api.gameval.SpriteID; + +/** Attack style of an NPC attack, with the protection prayer that blocks it. */ +public enum CombatStyle +{ + MELEE("Melee", new Color(0xE05050), SpriteID.Prayeron.PROTECT_FROM_MELEE), + RANGED("Range", new Color(0x6BD66B), SpriteID.Prayeron.PROTECT_FROM_MISSILES), + MAGIC("Mage", new Color(0x5AA9FF), SpriteID.Prayeron.PROTECT_FROM_MAGIC); + + public final String label; + public final Color color; + public final int prayerSprite; + + CombatStyle(String label, Color color, int prayerSprite) + { + this.label = label; + this.color = color; + this.prayerSprite = prayerSprite; + } + + /** + * Style from a cache name such as {@code HUNLLEF_ATTACK_RANGED} or {@code ZEBAK_MAGE_PROJANIM_INITIAL}; + * null when the name mentions no style or more than one. + */ + static CombatStyle fromName(String name) + { + CombatStyle found = null; + for (String token : name.toUpperCase(java.util.Locale.ROOT).split("_")) + { + CombatStyle s = fromToken(token); + if (s == null) + { + continue; + } + if (found != null && found != s) + { + return null; + } + found = s; + } + return found; + } + + private static CombatStyle fromToken(String token) + { + if (token.startsWith("MELEE")) + { + return MELEE; + } + if (token.startsWith("RANGE") || token.startsWith("ARROW") || token.startsWith("DART") || token.startsWith("JAVELIN")) + { + return RANGED; + } + if (token.startsWith("MAGIC") || token.startsWith("MAGE") && !token.startsWith("MAGENTA")) + { + return MAGIC; + } + return null; + } +} diff --git a/src/main/java/com/example/essentialsplus/helpers/ItemCharges.java b/src/main/java/com/example/essentialsplus/helpers/ItemCharges.java new file mode 100644 index 0000000..2e397c8 --- /dev/null +++ b/src/main/java/com/example/essentialsplus/helpers/ItemCharges.java @@ -0,0 +1,3187 @@ +package com.example.essentialsplus.helpers; + +import com.example.essentialsplus.EssentialsPlusConfig; +import com.example.essentialsplus.EssentialsPlusPlugin; +import java.awt.Color; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import javax.inject.Inject; +import javax.inject.Singleton; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; +import net.runelite.api.ChatMessageType; +import net.runelite.api.Client; +import net.runelite.api.EquipmentInventorySlot; +import net.runelite.api.GameState; +import net.runelite.api.Item; +import net.runelite.api.ItemContainer; +import net.runelite.api.Player; +import net.runelite.api.events.AnimationChanged; +import net.runelite.api.events.ChatMessage; +import net.runelite.api.events.GameStateChanged; +import net.runelite.api.events.GameTick; +import net.runelite.api.events.ItemContainerChanged; +import net.runelite.api.events.MenuOptionClicked; +import net.runelite.api.gameval.InventoryID; +import net.runelite.api.gameval.ItemID; +import net.runelite.api.gameval.VarbitID; +import net.runelite.client.config.ConfigManager; +import net.runelite.client.config.RuneScapeProfileType; +import net.runelite.client.eventbus.Subscribe; +import net.runelite.client.game.ItemManager; +import net.runelite.client.util.QuantityFormatter; +import net.runelite.client.util.Text; + +/** + * Comprehensive Item Charges Helper: + * 1. CHARGES_* varbits for weapon/armour charges + * 2. Degradable Barrows armor & weapons (chat-tracked + percentage) + * 3. Moons of Peril armor sets (Blood Moon, Blue Moon, Eclipse Moon) + * 4. Toxic blowpipe (dual count: darts + scales) + * 5. Dragonfire shields (Dragonfire shield, Dragonfire ward, Ancient wyvern shield) + * 6. Master scroll book (varbits) & Ring of the Elements (varbit) + * 7. Multi-charge jewelry & Potions & mixes (1-4 doses with color codes) + * 8. Utility containers, bags, buckets, cans, pouches (Bottomless compost/milk buckets, Gricoller's can, Colossal & Essence pouches, Tackle box, Huntsman's kit, etc.) + * 9. Diary items & Capes & Specialized weapons + */ +@Slf4j +@Singleton +public class ItemCharges +{ + public static final String CONFIG_PREFIX = "itemCharges."; + + public enum BarrowsPiece + { + DHAROK_HELM("dharoks_helm"), + DHAROK_BODY("dharoks_platebody"), + DHAROK_LEGS("dharoks_platelegs"), + DHAROK_WEAPON("dharoks_greataxe"), + + AHRIM_HOOD("ahrims_hood"), + AHRIM_BODY("ahrims_robetop"), + AHRIM_LEGS("ahrims_robeskirt"), + AHRIM_WEAPON("ahrims_staff"), + + GUTHAN_HELM("guthans_helm"), + GUTHAN_BODY("guthans_platebody"), + GUTHAN_LEGS("guthans_chainskirt"), + GUTHAN_WEAPON("guthans_warspear"), + + KARIL_COIF("karils_coif"), + KARIL_BODY("karils_leathertop"), + KARIL_LEGS("karils_leatherskirt"), + KARIL_WEAPON("karils_crossbow"), + + TORAG_HELM("torags_helm"), + TORAG_BODY("torags_platebody"), + TORAG_LEGS("torags_platelegs"), + TORAG_WEAPON("torags_hammers"), + + VERAC_HELM("veracs_helm"), + VERAC_BODY("veracs_brassard"), + VERAC_LEGS("veracs_plateskirt"), + VERAC_WEAPON("veracs_flail"); + + @Getter + private final String configKey; + + BarrowsPiece(String configKey) + { + this.configKey = configKey; + } + } + + public enum MoonPiece + { + BLOOD_CHEST("blood_moon_chestplate"), + BLOOD_HELM("blood_moon_helm"), + BLOOD_LEGS("blood_moon_tassets"), + + BLUE_CHEST("blue_moon_chestplate"), + BLUE_HELM("blue_moon_helm"), + BLUE_LEGS("blue_moon_tassets"), + + ECLIPSE_CHEST("eclipse_moon_chestplate"), + ECLIPSE_HELM("eclipse_moon_helm"), + ECLIPSE_LEGS("eclipse_moon_tassets"); + + @Getter + private final String configKey; + + MoonPiece(String configKey) + { + this.configKey = configKey; + } + } + + public static class ChargeValue + { + private final String text; + private final Color color; + + public ChargeValue(String text, Color color) + { + this.text = text; + this.color = color; + } + + public String getText() + { + return text; + } + + public Color getColor() + { + return color; + } + } + + private enum SourceType + { + VARBIT, + BOWFA_BLADE, + DIZANAS_QUIVER, + BLOWPIPE, + BARROWS, + MOONS, + CONFIG_COUNT, + BAG, + LOOTING_BAG, + MASTER_SCROLL_BOOK, + GHOMMALS_HILT, + FIXED_VALUE + } + + private static class ItemChargeDef + { + final SourceType type; + final int primaryVarbit; + final int secondaryVarbit; + final boolean isUncharged; + final boolean isInfinite; + final boolean hasDarts; + final BarrowsPiece barrowsPiece; + final int barrowsTier; // 100, 75, 50, 25, 0 + final MoonPiece moonPiece; + final int moonTier; // 3000, -1, 0 + final String configKey; + final Integer defaultCharges; + final int fixedAmount; + + ItemChargeDef(SourceType type, int primaryVarbit, int secondaryVarbit, boolean isUncharged, + boolean isInfinite, boolean hasDarts, BarrowsPiece barrowsPiece, int barrowsTier, + MoonPiece moonPiece, int moonTier, String configKey, Integer defaultCharges, int fixedAmount) + { + this.type = type; + this.primaryVarbit = primaryVarbit; + this.secondaryVarbit = secondaryVarbit; + this.isUncharged = isUncharged; + this.isInfinite = isInfinite; + this.hasDarts = hasDarts; + this.barrowsPiece = barrowsPiece; + this.barrowsTier = barrowsTier; + this.moonPiece = moonPiece; + this.moonTier = moonTier; + this.configKey = configKey; + this.defaultCharges = defaultCharges; + this.fixedAmount = fixedAmount; + } + + static ItemChargeDef varbit(int varbitId) + { + return new ItemChargeDef(SourceType.VARBIT, varbitId, -1, false, false, false, null, 0, null, 0, null, null, 0); + } + + static ItemChargeDef uncharged() + { + return new ItemChargeDef(SourceType.VARBIT, -1, -1, true, false, false, null, 0, null, 0, null, null, 0); + } + + static ItemChargeDef infinite() + { + return new ItemChargeDef(SourceType.VARBIT, -1, -1, false, true, false, null, 0, null, 0, null, null, 0); + } + + static ItemChargeDef fixed(int amount) + { + return new ItemChargeDef(SourceType.FIXED_VALUE, -1, -1, false, false, false, null, 0, null, 0, null, null, amount); + } + + static ItemChargeDef bowfaBlade(int chargesVarbit, int corruptionVarbit, boolean uncharged, boolean infinite) + { + return new ItemChargeDef(SourceType.BOWFA_BLADE, chargesVarbit, corruptionVarbit, uncharged, infinite, false, null, 0, null, 0, null, null, 0); + } + + static ItemChargeDef quiver(boolean uncharged, boolean infinite) + { + return new ItemChargeDef(SourceType.DIZANAS_QUIVER, VarbitID.CHARGES_DIZANAS_QUIVER_QUANTITY, VarbitID.CHARGES_DIZANAS_QUIVER_BLESSING, uncharged, infinite, false, null, 0, null, 0, null, null, 0); + } + + static ItemChargeDef blowpipe(boolean isUncharged, boolean hasDarts) + { + return new ItemChargeDef(SourceType.BLOWPIPE, VarbitID.CHARGES_TOXIC_BLOWPIPE_QUANTITY, -1, isUncharged, false, hasDarts, null, 0, null, 0, null, null, 0); + } + + static ItemChargeDef barrows(BarrowsPiece piece, int tier) + { + return new ItemChargeDef(SourceType.BARROWS, -1, -1, tier == 0, false, false, piece, tier, null, 0, piece.getConfigKey(), tier, 0); + } + + static ItemChargeDef moons(MoonPiece piece, int tier) + { + return new ItemChargeDef(SourceType.MOONS, -1, -1, tier == 0, false, false, null, 0, piece, tier, piece.getConfigKey(), tier > 0 ? tier : null, 0); + } + + static ItemChargeDef ghommalsHilt(int maxCharges, int varbitId) + { + return new ItemChargeDef(SourceType.GHOMMALS_HILT, varbitId, -1, false, false, false, null, 0, null, 0, null, null, maxCharges); + } + + static ItemChargeDef masterScrollBook() + { + return new ItemChargeDef(SourceType.MASTER_SCROLL_BOOK, -1, -1, false, false, false, null, 0, null, 0, null, null, 0); + } + + static ItemChargeDef configCount(String configKey, Integer defaultCharges, boolean isUncharged) + { + return new ItemChargeDef(SourceType.CONFIG_COUNT, -1, -1, isUncharged, false, false, null, 0, null, 0, configKey, defaultCharges, 0); + } + + static ItemChargeDef bag(String configKey) + { + return new ItemChargeDef(SourceType.BAG, -1, -1, false, false, false, null, 0, null, 0, configKey, null, 0); + } + + static ItemChargeDef lootingBag() + { + return new ItemChargeDef(SourceType.LOOTING_BAG, -1, -1, false, false, false, null, 0, null, 0, null, null, 0); + } + } + + private static final int[] MASTER_SCROLL_BOOK_VARBITS = { + 5672, 5673, 5674, 5675, 5676, 5677, 5678, 5679, 5680, 5681, 5682, 5683, 5684, + 6056, 8253, 10967, 10995, 11029, 16238 + }; + + private static final Pattern NUMBER_IN_PARENS_PATTERN = Pattern.compile("\\(([a-zA-Z\\s]*?)(\\d+)\\)$"); + + private static final Map CHARGE_MAP = new HashMap<>(); + + static + { + // 1. Toxic Blowpipe & Variants + CHARGE_MAP.put(ItemID.TOXIC_BLOWPIPE, ItemChargeDef.blowpipe(true, false)); + CHARGE_MAP.put(ItemID.TOXIC_BLOWPIPE_LOADED, ItemChargeDef.blowpipe(false, true)); + CHARGE_MAP.put(ItemID.TOXIC_BLOWPIPE_ORNAMENT, ItemChargeDef.blowpipe(true, false)); + CHARGE_MAP.put(ItemID.TOXIC_BLOWPIPE_LOADED_ORNAMENT, ItemChargeDef.blowpipe(false, true)); + CHARGE_MAP.put(ItemID.DRYGORE_BLOWPIPE, ItemChargeDef.blowpipe(true, false)); + CHARGE_MAP.put(ItemID.DRYGORE_BLOWPIPE_LOADED, ItemChargeDef.blowpipe(false, true)); + CHARGE_MAP.put(ItemID.CAMPHOR_BLOWPIPE_EMPTY, ItemChargeDef.blowpipe(true, false)); + CHARGE_MAP.put(ItemID.CAMPHOR_BLOWPIPE, ItemChargeDef.blowpipe(false, true)); + CHARGE_MAP.put(ItemID.IRONWOOD_BLOWPIPE_EMPTY, ItemChargeDef.blowpipe(true, false)); + CHARGE_MAP.put(ItemID.IRONWOOD_BLOWPIPE, ItemChargeDef.blowpipe(false, true)); + CHARGE_MAP.put(ItemID.ROSEWOOD_BLOWPIPE_EMPTY, ItemChargeDef.blowpipe(true, false)); + CHARGE_MAP.put(ItemID.ROSEWOOD_BLOWPIPE, ItemChargeDef.blowpipe(false, true)); + + // 2. Barrows Equipment + registerBarrows(BarrowsPiece.DHAROK_HELM, ItemID.BARROWS_DHAROK_HEAD, ItemID.BARROWS_DHAROK_HEAD_100, ItemID.BARROWS_DHAROK_HEAD_75, ItemID.BARROWS_DHAROK_HEAD_50, ItemID.BARROWS_DHAROK_HEAD_25, ItemID.BARROWS_DHAROK_HEAD_BROKEN); + registerBarrows(BarrowsPiece.DHAROK_BODY, ItemID.BARROWS_DHAROK_BODY, ItemID.BARROWS_DHAROK_BODY_100, ItemID.BARROWS_DHAROK_BODY_75, ItemID.BARROWS_DHAROK_BODY_50, ItemID.BARROWS_DHAROK_BODY_25, ItemID.BARROWS_DHAROK_BODY_BROKEN); + registerBarrows(BarrowsPiece.DHAROK_LEGS, ItemID.BARROWS_DHAROK_LEGS, ItemID.BARROWS_DHAROK_LEGS_100, ItemID.BARROWS_DHAROK_LEGS_75, ItemID.BARROWS_DHAROK_LEGS_50, ItemID.BARROWS_DHAROK_LEGS_25, ItemID.BARROWS_DHAROK_LEGS_BROKEN); + registerBarrows(BarrowsPiece.DHAROK_WEAPON, ItemID.BARROWS_DHAROK_WEAPON, ItemID.BARROWS_DHAROK_WEAPON_100, ItemID.BARROWS_DHAROK_WEAPON_75, ItemID.BARROWS_DHAROK_WEAPON_50, ItemID.BARROWS_DHAROK_WEAPON_25, ItemID.BARROWS_DHAROK_WEAPON_BROKEN); + + registerBarrows(BarrowsPiece.AHRIM_HOOD, ItemID.BARROWS_AHRIM_HEAD, ItemID.BARROWS_AHRIM_HEAD_100, ItemID.BARROWS_AHRIM_HEAD_75, ItemID.BARROWS_AHRIM_HEAD_50, ItemID.BARROWS_AHRIM_HEAD_25, ItemID.BARROWS_AHRIM_HEAD_BROKEN); + registerBarrows(BarrowsPiece.AHRIM_BODY, ItemID.BARROWS_AHRIM_BODY, ItemID.BARROWS_AHRIM_BODY_100, ItemID.BARROWS_AHRIM_BODY_75, ItemID.BARROWS_AHRIM_BODY_50, ItemID.BARROWS_AHRIM_BODY_25, ItemID.BARROWS_AHRIM_BODY_BROKEN); + registerBarrows(BarrowsPiece.AHRIM_LEGS, ItemID.BARROWS_AHRIM_LEGS, ItemID.BARROWS_AHRIM_LEGS_100, ItemID.BARROWS_AHRIM_LEGS_75, ItemID.BARROWS_AHRIM_LEGS_50, ItemID.BARROWS_AHRIM_LEGS_25, ItemID.BARROWS_AHRIM_LEGS_BROKEN); + registerBarrows(BarrowsPiece.AHRIM_WEAPON, ItemID.BARROWS_AHRIM_WEAPON, ItemID.BARROWS_AHRIM_WEAPON_100, ItemID.BARROWS_AHRIM_WEAPON_75, ItemID.BARROWS_AHRIM_WEAPON_50, ItemID.BARROWS_AHRIM_WEAPON_25, ItemID.BARROWS_AHRIM_WEAPON_BROKEN); + + registerBarrows(BarrowsPiece.AHRIM_HOOD, ItemID.BARROWS_AHRIM_HEAD_ORNAMENT, ItemID.BARROWS_AHRIM_HEAD_ORNAMENT_100, ItemID.BARROWS_AHRIM_HEAD_ORNAMENT_75, ItemID.BARROWS_AHRIM_HEAD_ORNAMENT_50, ItemID.BARROWS_AHRIM_HEAD_ORNAMENT_25, ItemID.BARROWS_AHRIM_HEAD_ORNAMENT_BROKEN); + registerBarrows(BarrowsPiece.AHRIM_BODY, ItemID.BARROWS_AHRIM_BODY_ORNAMENT, ItemID.BARROWS_AHRIM_BODY_ORNAMENT_100, ItemID.BARROWS_AHRIM_BODY_ORNAMENT_75, ItemID.BARROWS_AHRIM_BODY_ORNAMENT_50, ItemID.BARROWS_AHRIM_BODY_ORNAMENT_25, ItemID.BARROWS_AHRIM_BODY_ORNAMENT_BROKEN); + registerBarrows(BarrowsPiece.AHRIM_LEGS, ItemID.BARROWS_AHRIM_LEGS_ORNAMENT, ItemID.BARROWS_AHRIM_LEGS_ORNAMENT_100, ItemID.BARROWS_AHRIM_LEGS_ORNAMENT_75, ItemID.BARROWS_AHRIM_LEGS_ORNAMENT_50, ItemID.BARROWS_AHRIM_LEGS_ORNAMENT_25, ItemID.BARROWS_AHRIM_LEGS_ORNAMENT_BROKEN); + registerBarrows(BarrowsPiece.AHRIM_WEAPON, ItemID.BARROWS_AHRIM_WEAPON_ORNAMENT, ItemID.BARROWS_AHRIM_WEAPON_ORNAMENT_100, ItemID.BARROWS_AHRIM_WEAPON_ORNAMENT_75, ItemID.BARROWS_AHRIM_WEAPON_ORNAMENT_50, ItemID.BARROWS_AHRIM_WEAPON_ORNAMENT_25, ItemID.BARROWS_AHRIM_WEAPON_ORNAMENT_BROKEN); + + registerBarrows(BarrowsPiece.GUTHAN_HELM, ItemID.BARROWS_GUTHAN_HEAD, ItemID.BARROWS_GUTHAN_HEAD_100, ItemID.BARROWS_GUTHAN_HEAD_75, ItemID.BARROWS_GUTHAN_HEAD_50, ItemID.BARROWS_GUTHAN_HEAD_25, ItemID.BARROWS_GUTHAN_HEAD_BROKEN); + registerBarrows(BarrowsPiece.GUTHAN_BODY, ItemID.BARROWS_GUTHAN_BODY, ItemID.BARROWS_GUTHAN_BODY_100, ItemID.BARROWS_GUTHAN_BODY_75, ItemID.BARROWS_GUTHAN_BODY_50, ItemID.BARROWS_GUTHAN_BODY_25, ItemID.BARROWS_GUTHAN_BODY_BROKEN); + registerBarrows(BarrowsPiece.GUTHAN_LEGS, ItemID.BARROWS_GUTHAN_LEGS, ItemID.BARROWS_GUTHAN_LEGS_100, ItemID.BARROWS_GUTHAN_LEGS_75, ItemID.BARROWS_GUTHAN_LEGS_50, ItemID.BARROWS_GUTHAN_LEGS_25, ItemID.BARROWS_GUTHAN_LEGS_BROKEN); + registerBarrows(BarrowsPiece.GUTHAN_WEAPON, ItemID.BARROWS_GUTHAN_WEAPON, ItemID.BARROWS_GUTHAN_WEAPON_100, ItemID.BARROWS_GUTHAN_WEAPON_75, ItemID.BARROWS_GUTHAN_WEAPON_50, ItemID.BARROWS_GUTHAN_WEAPON_25, ItemID.BARROWS_GUTHAN_WEAPON_BROKEN); + + registerBarrows(BarrowsPiece.KARIL_COIF, ItemID.BARROWS_KARIL_HEAD, ItemID.BARROWS_KARIL_HEAD_100, ItemID.BARROWS_KARIL_HEAD_75, ItemID.BARROWS_KARIL_HEAD_50, ItemID.BARROWS_KARIL_HEAD_25, ItemID.BARROWS_KARIL_HEAD_BROKEN); + registerBarrows(BarrowsPiece.KARIL_BODY, ItemID.BARROWS_KARIL_BODY, ItemID.BARROWS_KARIL_BODY_100, ItemID.BARROWS_KARIL_BODY_75, ItemID.BARROWS_KARIL_BODY_50, ItemID.BARROWS_KARIL_BODY_25, ItemID.BARROWS_KARIL_BODY_BROKEN); + registerBarrows(BarrowsPiece.KARIL_LEGS, ItemID.BARROWS_KARIL_LEGS, ItemID.BARROWS_KARIL_LEGS_100, ItemID.BARROWS_KARIL_LEGS_75, ItemID.BARROWS_KARIL_LEGS_50, ItemID.BARROWS_KARIL_LEGS_25, ItemID.BARROWS_KARIL_LEGS_BROKEN); + registerBarrows(BarrowsPiece.KARIL_WEAPON, ItemID.BARROWS_KARIL_WEAPON, ItemID.BARROWS_KARIL_WEAPON_100, ItemID.BARROWS_KARIL_WEAPON_75, ItemID.BARROWS_KARIL_WEAPON_50, ItemID.BARROWS_KARIL_WEAPON_25, ItemID.BARROWS_KARIL_WEAPON_BROKEN); + + registerBarrows(BarrowsPiece.TORAG_HELM, ItemID.BARROWS_TORAG_HEAD, ItemID.BARROWS_TORAG_HEAD_100, ItemID.BARROWS_TORAG_HEAD_75, ItemID.BARROWS_TORAG_HEAD_50, ItemID.BARROWS_TORAG_HEAD_25, ItemID.BARROWS_TORAG_HEAD_BROKEN); + registerBarrows(BarrowsPiece.TORAG_BODY, ItemID.BARROWS_TORAG_BODY, ItemID.BARROWS_TORAG_BODY_100, ItemID.BARROWS_TORAG_BODY_75, ItemID.BARROWS_TORAG_BODY_50, ItemID.BARROWS_TORAG_BODY_25, ItemID.BARROWS_TORAG_BODY_BROKEN); + registerBarrows(BarrowsPiece.TORAG_LEGS, ItemID.BARROWS_TORAG_LEGS, ItemID.BARROWS_TORAG_LEGS_100, ItemID.BARROWS_TORAG_LEGS_75, ItemID.BARROWS_TORAG_LEGS_50, ItemID.BARROWS_TORAG_LEGS_25, ItemID.BARROWS_TORAG_LEGS_BROKEN); + registerBarrows(BarrowsPiece.TORAG_WEAPON, ItemID.BARROWS_TORAG_WEAPON, ItemID.BARROWS_TORAG_WEAPON_100, ItemID.BARROWS_TORAG_WEAPON_75, ItemID.BARROWS_TORAG_WEAPON_50, ItemID.BARROWS_TORAG_WEAPON_25, ItemID.BARROWS_TORAG_WEAPON_BROKEN); + + registerBarrows(BarrowsPiece.VERAC_HELM, ItemID.BARROWS_VERAC_HEAD, ItemID.BARROWS_VERAC_HEAD_100, ItemID.BARROWS_VERAC_HEAD_75, ItemID.BARROWS_VERAC_HEAD_50, ItemID.BARROWS_VERAC_HEAD_25, ItemID.BARROWS_VERAC_HEAD_BROKEN); + registerBarrows(BarrowsPiece.VERAC_BODY, ItemID.BARROWS_VERAC_BODY, ItemID.BARROWS_VERAC_BODY_100, ItemID.BARROWS_VERAC_BODY_75, ItemID.BARROWS_VERAC_BODY_50, ItemID.BARROWS_VERAC_BODY_25, ItemID.BARROWS_VERAC_BODY_BROKEN); + registerBarrows(BarrowsPiece.VERAC_LEGS, ItemID.BARROWS_VERAC_LEGS, ItemID.BARROWS_VERAC_LEGS_100, ItemID.BARROWS_VERAC_LEGS_75, ItemID.BARROWS_VERAC_LEGS_50, ItemID.BARROWS_VERAC_LEGS_25, ItemID.BARROWS_VERAC_LEGS_BROKEN); + registerBarrows(BarrowsPiece.VERAC_WEAPON, ItemID.BARROWS_VERAC_WEAPON, ItemID.BARROWS_VERAC_WEAPON_100, ItemID.BARROWS_VERAC_WEAPON_75, ItemID.BARROWS_VERAC_WEAPON_50, ItemID.BARROWS_VERAC_WEAPON_25, ItemID.BARROWS_VERAC_WEAPON_BROKEN); + + // 3. Moons of Peril Armor + registerMoons(MoonPiece.BLOOD_CHEST, ItemID.BLOOD_MOON_CHESTPLATE, ItemID.BLOOD_MOON_CHESTPLATE_DEGRADED, 0, ItemID.BLOOD_MOON_CHESTPLATE_BROKEN); + registerMoons(MoonPiece.BLOOD_HELM, ItemID.BLOOD_MOON_HELM, ItemID.BLOOD_MOON_HELM_DEGRADED, 0, ItemID.BLOOD_MOON_HELM_BROKEN); + registerMoons(MoonPiece.BLOOD_LEGS, ItemID.BLOOD_MOON_TASSETS, ItemID.BLOOD_MOON_TASSETS_DEGRADED, 0, ItemID.BLOOD_MOON_TASSETS_BROKEN); + + registerMoons(MoonPiece.BLUE_CHEST, ItemID.FROST_MOON_CHESTPLATE, ItemID.FROST_MOON_CHESTPLATE_DEGRADED, 0, ItemID.FROST_MOON_CHESTPLATE_BROKEN); + registerMoons(MoonPiece.BLUE_HELM, ItemID.FROST_MOON_HELM, ItemID.FROST_MOON_HELM_DEGRADED, 0, ItemID.FROST_MOON_HELM_BROKEN); + registerMoons(MoonPiece.BLUE_LEGS, ItemID.FROST_MOON_TASSETS, ItemID.FROST_MOON_TASSETS_DEGRADED, 0, ItemID.FROST_MOON_TASSETS_BROKEN); + + registerMoons(MoonPiece.ECLIPSE_CHEST, ItemID.ECLIPSE_MOON_CHESTPLATE, ItemID.ECLIPSE_MOON_CHESTPLATE_DEGRADED, 0, ItemID.ECLIPSE_MOON_CHESTPLATE_BROKEN); + registerMoons(MoonPiece.ECLIPSE_HELM, ItemID.ECLIPSE_MOON_HELM, ItemID.ECLIPSE_MOON_HELM_DEGRADED, 0, ItemID.ECLIPSE_MOON_HELM_BROKEN); + registerMoons(MoonPiece.ECLIPSE_LEGS, ItemID.ECLIPSE_MOON_TASSETS, ItemID.ECLIPSE_MOON_TASSETS_DEGRADED, 0, ItemID.ECLIPSE_MOON_TASSETS_BROKEN); + + // 4. Dragonfire Shields + CHARGE_MAP.put(ItemID.DRAGONFIRE_SHIELD, ItemChargeDef.configCount("dragonfire_shield", null, false)); + CHARGE_MAP.put(ItemID.DRAGONFIRE_SHIELD_UNCHARGED, ItemChargeDef.uncharged()); + CHARGE_MAP.put(ItemID.DRAGONFIRE_WARD, ItemChargeDef.configCount("dragonfire_ward", null, false)); + CHARGE_MAP.put(ItemID.DRAGONFIRE_WARD_UNCHARGED, ItemChargeDef.uncharged()); + CHARGE_MAP.put(net.runelite.api.ItemID.ANCIENT_WYVERN_SHIELD, ItemChargeDef.configCount("ancient_wyvern_shield", null, false)); + CHARGE_MAP.put(net.runelite.api.ItemID.ANCIENT_WYVERN_SHIELD_21634, ItemChargeDef.uncharged()); + + // 5. Master Scroll Book & Ring of the Elements + CHARGE_MAP.put(ItemID.BOOKOFSCROLLS_CHARGED, ItemChargeDef.masterScrollBook()); + CHARGE_MAP.put(ItemID.BOOKOFSCROLLS_EMPTY, ItemChargeDef.uncharged()); + CHARGE_MAP.put(ItemID.RING_OF_ELEMENTS, ItemChargeDef.uncharged()); + CHARGE_MAP.put(ItemID.RING_OF_ELEMENTS_CHARGED, ItemChargeDef.varbit(VarbitID.RING_OF_THE_ELEMENTS_CHARGES)); + + // 6. Ghommal's Hilt + CHARGE_MAP.put(ItemID.CA_OFFHAND_EASY, ItemChargeDef.ghommalsHilt(3, VarbitID.CA_TELEPORT_COUNT_TROLLHEIM)); + CHARGE_MAP.put(ItemID.CA_OFFHAND_MEDIUM, ItemChargeDef.ghommalsHilt(5, VarbitID.CA_TELEPORT_COUNT_TROLLHEIM)); + CHARGE_MAP.put(ItemID.CA_OFFHAND_HARD, ItemChargeDef.infinite()); + CHARGE_MAP.put(ItemID.CA_OFFHAND_ELITE, ItemChargeDef.ghommalsHilt(3, VarbitID.CA_TELEPORT_COUNT_MORULREK)); + CHARGE_MAP.put(ItemID.CA_OFFHAND_MASTER, ItemChargeDef.ghommalsHilt(5, VarbitID.CA_TELEPORT_COUNT_MORULREK)); + CHARGE_MAP.put(ItemID.CA_OFFHAND_GRANDMASTER, ItemChargeDef.infinite()); + + // 7. Buckets, Cans, Pouches & Bags + CHARGE_MAP.put(ItemID.BOTTOMLESS_COMPOST_BUCKET_FILLED, ItemChargeDef.configCount("bottomless_compost_bucket", null, false)); + CHARGE_MAP.put(ItemID.BOTTOMLESS_COMPOST_BUCKET, ItemChargeDef.uncharged()); + CHARGE_MAP.put(ItemID.BOTTOMLESS_MILK_BUCKET, ItemChargeDef.configCount("bottomless_milk_bucket", null, false)); + CHARGE_MAP.put(net.runelite.api.ItemID.BOTTOMLESS_MILK_BUCKET_EMPTY, ItemChargeDef.uncharged()); + CHARGE_MAP.put(ItemID.ZEAH_WATERINGCAN, ItemChargeDef.configCount("gricollers_can", 1000, false)); + + CHARGE_MAP.put(ItemID.HUNTSMANS_KIT, ItemChargeDef.bag("huntsmans_kit")); + CHARGE_MAP.put(ItemID.TACKLE_BOX, ItemChargeDef.bag("tackle_box")); + + CHARGE_MAP.put(ItemID.RCU_POUCH_COLOSSAL, ItemChargeDef.configCount("colossal_pouch", 40, false)); + CHARGE_MAP.put(ItemID.RCU_POUCH_COLOSSAL_DEGRADE, ItemChargeDef.configCount("colossal_pouch", 40, false)); + CHARGE_MAP.put(ItemID.RCU_POUCH_SMALL, ItemChargeDef.fixed(3)); + CHARGE_MAP.put(ItemID.RCU_POUCH_MEDIUM, ItemChargeDef.configCount("essence_pouch_medium", 6, false)); + CHARGE_MAP.put(ItemID.RCU_POUCH_MEDIUM_DEGRADE, ItemChargeDef.configCount("essence_pouch_medium", 6, false)); + CHARGE_MAP.put(ItemID.RCU_POUCH_LARGE, ItemChargeDef.configCount("essence_pouch_large", 9, false)); + CHARGE_MAP.put(ItemID.RCU_POUCH_LARGE_DEGRADE, ItemChargeDef.configCount("essence_pouch_large", 9, false)); + CHARGE_MAP.put(ItemID.RCU_POUCH_GIANT, ItemChargeDef.configCount("essence_pouch_giant", 12, false)); + CHARGE_MAP.put(ItemID.RCU_POUCH_GIANT_DEGRADE, ItemChargeDef.configCount("essence_pouch_giant", 12, false)); + + CHARGE_MAP.put(ItemID.HG_MEATPOUCH_SMALL, ItemChargeDef.bag("meat_pouch")); + CHARGE_MAP.put(ItemID.HG_MEATPOUCH_SMALL_OPEN, ItemChargeDef.bag("meat_pouch")); + CHARGE_MAP.put(ItemID.HG_MEATPOUCH_LARGE, ItemChargeDef.bag("meat_pouch")); + CHARGE_MAP.put(ItemID.HG_MEATPOUCH_LARGE_OPEN, ItemChargeDef.bag("meat_pouch")); + + CHARGE_MAP.put(ItemID.HG_FURPOUCH_SMALL, ItemChargeDef.bag("fur_pouch")); + CHARGE_MAP.put(ItemID.HG_FURPOUCH_SMALL_OPEN, ItemChargeDef.bag("fur_pouch")); + CHARGE_MAP.put(ItemID.HG_FURPOUCH_LARGE, ItemChargeDef.bag("fur_pouch")); + CHARGE_MAP.put(ItemID.HG_FURPOUCH_LARGE_OPEN, ItemChargeDef.bag("fur_pouch")); + + CHARGE_MAP.put(ItemID.FLAMTAER_BAG, ItemChargeDef.bag("flamtaer_bag")); + CHARGE_MAP.put(net.runelite.api.ItemID.LOG_BASKET, ItemChargeDef.bag("log_basket")); + CHARGE_MAP.put(net.runelite.api.ItemID.FORESTRY_BASKET, ItemChargeDef.bag("log_basket")); + + CHARGE_MAP.put(ItemID.HG_QUETZALWHISTLE_BASIC, ItemChargeDef.configCount("quetzal_whistle", 5, false)); + CHARGE_MAP.put(ItemID.HG_QUETZALWHISTLE_ENHANCED, ItemChargeDef.configCount("quetzal_whistle", 20, false)); + CHARGE_MAP.put(ItemID.HG_QUETZALWHISTLE_PERFECTED, ItemChargeDef.configCount("quetzal_whistle", 50, false)); + + CHARGE_MAP.put(ItemID.MM_PREPOT_DEVICE, ItemChargeDef.configCount("chugging_barrel", null, false)); + CHARGE_MAP.put(ItemID.MM_PREPOT_DEVICE_DISASSEMBLED, ItemChargeDef.uncharged()); + + // 8. Diary & Cape Items + CHARGE_MAP.put(ItemID.FALADOR_SHIELD_EASY, ItemChargeDef.configCount("falador_shield", 1, false)); + CHARGE_MAP.put(ItemID.FALADOR_SHIELD_MEDIUM, ItemChargeDef.configCount("falador_shield", 1, false)); + CHARGE_MAP.put(ItemID.FALADOR_SHIELD_HARD, ItemChargeDef.configCount("falador_shield", 1, false)); + CHARGE_MAP.put(ItemID.FALADOR_SHIELD_ELITE, ItemChargeDef.configCount("falador_shield", 2, false)); + + CHARGE_MAP.put(ItemID.VEOS_KHAREDSTS_MEMOIRS, ItemChargeDef.configCount("kharedsts_memoirs", null, false)); + CHARGE_MAP.put(ItemID.BOOK_OF_THE_DEAD, ItemChargeDef.configCount("kharedsts_memoirs", null, false)); + + CHARGE_MAP.put(net.runelite.api.ItemID.ARDOUGNE_CLOAK_1, ItemChargeDef.configCount("ardougne_cloak", 1, false)); + CHARGE_MAP.put(net.runelite.api.ItemID.ARDOUGNE_CLOAK_2, ItemChargeDef.configCount("ardougne_cloak", 3, false)); + CHARGE_MAP.put(net.runelite.api.ItemID.ARDOUGNE_CLOAK_3, ItemChargeDef.configCount("ardougne_cloak", 5, false)); + CHARGE_MAP.put(net.runelite.api.ItemID.ARDOUGNE_CLOAK_4, ItemChargeDef.infinite()); + + CHARGE_MAP.put(net.runelite.api.ItemID.EXPLORERS_RING_1, ItemChargeDef.configCount("explorers_ring", null, false)); + CHARGE_MAP.put(net.runelite.api.ItemID.EXPLORERS_RING_2, ItemChargeDef.configCount("explorers_ring", null, false)); + CHARGE_MAP.put(net.runelite.api.ItemID.EXPLORERS_RING_3, ItemChargeDef.configCount("explorers_ring", null, false)); + CHARGE_MAP.put(net.runelite.api.ItemID.EXPLORERS_RING_4, ItemChargeDef.configCount("explorers_ring", null, false)); + + CHARGE_MAP.put(net.runelite.api.ItemID.FREMENNIK_SEA_BOOTS_1, ItemChargeDef.configCount("fremennik_sea_boots", 1, false)); + CHARGE_MAP.put(net.runelite.api.ItemID.FREMENNIK_SEA_BOOTS_2, ItemChargeDef.configCount("fremennik_sea_boots", 1, false)); + CHARGE_MAP.put(net.runelite.api.ItemID.FREMENNIK_SEA_BOOTS_3, ItemChargeDef.configCount("fremennik_sea_boots", 1, false)); + CHARGE_MAP.put(net.runelite.api.ItemID.FREMENNIK_SEA_BOOTS_4, ItemChargeDef.infinite()); + + CHARGE_MAP.put(net.runelite.api.ItemID.KANDARIN_HEADGEAR_1, ItemChargeDef.configCount("kandarin_headgear", 1, false)); + CHARGE_MAP.put(net.runelite.api.ItemID.KANDARIN_HEADGEAR_2, ItemChargeDef.configCount("kandarin_headgear", 1, false)); + CHARGE_MAP.put(net.runelite.api.ItemID.KANDARIN_HEADGEAR_3, ItemChargeDef.configCount("kandarin_headgear", 1, false)); + CHARGE_MAP.put(net.runelite.api.ItemID.KANDARIN_HEADGEAR_4, ItemChargeDef.infinite()); + + CHARGE_MAP.put(net.runelite.api.ItemID.MORYTANIA_LEGS_1, ItemChargeDef.configCount("morytania_legs", 2, false)); + CHARGE_MAP.put(net.runelite.api.ItemID.MORYTANIA_LEGS_2, ItemChargeDef.configCount("morytania_legs", 5, false)); + CHARGE_MAP.put(net.runelite.api.ItemID.MORYTANIA_LEGS_3, ItemChargeDef.infinite()); + CHARGE_MAP.put(net.runelite.api.ItemID.MORYTANIA_LEGS_4, ItemChargeDef.infinite()); + + CHARGE_MAP.put(net.runelite.api.ItemID.WESTERN_BANNER_1, ItemChargeDef.configCount("western_banner", 1, false)); + CHARGE_MAP.put(net.runelite.api.ItemID.WESTERN_BANNER_2, ItemChargeDef.configCount("western_banner", 1, false)); + CHARGE_MAP.put(net.runelite.api.ItemID.WESTERN_BANNER_3, ItemChargeDef.configCount("western_banner", 1, false)); + CHARGE_MAP.put(net.runelite.api.ItemID.WESTERN_BANNER_4, ItemChargeDef.infinite()); + + CHARGE_MAP.put(ItemID.SKILLCAPE_MAGIC, ItemChargeDef.configCount("magic_cape", 5, false)); + CHARGE_MAP.put(ItemID.SKILLCAPE_MAGIC_TRIMMED, ItemChargeDef.configCount("magic_cape", 5, false)); + + // 9. Weapons & Tools + CHARGE_MAP.put(ItemID.ABYSSAL_TENTACLE, ItemChargeDef.configCount("abyssal_tentacle", null, false)); + CHARGE_MAP.put(ItemID.LEAGUE_3_WHIP_TENTACLE, ItemChargeDef.configCount("abyssal_tentacle", null, false)); + + CHARGE_MAP.put(ItemID.SCYTHE_OF_VITUR, ItemChargeDef.configCount("scythe_of_vitur", null, false)); + CHARGE_MAP.put(ItemID.SCYTHE_OF_VITUR_OR, ItemChargeDef.configCount("scythe_of_vitur", null, false)); + CHARGE_MAP.put(ItemID.SCYTHE_OF_VITUR_BL, ItemChargeDef.configCount("scythe_of_vitur", null, false)); + CHARGE_MAP.put(ItemID.DEADMAN_BLIGHTED_SCYTHE_OF_VITUR, ItemChargeDef.configCount("scythe_of_vitur", null, false)); + CHARGE_MAP.put(ItemID.SCYTHE_OF_VITUR_UNCHARGED, ItemChargeDef.uncharged()); + CHARGE_MAP.put(ItemID.SCYTHE_OF_VITUR_UNCHARGED_OR, ItemChargeDef.uncharged()); + CHARGE_MAP.put(ItemID.SCYTHE_OF_VITUR_UNCHARGED_BL, ItemChargeDef.uncharged()); + CHARGE_MAP.put(ItemID.DEADMAN_BLIGHTED_SCYTHE_OF_VITUR_UNCHARGED, ItemChargeDef.uncharged()); + + CHARGE_MAP.put(ItemID.IBANSTAFF, ItemChargeDef.configCount("ibans_staff", 120, false)); + CHARGE_MAP.put(ItemID.IBANSTAFF_UPGRADED, ItemChargeDef.configCount("ibans_staff", 2500, false)); + CHARGE_MAP.put(ItemID.IBAN_STAFF_ORN, ItemChargeDef.configCount("ibans_staff", 120, false)); + CHARGE_MAP.put(ItemID.IBAN_STAFF_UPGRADED_ORN, ItemChargeDef.configCount("ibans_staff", 2500, false)); + + CHARGE_MAP.put(net.runelite.api.ItemID.SLAYERS_STAFF_E, ItemChargeDef.configCount("slayers_staff_e", null, false)); + + CHARGE_MAP.put(net.runelite.api.ItemID.INFERNAL_AXE, ItemChargeDef.configCount("infernal_tools", 5000, false)); + CHARGE_MAP.put(net.runelite.api.ItemID.INFERNAL_AXE_UNCHARGED, ItemChargeDef.uncharged()); + CHARGE_MAP.put(net.runelite.api.ItemID.INFERNAL_PICKAXE, ItemChargeDef.configCount("infernal_tools", 5000, false)); + CHARGE_MAP.put(net.runelite.api.ItemID.INFERNAL_PICKAXE_UNCHARGED, ItemChargeDef.uncharged()); + CHARGE_MAP.put(net.runelite.api.ItemID.INFERNAL_HARPOON, ItemChargeDef.configCount("infernal_tools", 5000, false)); + CHARGE_MAP.put(net.runelite.api.ItemID.INFERNAL_HARPOON_UNCHARGED, ItemChargeDef.uncharged()); + + CHARGE_MAP.put(net.runelite.api.ItemID.SKULL_SCEPTRE, ItemChargeDef.configCount("skull_sceptre", null, false)); + CHARGE_MAP.put(net.runelite.api.ItemID.SKULL_SCEPTRE_I, ItemChargeDef.infinite()); + + CHARGE_MAP.put(net.runelite.api.ItemID.CRYSTAL_SAW, ItemChargeDef.configCount("crystal_saw", 28, false)); + CHARGE_MAP.put(net.runelite.api.ItemID.JAR_GENERATOR, ItemChargeDef.configCount("jar_generator", 100, false)); + CHARGE_MAP.put(ItemID.MAGIC_IMP_BOX_HALF, ItemChargeDef.fixed(1)); + CHARGE_MAP.put(ItemID.MAGIC_IMP_BOX_FULL, ItemChargeDef.fixed(2)); + CHARGE_MAP.put(ItemID.ECTOPHIAL, ItemChargeDef.fixed(1)); + CHARGE_MAP.put(ItemID.ECTOPHIAL_EMPTY, ItemChargeDef.uncharged()); + CHARGE_MAP.put(net.runelite.api.ItemID.ROYAL_SEED_POD, ItemChargeDef.infinite()); + + // 10. Jewelry & Amulets + CHARGE_MAP.put(ItemID.BLOOD_ESSENCE_ACTIVE, ItemChargeDef.configCount("blood_essence", 1000, false)); + CHARGE_MAP.put(ItemID.BLOOD_ESSENCE_INACTIVE, ItemChargeDef.uncharged()); + + CHARGE_MAP.put(ItemID.DODGY_NECKLACE, ItemChargeDef.configCount("dodgy_necklace", 10, false)); + CHARGE_MAP.put(ItemID.RING_OF_FORGING, ItemChargeDef.configCount("ring_of_forging", 140, false)); + CHARGE_MAP.put(ItemID.RING_OF_RECOIL, ItemChargeDef.configCount("ring_of_recoil", 40, false)); + CHARGE_MAP.put(ItemID.MAGIC_EMERALD_NECKLACE, ItemChargeDef.configCount("binding_necklace", 16, false)); + CHARGE_MAP.put(ItemID.AMULET_OF_CHEMISTRY, ItemChargeDef.configCount("amulet_of_chemistry", 5, false)); + CHARGE_MAP.put(ItemID.AMULET_OF_BOUNTY, ItemChargeDef.configCount("amulet_of_bounty", 10, false)); + CHARGE_MAP.put(ItemID.STRANGE_OLD_LOCKPICK, ItemChargeDef.configCount("strange_old_lockpick", 50, false)); + CHARGE_MAP.put(ItemID.STRANGE_OLD_LOCKPICK_FULL, ItemChargeDef.configCount("strange_old_lockpick", 50, false)); + CHARGE_MAP.put(ItemID.CHRONICLE, ItemChargeDef.configCount("chronicle", 1000, false)); + + CHARGE_MAP.put(net.runelite.api.ItemID.BRACELET_OF_SLAUGHTER, ItemChargeDef.configCount("bracelet_of_slaughter", 30, false)); + CHARGE_MAP.put(net.runelite.api.ItemID.EXPEDITIOUS_BRACELET, ItemChargeDef.configCount("expeditious_bracelet", 30, false)); + CHARGE_MAP.put(net.runelite.api.ItemID.BRACELET_OF_CLAY, ItemChargeDef.configCount("bracelet_of_clay", 28, false)); + CHARGE_MAP.put(net.runelite.api.ItemID.FLAMTAER_BRACELET, ItemChargeDef.configCount("flamtaer_bracelet", 80, false)); + CHARGE_MAP.put(net.runelite.api.ItemID.PHOENIX_NECKLACE, ItemChargeDef.fixed(1)); + CHARGE_MAP.put(net.runelite.api.ItemID.INOCULATION_BRACELET, ItemChargeDef.configCount("inoculation_bracelet", 100, false)); + CHARGE_MAP.put(net.runelite.api.ItemID.RING_OF_ENDURANCE, ItemChargeDef.configCount("ring_of_endurance", null, false)); + CHARGE_MAP.put(net.runelite.api.ItemID.RING_OF_ENDURANCE_UNCHARGED, ItemChargeDef.uncharged()); + CHARGE_MAP.put(net.runelite.api.ItemID.AMULET_OF_ETERNAL_GLORY, ItemChargeDef.infinite()); + CHARGE_MAP.put(net.runelite.api.ItemID.ETERNAL_TELEPORT_CRYSTAL, ItemChargeDef.infinite()); + + // 11. Bags & Sacks + CHARGE_MAP.put(ItemID.COAL_BAG, ItemChargeDef.bag("coal_bag")); + CHARGE_MAP.put(ItemID.COAL_BAG_OPEN, ItemChargeDef.bag("coal_bag")); + CHARGE_MAP.put(ItemID.GEM_BAG, ItemChargeDef.bag("gem_bag")); + CHARGE_MAP.put(ItemID.GEM_BAG_OPEN, ItemChargeDef.bag("gem_bag")); + CHARGE_MAP.put(ItemID.REWARD_GEM_BAG, ItemChargeDef.bag("gem_bag")); + CHARGE_MAP.put(ItemID.REWARD_GEM_BAG_GUILD, ItemChargeDef.bag("gem_bag")); + CHARGE_MAP.put(ItemID.STAR_REWARD_GEM_BAG, ItemChargeDef.bag("gem_bag")); + CHARGE_MAP.put(ItemID.SLAYER_HERB_SACK, ItemChargeDef.bag("herb_sack")); + CHARGE_MAP.put(ItemID.SLAYER_HERB_SACK_OPEN, ItemChargeDef.bag("herb_sack")); + CHARGE_MAP.put(ItemID.SLAYER_HERB_SACK_SILK, ItemChargeDef.bag("herb_sack")); + CHARGE_MAP.put(ItemID.SLAYER_HERB_SACK_SILK_OPEN, ItemChargeDef.bag("herb_sack")); + CHARGE_MAP.put(ItemID.FISH_BARREL_CLOSED, ItemChargeDef.bag("fish_barrel")); + CHARGE_MAP.put(ItemID.FISH_BARREL_OPEN, ItemChargeDef.bag("fish_barrel")); + CHARGE_MAP.put(ItemID.PLANK_SACK, ItemChargeDef.bag("plank_sack")); + CHARGE_MAP.put(ItemID.SEED_BOX, ItemChargeDef.bag("seed_box")); + CHARGE_MAP.put(ItemID.SEED_BOX_OPEN, ItemChargeDef.bag("seed_box")); + CHARGE_MAP.put(ItemID.LOOTING_BAG, ItemChargeDef.lootingBag()); + CHARGE_MAP.put(ItemID.LOOTING_BAG_OPEN, ItemChargeDef.lootingBag()); + + // 12. Helms & Staffs & Tridents + CHARGE_MAP.put(ItemID.SERPENTINE_HELM, ItemChargeDef.uncharged()); + CHARGE_MAP.put(ItemID.SERPENTINE_HELM_CHARGED, ItemChargeDef.varbit(VarbitID.CHARGES_SERPENTINE_HELM_QUANTITY)); + CHARGE_MAP.put(ItemID.SERPENTINE_HELM_CYAN, ItemChargeDef.uncharged()); + CHARGE_MAP.put(ItemID.SERPENTINE_HELM_CHARGED_CYAN, ItemChargeDef.varbit(VarbitID.CHARGES_SERPENTINE_HELM_QUANTITY)); + CHARGE_MAP.put(ItemID.SERPENTINE_HELM_RED, ItemChargeDef.uncharged()); + CHARGE_MAP.put(ItemID.SERPENTINE_HELM_CHARGED_RED, ItemChargeDef.varbit(VarbitID.CHARGES_SERPENTINE_HELM_QUANTITY)); + + CHARGE_MAP.put(ItemID.WARPED_SCEPTRE_UNCHARGED, ItemChargeDef.uncharged()); + CHARGE_MAP.put(ItemID.WARPED_SCEPTRE, ItemChargeDef.varbit(VarbitID.CHARGES_WARPED_SCEPTRE_QUANTITY)); + + CHARGE_MAP.put(ItemID.TUMEKENS_SHADOW, ItemChargeDef.varbit(VarbitID.CHARGES_TUMEKENS_SHADOW_QUANTITY)); + CHARGE_MAP.put(ItemID.TUMEKENS_SHADOW_UNCHARGED, ItemChargeDef.uncharged()); + CHARGE_MAP.put(ItemID.DEADMAN_BLIGHTED_TUMEKENS_SHADOW, ItemChargeDef.varbit(VarbitID.CHARGES_TUMEKENS_SHADOW_QUANTITY)); + CHARGE_MAP.put(ItemID.DEADMAN_BLIGHTED_TUMEKENS_SHADOW_UNCHARGED, ItemChargeDef.uncharged()); + CHARGE_MAP.put(ItemID.TUMEKENS_HEKA, ItemChargeDef.varbit(VarbitID.CHARGES_TUMEKENS_SHADOW_QUANTITY)); + CHARGE_MAP.put(ItemID.TUMEKENS_HEKA_UNCHARGED, ItemChargeDef.uncharged()); + + CHARGE_MAP.put(ItemID.SANGUINESTI_STAFF, ItemChargeDef.varbit(VarbitID.CHARGES_SANGUINESTI_STAFF_QUANTITY)); + CHARGE_MAP.put(ItemID.SANGUINESTI_STAFF_UNCHARGED, ItemChargeDef.uncharged()); + CHARGE_MAP.put(ItemID.SANGUINESTI_STAFF_OR, ItemChargeDef.varbit(VarbitID.CHARGES_SANGUINESTI_STAFF_QUANTITY)); + CHARGE_MAP.put(ItemID.SANGUINESTI_STAFF_UNCHARGED_OR, ItemChargeDef.uncharged()); + + // Trident of the Seas + CHARGE_MAP.put(ItemID.TOTS, ItemChargeDef.varbit(VarbitID.CHARGES_TRIDENT_OF_THE_SEAS_QUANTITY)); + CHARGE_MAP.put(ItemID.TOTS_CHARGED, ItemChargeDef.varbit(VarbitID.CHARGES_TRIDENT_OF_THE_SEAS_QUANTITY)); + CHARGE_MAP.put(ItemID.TOTS_UNCHARGED, ItemChargeDef.uncharged()); + CHARGE_MAP.put(ItemID.TOTS_ORN, ItemChargeDef.varbit(VarbitID.CHARGES_TRIDENT_OF_THE_SEAS_QUANTITY)); + CHARGE_MAP.put(ItemID.TOTS_CHARGED_ORN, ItemChargeDef.varbit(VarbitID.CHARGES_TRIDENT_OF_THE_SEAS_QUANTITY)); + CHARGE_MAP.put(ItemID.TOTS_UNCHARGED_ORN, ItemChargeDef.uncharged()); + CHARGE_MAP.put(ItemID.TOTS_I_CHARGED, ItemChargeDef.varbit(VarbitID.CHARGES_TRIDENT_OF_THE_SEAS_E_QUANTITY)); + CHARGE_MAP.put(ItemID.TOTS_I_UNCHARGED, ItemChargeDef.uncharged()); + CHARGE_MAP.put(ItemID.TOTS_I_CHARGED_ORN, ItemChargeDef.varbit(VarbitID.CHARGES_TRIDENT_OF_THE_SEAS_E_QUANTITY)); + CHARGE_MAP.put(ItemID.TOTS_I_UNCHARGED_ORN, ItemChargeDef.uncharged()); + + // Trident of the Swamp + CHARGE_MAP.put(ItemID.TOXIC_TOTS_CHARGED, ItemChargeDef.varbit(665)); + CHARGE_MAP.put(ItemID.TOXIC_TOTS_UNCHARGED, ItemChargeDef.uncharged()); + CHARGE_MAP.put(ItemID.TOXIC_TOTS_CHARGED_ORN, ItemChargeDef.varbit(665)); + CHARGE_MAP.put(ItemID.TOXIC_TOTS_UNCHARGED_ORN, ItemChargeDef.uncharged()); + CHARGE_MAP.put(ItemID.TOXIC_TOTS_I_CHARGED, ItemChargeDef.varbit(665)); + CHARGE_MAP.put(ItemID.TOXIC_TOTS_I_UNCHARGED, ItemChargeDef.uncharged()); + CHARGE_MAP.put(ItemID.TOXIC_TOTS_I_CHARGED_ORN, ItemChargeDef.varbit(665)); + CHARGE_MAP.put(ItemID.TOXIC_TOTS_I_UNCHARGED_ORN, ItemChargeDef.uncharged()); + + // Toxic Staff of the Dead + CHARGE_MAP.put(ItemID.TOXIC_SOTD, ItemChargeDef.uncharged()); + CHARGE_MAP.put(ItemID.TOXIC_SOTD_CHARGED, ItemChargeDef.varbit(VarbitID.CHARGES_TOXIC_STAFF_OF_THE_DEAD_QUANTITY)); + CHARGE_MAP.put(ItemID.TOXIC_SOTD_DEADMAN, ItemChargeDef.uncharged()); + CHARGE_MAP.put(ItemID.TOXIC_SOTD_CHARGED_DEADMAN, ItemChargeDef.varbit(VarbitID.CHARGES_TOXIC_STAFF_OF_THE_DEAD_QUANTITY)); + + // Bow of Faerdhinen + CHARGE_MAP.put(ItemID.BOW_OF_FAERDHINEN_INACTIVE, ItemChargeDef.bowfaBlade(VarbitID.CHARGES_BOW_OF_FAERDHINEN_QUANTITY, VarbitID.CHARGES_BOW_OF_FAERDHINEN_CORRUPTION, true, false)); + CHARGE_MAP.put(ItemID.BOW_OF_FAERDHINEN, ItemChargeDef.bowfaBlade(VarbitID.CHARGES_BOW_OF_FAERDHINEN_QUANTITY, VarbitID.CHARGES_BOW_OF_FAERDHINEN_CORRUPTION, false, false)); + CHARGE_MAP.put(ItemID.BOW_OF_FAERDHINEN_INFINITE, ItemChargeDef.bowfaBlade(VarbitID.CHARGES_BOW_OF_FAERDHINEN_QUANTITY, VarbitID.CHARGES_BOW_OF_FAERDHINEN_CORRUPTION, false, true)); + CHARGE_MAP.put(ItemID.BOW_OF_FAERDHINEN_INFINITE_DUMMY, ItemChargeDef.bowfaBlade(VarbitID.CHARGES_BOW_OF_FAERDHINEN_QUANTITY, VarbitID.CHARGES_BOW_OF_FAERDHINEN_CORRUPTION, false, true)); + CHARGE_MAP.put(ItemID.BOW_OF_FAERDHINEN_INFINITE_ITHELL, ItemChargeDef.bowfaBlade(VarbitID.CHARGES_BOW_OF_FAERDHINEN_QUANTITY, VarbitID.CHARGES_BOW_OF_FAERDHINEN_CORRUPTION, false, true)); + CHARGE_MAP.put(ItemID.BOW_OF_FAERDHINEN_INFINITE_IORWERTH, ItemChargeDef.bowfaBlade(VarbitID.CHARGES_BOW_OF_FAERDHINEN_QUANTITY, VarbitID.CHARGES_BOW_OF_FAERDHINEN_CORRUPTION, false, true)); + CHARGE_MAP.put(ItemID.BOW_OF_FAERDHINEN_INFINITE_TRAHAEARN, ItemChargeDef.bowfaBlade(VarbitID.CHARGES_BOW_OF_FAERDHINEN_QUANTITY, VarbitID.CHARGES_BOW_OF_FAERDHINEN_CORRUPTION, false, true)); + CHARGE_MAP.put(ItemID.BOW_OF_FAERDHINEN_INFINITE_CADARN, ItemChargeDef.bowfaBlade(VarbitID.CHARGES_BOW_OF_FAERDHINEN_QUANTITY, VarbitID.CHARGES_BOW_OF_FAERDHINEN_CORRUPTION, false, true)); + CHARGE_MAP.put(ItemID.BOW_OF_FAERDHINEN_INFINITE_CRWYS, ItemChargeDef.bowfaBlade(VarbitID.CHARGES_BOW_OF_FAERDHINEN_QUANTITY, VarbitID.CHARGES_BOW_OF_FAERDHINEN_CORRUPTION, false, true)); + CHARGE_MAP.put(ItemID.BOW_OF_FAERDHINEN_INFINITE_MEILYR, ItemChargeDef.bowfaBlade(VarbitID.CHARGES_BOW_OF_FAERDHINEN_QUANTITY, VarbitID.CHARGES_BOW_OF_FAERDHINEN_CORRUPTION, false, true)); + CHARGE_MAP.put(ItemID.BOW_OF_FAERDHINEN_INFINITE_AMLODD, ItemChargeDef.bowfaBlade(VarbitID.CHARGES_BOW_OF_FAERDHINEN_QUANTITY, VarbitID.CHARGES_BOW_OF_FAERDHINEN_CORRUPTION, false, true)); + CHARGE_MAP.put(ItemID.BR_BOW_OF_FAERDHINEN, ItemChargeDef.bowfaBlade(VarbitID.CHARGES_BOW_OF_FAERDHINEN_QUANTITY, VarbitID.CHARGES_BOW_OF_FAERDHINEN_CORRUPTION, false, false)); + CHARGE_MAP.put(ItemID.BOW_OF_FAERDHINEN_INFINITE_DEADMAN, ItemChargeDef.bowfaBlade(VarbitID.CHARGES_BOW_OF_FAERDHINEN_QUANTITY, VarbitID.CHARGES_BOW_OF_FAERDHINEN_CORRUPTION, false, true)); + + // Blade of Saeldor + CHARGE_MAP.put(ItemID.BLADE_OF_SAELDOR_INACTIVE, ItemChargeDef.bowfaBlade(VarbitID.CHARGES_BLADE_OF_SAELDOR_QUANTITY, VarbitID.CHARGES_BLADE_OF_SAELDOR_CORRUPTION, true, false)); + CHARGE_MAP.put(ItemID.BLADE_OF_SAELDOR, ItemChargeDef.bowfaBlade(VarbitID.CHARGES_BLADE_OF_SAELDOR_QUANTITY, VarbitID.CHARGES_BLADE_OF_SAELDOR_CORRUPTION, false, false)); + CHARGE_MAP.put(ItemID.BLADE_OF_SAELDOR_INFINITE, ItemChargeDef.bowfaBlade(VarbitID.CHARGES_BLADE_OF_SAELDOR_QUANTITY, VarbitID.CHARGES_BLADE_OF_SAELDOR_CORRUPTION, false, true)); + CHARGE_MAP.put(ItemID.BLADE_OF_SAELDOR_INFINITE_DUMMY, ItemChargeDef.bowfaBlade(VarbitID.CHARGES_BLADE_OF_SAELDOR_QUANTITY, VarbitID.CHARGES_BLADE_OF_SAELDOR_CORRUPTION, false, true)); + CHARGE_MAP.put(ItemID.BLADE_OF_SAELDOR_INFINITE_ITHELL, ItemChargeDef.bowfaBlade(VarbitID.CHARGES_BLADE_OF_SAELDOR_QUANTITY, VarbitID.CHARGES_BLADE_OF_SAELDOR_CORRUPTION, false, true)); + CHARGE_MAP.put(ItemID.BLADE_OF_SAELDOR_INFINITE_IORWERTH, ItemChargeDef.bowfaBlade(VarbitID.CHARGES_BLADE_OF_SAELDOR_QUANTITY, VarbitID.CHARGES_BLADE_OF_SAELDOR_CORRUPTION, false, true)); + CHARGE_MAP.put(ItemID.BLADE_OF_SAELDOR_INFINITE_TRAHAEARN, ItemChargeDef.bowfaBlade(VarbitID.CHARGES_BLADE_OF_SAELDOR_QUANTITY, VarbitID.CHARGES_BLADE_OF_SAELDOR_CORRUPTION, false, true)); + CHARGE_MAP.put(ItemID.BLADE_OF_SAELDOR_INFINITE_CADARN, ItemChargeDef.bowfaBlade(VarbitID.CHARGES_BLADE_OF_SAELDOR_QUANTITY, VarbitID.CHARGES_BLADE_OF_SAELDOR_CORRUPTION, false, true)); + CHARGE_MAP.put(ItemID.BLADE_OF_SAELDOR_INFINITE_CRWYS, ItemChargeDef.bowfaBlade(VarbitID.CHARGES_BLADE_OF_SAELDOR_QUANTITY, VarbitID.CHARGES_BLADE_OF_SAELDOR_CORRUPTION, false, true)); + CHARGE_MAP.put(ItemID.BLADE_OF_SAELDOR_INFINITE_MEILYR, ItemChargeDef.bowfaBlade(VarbitID.CHARGES_BLADE_OF_SAELDOR_QUANTITY, VarbitID.CHARGES_BLADE_OF_SAELDOR_CORRUPTION, false, true)); + CHARGE_MAP.put(ItemID.BLADE_OF_SAELDOR_INFINITE_AMLODD, ItemChargeDef.bowfaBlade(VarbitID.CHARGES_BLADE_OF_SAELDOR_QUANTITY, VarbitID.CHARGES_BLADE_OF_SAELDOR_CORRUPTION, false, true)); + + // Venator Bow + CHARGE_MAP.put(ItemID.VENATOR_BOW_UNCHARGED, ItemChargeDef.uncharged()); + CHARGE_MAP.put(ItemID.VENATOR_BOW, ItemChargeDef.varbit(VarbitID.CHARGES_VENATOR_BOW_QUANTITY)); + CHARGE_MAP.put(ItemID.VENATOR_BOW_ORNAMENT, ItemChargeDef.varbit(VarbitID.CHARGES_VENATOR_BOW_QUANTITY)); + CHARGE_MAP.put(ItemID.VENATOR_BOW_ORNAMENT_UNCHARGED, ItemChargeDef.uncharged()); + + // Tonalztics & Arclight & Dizana's Quiver + CHARGE_MAP.put(ItemID.TONALZTICS_OF_RALOS_UNCHARGED, ItemChargeDef.uncharged()); + CHARGE_MAP.put(ItemID.TONALZTICS_OF_RALOS_CHARGED, ItemChargeDef.varbit(VarbitID.CHARGES_TONALZTICS_OF_RALOS_QUANTITY)); + CHARGE_MAP.put(ItemID.ARCLIGHT, ItemChargeDef.varbit(VarbitID.CHARGES_ARCLIGHT_QUANTITY)); + CHARGE_MAP.put(ItemID.ARCLIGHT_INACTIVE, ItemChargeDef.uncharged()); + + CHARGE_MAP.put(ItemID.DIZANAS_QUIVER_CHARGED, ItemChargeDef.quiver(false, false)); + CHARGE_MAP.put(ItemID.DIZANAS_QUIVER_CHARGED_TROUVER, ItemChargeDef.quiver(false, false)); + CHARGE_MAP.put(ItemID.DIZANAS_QUIVER_UNCHARGED, ItemChargeDef.quiver(true, false)); + CHARGE_MAP.put(ItemID.DIZANAS_QUIVER_UNCHARGED_TROUVER, ItemChargeDef.quiver(true, false)); + CHARGE_MAP.put(ItemID.DIZANAS_QUIVER_INFINITE, ItemChargeDef.quiver(false, true)); + CHARGE_MAP.put(ItemID.DIZANAS_QUIVER_INFINITE_TROUVER, ItemChargeDef.quiver(false, true)); + CHARGE_MAP.put(ItemID.DIZANAS_QUIVER_BROKEN, ItemChargeDef.quiver(true, false)); + CHARGE_MAP.put(ItemID.DIZANAS_QUIVER_INFINITE_BROKEN, ItemChargeDef.quiver(false, true)); + CHARGE_MAP.put(ItemID.DIZANAS_QUIVER_TROUVER_BROKEN, ItemChargeDef.quiver(true, false)); + CHARGE_MAP.put(ItemID.DIZANAS_QUIVER_TROUVER_MANGLED, ItemChargeDef.quiver(true, false)); + CHARGE_MAP.put(ItemID.DIZANAS_QUIVER_INFINITE_TROUVER_BROKEN, ItemChargeDef.quiver(false, true)); + CHARGE_MAP.put(ItemID.DIZANAS_QUIVER_INFINITE_TROUVER_MANGLED, ItemChargeDef.quiver(false, true)); + CHARGE_MAP.put(ItemID.SKILLCAPE_MAX_DIZANAS, ItemChargeDef.quiver(false, false)); + CHARGE_MAP.put(ItemID.SKILLCAPE_MAX_DIZANAS_TROUVER, ItemChargeDef.quiver(false, false)); + CHARGE_MAP.put(ItemID.SKILLCAPE_MAX_DIZANAS_BROKEN, ItemChargeDef.quiver(true, false)); + CHARGE_MAP.put(ItemID.SKILLCAPE_MAX_DIZANAS_TROUVER_BROKEN, ItemChargeDef.quiver(true, false)); + CHARGE_MAP.put(ItemID.SKILLCAPE_MAX_DIZANAS_TROUVER_MANGLED, ItemChargeDef.quiver(true, false)); + + // Tomes & Ash Sanctifier & Bonecrusher + CHARGE_MAP.put(ItemID.TOME_OF_FIRE, ItemChargeDef.varbit(VarbitID.CHARGES_TOME_OF_FIRE_QUANTITY)); + CHARGE_MAP.put(ItemID.TOME_OF_FIRE_UNCHARGED, ItemChargeDef.uncharged()); + CHARGE_MAP.put(ItemID.BR_TOME_OF_FIRE, ItemChargeDef.varbit(VarbitID.CHARGES_TOME_OF_FIRE_QUANTITY)); + CHARGE_MAP.put(ItemID.TOME_OF_WATER, ItemChargeDef.varbit(VarbitID.CHARGES_TOME_OF_WATER_QUANTITY)); + CHARGE_MAP.put(ItemID.TOME_OF_WATER_UNCHARGED, ItemChargeDef.uncharged()); + CHARGE_MAP.put(ItemID.TOME_OF_EARTH, ItemChargeDef.varbit(VarbitID.CHARGES_TOME_OF_EARTH_QUANTITY)); + CHARGE_MAP.put(ItemID.TOME_OF_EARTH_UNCHARGED, ItemChargeDef.uncharged()); + + CHARGE_MAP.put(ItemID.ASH_SANCTIFIER, ItemChargeDef.varbit(VarbitID.CHARGES_ASH_SANCTIFIER_QUANTITY)); + CHARGE_MAP.put(ItemID.BONECRUSHER, ItemChargeDef.varbit(VarbitID.CHARGES_BONECRUSHER_QUANTITY)); + CHARGE_MAP.put(ItemID.BONECRUSHER_NECKLACE, ItemChargeDef.varbit(VarbitID.CHARGES_BONECRUSHER_QUANTITY)); + CHARGE_MAP.put(ItemID.NATURE_STAFF_CHARGED, ItemChargeDef.varbit(VarbitID.CHARGES_BRYOPHYTAS_STAFF_QUANTITY)); + CHARGE_MAP.put(22368, ItemChargeDef.uncharged()); // BRYOPHYTAS_STAFF_UNCHARGED + CHARGE_MAP.put(ItemID.WATER_CIRCLET_CHARGED, ItemChargeDef.varbit(VarbitID.CHARGES_CIRCLET_OF_WATER_QUANTITY)); + CHARGE_MAP.put(ItemID.WATER_CIRCLET, ItemChargeDef.uncharged()); + + CHARGE_MAP.put(ItemID.ARCEUUS_SOULBEARER, ItemChargeDef.varbit(VarbitID.CHARGES_SOUL_BEARER_QUANTITY)); + CHARGE_MAP.put(19636, ItemChargeDef.uncharged()); + CHARGE_MAP.put(ItemID.CELESTIAL_RING_CHARGED, ItemChargeDef.varbit(VarbitID.CHARGES_CELESTIAL_RING_QUANTITY)); + CHARGE_MAP.put(ItemID.CELESTIAL_RING, ItemChargeDef.uncharged()); + + // Wilderness Weapons & Bracelet + CHARGE_MAP.put(ItemID.WILD_CAVE_CHAINMACE_CHARGED, ItemChargeDef.varbit(VarbitID.CHARGES_WILDERNESS_WEAPON_QUANTITY)); + CHARGE_MAP.put(ItemID.WILD_CAVE_CHAINMACE_UNCHARGED, ItemChargeDef.uncharged()); + CHARGE_MAP.put(ItemID.WILD_CAVE_BOW_CHARGED, ItemChargeDef.varbit(VarbitID.CHARGES_WILDERNESS_WEAPON_QUANTITY)); + CHARGE_MAP.put(ItemID.WILD_CAVE_BOW_UNCHARGED, ItemChargeDef.uncharged()); + CHARGE_MAP.put(ItemID.WILD_CAVE_SCEPTRE_CHARGED, ItemChargeDef.varbit(VarbitID.CHARGES_WILDERNESS_WEAPON_QUANTITY)); + CHARGE_MAP.put(ItemID.WILD_CAVE_SCEPTRE_UNCHARGED, ItemChargeDef.uncharged()); + CHARGE_MAP.put(ItemID.WILD_CAVE_SCEPTRE_CHARGED_RECOL, ItemChargeDef.varbit(VarbitID.CHARGES_WILDERNESS_WEAPON_QUANTITY)); + CHARGE_MAP.put(ItemID.WILD_CAVE_SCEPTRE_UNCHARGED_RECOL, ItemChargeDef.uncharged()); + CHARGE_MAP.put(ItemID.WILD_CAVE_WEBWEAVER_CHARGED, ItemChargeDef.varbit(VarbitID.CHARGES_WILDERNESS_WEAPON_QUANTITY)); + CHARGE_MAP.put(ItemID.WILD_CAVE_WEBWEAVER_UNCHARGED, ItemChargeDef.uncharged()); + CHARGE_MAP.put(ItemID.WILD_CAVE_URSINE_CHARGED, ItemChargeDef.varbit(VarbitID.CHARGES_WILDERNESS_WEAPON_QUANTITY)); + CHARGE_MAP.put(ItemID.WILD_CAVE_URSINE_UNCHARGED, ItemChargeDef.uncharged()); + CHARGE_MAP.put(ItemID.WILD_CAVE_ACCURSED_CHARGED, ItemChargeDef.varbit(VarbitID.CHARGES_WILDERNESS_WEAPON_QUANTITY)); + CHARGE_MAP.put(ItemID.WILD_CAVE_ACCURSED_UNCHARGED, ItemChargeDef.uncharged()); + CHARGE_MAP.put(ItemID.WILD_CAVE_ACCURSED_CHARGED_RECOL, ItemChargeDef.varbit(VarbitID.CHARGES_WILDERNESS_WEAPON_QUANTITY)); + CHARGE_MAP.put(ItemID.WILD_CAVE_ACCURSED_UNCHARGED_RECOL, ItemChargeDef.uncharged()); + CHARGE_MAP.put(ItemID.WILD_CAVE_BRACELET_CHARGED, ItemChargeDef.varbit(VarbitID.CHARGES_BRACELET_OF_ETHEREUM_QUANTITY)); + CHARGE_MAP.put(ItemID.WILD_CAVE_BRACELET_UNCHARGED, ItemChargeDef.uncharged()); + + // Ring of Suffering & Shadows & Echo boots & Blood fury & Misc + CHARGE_MAP.put(ItemID.ZENYTE_RING_ENCHANTED, ItemChargeDef.varbit(VarbitID.CHARGES_RING_OF_SUFFERING_QUANTITY)); + CHARGE_MAP.put(19710, ItemChargeDef.varbit(VarbitID.CHARGES_RING_OF_SUFFERING_QUANTITY)); + CHARGE_MAP.put(20655, ItemChargeDef.varbit(VarbitID.CHARGES_RING_OF_SUFFERING_QUANTITY)); + CHARGE_MAP.put(20657, ItemChargeDef.varbit(VarbitID.CHARGES_RING_OF_SUFFERING_QUANTITY)); + CHARGE_MAP.put(25246, ItemChargeDef.varbit(VarbitID.CHARGES_RING_OF_SUFFERING_QUANTITY)); + CHARGE_MAP.put(25248, ItemChargeDef.varbit(VarbitID.CHARGES_RING_OF_SUFFERING_QUANTITY)); + CHARGE_MAP.put(26761, ItemChargeDef.varbit(VarbitID.CHARGES_RING_OF_SUFFERING_QUANTITY)); + CHARGE_MAP.put(26762, ItemChargeDef.varbit(VarbitID.CHARGES_RING_OF_SUFFERING_QUANTITY)); + + CHARGE_MAP.put(ItemID.XERIC_TALISMAN, ItemChargeDef.varbit(VarbitID.CHARGES_XERICS_TALISMAN_QUANTITY)); + CHARGE_MAP.put(ItemID.XERIC_TALISMAN_EMPTY, ItemChargeDef.uncharged()); + CHARGE_MAP.put(ItemID.RING_OF_SHADOWS, ItemChargeDef.varbit(VarbitID.CHARGES_RING_OF_SHADOWS_QUANTITY)); + CHARGE_MAP.put(ItemID.RING_OF_SHADOWS_UNCHARGED, ItemChargeDef.uncharged()); + CHARGE_MAP.put(ItemID.ECHO_BOOTS, ItemChargeDef.varbit(VarbitID.CHARGES_ECHO_BOOTS_QUANTITY)); + CHARGE_MAP.put(ItemID.BLOOD_AMULET, ItemChargeDef.varbit(VarbitID.CHARGES_BLOOD_FURY_QUANTITY)); + CHARGE_MAP.put(ItemID.GIANTSOUL_AMULET_CHARGED, ItemChargeDef.varbit(VarbitID.CHARGES_GIANTSOUL_AMULET_QUANTITY)); + CHARGE_MAP.put(ItemID.GIANTSOUL_AMULET_UNCHARGED, ItemChargeDef.uncharged()); + CHARGE_MAP.put(ItemID.EYE_OF_AYAK, ItemChargeDef.varbit(VarbitID.CHARGES_EYE_OF_AYAK_QUANTITY)); + CHARGE_MAP.put(ItemID.EYE_OF_AYAK_UNCHARGED, ItemChargeDef.uncharged()); + CHARGE_MAP.put(ItemID.HORN_OF_PLENTY, ItemChargeDef.varbit(VarbitID.CHARGES_HORN_OF_PLENTY_QUANTITY)); + CHARGE_MAP.put(ItemID.HORN_OF_PLENTY_UNCHARGED, ItemChargeDef.uncharged()); + CHARGE_MAP.put(ItemID.SAILORS_AMULET, ItemChargeDef.varbit(VarbitID.CHARGES_SAILORS_AMULET_QUANTITY)); + CHARGE_MAP.put(ItemID.SAILORS_AMULET_EMPTY, ItemChargeDef.uncharged()); + CHARGE_MAP.put(ItemID.COWBELL_AMULET, ItemChargeDef.varbit(VarbitID.CHARGES_COWBELL_AMULET_QUANTITY)); + CHARGE_MAP.put(ItemID.COWBELL_AMULET_EMPTY, ItemChargeDef.uncharged()); + CHARGE_MAP.put(ItemID.PENDANT_OF_ATES, ItemChargeDef.varbit(VarbitID.CHARGES_PENDANT_OF_ATES_QUANTITY)); + CHARGE_MAP.put(ItemID.PENDANT_OF_ATES_EMPTY, ItemChargeDef.uncharged()); + CHARGE_MAP.put(ItemID.ALCHEMIST_RING, ItemChargeDef.varbit(VarbitID.CHARGES_ALCHEMISTS_AMULET_QUANTITY)); + CHARGE_MAP.put(ItemID.AMULET_OF_CHEMISTRY_IMBUED_CHARGED, ItemChargeDef.varbit(VarbitID.CHARGES_ALCHEMISTS_AMULET_QUANTITY)); + CHARGE_MAP.put(ItemID.AMULET_OF_CHEMISTRY_IMBUED_UNCHARGED, ItemChargeDef.uncharged()); + + // Crystal Armor & Tools + CHARGE_MAP.put(ItemID.CRYSTAL_HELMET, ItemChargeDef.varbit(VarbitID.CHARGES_CRYSTAL_ARMOUR_QUANTITY)); + CHARGE_MAP.put(ItemID.CRYSTAL_HELMET_INACTIVE, ItemChargeDef.uncharged()); + CHARGE_MAP.put(ItemID.CRYSTAL_CHESTPLATE, ItemChargeDef.varbit(VarbitID.CHARGES_CRYSTAL_ARMOUR_QUANTITY)); + CHARGE_MAP.put(ItemID.CRYSTAL_CHESTPLATE_INACTIVE, ItemChargeDef.uncharged()); + CHARGE_MAP.put(ItemID.CRYSTAL_PLATELEGS, ItemChargeDef.varbit(VarbitID.CHARGES_CRYSTAL_ARMOUR_QUANTITY)); + CHARGE_MAP.put(ItemID.CRYSTAL_PLATELEGS_INACTIVE, ItemChargeDef.uncharged()); + + int[] crystalArmourIds = {27697, 27701, 27709, 27713, 27721, 27725, 27733, 27737, 27745, 27749, 27757, 27761, 27769, 27773}; + for (int id : crystalArmourIds) + { + CHARGE_MAP.put(id, ItemChargeDef.varbit(VarbitID.CHARGES_CRYSTAL_ARMOUR_QUANTITY)); + } + int[] crystalArmourInactiveIds = {27699, 27703, 27711, 27715, 27723, 27727, 27735, 27739, 27747, 27751, 27759, 27763, 27771, 27775}; + for (int id : crystalArmourInactiveIds) + { + CHARGE_MAP.put(id, ItemChargeDef.uncharged()); + } + + CHARGE_MAP.put(ItemID.CRYSTAL_AXE, ItemChargeDef.varbit(VarbitID.CHARGES_CRYSTAL_TOOLS_QUANTITY)); + CHARGE_MAP.put(ItemID.CRYSTAL_AXE_INACTIVE, ItemChargeDef.uncharged()); + CHARGE_MAP.put(ItemID.CRYSTAL_AXE_2H, ItemChargeDef.varbit(VarbitID.CHARGES_CRYSTAL_TOOLS_QUANTITY)); + CHARGE_MAP.put(ItemID.CRYSTAL_AXE_2H_INACTIVE, ItemChargeDef.uncharged()); + CHARGE_MAP.put(ItemID.CRYSTAL_PICKAXE, ItemChargeDef.varbit(VarbitID.CHARGES_CRYSTAL_TOOLS_QUANTITY)); + CHARGE_MAP.put(ItemID.CRYSTAL_PICKAXE_INACTIVE, ItemChargeDef.uncharged()); + CHARGE_MAP.put(ItemID.CRYSTAL_HARPOON, ItemChargeDef.varbit(VarbitID.CHARGES_CRYSTAL_TOOLS_QUANTITY)); + CHARGE_MAP.put(ItemID.CRYSTAL_HARPOON_INACTIVE, ItemChargeDef.uncharged()); + + CHARGE_MAP.put(ItemID.CRYSTAL_BOW, ItemChargeDef.varbit(VarbitID.CHARGES_CRYSTAL_WEAK_QUANTITY)); + CHARGE_MAP.put(ItemID.CRYSTAL_BOW_INACTIVE, ItemChargeDef.uncharged()); + CHARGE_MAP.put(ItemID.CRYSTAL_HALBERD, ItemChargeDef.varbit(VarbitID.CHARGES_CRYSTAL_WEAK_QUANTITY)); + CHARGE_MAP.put(ItemID.CRYSTAL_HALBERD_INACTIVE, ItemChargeDef.uncharged()); + CHARGE_MAP.put(ItemID.CRYSTAL_SHIELD, ItemChargeDef.varbit(VarbitID.CHARGES_CRYSTAL_WEAK_QUANTITY)); + CHARGE_MAP.put(ItemID.CRYSTAL_SHIELD_INACTIVE, ItemChargeDef.uncharged()); + } + + private static void registerBarrows(BarrowsPiece piece, int baseId, int id100, int id75, int id50, int id25, int id0) + { + CHARGE_MAP.put(baseId, ItemChargeDef.barrows(piece, 100)); + CHARGE_MAP.put(id100, ItemChargeDef.barrows(piece, 100)); + CHARGE_MAP.put(id75, ItemChargeDef.barrows(piece, 75)); + CHARGE_MAP.put(id50, ItemChargeDef.barrows(piece, 50)); + CHARGE_MAP.put(id25, ItemChargeDef.barrows(piece, 25)); + CHARGE_MAP.put(id0, ItemChargeDef.barrows(piece, 0)); + } + + private static void registerMoons(MoonPiece piece, int baseId, int degradedId1, int degradedId2, int brokenId) + { + CHARGE_MAP.put(baseId, ItemChargeDef.moons(piece, 3000)); + CHARGE_MAP.put(degradedId1, ItemChargeDef.moons(piece, -1)); + if (degradedId2 != 0) + { + CHARGE_MAP.put(degradedId2, ItemChargeDef.moons(piece, -1)); + } + CHARGE_MAP.put(brokenId, ItemChargeDef.moons(piece, 0)); + } + + // Chat patterns + // 1. Blowpipe + private static final Pattern BLOWPIPE_CHECK_PATTERN = Pattern.compile( + "Darts:\\s*(?.+?)\\s+x\\s+(?[0-9,]+)\\.\\s*Scales:\\s*(?[0-9,]+)\\s*\\((?[0-9.]+)%\\)", + Pattern.CASE_INSENSITIVE + ); + private static final Pattern BLOWPIPE_CHECK_NO_DARTS_PATTERN = Pattern.compile( + "Darts:\\s*None\\.\\s*Scales:\\s*(?[0-9,]+)\\s*\\((?[0-9.]+)%\\)", + Pattern.CASE_INSENSITIVE + ); + private static final Pattern BLOWPIPE_CHECK_NO_SCALES_PATTERN = Pattern.compile( + "Darts:\\s*(?.+?)\\s+x\\s+(?[0-9,]+)\\.\\s*Scales:\\s*0\\s*\\(0%\\)", + Pattern.CASE_INSENSITIVE + ); + private static final Pattern BLOWPIPE_CHECK_EMPTY_PATTERN = Pattern.compile( + "Darts:\\s*None\\.\\s*Scales:\\s*0\\s*\\(0%\\)", + Pattern.CASE_INSENSITIVE + ); + private static final Pattern BLOWPIPE_LOAD_DARTS_PATTERN = Pattern.compile( + "You load the blowpipe with ([0-9,]+) (.+?)(?:\\.|\\z)", + Pattern.CASE_INSENSITIVE + ); + private static final Pattern BLOWPIPE_ADD_SCALES_PATTERN = Pattern.compile( + "You (?:add|load) ([0-9,]+) (?:Zulrah's )?scales (?:to|into) (?:the|your) blowpipe", + Pattern.CASE_INSENSITIVE + ); + private static final Pattern BLOWPIPE_UNCHARGE_PATTERN = Pattern.compile( + "You uncharge the blowpipe\\.", + Pattern.CASE_INSENSITIVE + ); + private static final Pattern BLOWPIPE_OUT_OF_DARTS_PATTERN = Pattern.compile( + "Your blowpipe has run out of darts\\.", + Pattern.CASE_INSENSITIVE + ); + private static final Pattern BLOWPIPE_OUT_OF_SCALES_PATTERN = Pattern.compile( + "Your blowpipe has run out of scales\\.", + Pattern.CASE_INSENSITIVE + ); + private static final Pattern BLOWPIPE_OUT_OF_CHARGES_PATTERN = Pattern.compile( + "Your blowpipe has run out of (?:charges|darts and scales)\\.", + Pattern.CASE_INSENSITIVE + ); + + // 2. Barrows & Moons colon check & degradation + private static final Pattern BARROWS_COLON_PATTERN = Pattern.compile( + "(?[^:]+?):\\s*(?\\d+)%\\s*(?:remaining|left|charges?|until degradation)?.*", + Pattern.CASE_INSENSITIVE + ); + private static final Pattern BARROWS_COLON_DEGRADED_PATTERN = Pattern.compile( + "(?[^:]+?):\\s*(?:fully\\s+degraded|degraded|broken|0% remaining).*?", + Pattern.CASE_INSENSITIVE + ); + private static final Pattern BARROWS_REPAIRED_SINGLE_PATTERN = Pattern.compile( + "(?:Your\\s+)?(?.+?)(?:\\s+(?:100|75|50|25|0))?\\s+has\\s+been\\s+repaired\\b.*", + Pattern.CASE_INSENSITIVE + ); + private static final Pattern BARROWS_REPAIRED_ALL_PATTERN = Pattern.compile( + "(?:You repair (?:all )?your (?:degraded )?(?:items|equipment|armour)|Your equipment has been repaired|All your items have been repaired)\\.?", + Pattern.CASE_INSENSITIVE + ); + private static final Pattern BARROWS_CHECK_PERCENT_PATTERN = Pattern.compile( + "(?:Your\\s+)?(?.+?)(?:\\s+(?:100|75|50|25|0))?\\s+(?:only\\s+)?has\\s+(\\d+)%\\s+(?:of\\s+its\\s+)?charges?\\s+(?:remaining|left)\\.?", + Pattern.CASE_INSENSITIVE + ); + private static final Pattern BARROWS_CHECK_NUMERIC_PATTERN = Pattern.compile( + "(?:Your\\s+)?(?.+?)(?:\\s+(?:100|75|50|25|0))?\\s+has\\s+([0-9,]+)\\s+charges?\\s+(?:remaining|left)\\.?", + Pattern.CASE_INSENSITIVE + ); + private static final Pattern BARROWS_DEGRADED_PATTERN = Pattern.compile( + "(?:Your\\s+)?(?.+?)(?:\\s+(?:100|75|50|25|0))?\\s+has\\s+(?:run\\s+out\\s+of\\s+charges|fully\\s+degraded)\\.?", + Pattern.CASE_INSENSITIVE + ); + + // 3. Moons of Peril check + private static final Pattern MOONS_CHECK_PATTERN = Pattern.compile( + "Your (?(?:Blood|Blue|Eclipse) moon [a-z]+)(?: only)? (?:has|have) (?[0-9,]+) charges? (?:remaining|left)\\.", + Pattern.CASE_INSENSITIVE + ); + + // 4. Dragonfire Shields + private static final Pattern DRAGONFIRE_CHECK_PATTERN = Pattern.compile( + "(?:The shield|Your shield|Your dragonfire shield|Your dragonfire ward|Your ancient wyvern shield) (?:only )?has (?[0-9,]+) charges?(?: remaining| left)?\\.", + Pattern.CASE_INSENSITIVE + ); + private static final Pattern DRAGONFIRE_INC_PATTERN = Pattern.compile( + "Your dragonfire (?:shield|ward) glows more brightly\\.", + Pattern.CASE_INSENSITIVE + ); + private static final Pattern DRAGONFIRE_VENT_PATTERN = Pattern.compile( + "You vent the shield's remaining charges harmlessly into the air\\.", + Pattern.CASE_INSENSITIVE + ); + private static final Pattern DRAGONFIRE_FULL_PATTERN = Pattern.compile( + "Your (?:dragonfire shield|dragonfire ward|ancient wyvern shield) is already fully charged\\.", + Pattern.CASE_INSENSITIVE + ); + private static final Pattern DRAGONFIRE_EMPTY_PATTERN = Pattern.compile( + "Your (?:shield|dragonfire shield|dragonfire ward|ancient wyvern shield) (?:has run out of charges|has no charges left)\\.", + Pattern.CASE_INSENSITIVE + ); + + // 5. Abyssal Tentacle & Scythe & Iban's & Tools + private static final Pattern TENTACLE_CHECK_PATTERN = Pattern.compile( + "Your (?:abyssal tentacle|tentacle whip) (?:only )?has ([0-9,]+) charges? (?:remaining|left)\\.", + Pattern.CASE_INSENSITIVE + ); + private static final Pattern TENTACLE_DEGRADED_PATTERN = Pattern.compile( + "Your (?:abyssal tentacle|tentacle whip) (?:has degraded|has run out of charges)\\.", + Pattern.CASE_INSENSITIVE + ); + + private static final Pattern SCYTHE_CHECK_PATTERN = Pattern.compile( + "Your Scythe of [Vv]itur (?:only )?has ([0-9,]+) charges? (?:remaining|left)\\.", + Pattern.CASE_INSENSITIVE + ); + private static final Pattern SCYTHE_EMPTY_PATTERN = Pattern.compile( + "Your Scythe of [Vv]itur (?:has run out of charges|has no charges left)\\.", + Pattern.CASE_INSENSITIVE + ); + + private static final Pattern IBANS_CHECK_PATTERN = Pattern.compile( + "(?:You have|Your staff (?:only )?has) ([0-9,]+) charges left(?: on the staff)?\\.", + Pattern.CASE_INSENSITIVE + ); + private static final Pattern IBANS_EMPTY_PATTERN = Pattern.compile( + "(?:You have no charges left on the staff\\.|Your staff has run out of charges\\.)", + Pattern.CASE_INSENSITIVE + ); + private static final Pattern IBANS_RECHARGE_PATTERN = Pattern.compile( + "You hold the staff above the well and feel the power of Zamorak flow through you\\.", + Pattern.CASE_INSENSITIVE + ); + + private static final Pattern SLAYER_STAFF_E_PATTERN = Pattern.compile( + "Your Slayer's staff \\(e\\) has ([0-9,]+) charges left\\.", + Pattern.CASE_INSENSITIVE + ); + private static final Pattern INFERNAL_CHECK_PATTERN = Pattern.compile( + "Your infernal (?:axe|pickaxe|harpoon) (?:only )?has ([0-9,]+) charges? left\\.", + Pattern.CASE_INSENSITIVE + ); + private static final Pattern SKULL_SCEPTRE_CHECK_PATTERN = Pattern.compile( + "Your [Ss]kull [Ss]ceptre has ([0-9,]+) charges left\\.", + Pattern.CASE_INSENSITIVE + ); + private static final Pattern CRYSTAL_SAW_CHECK_PATTERN = Pattern.compile( + "Your crystal saw has ([0-9,]+) charges left\\.", + Pattern.CASE_INSENSITIVE + ); + private static final Pattern JAR_GENERATOR_CHECK_PATTERN = Pattern.compile( + "Your jar generator has ([0-9,]+) charges left\\.", + Pattern.CASE_INSENSITIVE + ); + + // 6. Jewelry + private static final Pattern DODGY_CHECK_PATTERN = Pattern.compile( + "Your dodgy necklace (?:protects you\\..*It )?has (\\d+) charges? left\\.", + Pattern.CASE_INSENSITIVE + ); + private static final Pattern DODGY_BREAK_PATTERN = Pattern.compile( + "Your dodgy necklace protects you\\..*It then crumbles to dust\\.", + Pattern.CASE_INSENSITIVE + ); + + private static final Pattern FORGING_CHECK_PATTERN = Pattern.compile( + "You can smelt ([0-9]+|one) more pieces? of iron ore before a ring melts\\.", + Pattern.CASE_INSENSITIVE + ); + private static final Pattern FORGING_BREAK_PATTERN = Pattern.compile( + "Your Ring of Forging has melted\\.", + Pattern.CASE_INSENSITIVE + ); + + private static final Pattern RECOIL_CHECK_PATTERN = Pattern.compile( + "Your [Rr]ing of [Rr]ecoil has (?:one|(\\d+)) charges? left\\.", + Pattern.CASE_INSENSITIVE + ); + private static final Pattern RECOIL_BREAK_PATTERN = Pattern.compile( + "Your [Rr]ing of [Rr]ecoil has shattered\\.", + Pattern.CASE_INSENSITIVE + ); + + private static final Pattern BINDING_CHECK_PATTERN = Pattern.compile( + "Your Binding necklace has (\\d+) charges? left\\.", + Pattern.CASE_INSENSITIVE + ); + private static final Pattern BINDING_BREAK_PATTERN = Pattern.compile( + "Your Binding necklace has disintegrated\\.", + Pattern.CASE_INSENSITIVE + ); + + private static final Pattern CHEMISTRY_CHECK_PATTERN = Pattern.compile( + "Your amulet of chemistry (?:helps you create a \\d-dose potion\\. It )?has (\\d+|one) charges? left\\.", + Pattern.CASE_INSENSITIVE + ); + private static final Pattern CHEMISTRY_BREAK_PATTERN = Pattern.compile( + "Your amulet of chemistry helps you create a \\d-dose potion\\. It then crumbles to dust\\.", + Pattern.CASE_INSENSITIVE + ); + + private static final Pattern BOUNTY_CHECK_PATTERN = Pattern.compile( + "Your amulet of bounty (?:saves some seeds for you\\. It )?has (\\d+|one) charges? left\\.", + Pattern.CASE_INSENSITIVE + ); + private static final Pattern BOUNTY_BREAK_PATTERN = Pattern.compile( + "Your amulet of bounty saves some seeds for you\\. It then crumbles to dust\\.", + Pattern.CASE_INSENSITIVE + ); + + private static final Pattern SLAUGHTER_CHECK_PATTERN = Pattern.compile( + "Your bracelet of slaughter has ([0-9,]+) charges? remaining\\.", + Pattern.CASE_INSENSITIVE + ); + private static final Pattern EXPEDITIOUS_CHECK_PATTERN = Pattern.compile( + "Your expeditious bracelet has ([0-9,]+) charges? remaining\\.", + Pattern.CASE_INSENSITIVE + ); + private static final Pattern CLAY_CHECK_PATTERN = Pattern.compile( + "Your bracelet of clay has ([0-9,]+) charges? remaining\\.", + Pattern.CASE_INSENSITIVE + ); + private static final Pattern ENDURANCE_CHECK_PATTERN = Pattern.compile( + "Your ring of endurance has ([0-9,]+) charges? left\\.", + Pattern.CASE_INSENSITIVE + ); + + private static final Pattern BLOOD_ESSENCE_CHECK_PATTERN = Pattern.compile( + "Your blood essence has (?:activated! It now has )?([0-9,]+) charges? (?:remaining|left)\\.", + Pattern.CASE_INSENSITIVE + ); + private static final Pattern BLOOD_ESSENCE_EMPTY_PATTERN = Pattern.compile( + "Your blood essence has run out of charges and has disintegrated\\.", + Pattern.CASE_INSENSITIVE + ); + + private static final Pattern LOCKPICK_CHECK_PATTERN = Pattern.compile( + "Your strange old lockpick has (\\d+)(?:% of its charges remaining| charges? left)\\.", + Pattern.CASE_INSENSITIVE + ); + + private static final Pattern CHRONICLE_CHECK_PATTERN = Pattern.compile( + "Your book has ([0-9,]+) charges left\\.", + Pattern.CASE_INSENSITIVE + ); + private static final Pattern CHRONICLE_ADD_PATTERN = Pattern.compile( + "You add (?:[0-9,]+|a single) charges? to your book\\. It now has ([0-9,]+|one) charges?\\.", + Pattern.CASE_INSENSITIVE + ); + + // 7. Buckets, Cans, Diary & Containers + private static final Pattern COMPOST_BUCKET_HOLDING_PATTERN = Pattern.compile( + "Your bottomless compost bucket is currently holding ([0-9,]+|one|a single) uses?(?: of (?.+?)? ?compost)?\\.", + Pattern.CASE_INSENSITIVE + ); + private static final Pattern COMPOST_BUCKET_REMAINING_PATTERN = Pattern.compile( + "Your bottomless compost bucket has ([0-9,]+|a single) uses?(?: of (?.+?)? ?compost)? remaining\\.", + Pattern.CASE_INSENSITIVE + ); + private static final Pattern COMPOST_BUCKET_FILL_PATTERN = Pattern.compile( + "You fill your bottomless compost bucket with .*buckets? of (?.+?)? ?compost\\. Your bottomless compost bucket now contains a total of ([0-9,]+) uses\\.", + Pattern.CASE_INSENSITIVE + ); + private static final Pattern COMPOST_BUCKET_TREAT_PATTERN = Pattern.compile( + "You treat the .* with (?.*) ?compost\\.", + Pattern.CASE_INSENSITIVE + ); + private static final Pattern COMPOST_BUCKET_EMPTY_PATTERN = Pattern.compile( + "(?:Your bottomless compost bucket has run out of compost!|You discard the contents of your bottomless compost bucket\\.)", + Pattern.CASE_INSENSITIVE + ); + + private static final Pattern GRICOLLERS_CAN_CHECK_PATTERN = Pattern.compile( + "(?:Watering can charges remaining:\\s*([0-9,]+)%|Your Gricoller's can (?:contains|has)\\s*([0-9,]+)\\s*charges?)", + Pattern.CASE_INSENSITIVE + ); + private static final Pattern GRICOLLERS_CAN_FILL_PATTERN = Pattern.compile( + "You fill the watering can", + Pattern.CASE_INSENSITIVE + ); + private static final Pattern GRICOLLERS_CAN_WATER_PATTERN = Pattern.compile( + "You water", + Pattern.CASE_INSENSITIVE + ); + + private static final Pattern ESSENCE_POUCH_CHECK_PATTERN = Pattern.compile( + "There (?:is|are) ([0-9]+|one|two|three|four|five|six|seven|eight|nine|ten|eleven|twelve|thirteen|fourteen|fifteen|sixteen|seventeen|eighteen|nineteen|twenty|twenty-five|thirty|thirty-five|forty) (?normal|pure|daeyalt|guardian) essences? in (?:this|your(?: colossal| giant| large| medium)?) pouch\\.", + Pattern.CASE_INSENSITIVE + ); + private static final Pattern ESSENCE_POUCH_EMPTY_PATTERN = Pattern.compile( + "There is no essence in (?:this|your(?: colossal| giant| large| medium)?) pouch\\.", + Pattern.CASE_INSENSITIVE + ); + + private static final Pattern KHAREDST_CHECK_PATTERN = Pattern.compile( + "(?:(?:Kharedst's Memoirs)|(?:The Book of the Dead)) now has ([0-9,]+) (?:memories|memory|charges)", + Pattern.CASE_INSENSITIVE + ); + private static final Pattern KHAREDST_COVER_PATTERN = Pattern.compile( + "On the inside of the cover a message is displayed in dark ink\\. It reads: ([0-9,]+)/.+? (?:memories|memory) remain\\.", + Pattern.CASE_INSENSITIVE + ); + private static final Pattern KHAREDST_EMPTY_PATTERN = Pattern.compile( + "(?:(?:Kharedst's Memoirs)|(?:The Book of the Dead)) holds no charges\\.", + Pattern.CASE_INSENSITIVE + ); + + private static final Pattern QUETZAL_CHECK_PATTERN = Pattern.compile( + "Your quetzal whistle has ([0-9,]+) charges? remaining\\.", + Pattern.CASE_INSENSITIVE + ); + + private static final Pattern MAGIC_CAPE_CHECK_PATTERN = Pattern.compile( + "You have ([0-9,]+) spellbook swaps left today\\.", + Pattern.CASE_INSENSITIVE + ); + + private static final Pattern FALADOR_SHIELD_CHECK_PATTERN = Pattern.compile( + "You have (one|two|\\d+) remaining charges? for today\\.", + Pattern.CASE_INSENSITIVE + ); + + // 8. Bags & Sacks + private static final Pattern COAL_BAG_AMOUNT_PATTERN = Pattern.compile("The coal bag contains ([0-9]+) pieces? of coal\\.", Pattern.CASE_INSENSITIVE); + private static final Pattern COAL_BAG_ONE_PATTERN = Pattern.compile("The coal bag contains one piece of coal\\.", Pattern.CASE_INSENSITIVE); + private static final Pattern COAL_BAG_EMPTY_PATTERN = Pattern.compile("(The coal bag is empty\\.|You empty (all of )?the coal from the coal bag\\.|The coal bag is now empty\\.|You empty the coal bag\\.)", Pattern.CASE_INSENSITIVE); + private static final Pattern COAL_BAG_FULL_PATTERN = Pattern.compile("(The coal bag is now full\\.|The coal bag cannot hold any more coal\\.)", Pattern.CASE_INSENSITIVE); + private static final Pattern COAL_BAG_FILL_AMOUNT_PATTERN = Pattern.compile("You fill the coal bag with ([0-9]+) pieces? of coal\\.", Pattern.CASE_INSENSITIVE); + + private static final Pattern GEM_BAG_AMOUNT_PATTERN = Pattern.compile( + "The gem bag contains ([0-9]+) sapphires?, ([0-9]+) emeralds?, ([0-9]+) rub(?:y|ies), ([0-9]+) diamonds?, ([0-9]+) dragonstones?\\.", + Pattern.CASE_INSENSITIVE + ); + private static final Pattern GEM_BAG_TOTAL_PATTERN = Pattern.compile( + "The gem bag contains (\\d+) uncut gems?\\.", + Pattern.CASE_INSENSITIVE + ); + private static final Pattern GEM_BAG_EMPTY_PATTERN = Pattern.compile( + "(The gem bag is empty\\.|You empty (all of )?the gems from the gem bag\\.)", + Pattern.CASE_INSENSITIVE + ); + + private static final Pattern HERB_SACK_AMOUNT_PATTERN = Pattern.compile( + "The herb sack contains ([0-9]+) grimy herbs?\\.", + Pattern.CASE_INSENSITIVE + ); + private static final Pattern HERB_SACK_EMPTY_PATTERN = Pattern.compile( + "(The herb sack is empty\\.|You empty (all of )?the herbs from the herb sack\\.)", + Pattern.CASE_INSENSITIVE + ); + + private static final Pattern FISH_BARREL_AMOUNT_PATTERN = Pattern.compile( + "The fish barrel contains ([0-9]+) (?:raw )?fish\\.", + Pattern.CASE_INSENSITIVE + ); + private static final Pattern FISH_BARREL_EMPTY_PATTERN = Pattern.compile( + "(The fish barrel is empty\\.|You empty (all of )?the fish from the fish barrel\\.)", + Pattern.CASE_INSENSITIVE + ); + + private static final Pattern PLANK_SACK_AMOUNT_PATTERN = Pattern.compile( + "The plank sack contains ([0-9]+) planks?\\.", + Pattern.CASE_INSENSITIVE + ); + private static final Pattern PLANK_SACK_EMPTY_PATTERN = Pattern.compile( + "(The plank sack is empty\\.|You empty (all of )?the planks from the plank sack\\.)", + Pattern.CASE_INSENSITIVE + ); + + private static final Pattern SEED_BOX_AMOUNT_PATTERN = Pattern.compile( + "The seed box contains (\\d+) seeds?\\.", + Pattern.CASE_INSENSITIVE + ); + private static final Pattern SEED_BOX_EMPTY_PATTERN = Pattern.compile( + "The seed box is empty\\.", + Pattern.CASE_INSENSITIVE + ); + + private static final Pattern MEAT_POUCH_AMOUNT_PATTERN = Pattern.compile( + "Your meat pouch is currently holding (\\d+) meat", + Pattern.CASE_INSENSITIVE + ); + private static final Pattern MEAT_POUCH_EMPTY_PATTERN = Pattern.compile( + "(?:Your meat pouch is empty\\.|Your meat pouch is currently holding 0 meat)", + Pattern.CASE_INSENSITIVE + ); + + private static final Pattern FUR_POUCH_AMOUNT_PATTERN = Pattern.compile( + "Your fur pouch is currently holding (\\d+) fur", + Pattern.CASE_INSENSITIVE + ); + private static final Pattern FUR_POUCH_EMPTY_PATTERN = Pattern.compile( + "(?:Your fur pouch is empty\\.|Your fur pouch is currently holding 0 fur)", + Pattern.CASE_INSENSITIVE + ); + + private static final Pattern LOG_BASKET_AMOUNT_PATTERN = Pattern.compile( + "The log basket contains ([0-9]+) logs?\\.", + Pattern.CASE_INSENSITIVE + ); + + private final Client client; + private final EssentialsPlusConfig config; + private final EssentialsPlusPlugin plugin; + private final ItemManager itemManager; + private final ConfigManager configManager; + + // In-memory caches + private Integer cachedBlowpipeDarts = null; + private String cachedBlowpipeDartType = null; + private Integer cachedBlowpipeScales = null; + + private final Map cachedBarrows = new HashMap<>(); + private final Map cachedMoons = new HashMap<>(); + private final Map cachedCounts = new HashMap<>(); + + private final Map barrowsCombatTicks = new HashMap<>(); + private final Map moonsCombatTicks = new HashMap<>(); + + private int lastIronOreCount = -1; + private int lastCoalCount = -1; + private boolean pendingCoalFill = false; + private int pendingCoalFillTick = 0; + private boolean pendingCoalEmpty = false; + private int pendingCoalEmptyTick = 0; + private long cachedLootingBagCoins = 0; + private boolean lootingBagKnown = false; + private String lastAccountKey = null; + + private boolean isSmithingOrMaxCape(int itemId) + { + if (itemId <= 0 || itemManager == null) + { + return false; + } + net.runelite.api.ItemComposition comp = itemManager.getItemComposition(itemId); + if (comp == null || comp.getName() == null) + { + return false; + } + String name = comp.getName().toLowerCase(java.util.Locale.ENGLISH); + return name.contains("smithing cape") || name.equals("max cape"); + } + + public int getCoalBagCapacity() + { + ItemContainer worn = client.getItemContainer(InventoryID.WORN); + if (worn != null) + { + Item cape = worn.getItem(EquipmentInventorySlot.CAPE.getSlotIdx()); + if (cape != null && isSmithingOrMaxCape(cape.getId())) + { + return 36; + } + } + ItemContainer inv = client.getItemContainer(InventoryID.INV); + if (inv != null) + { + for (Item item : inv.getItems()) + { + if (item != null && isSmithingOrMaxCape(item.getId())) + { + return 36; + } + } + } + return 27; + } + + @Inject + public ItemCharges(Client client, EssentialsPlusConfig config, EssentialsPlusPlugin plugin, ItemManager itemManager, ConfigManager configManager) + { + this.client = client; + this.config = config; + this.plugin = plugin; + this.itemManager = itemManager; + this.configManager = configManager; + } + + public void reset() + { + saveAccountData(); + cachedBlowpipeDarts = null; + cachedBlowpipeDartType = null; + cachedBlowpipeScales = null; + cachedBarrows.clear(); + cachedMoons.clear(); + cachedCounts.clear(); + barrowsCombatTicks.clear(); + moonsCombatTicks.clear(); + lastIronOreCount = -1; + lastCoalCount = -1; + cachedLootingBagCoins = 0; + lootingBagKnown = false; + lastAccountKey = null; + } + + public void resetCoalBag() + { + cachedCounts.remove("coal_bag"); + String key = currentAccountKey(); + if (key != null && configManager != null) + { + configManager.unsetConfiguration(EssentialsPlusConfig.GROUP, CONFIG_PREFIX + "coal_bag." + key); + configManager.unsetConfiguration(EssentialsPlusConfig.GROUP, "coalBag." + key); + } + } + + public void resetAllTracked() + { + cachedBlowpipeDarts = null; + cachedBlowpipeDartType = null; + cachedBlowpipeScales = null; + cachedBarrows.clear(); + cachedMoons.clear(); + cachedCounts.clear(); + barrowsCombatTicks.clear(); + moonsCombatTicks.clear(); + + String currentKey = currentAccountKey(); + if (currentKey != null && configManager != null) + { + configManager.unsetConfiguration(EssentialsPlusConfig.GROUP, CONFIG_PREFIX + "blowpipe_darts." + currentKey); + configManager.unsetConfiguration(EssentialsPlusConfig.GROUP, CONFIG_PREFIX + "blowpipe_type." + currentKey); + configManager.unsetConfiguration(EssentialsPlusConfig.GROUP, CONFIG_PREFIX + "blowpipe_scales." + currentKey); + + for (BarrowsPiece piece : BarrowsPiece.values()) + { + configManager.unsetConfiguration(EssentialsPlusConfig.GROUP, CONFIG_PREFIX + "barrows_" + piece.getConfigKey() + "." + currentKey); + } + + for (MoonPiece piece : MoonPiece.values()) + { + configManager.unsetConfiguration(EssentialsPlusConfig.GROUP, CONFIG_PREFIX + "moons_" + piece.getConfigKey() + "." + currentKey); + } + + String[] keys = { + "coal_bag", "gem_bag", "herb_sack", "fish_barrel", "plank_sack", "seed_box", + "abyssal_tentacle", "scythe_of_vitur", "ibans_staff", "slayers_staff_e", "infernal_tools", "skull_sceptre", + "crystal_saw", "jar_generator", "blood_essence", "dodgy_necklace", "ring_of_forging", "ring_of_recoil", + "binding_necklace", "amulet_of_chemistry", "amulet_of_bounty", "bracelet_of_slaughter", "expeditious_bracelet", + "bracelet_of_clay", "flamtaer_bracelet", "inoculation_bracelet", "ring_of_endurance", "strange_old_lockpick", "chronicle", + "dragonfire_shield", "dragonfire_ward", "ancient_wyvern_shield", "bottomless_compost_bucket", "bottomless_milk_bucket", + "gricollers_can", "colossal_pouch", "essence_pouch_medium", "essence_pouch_large", "essence_pouch_giant", + "meat_pouch", "fur_pouch", "flamtaer_bag", "log_basket", "quetzal_whistle", "chugging_barrel", + "falador_shield", "kharedsts_memoirs", "ardougne_cloak", "explorers_ring", "fremennik_sea_boots", + "kandarin_headgear", "morytania_legs", "western_banner", "magic_cape" + }; + for (String k : keys) + { + configManager.unsetConfiguration(EssentialsPlusConfig.GROUP, CONFIG_PREFIX + k + "." + currentKey); + } + configManager.unsetConfiguration(EssentialsPlusConfig.GROUP, "coalBag." + currentKey); + } + } + + public void decrementCount(String key, int amount) + { + loadAccountDataIfNecessary(); + Integer val = cachedCounts.get(key); + if (val != null && val > 0) + { + cachedCounts.put(key, Math.max(0, val - amount)); + saveAccountData(); + } + } + + private String currentAccountKey() + { + long hash = client.getAccountHash(); + if (hash == -1) + { + return null; + } + RuneScapeProfileType profile = client.getWorldType() != null ? RuneScapeProfileType.getCurrent(client) : RuneScapeProfileType.STANDARD; + return EssentialsPlusPlugin.accountKey(hash, profile); + } + + private void loadAccountDataIfNecessary() + { + String currentKey = currentAccountKey(); + if (currentKey == null) + { + return; + } + + if (!currentKey.equals(lastAccountKey)) + { + lastAccountKey = currentKey; + cachedBarrows.clear(); + cachedMoons.clear(); + cachedCounts.clear(); + + if (configManager != null) + { + // 1. Blowpipe + String dartsStr = configManager.getConfiguration(EssentialsPlusConfig.GROUP, CONFIG_PREFIX + "blowpipe_darts." + currentKey); + cachedBlowpipeDarts = parseIntOrNull(dartsStr); + cachedBlowpipeDartType = configManager.getConfiguration(EssentialsPlusConfig.GROUP, CONFIG_PREFIX + "blowpipe_type." + currentKey); + String scalesStr = configManager.getConfiguration(EssentialsPlusConfig.GROUP, CONFIG_PREFIX + "blowpipe_scales." + currentKey); + cachedBlowpipeScales = parseIntOrNull(scalesStr); + + // 2. Barrows + for (BarrowsPiece piece : BarrowsPiece.values()) + { + String val = configManager.getConfiguration(EssentialsPlusConfig.GROUP, CONFIG_PREFIX + "barrows_" + piece.getConfigKey() + "." + currentKey); + Integer parsed = parseIntOrNull(val); + if (parsed != null) + { + cachedBarrows.put(piece, parsed); + } + } + + // 3. Moons + for (MoonPiece piece : MoonPiece.values()) + { + String val = configManager.getConfiguration(EssentialsPlusConfig.GROUP, CONFIG_PREFIX + "moons_" + piece.getConfigKey() + "." + currentKey); + Integer parsed = parseIntOrNull(val); + if (parsed != null) + { + cachedMoons.put(piece, parsed); + } + } + + // 4. Counts & Bags + String[] keys = { + "coal_bag", "gem_bag", "herb_sack", "fish_barrel", "plank_sack", "seed_box", + "abyssal_tentacle", "scythe_of_vitur", "ibans_staff", "slayers_staff_e", "infernal_tools", "skull_sceptre", + "crystal_saw", "jar_generator", "blood_essence", "dodgy_necklace", "ring_of_forging", "ring_of_recoil", + "binding_necklace", "amulet_of_chemistry", "amulet_of_bounty", "bracelet_of_slaughter", "expeditious_bracelet", + "bracelet_of_clay", "flamtaer_bracelet", "inoculation_bracelet", "ring_of_endurance", "strange_old_lockpick", "chronicle", + "dragonfire_shield", "dragonfire_ward", "ancient_wyvern_shield", "bottomless_compost_bucket", "bottomless_milk_bucket", + "gricollers_can", "colossal_pouch", "essence_pouch_medium", "essence_pouch_large", "essence_pouch_giant", + "meat_pouch", "fur_pouch", "flamtaer_bag", "log_basket", "quetzal_whistle", "chugging_barrel", + "falador_shield", "kharedsts_memoirs", "ardougne_cloak", "explorers_ring", "fremennik_sea_boots", + "kandarin_headgear", "morytania_legs", "western_banner", "magic_cape" + }; + for (String k : keys) + { + String val = configManager.getConfiguration(EssentialsPlusConfig.GROUP, CONFIG_PREFIX + k + "." + currentKey); + Integer parsed = parseIntOrNull(val); + if (parsed != null) + { + cachedCounts.put(k, parsed); + } + } + + // Backward compatibility: check old coal bag prefix + if (!cachedCounts.containsKey("coal_bag")) + { + String oldCoal = configManager.getConfiguration(EssentialsPlusConfig.GROUP, "coalBag." + currentKey); + Integer parsedCoal = parseIntOrNull(oldCoal); + if (parsedCoal != null) + { + cachedCounts.put("coal_bag", parsedCoal); + } + } + } + } + } + + private void saveAccountData() + { + String currentKey = lastAccountKey != null ? lastAccountKey : currentAccountKey(); + if (currentKey == null || configManager == null) + { + return; + } + + // Blowpipe + setOrUnset(CONFIG_PREFIX + "blowpipe_darts." + currentKey, cachedBlowpipeDarts); + setOrUnsetString(CONFIG_PREFIX + "blowpipe_type." + currentKey, cachedBlowpipeDartType); + setOrUnset(CONFIG_PREFIX + "blowpipe_scales." + currentKey, cachedBlowpipeScales); + + // Barrows + for (Map.Entry entry : cachedBarrows.entrySet()) + { + setOrUnset(CONFIG_PREFIX + "barrows_" + entry.getKey().getConfigKey() + "." + currentKey, entry.getValue()); + } + + // Moons + for (Map.Entry entry : cachedMoons.entrySet()) + { + setOrUnset(CONFIG_PREFIX + "moons_" + entry.getKey().getConfigKey() + "." + currentKey, entry.getValue()); + } + + // Counts + for (Map.Entry entry : cachedCounts.entrySet()) + { + setOrUnset(CONFIG_PREFIX + entry.getKey() + "." + currentKey, entry.getValue()); + } + } + + private void setOrUnset(String fullKey, Integer value) + { + if (configManager == null) return; + if (value != null) + { + configManager.setConfiguration(EssentialsPlusConfig.GROUP, fullKey, value); + } + else + { + configManager.unsetConfiguration(EssentialsPlusConfig.GROUP, fullKey); + } + } + + private void setOrUnsetString(String fullKey, String value) + { + if (configManager == null) return; + if (value != null && !value.isEmpty()) + { + configManager.setConfiguration(EssentialsPlusConfig.GROUP, fullKey, value); + } + else + { + configManager.unsetConfiguration(EssentialsPlusConfig.GROUP, fullKey); + } + } + + private Integer parseIntOrNull(String str) + { + if (str == null) + { + return null; + } + try + { + return Integer.parseInt(str.replace(",", "").trim()); + } + catch (NumberFormatException e) + { + return null; + } + } + + private int parseWordOrInt(String str) + { + if (str == null) return 0; + String s = str.trim().toLowerCase(); + switch (s) + { + case "one": case "a single": return 1; + case "two": case "both": return 2; + case "three": return 3; + case "four": return 4; + case "five": return 5; + case "six": return 6; + case "seven": return 7; + case "eight": return 8; + case "nine": return 9; + case "ten": return 10; + case "eleven": return 11; + case "twelve": return 12; + case "thirteen": return 13; + case "fourteen": return 14; + case "fifteen": return 15; + case "sixteen": return 16; + case "seventeen": return 17; + case "eighteen": return 18; + case "nineteen": return 19; + case "twenty": return 20; + case "twenty-five": return 25; + case "thirty": return 30; + case "thirty-five": return 35; + case "forty": return 40; + default: + Integer parsed = parseIntOrNull(s); + return parsed != null ? parsed : 0; + } + } + + @Subscribe + public void onGameTick(GameTick event) + { + loadAccountDataIfNecessary(); + updateLootingBag(); + + // Combat tick degradation for Barrows (~900 ticks = 1%) and Moons (~90 ticks = 1 charge) + Player me = client.getLocalPlayer(); + if (me != null && me.getInteracting() != null) + { + ItemContainer worn = client.getItemContainer(InventoryID.WORN); + if (worn != null) + { + for (EquipmentInventorySlot slot : new EquipmentInventorySlot[]{ + EquipmentInventorySlot.HEAD, EquipmentInventorySlot.BODY, + EquipmentInventorySlot.LEGS, EquipmentInventorySlot.WEAPON}) + { + Item item = worn.getItem(slot.getSlotIdx()); + if (item != null) + { + ItemChargeDef def = CHARGE_MAP.get(item.getId()); + if (def != null) + { + if (def.type == SourceType.BARROWS && def.barrowsPiece != null && def.barrowsTier > 0) + { + int ticks = barrowsCombatTicks.getOrDefault(def.barrowsPiece, 0) + 1; + if (ticks >= 900) + { + ticks = 0; + Integer cur = cachedBarrows.get(def.barrowsPiece); + if (cur != null && cur > 0) + { + cachedBarrows.put(def.barrowsPiece, cur - 1); + saveAccountData(); + } + } + barrowsCombatTicks.put(def.barrowsPiece, ticks); + } + else if (def.type == SourceType.MOONS && def.moonPiece != null && def.moonTier != 0) + { + int ticks = moonsCombatTicks.getOrDefault(def.moonPiece, 0) + 1; + if (ticks >= 90) + { + ticks = 0; + Integer cur = cachedMoons.get(def.moonPiece); + if (cur != null && cur > 0) + { + cachedMoons.put(def.moonPiece, cur - 1); + saveAccountData(); + } + } + moonsCombatTicks.put(def.moonPiece, ticks); + } + } + } + } + } + } + } + + @Subscribe + public void onAnimationChanged(AnimationChanged event) + { + if (!config.itemCharges()) + { + return; + } + + Player me = client.getLocalPlayer(); + if (event.getActor() != me || me == null) + { + return; + } + + int anim = me.getAnimation(); + if (anim == -1) + { + return; + } + + ItemContainer worn = client.getItemContainer(InventoryID.WORN); + if (worn == null) + { + return; + } + + Item weapon = worn.getItem(EquipmentInventorySlot.WEAPON.getSlotIdx()); + if (weapon == null) + { + return; + } + + int weaponId = weapon.getId(); + + // 1. Abyssal Tentacle (whip slash anim 1658) + if ((weaponId == ItemID.ABYSSAL_TENTACLE || weaponId == ItemID.LEAGUE_3_WHIP_TENTACLE) && anim == 1658) + { + decrementCount("abyssal_tentacle", 1); + } + // 2. Scythe of Vitur (scythe slash anim 8056, 1203) + else if ((weaponId == ItemID.SCYTHE_OF_VITUR || weaponId == ItemID.SCYTHE_OF_VITUR_OR + || weaponId == ItemID.SCYTHE_OF_VITUR_BL || weaponId == ItemID.DEADMAN_BLIGHTED_SCYTHE_OF_VITUR) + && (anim == 8056 || anim == 1203)) + { + decrementCount("scythe_of_vitur", 1); + } + // 3. Iban's Staff (cast anim 708, 710, 711) + else if ((weaponId == ItemID.IBANSTAFF || weaponId == ItemID.IBANSTAFF_UPGRADED + || weaponId == ItemID.IBAN_STAFF_ORN || weaponId == ItemID.IBAN_STAFF_UPGRADED_ORN) + && (anim == 708 || anim == 710 || anim == 711)) + { + decrementCount("ibans_staff", 1); + } + // 4. Toxic Blowpipe (attack anim 5061, 10023) + else if (CHARGE_MAP.containsKey(weaponId) && CHARGE_MAP.get(weaponId).type == SourceType.BLOWPIPE + && (anim == 5061 || anim == 10023)) + { + loadAccountDataIfNecessary(); + if (cachedBlowpipeDarts != null && cachedBlowpipeDarts > 0) + { + Item cape = worn.getItem(EquipmentInventorySlot.CAPE.getSlotIdx()); + int capeId = cape != null ? cape.getId() : -1; + boolean hasAssembler = false; + boolean hasAccumulator = false; + if (capeId > 0 && itemManager != null) + { + String name = itemManager.getItemComposition(capeId).getName().toLowerCase(); + hasAssembler = name.contains("assembler") || name.contains("dizana") || name.contains("quiver"); + hasAccumulator = name.contains("accumulator"); + } + + double consumeChance = hasAssembler ? 0.20 : (hasAccumulator ? 0.28 : 1.0); + if (java.util.concurrent.ThreadLocalRandom.current().nextDouble() < consumeChance) + { + cachedBlowpipeDarts = Math.max(0, cachedBlowpipeDarts - 1); + saveAccountData(); + } + } + } + } + + @Subscribe + public void onMenuOptionClicked(MenuOptionClicked event) + { + if (!config.itemCharges()) + { + return; + } + + int itemId = event.getItemId(); + String option = event.getMenuOption(); + if (option == null) + { + return; + } + + if (itemId == ItemID.COAL_BAG || itemId == ItemID.COAL_BAG_OPEN) + { + if (option.equalsIgnoreCase("Fill")) + { + pendingCoalFill = true; + pendingCoalFillTick = client.getTickCount(); + } + else if (option.equalsIgnoreCase("Empty")) + { + pendingCoalEmpty = true; + pendingCoalEmptyTick = client.getTickCount(); + } + } + } + + @Subscribe + public void onItemContainerChanged(ItemContainerChanged event) + { + if (!config.itemCharges()) + { + return; + } + + if (event.getContainerId() == InventoryID.WORN) + { + checkWornDegradedTiers(event.getItemContainer()); + } + else if (event.getContainerId() == InventoryID.INV) + { + ItemContainer inv = event.getItemContainer(); + int ironOre = 0; + int coal = 0; + for (Item item : inv.getItems()) + { + if (item != null) + { + if (item.getId() == ItemID.IRON_ORE) ironOre += item.getQuantity(); + else if (item.getId() == ItemID.COAL) coal += item.getQuantity(); + } + } + + // 1. Ring of Forging check + if (lastIronOreCount != -1 && ironOre < lastIronOreCount) + { + ItemContainer worn = client.getItemContainer(InventoryID.WORN); + if (worn != null) + { + Item ring = worn.getItem(EquipmentInventorySlot.RING.getSlotIdx()); + if (ring != null && ring.getId() == ItemID.RING_OF_FORGING) + { + int smelted = lastIronOreCount - ironOre; + decrementCount("ring_of_forging", smelted); + } + } + } + lastIronOreCount = ironOre; + + // 2. Coal Bag tracking: ONLY update when a Fill or Empty action was triggered on the bag + int currentTick = client.getTickCount(); + if (lastCoalCount != -1 && coal != lastCoalCount) + { + loadAccountDataIfNecessary(); + Integer bagCoal = cachedCounts.get("coal_bag"); + if (bagCoal == null) + { + bagCoal = 0; + } + + if (pendingCoalFill && currentTick - pendingCoalFillTick <= 2) + { + int diff = lastCoalCount - coal; // Coal lost from inventory into bag + if (diff > 0) + { + int cap = getCoalBagCapacity(); + int add = Math.min(diff, cap - bagCoal); + cachedCounts.put("coal_bag", bagCoal + add); + saveAccountData(); + } + pendingCoalFill = false; + } + else if (pendingCoalEmpty && currentTick - pendingCoalEmptyTick <= 2) + { + int diff = coal - lastCoalCount; // Coal gained into inventory from bag + if (diff > 0) + { + int rem = Math.min(diff, bagCoal); + cachedCounts.put("coal_bag", Math.max(0, bagCoal - rem)); + saveAccountData(); + } + pendingCoalEmpty = false; + } + } + lastCoalCount = coal; + } + } + + private void checkWornDegradedTiers(ItemContainer worn) + { + if (worn == null) return; + for (EquipmentInventorySlot slot : new EquipmentInventorySlot[]{ + EquipmentInventorySlot.HEAD, EquipmentInventorySlot.BODY, + EquipmentInventorySlot.LEGS, EquipmentInventorySlot.WEAPON}) + { + Item item = worn.getItem(slot.getSlotIdx()); + if (item != null) + { + ItemChargeDef def = CHARGE_MAP.get(item.getId()); + if (def != null) + { + if (def.type == SourceType.BARROWS && def.barrowsPiece != null) + { + Integer cached = cachedBarrows.get(def.barrowsPiece); + if (def.barrowsTier == 0) + { + cachedBarrows.put(def.barrowsPiece, 0); + saveAccountData(); + } + else if (cached != null && cached > def.barrowsTier) + { + cachedBarrows.put(def.barrowsPiece, def.barrowsTier); + saveAccountData(); + } + } + else if (def.type == SourceType.MOONS && def.moonPiece != null) + { + if (def.moonTier == 0) + { + cachedMoons.put(def.moonPiece, 0); + saveAccountData(); + } + } + } + } + } + } + + private void updateLootingBag() + { + ItemContainer container = client.getItemContainer(InventoryID.LOOTING_BAG); + if (container != null) + { + lootingBagKnown = true; + long coins = 0; + for (Item item : container.getItems()) + { + if (item != null && item.getId() == ItemID.COINS) + { + coins += item.getQuantity(); + } + } + cachedLootingBagCoins = coins; + } + } + + @Subscribe + public void onGameStateChanged(GameStateChanged event) + { + if (event.getGameState() != GameState.LOGGED_IN) + { + reset(); + } + } + + @Subscribe + public void onChatMessage(ChatMessage event) + { + if (event.getType() != ChatMessageType.GAMEMESSAGE && event.getType() != ChatMessageType.SPAM && event.getType() != ChatMessageType.MESBOX) + { + return; + } + + String message = Text.removeTags(event.getMessage()).replace('’', '\'').replace('‘', '\'').trim(); + loadAccountDataIfNecessary(); + + // 1. Toxic Blowpipe + Matcher mBpCheck = BLOWPIPE_CHECK_PATTERN.matcher(message); + if (mBpCheck.find()) + { + cachedBlowpipeDartType = mBpCheck.group("dart").trim(); + cachedBlowpipeDarts = parseIntOrNull(mBpCheck.group("darts")); + cachedBlowpipeScales = parseIntOrNull(mBpCheck.group("scales")); + saveAccountData(); + return; + } + + Matcher mBpNoDarts = BLOWPIPE_CHECK_NO_DARTS_PATTERN.matcher(message); + if (mBpNoDarts.find()) + { + cachedBlowpipeDartType = null; + cachedBlowpipeDarts = 0; + cachedBlowpipeScales = parseIntOrNull(mBpNoDarts.group("scales")); + saveAccountData(); + return; + } + + Matcher mBpNoScales = BLOWPIPE_CHECK_NO_SCALES_PATTERN.matcher(message); + if (mBpNoScales.find()) + { + cachedBlowpipeDartType = mBpNoScales.group("dart").trim(); + cachedBlowpipeDarts = parseIntOrNull(mBpNoScales.group("darts")); + cachedBlowpipeScales = 0; + saveAccountData(); + return; + } + + if (BLOWPIPE_CHECK_EMPTY_PATTERN.matcher(message).find() || BLOWPIPE_UNCHARGE_PATTERN.matcher(message).find() || BLOWPIPE_OUT_OF_CHARGES_PATTERN.matcher(message).find()) + { + cachedBlowpipeDartType = null; + cachedBlowpipeDarts = 0; + cachedBlowpipeScales = 0; + saveAccountData(); + return; + } + + Matcher mBpLoadDarts = BLOWPIPE_LOAD_DARTS_PATTERN.matcher(message); + if (mBpLoadDarts.find()) + { + int count = parseIntOrNull(mBpLoadDarts.group(1)); + String type = mBpLoadDarts.group(2).trim(); + cachedBlowpipeDartType = type; + cachedBlowpipeDarts = (cachedBlowpipeDarts != null ? cachedBlowpipeDarts : 0) + count; + saveAccountData(); + return; + } + + Matcher mBpAddScales = BLOWPIPE_ADD_SCALES_PATTERN.matcher(message); + if (mBpAddScales.find()) + { + int count = parseIntOrNull(mBpAddScales.group(1)); + cachedBlowpipeScales = (cachedBlowpipeScales != null ? cachedBlowpipeScales : 0) + count; + saveAccountData(); + return; + } + + if (BLOWPIPE_OUT_OF_DARTS_PATTERN.matcher(message).find()) + { + cachedBlowpipeDarts = 0; + saveAccountData(); + return; + } + + if (BLOWPIPE_OUT_OF_SCALES_PATTERN.matcher(message).find()) + { + cachedBlowpipeScales = 0; + saveAccountData(); + return; + } + + // 2. Moons of Peril check + Matcher mMoons = MOONS_CHECK_PATTERN.matcher(message); + if (mMoons.find()) + { + String itemName = mMoons.group("item"); + int charges = parseIntOrNull(mMoons.group("charges")); + MoonPiece piece = matchMoonPiece(itemName); + if (piece != null) + { + cachedMoons.put(piece, charges); + saveAccountData(); + return; + } + } + + // 3. Barrows & Moons colon check + Matcher mBarrowsColon = BARROWS_COLON_PATTERN.matcher(message); + if (mBarrowsColon.find()) + { + String itemName = mBarrowsColon.group("item"); + int pct = Integer.parseInt(mBarrowsColon.group("pct")); + BarrowsPiece piece = matchBarrowsPiece(itemName); + if (piece != null) + { + cachedBarrows.put(piece, pct); + saveAccountData(); + return; + } + MoonPiece moonPiece = matchMoonPiece(itemName); + if (moonPiece != null) + { + cachedMoons.put(moonPiece, (pct * 3000) / 100); + saveAccountData(); + return; + } + } + + Matcher mBarrowsColonDeg = BARROWS_COLON_DEGRADED_PATTERN.matcher(message); + if (mBarrowsColonDeg.find()) + { + String itemName = mBarrowsColonDeg.group("item"); + BarrowsPiece piece = matchBarrowsPiece(itemName); + if (piece != null) + { + cachedBarrows.put(piece, 0); + saveAccountData(); + return; + } + MoonPiece moonPiece = matchMoonPiece(itemName); + if (moonPiece != null) + { + cachedMoons.put(moonPiece, 0); + saveAccountData(); + return; + } + } + + if (BARROWS_REPAIRED_ALL_PATTERN.matcher(message).find()) + { + for (BarrowsPiece piece : BarrowsPiece.values()) + { + cachedBarrows.put(piece, 100); + } + for (MoonPiece piece : MoonPiece.values()) + { + cachedMoons.put(piece, 3000); + } + saveAccountData(); + return; + } + + Matcher mBarrowsRepaired = BARROWS_REPAIRED_SINGLE_PATTERN.matcher(message); + if (mBarrowsRepaired.find()) + { + String itemName = mBarrowsRepaired.group("item"); + BarrowsPiece piece = matchBarrowsPiece(itemName); + if (piece != null) + { + cachedBarrows.put(piece, 100); + saveAccountData(); + return; + } + MoonPiece moonPiece = matchMoonPiece(itemName); + if (moonPiece != null) + { + cachedMoons.put(moonPiece, 3000); + saveAccountData(); + return; + } + } + + Matcher mBarrowsPct = BARROWS_CHECK_PERCENT_PATTERN.matcher(message); + if (mBarrowsPct.find()) + { + String itemName = mBarrowsPct.group(1); + int pct = Integer.parseInt(mBarrowsPct.group(2)); + BarrowsPiece piece = matchBarrowsPiece(itemName); + if (piece != null) + { + cachedBarrows.put(piece, pct); + saveAccountData(); + return; + } + } + + Matcher mBarrowsNum = BARROWS_CHECK_NUMERIC_PATTERN.matcher(message); + if (mBarrowsNum.find()) + { + String itemName = mBarrowsNum.group(1); + Integer count = parseIntOrNull(mBarrowsNum.group(2)); + if (count != null) + { + int pct = (count <= 100) ? count : Math.max(0, Math.min(100, (int) Math.round((count * 100.0) / 100000.0))); + BarrowsPiece piece = matchBarrowsPiece(itemName); + if (piece != null) + { + cachedBarrows.put(piece, pct); + saveAccountData(); + return; + } + } + } + + Matcher mBarrowsDeg = BARROWS_DEGRADED_PATTERN.matcher(message); + if (mBarrowsDeg.find()) + { + String itemName = mBarrowsDeg.group("item"); + BarrowsPiece piece = matchBarrowsPiece(itemName); + if (piece != null) + { + cachedBarrows.put(piece, 0); + saveAccountData(); + return; + } + MoonPiece moonPiece = matchMoonPiece(itemName); + if (moonPiece != null) + { + cachedMoons.put(moonPiece, 0); + saveAccountData(); + return; + } + } + + // 4. Dragonfire Shields + Matcher mDf = DRAGONFIRE_CHECK_PATTERN.matcher(message); + if (mDf.find()) + { + int charges = parseIntOrNull(mDf.group("charges")); + cachedCounts.put("dragonfire_shield", charges); + cachedCounts.put("dragonfire_ward", charges); + cachedCounts.put("ancient_wyvern_shield", charges); + saveAccountData(); + return; + } + if (DRAGONFIRE_INC_PATTERN.matcher(message).find()) + { + Integer cur = cachedCounts.get("dragonfire_shield"); + if (cur != null) + { + cachedCounts.put("dragonfire_shield", Math.min(50, cur + 1)); + cachedCounts.put("dragonfire_ward", Math.min(50, cur + 1)); + saveAccountData(); + } + return; + } + if (DRAGONFIRE_VENT_PATTERN.matcher(message).find() || DRAGONFIRE_EMPTY_PATTERN.matcher(message).find()) + { + cachedCounts.put("dragonfire_shield", 0); + cachedCounts.put("dragonfire_ward", 0); + cachedCounts.put("ancient_wyvern_shield", 0); + saveAccountData(); + return; + } + if (DRAGONFIRE_FULL_PATTERN.matcher(message).find()) + { + cachedCounts.put("dragonfire_shield", 50); + cachedCounts.put("dragonfire_ward", 50); + cachedCounts.put("ancient_wyvern_shield", 50); + saveAccountData(); + return; + } + + // 5. Abyssal Tentacle & Scythe & Iban's & Tools + Matcher mTent = TENTACLE_CHECK_PATTERN.matcher(message); + if (mTent.find()) + { + cachedCounts.put("abyssal_tentacle", parseIntOrNull(mTent.group(1))); + saveAccountData(); + return; + } + if (TENTACLE_DEGRADED_PATTERN.matcher(message).find()) + { + cachedCounts.put("abyssal_tentacle", 0); + saveAccountData(); + return; + } + + Matcher mScythe = SCYTHE_CHECK_PATTERN.matcher(message); + if (mScythe.find()) + { + cachedCounts.put("scythe_of_vitur", parseIntOrNull(mScythe.group(1))); + saveAccountData(); + return; + } + if (SCYTHE_EMPTY_PATTERN.matcher(message).find()) + { + cachedCounts.put("scythe_of_vitur", 0); + saveAccountData(); + return; + } + + Matcher mIban = IBANS_CHECK_PATTERN.matcher(message); + if (mIban.find()) + { + cachedCounts.put("ibans_staff", parseIntOrNull(mIban.group(1))); + saveAccountData(); + return; + } + if (IBANS_EMPTY_PATTERN.matcher(message).find()) + { + cachedCounts.put("ibans_staff", 0); + saveAccountData(); + return; + } + if (IBANS_RECHARGE_PATTERN.matcher(message).find()) + { + cachedCounts.put("ibans_staff", 2500); + saveAccountData(); + return; + } + + Matcher mSlayerStaff = SLAYER_STAFF_E_PATTERN.matcher(message); + if (mSlayerStaff.find()) + { + cachedCounts.put("slayers_staff_e", parseIntOrNull(mSlayerStaff.group(1))); + saveAccountData(); + return; + } + + Matcher mInfernal = INFERNAL_CHECK_PATTERN.matcher(message); + if (mInfernal.find()) + { + cachedCounts.put("infernal_tools", parseIntOrNull(mInfernal.group(1))); + saveAccountData(); + return; + } + + Matcher mSkull = SKULL_SCEPTRE_CHECK_PATTERN.matcher(message); + if (mSkull.find()) + { + cachedCounts.put("skull_sceptre", parseIntOrNull(mSkull.group(1))); + saveAccountData(); + return; + } + + Matcher mSaw = CRYSTAL_SAW_CHECK_PATTERN.matcher(message); + if (mSaw.find()) + { + cachedCounts.put("crystal_saw", parseIntOrNull(mSaw.group(1))); + saveAccountData(); + return; + } + + Matcher mJar = JAR_GENERATOR_CHECK_PATTERN.matcher(message); + if (mJar.find()) + { + cachedCounts.put("jar_generator", parseIntOrNull(mJar.group(1))); + saveAccountData(); + return; + } + + // 6. Jewelry + Matcher mDodgy = DODGY_CHECK_PATTERN.matcher(message); + if (mDodgy.find()) + { + cachedCounts.put("dodgy_necklace", parseIntOrNull(mDodgy.group(1))); + saveAccountData(); + return; + } + if (DODGY_BREAK_PATTERN.matcher(message).find()) + { + cachedCounts.put("dodgy_necklace", 10); + saveAccountData(); + return; + } + + Matcher mForging = FORGING_CHECK_PATTERN.matcher(message); + if (mForging.find()) + { + cachedCounts.put("ring_of_forging", parseWordOrInt(mForging.group(1))); + saveAccountData(); + return; + } + if (FORGING_BREAK_PATTERN.matcher(message).find()) + { + cachedCounts.put("ring_of_forging", 140); + saveAccountData(); + return; + } + + Matcher mRecoil = RECOIL_CHECK_PATTERN.matcher(message); + if (mRecoil.find()) + { + String grp = mRecoil.group(1); + cachedCounts.put("ring_of_recoil", grp == null ? 1 : parseIntOrNull(grp)); + saveAccountData(); + return; + } + if (RECOIL_BREAK_PATTERN.matcher(message).find()) + { + cachedCounts.put("ring_of_recoil", 40); + saveAccountData(); + return; + } + + Matcher mBinding = BINDING_CHECK_PATTERN.matcher(message); + if (mBinding.find()) + { + cachedCounts.put("binding_necklace", parseIntOrNull(mBinding.group(1))); + saveAccountData(); + return; + } + if (BINDING_BREAK_PATTERN.matcher(message).find()) + { + cachedCounts.put("binding_necklace", 16); + saveAccountData(); + return; + } + + Matcher mChem = CHEMISTRY_CHECK_PATTERN.matcher(message); + if (mChem.find()) + { + cachedCounts.put("amulet_of_chemistry", parseWordOrInt(mChem.group(1))); + saveAccountData(); + return; + } + if (CHEMISTRY_BREAK_PATTERN.matcher(message).find()) + { + cachedCounts.put("amulet_of_chemistry", 5); + saveAccountData(); + return; + } + + Matcher mBounty = BOUNTY_CHECK_PATTERN.matcher(message); + if (mBounty.find()) + { + cachedCounts.put("amulet_of_bounty", parseWordOrInt(mBounty.group(1))); + saveAccountData(); + return; + } + if (BOUNTY_BREAK_PATTERN.matcher(message).find()) + { + cachedCounts.put("amulet_of_bounty", 10); + saveAccountData(); + return; + } + + Matcher mSlaughter = SLAUGHTER_CHECK_PATTERN.matcher(message); + if (mSlaughter.find()) + { + cachedCounts.put("bracelet_of_slaughter", parseIntOrNull(mSlaughter.group(1))); + saveAccountData(); + return; + } + + Matcher mExpeditious = EXPEDITIOUS_CHECK_PATTERN.matcher(message); + if (mExpeditious.find()) + { + cachedCounts.put("expeditious_bracelet", parseIntOrNull(mExpeditious.group(1))); + saveAccountData(); + return; + } + + Matcher mClay = CLAY_CHECK_PATTERN.matcher(message); + if (mClay.find()) + { + cachedCounts.put("bracelet_of_clay", parseIntOrNull(mClay.group(1))); + saveAccountData(); + return; + } + + Matcher mEndurance = ENDURANCE_CHECK_PATTERN.matcher(message); + if (mEndurance.find()) + { + cachedCounts.put("ring_of_endurance", parseIntOrNull(mEndurance.group(1))); + saveAccountData(); + return; + } + + Matcher mEss = BLOOD_ESSENCE_CHECK_PATTERN.matcher(message); + if (mEss.find()) + { + cachedCounts.put("blood_essence", parseIntOrNull(mEss.group(1))); + saveAccountData(); + return; + } + if (BLOOD_ESSENCE_EMPTY_PATTERN.matcher(message).find()) + { + cachedCounts.put("blood_essence", 0); + saveAccountData(); + return; + } + + Matcher mLock = LOCKPICK_CHECK_PATTERN.matcher(message); + if (mLock.find()) + { + cachedCounts.put("strange_old_lockpick", parseIntOrNull(mLock.group(1))); + saveAccountData(); + return; + } + + Matcher mChron = CHRONICLE_CHECK_PATTERN.matcher(message); + if (mChron.find()) + { + cachedCounts.put("chronicle", parseIntOrNull(mChron.group(1))); + saveAccountData(); + return; + } + Matcher mChronAdd = CHRONICLE_ADD_PATTERN.matcher(message); + if (mChronAdd.find()) + { + cachedCounts.put("chronicle", parseWordOrInt(mChronAdd.group(1))); + saveAccountData(); + return; + } + + // 7. Buckets, Cans, Pouches & Diary + Matcher mCompHold = COMPOST_BUCKET_HOLDING_PATTERN.matcher(message); + if (mCompHold.find()) + { + cachedCounts.put("bottomless_compost_bucket", parseWordOrInt(mCompHold.group(1))); + saveAccountData(); + return; + } + Matcher mCompRem = COMPOST_BUCKET_REMAINING_PATTERN.matcher(message); + if (mCompRem.find()) + { + cachedCounts.put("bottomless_compost_bucket", parseWordOrInt(mCompRem.group(1))); + saveAccountData(); + return; + } + Matcher mCompFill = COMPOST_BUCKET_FILL_PATTERN.matcher(message); + if (mCompFill.find()) + { + cachedCounts.put("bottomless_compost_bucket", parseIntOrNull(mCompFill.group(1))); + saveAccountData(); + return; + } + if (COMPOST_BUCKET_TREAT_PATTERN.matcher(message).find()) + { + decrementCount("bottomless_compost_bucket", 1); + return; + } + if (COMPOST_BUCKET_EMPTY_PATTERN.matcher(message).find()) + { + cachedCounts.put("bottomless_compost_bucket", 0); + saveAccountData(); + return; + } + + Matcher mGricoller = GRICOLLERS_CAN_CHECK_PATTERN.matcher(message); + if (mGricoller.find()) + { + if (mGricoller.group(1) != null) + { + int pct = Integer.parseInt(mGricoller.group(1)); + cachedCounts.put("gricollers_can", pct * 10); + } + else if (mGricoller.group(2) != null) + { + int charges = parseIntOrNull(mGricoller.group(2)); + cachedCounts.put("gricollers_can", charges); + } + saveAccountData(); + return; + } + if (GRICOLLERS_CAN_FILL_PATTERN.matcher(message).find()) + { + cachedCounts.put("gricollers_can", 1000); + saveAccountData(); + return; + } + if (GRICOLLERS_CAN_WATER_PATTERN.matcher(message).find()) + { + decrementCount("gricollers_can", 1); + return; + } + + Matcher mPouch = ESSENCE_POUCH_CHECK_PATTERN.matcher(message); + if (mPouch.find()) + { + int amount = parseWordOrInt(mPouch.group(1)); + cachedCounts.put("colossal_pouch", amount); + saveAccountData(); + return; + } + if (ESSENCE_POUCH_EMPTY_PATTERN.matcher(message).find()) + { + cachedCounts.put("colossal_pouch", 0); + saveAccountData(); + return; + } + + Matcher mKharedst = KHAREDST_CHECK_PATTERN.matcher(message); + if (mKharedst.find()) + { + cachedCounts.put("kharedsts_memoirs", parseIntOrNull(mKharedst.group(1))); + saveAccountData(); + return; + } + Matcher mKharedstCover = KHAREDST_COVER_PATTERN.matcher(message); + if (mKharedstCover.find()) + { + cachedCounts.put("kharedsts_memoirs", parseIntOrNull(mKharedstCover.group(1))); + saveAccountData(); + return; + } + if (KHAREDST_EMPTY_PATTERN.matcher(message).find()) + { + cachedCounts.put("kharedsts_memoirs", 0); + saveAccountData(); + return; + } + + Matcher mQuetzal = QUETZAL_CHECK_PATTERN.matcher(message); + if (mQuetzal.find()) + { + cachedCounts.put("quetzal_whistle", parseIntOrNull(mQuetzal.group(1))); + saveAccountData(); + return; + } + + Matcher mMagicCape = MAGIC_CAPE_CHECK_PATTERN.matcher(message); + if (mMagicCape.find()) + { + cachedCounts.put("magic_cape", parseIntOrNull(mMagicCape.group(1))); + saveAccountData(); + return; + } + + Matcher mFalador = FALADOR_SHIELD_CHECK_PATTERN.matcher(message); + if (mFalador.find()) + { + cachedCounts.put("falador_shield", parseWordOrInt(mFalador.group(1))); + saveAccountData(); + return; + } + + // 8. Coal Bag & Gem Bag & Herb Sack & Fish Barrel & Plank Sack & Seed Box & Pouches + Matcher mCoal = COAL_BAG_AMOUNT_PATTERN.matcher(message); + if (mCoal.find()) + { + cachedCounts.put("coal_bag", parseIntOrNull(mCoal.group(1))); + saveAccountData(); + return; + } + if (COAL_BAG_ONE_PATTERN.matcher(message).find()) + { + cachedCounts.put("coal_bag", 1); + saveAccountData(); + return; + } + if (COAL_BAG_EMPTY_PATTERN.matcher(message).find()) + { + cachedCounts.put("coal_bag", 0); + saveAccountData(); + return; + } + if (COAL_BAG_FULL_PATTERN.matcher(message).find()) + { + cachedCounts.put("coal_bag", getCoalBagCapacity()); + saveAccountData(); + return; + } + Matcher mCoalFill = COAL_BAG_FILL_AMOUNT_PATTERN.matcher(message); + if (mCoalFill.find()) + { + int filled = parseIntOrNull(mCoalFill.group(1)); + int current = cachedCounts.getOrDefault("coal_bag", 0); + cachedCounts.put("coal_bag", Math.min(getCoalBagCapacity(), current + filled)); + saveAccountData(); + return; + } + + Matcher mGem = GEM_BAG_AMOUNT_PATTERN.matcher(message); + if (mGem.find()) + { + int s = parseIntOrNull(mGem.group(1)); + int e = parseIntOrNull(mGem.group(2)); + int r = parseIntOrNull(mGem.group(3)); + int d = parseIntOrNull(mGem.group(4)); + int ds = parseIntOrNull(mGem.group(5)); + cachedCounts.put("gem_bag", s + e + r + d + ds); + saveAccountData(); + return; + } + Matcher mGemTotal = GEM_BAG_TOTAL_PATTERN.matcher(message); + if (mGemTotal.find()) + { + cachedCounts.put("gem_bag", parseIntOrNull(mGemTotal.group(1))); + saveAccountData(); + return; + } + if (GEM_BAG_EMPTY_PATTERN.matcher(message).find()) + { + cachedCounts.put("gem_bag", 0); + saveAccountData(); + return; + } + + Matcher mHerb = HERB_SACK_AMOUNT_PATTERN.matcher(message); + if (mHerb.find()) + { + cachedCounts.put("herb_sack", parseIntOrNull(mHerb.group(1))); + saveAccountData(); + return; + } + if (HERB_SACK_EMPTY_PATTERN.matcher(message).find()) + { + cachedCounts.put("herb_sack", 0); + saveAccountData(); + return; + } + + Matcher mFish = FISH_BARREL_AMOUNT_PATTERN.matcher(message); + if (mFish.find()) + { + cachedCounts.put("fish_barrel", parseIntOrNull(mFish.group(1))); + saveAccountData(); + return; + } + if (FISH_BARREL_EMPTY_PATTERN.matcher(message).find()) + { + cachedCounts.put("fish_barrel", 0); + saveAccountData(); + return; + } + + Matcher mPlank = PLANK_SACK_AMOUNT_PATTERN.matcher(message); + if (mPlank.find()) + { + cachedCounts.put("plank_sack", parseIntOrNull(mPlank.group(1))); + saveAccountData(); + return; + } + if (PLANK_SACK_EMPTY_PATTERN.matcher(message).find()) + { + cachedCounts.put("plank_sack", 0); + saveAccountData(); + return; + } + + Matcher mSeed = SEED_BOX_AMOUNT_PATTERN.matcher(message); + if (mSeed.find()) + { + cachedCounts.put("seed_box", parseIntOrNull(mSeed.group(1))); + saveAccountData(); + return; + } + if (SEED_BOX_EMPTY_PATTERN.matcher(message).find()) + { + cachedCounts.put("seed_box", 0); + saveAccountData(); + return; + } + + Matcher mMeat = MEAT_POUCH_AMOUNT_PATTERN.matcher(message); + if (mMeat.find()) + { + cachedCounts.put("meat_pouch", parseIntOrNull(mMeat.group(1))); + saveAccountData(); + return; + } + if (MEAT_POUCH_EMPTY_PATTERN.matcher(message).find()) + { + cachedCounts.put("meat_pouch", 0); + saveAccountData(); + return; + } + + Matcher mFur = FUR_POUCH_AMOUNT_PATTERN.matcher(message); + if (mFur.find()) + { + cachedCounts.put("fur_pouch", parseIntOrNull(mFur.group(1))); + saveAccountData(); + return; + } + if (FUR_POUCH_EMPTY_PATTERN.matcher(message).find()) + { + cachedCounts.put("fur_pouch", 0); + saveAccountData(); + return; + } + + Matcher mLogBasket = LOG_BASKET_AMOUNT_PATTERN.matcher(message); + if (mLogBasket.find()) + { + cachedCounts.put("log_basket", parseIntOrNull(mLogBasket.group(1))); + saveAccountData(); + } + } + + private BarrowsPiece matchBarrowsPiece(String itemName) + { + if (itemName == null) + { + return null; + } + String s = itemName.toLowerCase().replace("'", "").replace("-", " ").trim(); + if (s.contains("dharok")) + { + if (s.contains("helm") || s.contains("head")) return BarrowsPiece.DHAROK_HELM; + if (s.contains("body") || s.contains("platebody") || s.contains("chest")) return BarrowsPiece.DHAROK_BODY; + if (s.contains("leg") || s.contains("plateleg")) return BarrowsPiece.DHAROK_LEGS; + if (s.contains("axe") || s.contains("great") || s.contains("weapon")) return BarrowsPiece.DHAROK_WEAPON; + } + else if (s.contains("ahrim")) + { + if (s.contains("hood") || s.contains("head") || s.contains("helm")) return BarrowsPiece.AHRIM_HOOD; + if (s.contains("top") || s.contains("body") || s.contains("robetop")) return BarrowsPiece.AHRIM_BODY; + if (s.contains("skirt") || s.contains("leg") || s.contains("bottom") || s.contains("robeskirt")) return BarrowsPiece.AHRIM_LEGS; + if (s.contains("staff") || s.contains("weapon")) return BarrowsPiece.AHRIM_WEAPON; + } + else if (s.contains("guthan")) + { + if (s.contains("helm") || s.contains("head")) return BarrowsPiece.GUTHAN_HELM; + if (s.contains("body") || s.contains("plate") || s.contains("chest")) return BarrowsPiece.GUTHAN_BODY; + if (s.contains("skirt") || s.contains("chain") || s.contains("leg")) return BarrowsPiece.GUTHAN_LEGS; + if (s.contains("spear") || s.contains("war") || s.contains("weapon")) return BarrowsPiece.GUTHAN_WEAPON; + } + else if (s.contains("karil")) + { + if (s.contains("coif") || s.contains("head") || s.contains("helm")) return BarrowsPiece.KARIL_COIF; + if (s.contains("top") || s.contains("body") || s.contains("leather")) return BarrowsPiece.KARIL_BODY; + if (s.contains("skirt") || s.contains("leg") || s.contains("bottom")) return BarrowsPiece.KARIL_LEGS; + if (s.contains("crossbow") || s.contains("bow") || s.contains("weapon")) return BarrowsPiece.KARIL_WEAPON; + } + else if (s.contains("torag")) + { + if (s.contains("helm") || s.contains("head")) return BarrowsPiece.TORAG_HELM; + if (s.contains("body") || s.contains("plate") || s.contains("chest")) return BarrowsPiece.TORAG_BODY; + if (s.contains("leg")) return BarrowsPiece.TORAG_LEGS; + if (s.contains("hammer") || s.contains("weapon")) return BarrowsPiece.TORAG_WEAPON; + } + else if (s.contains("verac")) + { + if (s.contains("helm") || s.contains("head")) return BarrowsPiece.VERAC_HELM; + if (s.contains("brassard") || s.contains("body") || s.contains("top")) return BarrowsPiece.VERAC_BODY; + if (s.contains("skirt") || s.contains("plate") || s.contains("leg")) return BarrowsPiece.VERAC_LEGS; + if (s.contains("flail") || s.contains("weapon")) return BarrowsPiece.VERAC_WEAPON; + } + return null; + } + + private MoonPiece matchMoonPiece(String itemName) + { + if (itemName == null) + { + return null; + } + String s = itemName.toLowerCase().replace("'", "").replace("-", " ").trim(); + if (s.contains("blood moon") || s.contains("bloodmoon")) + { + if (s.contains("chest") || s.contains("body") || s.contains("top")) return MoonPiece.BLOOD_CHEST; + if (s.contains("helm") || s.contains("head") || s.contains("hood")) return MoonPiece.BLOOD_HELM; + if (s.contains("tasset") || s.contains("leg") || s.contains("skirt") || s.contains("bottom")) return MoonPiece.BLOOD_LEGS; + } + else if (s.contains("blue moon") || s.contains("bluemoon")) + { + if (s.contains("chest") || s.contains("body") || s.contains("top")) return MoonPiece.BLUE_CHEST; + if (s.contains("helm") || s.contains("head") || s.contains("hood")) return MoonPiece.BLUE_HELM; + if (s.contains("tasset") || s.contains("leg") || s.contains("skirt") || s.contains("bottom")) return MoonPiece.BLUE_LEGS; + } + else if (s.contains("eclipse moon") || s.contains("eclipsemoon")) + { + if (s.contains("chest") || s.contains("body") || s.contains("top")) return MoonPiece.ECLIPSE_CHEST; + if (s.contains("helm") || s.contains("head") || s.contains("hood")) return MoonPiece.ECLIPSE_HELM; + if (s.contains("tasset") || s.contains("leg") || s.contains("skirt") || s.contains("bottom")) return MoonPiece.ECLIPSE_LEGS; + } + return null; + } + + public List chargesFor(int itemId) + { + if (!config.itemCharges()) + { + return Collections.emptyList(); + } + + boolean weapons = plugin.getHelperValue("itemChargesWeapons", Boolean.class, true); + boolean bags = plugin.getHelperValue("itemChargesBags", Boolean.class, true); + + Color primaryColor = plugin.getHelperValue("itemChargesColor", Color.class, Color.WHITE); + Color secondaryColor = plugin.getHelperValue("itemChargesSecondaryColor", Color.class, new Color(0xC0C0C0)); + Color lowColor = plugin.getHelperValue("itemChargesLowColor", Color.class, new Color(0xFF3333)); + int lowAt = plugin.getHelperValue("itemChargesLowAt", Integer.class, 0); + + ItemChargeDef def = CHARGE_MAP.get(itemId); + if (def == null && itemManager != null) + { + int canonical = itemManager.canonicalize(itemId); + if (canonical != itemId) + { + def = CHARGE_MAP.get(canonical); + } + } + + List result = new ArrayList<>(2); + + if (def != null) + { + switch (def.type) + { + case VARBIT: + if (!weapons) + { + return Collections.emptyList(); + } + if (def.isUncharged) + { + result.add(new ChargeValue("0", lowAt > 0 ? lowColor : primaryColor)); + return result; + } + if (def.isInfinite) + { + result.add(new ChargeValue("∞", primaryColor)); + return result; + } + if (def.primaryVarbit != -1) + { + int val = client.getVarbitValue(def.primaryVarbit); + Color c = (lowAt > 0 && val <= lowAt) ? lowColor : primaryColor; + result.add(new ChargeValue(formatQuantity(val), c)); + } + return result; + + case BOWFA_BLADE: + if (!weapons) + { + return Collections.emptyList(); + } + if (def.isUncharged) + { + result.add(new ChargeValue("0", lowAt > 0 ? lowColor : primaryColor)); + return result; + } + if (def.isInfinite) + { + result.add(new ChargeValue("∞", primaryColor)); + return result; + } + if (def.secondaryVarbit != -1 && client.getVarbitValue(def.secondaryVarbit) == 1) + { + result.add(new ChargeValue("∞", primaryColor)); + return result; + } + if (def.primaryVarbit != -1) + { + int val = client.getVarbitValue(def.primaryVarbit); + Color c = (lowAt > 0 && val <= lowAt) ? lowColor : primaryColor; + result.add(new ChargeValue(formatQuantity(val), c)); + } + return result; + + case DIZANAS_QUIVER: + if (!weapons) + { + return Collections.emptyList(); + } + if (def.isUncharged) + { + result.add(new ChargeValue("0", lowAt > 0 ? lowColor : primaryColor)); + return result; + } + if (def.isInfinite) + { + result.add(new ChargeValue("∞", primaryColor)); + int blessing = client.getVarbitValue(def.secondaryVarbit); + if (blessing > 0) + { + result.add(new ChargeValue(formatQuantity(blessing), secondaryColor)); + } + return result; + } + int ammo = client.getVarbitValue(def.primaryVarbit); + int blessing = client.getVarbitValue(def.secondaryVarbit); + Color c = (lowAt > 0 && ammo <= lowAt) ? lowColor : primaryColor; + result.add(new ChargeValue(formatQuantity(ammo), c)); + if (blessing > 0) + { + result.add(new ChargeValue(formatQuantity(blessing), secondaryColor)); + } + return result; + + case BLOWPIPE: + if (!weapons) + { + return Collections.emptyList(); + } + loadAccountDataIfNecessary(); + if (def.isUncharged) + { + result.add(new ChargeValue("0", lowAt > 0 ? lowColor : primaryColor)); + result.add(new ChargeValue("0", secondaryColor)); + return result; + } + + // Top line: Darts + Integer darts = cachedBlowpipeDarts; + String dartsText = (darts != null) ? formatQuantity(darts) : (def.hasDarts ? "?" : "0"); + Color dartColor = colorForDart(cachedBlowpipeDartType, secondaryColor); + if (darts != null && darts == 0) + { + dartColor = lowColor; + } + result.add(new ChargeValue(dartsText, dartColor)); + + // Bottom line: Scales + Integer scales = cachedBlowpipeScales; + if (scales == null) + { + int varbitVal = client.getVarbitValue(VarbitID.CHARGES_TOXIC_BLOWPIPE_QUANTITY); + if (varbitVal > 0) + { + scales = varbitVal; + } + } + + String scalesText = (scales != null) ? formatQuantity(scales) : (def.hasDarts ? "?" : "0"); + Color scalesColor = (scales != null && ((lowAt > 0 && scales <= lowAt) || scales == 0)) ? lowColor : primaryColor; + result.add(new ChargeValue(scalesText, scalesColor)); + return result; + + case BARROWS: + if (!weapons) + { + return Collections.emptyList(); + } + loadAccountDataIfNecessary(); + int tier = def.barrowsTier; + if (tier == 0) + { + result.add(new ChargeValue("0%", lowColor)); + return result; + } + + Integer cachedPct = cachedBarrows.get(def.barrowsPiece); + if (cachedPct == null) + { + result.add(new ChargeValue("?", primaryColor)); + return result; + } + + Color barrowsColor = (cachedPct <= 25 || (lowAt > 0 && cachedPct <= lowAt)) ? lowColor : primaryColor; + result.add(new ChargeValue(cachedPct + "%", barrowsColor)); + return result; + + case MOONS: + if (!weapons) + { + return Collections.emptyList(); + } + loadAccountDataIfNecessary(); + if (def.moonTier == 0) + { + result.add(new ChargeValue("0%", lowColor)); + return result; + } + + Integer cachedMoonCharges = cachedMoons.get(def.moonPiece); + if (cachedMoonCharges == null) + { + if (def.moonTier > 0) + { + cachedMoonCharges = def.moonTier; + } + else + { + result.add(new ChargeValue("?", primaryColor)); + return result; + } + } + + int moonPct = Math.max(0, Math.min(100, (cachedMoonCharges * 100) / 3000)); + Color moonColor = (moonPct <= 25 || (lowAt > 0 && moonPct <= lowAt)) ? lowColor : primaryColor; + result.add(new ChargeValue(moonPct + "%", moonColor)); + return result; + + case MASTER_SCROLL_BOOK: + if (!weapons) + { + return Collections.emptyList(); + } + int totalScrolls = 0; + for (int v : MASTER_SCROLL_BOOK_VARBITS) + { + totalScrolls += client.getVarbitValue(v); + } + Color scrollColor = (lowAt > 0 && totalScrolls <= lowAt) ? lowColor : primaryColor; + result.add(new ChargeValue(formatQuantity(totalScrolls), scrollColor)); + return result; + + case GHOMMALS_HILT: + if (!weapons) + { + return Collections.emptyList(); + } + int used = client.getVarbitValue(def.primaryVarbit); + int remaining = Math.max(0, def.fixedAmount - used); + Color hiltColor = (remaining == 0 || (lowAt > 0 && remaining <= lowAt)) ? lowColor : primaryColor; + result.add(new ChargeValue(String.valueOf(remaining), hiltColor)); + return result; + + case FIXED_VALUE: + if (!weapons && !bags) + { + return Collections.emptyList(); + } + Color fixColor = (lowAt > 0 && def.fixedAmount <= lowAt) ? lowColor : primaryColor; + result.add(new ChargeValue(String.valueOf(def.fixedAmount), fixColor)); + return result; + + case CONFIG_COUNT: + if (!weapons) + { + return Collections.emptyList(); + } + if (def.isUncharged) + { + result.add(new ChargeValue("0", lowAt > 0 ? lowColor : primaryColor)); + return result; + } + loadAccountDataIfNecessary(); + Integer count = cachedCounts.get(def.configKey); + if (count == null) + { + if (def.defaultCharges != null) + { + Color countColor = (def.defaultCharges <= 0 || (lowAt > 0 && def.defaultCharges <= lowAt)) ? lowColor : primaryColor; + result.add(new ChargeValue(formatQuantity(def.defaultCharges), countColor)); + } + else + { + result.add(new ChargeValue("?", primaryColor)); + } + } + else + { + Color countColor = (count <= 0 || (lowAt > 0 && count <= lowAt)) ? lowColor : primaryColor; + result.add(new ChargeValue(formatQuantity(count), countColor)); + } + return result; + + case BAG: + if (!bags) + { + return Collections.emptyList(); + } + loadAccountDataIfNecessary(); + Integer bagCount = cachedCounts.get(def.configKey); + if (bagCount == null) + { + result.add(new ChargeValue("?", primaryColor)); + } + else + { + Color countColor = (lowAt > 0 && bagCount <= lowAt) ? lowColor : primaryColor; + result.add(new ChargeValue(String.valueOf(bagCount), countColor)); + } + return result; + + case LOOTING_BAG: + if (!bags) + { + return Collections.emptyList(); + } + updateLootingBag(); + if (!lootingBagKnown) + { + result.add(new ChargeValue("?", primaryColor)); + } + else + { + result.add(new ChargeValue(QuantityFormatter.quantityToStackSize(cachedLootingBagCoins), primaryColor)); + } + return result; + } + } + + // Fallback: Check item name for parenthesized dose / charges (e.g. Prayer potion(4), Ring of dueling(8), Apples(5), Waterskin(4), etc.) + String name = null; + if (itemManager != null) + { + net.runelite.api.ItemComposition comp = itemManager.getItemComposition(itemId); + if (comp != null) + { + name = comp.getName(); + } + } + else if (client != null) + { + net.runelite.api.ItemComposition comp = client.getItemDefinition(itemId); + if (comp != null) + { + name = comp.getName(); + } + } + + if (name != null) + { + Matcher m = NUMBER_IN_PARENS_PATTERN.matcher(name); + if (m.find()) + { + int num = Integer.parseInt(m.group(2)); + if (isPotion(name)) + { + Color doseColor; + switch (num) + { + case 4: + doseColor = primaryColor; + break; + case 3: + doseColor = new Color(0x4DD0E1); // cyan + break; + case 2: + doseColor = new Color(0xFFB74D); // orange + break; + case 1: + default: + doseColor = lowColor; // red + break; + } + result.add(new ChargeValue(String.valueOf(num), doseColor)); + return result; + } + else + { + Color c = (lowAt > 0 && num <= lowAt) ? lowColor : primaryColor; + result.add(new ChargeValue(String.valueOf(num), c)); + return result; + } + } + + String lower = name.toLowerCase(); + if (lower.endsWith("(0)") || lower.endsWith("(empty)") || lower.equals("basket") || lower.equals("empty sack") || lower.equals("vial")) + { + result.add(new ChargeValue("0", lowAt > 0 ? lowColor : primaryColor)); + return result; + } + } + + return Collections.emptyList(); + } + + private static boolean isPotion(String name) + { + String lower = name.toLowerCase(); + return lower.contains("potion") || lower.contains("brew") || lower.contains("restore") + || lower.contains("mix") || lower.contains("serum") || lower.contains("balm") + || lower.contains("elixir") || lower.contains("dressing") || lower.contains("salts") + || lower.contains("tears of elidinis") || lower.contains("overload") || lower.contains("salve") + || lower.contains("guthix rest") || lower.contains("menaphite remedy") || lower.contains("absorption") + || lower.contains("surge") || lower.contains("egniol") || lower.contains("xeric's aid") + || lower.contains("twisted") || lower.contains("kodai") || lower.contains("elder") + || lower.contains("revitalisation") || lower.contains("prayer enhance") || lower.contains("nectar") + || lower.contains("liquid adrenaline") || lower.contains("ambrosia") || lower.contains("antidote") + || lower.contains("antivenom") || lower.contains("antifire") || lower.contains("antipoison") + || lower.contains("sanfew") || lower.contains("relicym") || lower.contains("combat") + || lower.contains("ranging") || lower.contains("magic") || lower.contains("strength") + || lower.contains("defence") || lower.contains("attack") || lower.contains("agility") + || lower.contains("hunter") || lower.contains("fishing") || lower.contains("stamina") + || lower.contains("energy") || lower.contains("bastion") || lower.contains("battlemage") + || lower.contains("zamorak") || lower.contains("saradomin") || lower.contains("guthix") + || lower.contains("super") || lower.contains("divine") || lower.contains("extended") + || lower.contains("blighted"); + } + + private Color colorForDart(String dartType, Color defaultColor) + { + if (dartType == null) + { + return defaultColor; + } + String lower = dartType.toLowerCase(); + if (lower.contains("dragon")) + { + return new Color(0xFF7043); // reddish orange + } + if (lower.contains("amethyst")) + { + return new Color(0xBA68C8); // purple + } + if (lower.contains("rune")) + { + return new Color(0x4DD0E1); // cyan + } + if (lower.contains("adamant")) + { + return new Color(0x81C784); // light green + } + if (lower.contains("mithril")) + { + return new Color(0x64B5F6); // steel blue + } + if (lower.contains("black")) + { + return new Color(0x616161); // dark gray + } + if (lower.contains("steel")) + { + return new Color(0xB0BEC5); // silver + } + if (lower.contains("iron")) + { + return new Color(0x9E9E9E); // gray + } + if (lower.contains("bronze")) + { + return new Color(0xA1887F); // bronze brown + } + return defaultColor; + } + + private String formatQuantity(int val) + { + if (val >= 10_000_000) + { + return (val / 1_000_000) + "M"; + } + if (val >= 100_000) + { + return (val / 1_000) + "K"; + } + if (val >= 1_000) + { + if (val % 1000 == 0) + { + return (val / 1000) + "K"; + } + double k = val / 1000.0; + return String.format(java.util.Locale.ENGLISH, "%.1fK", k); + } + return String.valueOf(val); + } +} diff --git a/src/main/java/com/example/essentialsplus/helpers/ItemChargesOverlay.java b/src/main/java/com/example/essentialsplus/helpers/ItemChargesOverlay.java new file mode 100644 index 0000000..3390e83 --- /dev/null +++ b/src/main/java/com/example/essentialsplus/helpers/ItemChargesOverlay.java @@ -0,0 +1,105 @@ +package com.example.essentialsplus.helpers; + +import com.example.essentialsplus.EssentialsPlusConfig; +import com.example.essentialsplus.EssentialsPlusPlugin; +import java.awt.Dimension; +import java.awt.Font; +import java.awt.FontMetrics; +import java.awt.Graphics2D; +import java.awt.Rectangle; +import java.awt.RenderingHints; +import java.util.List; +import javax.inject.Inject; +import net.runelite.api.widgets.WidgetItem; +import net.runelite.client.ui.FontManager; +import net.runelite.client.ui.overlay.OverlayLayer; +import net.runelite.client.ui.overlay.OverlayPosition; +import net.runelite.client.ui.overlay.WidgetItemOverlay; +import net.runelite.client.ui.overlay.components.TextComponent; + +/** + * Overlay displaying charge counts / statuses on item icons in Inventory, Equipment and (optionally) Bank. + */ +public class ItemChargesOverlay extends WidgetItemOverlay +{ + private final EssentialsPlusConfig config; + private final EssentialsPlusPlugin plugin; + private final ItemCharges itemCharges; + + @Inject + public ItemChargesOverlay(EssentialsPlusConfig config, EssentialsPlusPlugin plugin, ItemCharges itemCharges) + { + this.config = config; + this.plugin = plugin; + this.itemCharges = itemCharges; + + showOnInventory(); + showOnEquipment(); + showOnBank(); + } + + @Override + public void renderItemOverlay(Graphics2D g, int itemId, WidgetItem widgetItem) + { + if (!config.itemCharges()) + { + return; + } + + // Check if on bank and if bank display is allowed + boolean showBank = plugin.getHelperValue("itemChargesBank", Boolean.class, true); + if (!showBank && widgetItem.getWidget() != null) + { + int group = net.runelite.api.widgets.WidgetUtil.componentToInterface(widgetItem.getWidget().getId()); + if (group == net.runelite.api.gameval.InterfaceID.BANKMAIN || group == 12) // 12 = InterfaceID.BANK + { + return; + } + } + + List charges = itemCharges.chargesFor(itemId); + if (charges == null || charges.isEmpty()) + { + return; + } + + g.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON); + + TimerTextSize size = plugin.getHelperValue("itemChargesSize", TimerTextSize.class, TimerTextSize.SMALL); + Font font = (size == TimerTextSize.SMALL) ? FontManager.getRunescapeSmallFont() : size.font(); + g.setFont(font); + + Rectangle bounds = widgetItem.getCanvasBounds(); + if (bounds == null) + { + return; + } + + // Single charge or Top charge (top-left: bounds.x - 1, bounds.y + 11) + ItemCharges.ChargeValue primary = charges.get(0); + if (primary != null && primary.getText() != null && !primary.getText().isEmpty()) + { + TextComponent textComponent = new TextComponent(); + textComponent.setFont(font); + textComponent.setColor(primary.getColor()); + textComponent.setText(primary.getText()); + textComponent.setPosition(new java.awt.Point(bounds.x - 1, bounds.y + 11)); + textComponent.render(g); + } + + // Secondary charge (bottom-left: bounds.x - 1, bounds.y + bounds.height - 2) + if (charges.size() > 1) + { + ItemCharges.ChargeValue secondary = charges.get(1); + if (secondary != null && secondary.getText() != null && !secondary.getText().isEmpty()) + { + TextComponent secondaryComponent = new TextComponent(); + secondaryComponent.setFont(font); + secondaryComponent.setColor(secondary.getColor()); + secondaryComponent.setText(secondary.getText()); + secondaryComponent.setPosition(new java.awt.Point(bounds.x - 1, bounds.y + bounds.height - 2)); + secondaryComponent.render(g); + } + } + } +} diff --git a/src/main/java/com/example/essentialsplus/helpers/ItemPriceOverlay.java b/src/main/java/com/example/essentialsplus/helpers/ItemPriceOverlay.java new file mode 100644 index 0000000..b56ac19 --- /dev/null +++ b/src/main/java/com/example/essentialsplus/helpers/ItemPriceOverlay.java @@ -0,0 +1,114 @@ +package com.example.essentialsplus.helpers; + +import com.example.essentialsplus.EssentialsPlusConfig; +import java.awt.Color; +import java.awt.Dimension; +import java.awt.Graphics2D; +import javax.inject.Inject; +import net.runelite.api.Client; +import net.runelite.api.ItemComposition; +import net.runelite.api.MenuEntry; +import net.runelite.api.gameval.ItemID; +import net.runelite.api.widgets.Widget; +import net.runelite.api.widgets.WidgetUtil; +import net.runelite.client.game.ItemManager; +import net.runelite.client.ui.overlay.Overlay; +import net.runelite.client.ui.overlay.OverlayLayer; +import net.runelite.client.ui.overlay.OverlayPosition; +import net.runelite.client.ui.overlay.tooltip.Tooltip; +import net.runelite.client.ui.overlay.tooltip.TooltipManager; +import net.runelite.client.util.ColorUtil; +import net.runelite.client.util.QuantityFormatter; + +/** Tooltip line with the GE and/or high alch value of the inventory item under the mouse. */ +public class ItemPriceOverlay extends Overlay +{ + private static final int INVENTORY = 149; + private static final int BANK_INVENTORY = 15; + private static final Color GE = new Color(0xFFDC33); + private static final Color HA = new Color(0xFF9040); + + private final Client client; + private final EssentialsPlusConfig config; + private final ItemManager itemManager; + private final TooltipManager tooltipManager; + + @Inject + ItemPriceOverlay(Client client, EssentialsPlusConfig config, ItemManager itemManager, TooltipManager tooltipManager) + { + this.client = client; + this.config = config; + this.itemManager = itemManager; + this.tooltipManager = tooltipManager; + setPosition(OverlayPosition.DYNAMIC); + setLayer(OverlayLayer.ABOVE_WIDGETS); + } + + @Override + public Dimension render(Graphics2D g) + { + if (!config.itemPrices() || (!config.itemPricesGe() && !config.itemPricesHa()) || client.isMenuOpen()) + { + return null; + } + MenuEntry[] entries = client.getMenuEntries(); + if (entries.length == 0) + { + return null; + } + Widget w = entries[entries.length - 1].getWidget(); + if (w == null) + { + return null; + } + int group = WidgetUtil.componentToInterface(w.getId()); + if (group != INVENTORY && group != BANK_INVENTORY) + { + return null; + } + int id = w.getItemId(); + if (id <= 0 || id == ItemID.COINS || id == ItemID.PLATINUM) + { + return null; + } + long qty = Math.max(1, w.getItemQuantity()); + String text = text(itemManager.canonicalize(id), qty); + if (!text.isEmpty()) + { + tooltipManager.add(new Tooltip(text)); + } + return null; + } + + private String text(int id, long qty) + { + StringBuilder sb = new StringBuilder(); + if (config.itemPricesGe()) + { + long ge = (long) itemManager.getItemPrice(id) * qty; + if (ge > 0) + { + sb.append("GE: ").append(ColorUtil.wrapWithColorTag(gp(ge), GE)); + } + } + if (config.itemPricesHa()) + { + ItemComposition comp = itemManager.getItemComposition(id); + long ha = comp == null ? 0 : (long) comp.getHaPrice() * qty; + if (ha > 0) + { + if (sb.length() > 0) + { + sb.append(" "); + } + sb.append("HA: ").append(ColorUtil.wrapWithColorTag(gp(ha), HA)); + } + } + return sb.toString(); + } + + private static String gp(long v) + { + return QuantityFormatter.quantityToStackSize(v) + " gp"; + } +} diff --git a/src/main/java/com/example/essentialsplus/helpers/TimerTextSize.java b/src/main/java/com/example/essentialsplus/helpers/TimerTextSize.java new file mode 100644 index 0000000..cf60ffc --- /dev/null +++ b/src/main/java/com/example/essentialsplus/helpers/TimerTextSize.java @@ -0,0 +1,52 @@ +package com.example.essentialsplus.helpers; + +import java.awt.Font; +import net.runelite.client.ui.FontManager; + +/** Font of the attack timer numbers. */ +public enum TimerTextSize +{ + SMALL("Small"), + NORMAL("Normal"), + LARGE("Large"); + + private final String label; + + TimerTextSize(String label) + { + this.label = label; + } + + public Font font() + { + switch (this) + { + case SMALL: + return FontManager.getRunescapeSmallFont(); + case LARGE: + return FontManager.getRunescapeBoldFont().deriveFont(24f); + default: + return FontManager.getRunescapeBoldFont(); + } + } + + /** Icon edge length in pixels. */ + public int iconSize() + { + switch (this) + { + case SMALL: + return 16; + case LARGE: + return 32; + default: + return 22; + } + } + + @Override + public String toString() + { + return label; + } +} diff --git a/src/main/java/com/example/essentialsplus/helpers/TreeCooldownOverlay.java b/src/main/java/com/example/essentialsplus/helpers/TreeCooldownOverlay.java new file mode 100644 index 0000000..3f480d6 --- /dev/null +++ b/src/main/java/com/example/essentialsplus/helpers/TreeCooldownOverlay.java @@ -0,0 +1,184 @@ +package com.example.essentialsplus.helpers; + +import com.example.essentialsplus.EssentialsPlusConfig; +import com.example.essentialsplus.EssentialsPlusPlugin; +import java.awt.Color; +import java.awt.Dimension; +import java.awt.FontMetrics; +import java.awt.Graphics2D; +import java.awt.RenderingHints; +import javax.inject.Inject; +import net.runelite.api.Client; +import net.runelite.api.Perspective; +import net.runelite.api.Point; +import net.runelite.api.coords.LocalPoint; +import net.runelite.client.ui.overlay.Overlay; +import net.runelite.client.ui.overlay.OverlayLayer; +import net.runelite.client.ui.overlay.OverlayPosition; + +/** + * Overlay displaying: + * - Remaining seconds until stump respawn + * - Remaining chop duration on high tier trees (Maple+) + * - Optional progress bar and customizable styling + */ +public class TreeCooldownOverlay extends Overlay +{ + private static final Color BAR_BG = new Color(0, 0, 0, 160); + private static final Color BAR_BORDER = new Color(0, 0, 0, 220); + private static final int BAR_WIDTH = 32; + private static final int BAR_HEIGHT = 4; + + private final Client client; + private final EssentialsPlusConfig config; + private final EssentialsPlusPlugin plugin; + private final TreeCooldowns treeCooldowns; + + @Inject + public TreeCooldownOverlay(Client client, EssentialsPlusConfig config, EssentialsPlusPlugin plugin, TreeCooldowns treeCooldowns) + { + this.client = client; + this.config = config; + this.plugin = plugin; + this.treeCooldowns = treeCooldowns; + setPosition(OverlayPosition.DYNAMIC); + setLayer(OverlayLayer.ABOVE_SCENE); + } + + @Override + public Dimension render(Graphics2D g) + { + if (!config.treeCooldowns()) + { + return null; + } + + g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); + + boolean showRespawn = plugin.getHelperValue("treeCooldownRespawn", Boolean.class, true); + boolean showChop = plugin.getHelperValue("treeCooldownChop", Boolean.class, true); + boolean showBar = plugin.getHelperValue("treeCooldownBar", Boolean.class, false); + TimerTextSize size = plugin.getHelperValue("treeCooldownSize", TimerTextSize.class, TimerTextSize.NORMAL); + int offset = plugin.getHelperValue("treeCooldownOffset", Integer.class, 50); + Color textColor = plugin.getHelperValue("treeCooldownColor", Color.class, new Color(0x8FBF3F)); + Color readyColor = plugin.getHelperValue("treeCooldownReadyColor", Color.class, new Color(0x66FF66)); + + int plane = client.getPlane(); + + if (showRespawn) + { + for (TreeCooldowns.RespawnTimer timer : treeCooldowns.getActiveRespawnTimers()) + { + if (timer.worldPoint.getPlane() != plane) + { + continue; + } + + int sec = timer.getRemainingSeconds(); + if (sec <= 0) + { + continue; + } + + Point anchor = null; + if (timer.stumpObject != null) + { + anchor = timer.stumpObject.getCanvasTextLocation(g, "", offset); + } + if (anchor == null) + { + LocalPoint lp = LocalPoint.fromWorld(client, timer.worldPoint); + if (lp != null) + { + anchor = Perspective.getCanvasTextLocation(client, g, lp, "", offset); + } + } + if (anchor == null) + { + continue; + } + + Color c = (sec <= 2) ? readyColor : textColor; + drawTimer(g, anchor, sec, c, size, showBar, timer.getProgress()); + } + } + + if (showChop) + { + for (TreeCooldowns.ChopTimer timer : treeCooldowns.getActiveChopTimers()) + { + if (timer.worldPoint.getPlane() != plane) + { + continue; + } + + int sec = timer.getRemainingSeconds(); + if (sec <= 0) + { + continue; + } + + Point anchor = null; + if (timer.treeObject != null) + { + anchor = timer.treeObject.getCanvasTextLocation(g, "", offset + 80); + } + if (anchor == null) + { + LocalPoint lp = LocalPoint.fromWorld(client, timer.worldPoint); + if (lp != null) + { + anchor = Perspective.getCanvasTextLocation(client, g, lp, "", offset + 80); + } + } + if (anchor == null) + { + continue; + } + + Color c = (sec <= 5) ? readyColor : textColor; + drawTimer(g, anchor, sec, c, size, showBar, timer.getProgress()); + } + } + + return null; + } + + private void drawTimer(Graphics2D g, Point anchor, int seconds, Color color, TimerTextSize size, boolean showBar, double progress) + { + g.setFont(size.font()); + String text = String.valueOf(seconds); + FontMetrics fm = g.getFontMetrics(); + int textX = anchor.getX() - fm.stringWidth(text) / 2; + int textY = anchor.getY(); + + shadowed(g, text, textX, textY, color); + + if (showBar) + { + int barX = anchor.getX() - BAR_WIDTH / 2; + int barY = textY + 4; + + g.setColor(BAR_BG); + g.fillRect(barX, barY, BAR_WIDTH, BAR_HEIGHT); + + int fill = (int) Math.round(BAR_WIDTH * Math.max(0.0, Math.min(1.0, progress))); + if (fill > 0) + { + g.setColor(color); + g.fillRect(barX, barY, fill, BAR_HEIGHT); + } + + g.setColor(BAR_BORDER); + g.drawRect(barX, barY, BAR_WIDTH, BAR_HEIGHT); + } + } + + private static void shadowed(Graphics2D g, String text, int x, int y, Color color) + { + g.setColor(Color.BLACK); + g.drawString(text, x + 1, y + 1); + g.setColor(color); + g.drawString(text, x, y); + } +} diff --git a/src/main/java/com/example/essentialsplus/helpers/TreeCooldowns.java b/src/main/java/com/example/essentialsplus/helpers/TreeCooldowns.java new file mode 100644 index 0000000..97f7188 --- /dev/null +++ b/src/main/java/com/example/essentialsplus/helpers/TreeCooldowns.java @@ -0,0 +1,790 @@ +package com.example.essentialsplus.helpers; + +import com.example.essentialsplus.EssentialsPlusConfig; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import javax.inject.Inject; +import javax.inject.Singleton; +import net.runelite.api.Client; +import net.runelite.api.GameObject; +import net.runelite.api.GameState; +import net.runelite.api.ObjectComposition; +import net.runelite.api.Perspective; +import net.runelite.api.Player; +import net.runelite.api.ScriptID; +import net.runelite.api.Tile; +import net.runelite.api.coords.LocalPoint; +import net.runelite.api.coords.WorldPoint; +import net.runelite.api.events.GameObjectDespawned; +import net.runelite.api.events.GameObjectSpawned; +import net.runelite.api.events.GameStateChanged; +import net.runelite.api.events.GameTick; +import net.runelite.api.events.ScriptPreFired; +import net.runelite.api.gameval.AnimationID; +import net.runelite.api.gameval.ObjectID; +import net.runelite.client.eventbus.Subscribe; + +/** + * Tracks tree cooldowns: + * 1. Remaining chop duration for long-lived trees (Maple+, despawnSec > 30) with regeneration when idle. + * 2. Respawn timers on stumps felled nearby (exact tick synchronization via game script and object events). + */ +@Singleton +public class TreeCooldowns +{ + /** Woodcutting animations for standard, gilded, infernal, crystal, 3rd age, trailblazer, and forestry 2H axes. */ + public static final Set WOODCUT_ANIMS = Set.of( + AnimationID.HUMAN_WOODCUTTING_BRONZE_AXE, + AnimationID.HUMAN_WOODCUTTING_IRON_AXE, + AnimationID.HUMAN_WOODCUTTING_STEEL_AXE, + AnimationID.HUMAN_WOODCUTTING_BLACK_AXE, + AnimationID.HUMAN_WOODCUTTING_MITHRIL_AXE, + AnimationID.HUMAN_WOODCUTTING_ADAMANT_AXE, + AnimationID.HUMAN_WOODCUTTING_RUNE_AXE, + AnimationID.HUMAN_WOODCUTTING_GILDED_AXE, + AnimationID.HUMAN_WOODCUTTING_DRAGON_AXE, + AnimationID.HUMAN_WOODCUTTING_TRAILBLAZER_AXE_NO_INFERNAL, + AnimationID.HUMAN_WOODCUTTING_INFERNAL_AXE, + AnimationID.HUMAN_WOODCUTTING_3A_AXE, + AnimationID.HUMAN_WOODCUTTING_CRYSTAL_AXE, + AnimationID.HUMAN_WOODCUTTING_TRAILBLAZER_RELOADED_AXE_NO_INFERNAL, + AnimationID.HUMAN_WOODCUTTING_TRAILBLAZER_AXE, + AnimationID.HUMAN_WOODCUTTING_TRAILBLAZER_RELOADED_AXE, + AnimationID.FORESTRY_2H_AXE_CHOPPING_BRONZE, + AnimationID.FORESTRY_2H_AXE_CHOPPING_IRON, + AnimationID.FORESTRY_2H_AXE_CHOPPING_STEEL, + AnimationID.FORESTRY_2H_AXE_CHOPPING_BLACK, + AnimationID.FORESTRY_2H_AXE_CHOPPING_MITHRIL, + AnimationID.FORESTRY_2H_AXE_CHOPPING_ADAMANT, + AnimationID.FORESTRY_2H_AXE_CHOPPING_RUNE, + AnimationID.FORESTRY_2H_AXE_CHOPPING_DRAGON, + AnimationID.FORESTRY_2H_AXE_CHOPPING_CRYSTAL, + AnimationID.FORESTRY_2H_AXE_CHOPPING_CRYSTAL_INACTIVE, + AnimationID.FORESTRY_2H_AXE_CHOPPING_3A + ); + + /** Classification of tree species with their official despawn and respawn periods. */ + public enum TreeType + { + OAK("Oak", 27, 8.4, 14), + WILLOW("Willow", 30, 8.4, 14), + TEAK("Teak", 30, 9.0, 15), + MAPLE("Maple", 60, 35.4, 59), + HOLLOW("Hollow", 36, 25.8, 43), + MAHOGANY("Mahogany", 60, 8.4, 14), + ARCTIC_PINE("Arctic pine", 84, 8.4, 14), + YEW("Yew", 114, 59.4, 99), + CAMPHOR("Camphor", 120, 59.4, 99), + MAGIC("Magic", 234, 119.4, 199), + IRONWOOD("Ironwood", 240, 119.4, 199), + REDWOOD("Redwood", 264, 119.4, 199), + ROSEWOOD("Rosewood", 275, 123.0, 205), + NORMAL("Tree", 0, 10.0, 16); + + public final String displayName; + public final int despawnSec; + public final double respawnSec; + public final int respawnTicks; + + TreeType(String displayName, int despawnSec, double respawnSec, int respawnTicks) + { + this.displayName = displayName; + this.despawnSec = despawnSec; + this.respawnSec = respawnSec; + this.respawnTicks = respawnTicks; + } + + public int getDespawnTicks() + { + return (int) Math.round(despawnSec / 0.6); + } + + public boolean hasChopTimer() + { + return despawnSec > 30; + } + + public static TreeType fromRespawnTicks(int ticks) + { + for (TreeType t : values()) + { + if (t.respawnTicks == ticks) + { + return t; + } + } + if (ticks <= 14) return OAK; + if (ticks <= 16) return NORMAL; + if (ticks <= 45) return HOLLOW; + if (ticks <= 65) return MAPLE; + if (ticks <= 105) return YEW; + if (ticks <= 150) return ROSEWOOD; + return MAGIC; + } + } + + /** Information about a tree currently in scene. */ + public static class TrackedTree + { + public final GameObject gameObject; + public final TreeType treeType; + public final WorldPoint worldPoint; + public final int sizeX; + public final int sizeY; + + public int choppedTicks; + public int lastChoppedTick; + + public TrackedTree(GameObject gameObject, TreeType treeType) + { + this.gameObject = gameObject; + this.treeType = treeType; + this.worldPoint = gameObject.getWorldLocation(); + this.sizeX = gameObject.sizeX(); + this.sizeY = gameObject.sizeY(); + this.choppedTicks = 0; + this.lastChoppedTick = -1; + } + } + + /** Respawn timer for a felled tree stump. */ + public static class RespawnTimer + { + public final TreeType treeType; + public final WorldPoint worldPoint; + public final long startTimeMs; + public final long deadlineMs; + public final int totalTicks; + public final int startTick; + public GameObject stumpObject; + + public RespawnTimer(TreeType treeType, WorldPoint worldPoint, long startTimeMs, long deadlineMs, int totalTicks, int startTick) + { + this.treeType = treeType; + this.worldPoint = worldPoint; + this.startTimeMs = startTimeMs; + this.deadlineMs = deadlineMs; + this.totalTicks = totalTicks; + this.startTick = startTick; + } + + public int getRemainingSeconds() + { + long remaining = deadlineMs - System.currentTimeMillis(); + return (int) Math.max(0, Math.ceil(remaining / 1000.0)); + } + + public double getProgress() + { + long total = deadlineMs - startTimeMs; + if (total <= 0) + { + return 1.0; + } + long elapsed = System.currentTimeMillis() - startTimeMs; + return Math.max(0.0, Math.min(1.0, (double) elapsed / total)); + } + } + + /** Active chop timer for a tree being harvested. */ + public static class ChopTimer + { + public final TreeType treeType; + public final WorldPoint worldPoint; + public final GameObject treeObject; + public final int remainingTicks; + public final int totalTicks; + + public ChopTimer(TreeType treeType, WorldPoint worldPoint, GameObject treeObject, int remainingTicks, int totalTicks) + { + this.treeType = treeType; + this.worldPoint = worldPoint; + this.treeObject = treeObject; + this.remainingTicks = remainingTicks; + this.totalTicks = totalTicks; + } + + public int getRemainingSeconds() + { + return (int) Math.max(0, Math.round(remainingTicks * 0.6)); + } + + public double getProgress() + { + if (totalTicks <= 0) + { + return 1.0; + } + return Math.max(0.0, Math.min(1.0, (double) remainingTicks / totalTicks)); + } + } + + private final Client client; + private final EssentialsPlusConfig config; + + private final Map trackedTrees = new ConcurrentHashMap<>(); + private final Map respawnTimers = new ConcurrentHashMap<>(); + private final Map knownTreeIds = new ConcurrentHashMap<>(); + private final Map knownStumpIds = new ConcurrentHashMap<>(); + private final Set nonTreeIds = Collections.synchronizedSet(new HashSet<>()); + + private TrackedTree activeTree; + private int lastLoginTick; + + @Inject + public TreeCooldowns(Client client, EssentialsPlusConfig config) + { + this.client = client; + this.config = config; + initKnownIds(); + } + + private void initKnownIds() + { + // Common stumps + registerStump(ObjectID.OAKTREE_STUMP, TreeType.OAK); + registerStump(ObjectID.OAK_TREE_STUMP, TreeType.OAK); + registerStump(ObjectID.TREE_OAK_STUMP01, TreeType.OAK); + + registerStump(ObjectID.WILLOW_TREE_STUMP, TreeType.WILLOW); + registerStump(ObjectID.WILLOW_TREE2OR3OR4_STUMP, TreeType.WILLOW); + registerStump(ObjectID.WILLOW_TREE_STUMP_NEW, TreeType.WILLOW); + + registerStump(ObjectID.TEAK_STUMP, TreeType.TEAK); + registerStump(ObjectID.TEAK_TREE_STUMP, TreeType.TEAK); + registerStump(ObjectID.PRIF_TEAK_STUMP, TreeType.TEAK); + registerStump(ObjectID.TEAKTREE_UPDATE_STUMP, TreeType.TEAK); + + registerStump(ObjectID.MAPLE_TREE_STUMP, TreeType.MAPLE); + registerStump(ObjectID.MAPLE_TREE_STUMP_NEW, TreeType.MAPLE); + registerStump(ObjectID.MAPLETREE_UPDATE_STUMP, TreeType.MAPLE); + + registerStump(ObjectID.HOLLOW_TREE_STUMP, TreeType.HOLLOW); + registerStump(ObjectID.HOLLOW_TREE_STUMP_BIG, TreeType.HOLLOW); + + registerStump(ObjectID.MAHOGANY_STUMP, TreeType.MAHOGANY); + registerStump(ObjectID.MAHOGANY_TREE_STUMP, TreeType.MAHOGANY); + registerStump(ObjectID.PRIF_MAHOGANY_STUMP, TreeType.MAHOGANY); + registerStump(ObjectID.MAHOGANYTREE_UPDATE_STUMP, TreeType.MAHOGANY); + + registerStump(ObjectID.ARCTIC_PINE_TREE_STUMP, TreeType.ARCTIC_PINE); + + registerStump(ObjectID.YEWTREE_STUMP, TreeType.YEW); + registerStump(ObjectID.YEW_TREE_STUMP, TreeType.YEW); + registerStump(ObjectID.YEW_TREE_STUMP_NEW, TreeType.YEW); + registerStump(ObjectID.PRIF_YEWTREE_STUMP, TreeType.YEW); + registerStump(ObjectID.TREE_YEW_STUMP01, TreeType.YEW); + registerStump(ObjectID.YEWTREE_UPDATE_STUMP, TreeType.YEW); + registerStump(ObjectID.YEWTREE_UPDATE2_STUMP, TreeType.YEW); + + registerStump(ObjectID.CAMPHOR_TREE_UPDATE_STUMP, TreeType.CAMPHOR); + registerStump(ObjectID.CAMPHOR_TREE_STUMP, TreeType.CAMPHOR); + + registerStump(ObjectID.MAGIC_TREE_STUMP, TreeType.MAGIC); + registerStump(ObjectID.MAGIC_TREE_STUMP_NEW, TreeType.MAGIC); + + registerStump(ObjectID.IRONWOOD_TREE_UPDATE_STUMP, TreeType.IRONWOOD); + registerStump(ObjectID.IRONWOOD_TREE_STUMP, TreeType.IRONWOOD); + + registerStump(ObjectID.REDWOODTREE_L_CUT, TreeType.REDWOOD); + registerStump(ObjectID.REDWOODTREE_R_CUT, TreeType.REDWOOD); + + registerStump(ObjectID.ROSEWOOD_TREE_UPDATE_STUMP, TreeType.ROSEWOOD); + registerStump(ObjectID.ROSEWOOD_TREE_STUMP, TreeType.ROSEWOOD); + + registerStump(ObjectID.TREESTUMP, TreeType.NORMAL); + registerStump(ObjectID.TREESTUMP2, TreeType.NORMAL); + registerStump(ObjectID.TREESTUMP2_LIGHT, TreeType.NORMAL); + registerStump(ObjectID.TREESTUMP2_GREEN, TreeType.NORMAL); + registerStump(ObjectID.TREESTUMP2_SMALL, TreeType.NORMAL); + registerStump(ObjectID.TREESTUMP2_BIG, TreeType.NORMAL); + registerStump(ObjectID.DEADTREE1_STUMP, TreeType.NORMAL); + registerStump(ObjectID.DEADTREE2_STUMP, TreeType.NORMAL); + registerStump(ObjectID.DEADTREE3_STUMP, TreeType.NORMAL); + registerStump(ObjectID.DEADTREE4_STUMP, TreeType.NORMAL); + registerStump(ObjectID.DEADTREE6_STUMP, TreeType.NORMAL); + registerStump(ObjectID.EVERGREEN_LARGE_STUMP, TreeType.NORMAL); + registerStump(ObjectID.ACHEY_TREE_STUMP, TreeType.NORMAL); + registerStump(ObjectID.JUNGLESTUMP_KHARAZI, TreeType.NORMAL); + + // Common trees + registerTree(ObjectID.OAKTREE, TreeType.OAK); + registerTree(ObjectID.WILLOWTREE, TreeType.WILLOW); + registerTree(ObjectID.TEAKTREE, TreeType.TEAK); + registerTree(ObjectID.TEAKTREE_UPDATE, TreeType.TEAK); + registerTree(ObjectID.MAPLETREE, TreeType.MAPLE); + registerTree(ObjectID.MAPLETREE_UPDATE, TreeType.MAPLE); + registerTree(ObjectID.HOLLOW_TREE, TreeType.HOLLOW); + registerTree(ObjectID.MAHOGANYTREE, TreeType.MAHOGANY); + registerTree(ObjectID.MAHOGANYTREE_UPDATE, TreeType.MAHOGANY); + registerTree(ObjectID.YEWTREE, TreeType.YEW); + registerTree(ObjectID.YEWTREE_UPDATE, TreeType.YEW); + registerTree(ObjectID.YEWTREE_UPDATE2, TreeType.YEW); + registerTree(ObjectID.CAMPHOR_TREE, TreeType.CAMPHOR); + registerTree(ObjectID.MAGICTREE, TreeType.MAGIC); + registerTree(ObjectID.IRONWOOD_TREE, TreeType.IRONWOOD); + registerTree(ObjectID.REDWOODTREE_L, TreeType.REDWOOD); + registerTree(ObjectID.REDWOODTREE_R, TreeType.REDWOOD); + registerTree(ObjectID.ROSEWOOD_TREE, TreeType.ROSEWOOD); + registerTree(ObjectID.TREE, TreeType.NORMAL); + } + + private void registerTree(int id, TreeType type) + { + knownTreeIds.put(id, type); + } + + private void registerStump(int id, TreeType type) + { + knownStumpIds.put(id, type); + } + + public void reset() + { + trackedTrees.clear(); + respawnTimers.clear(); + activeTree = null; + } + + @Subscribe + public void onGameStateChanged(GameStateChanged event) + { + if (event.getGameState() != GameState.LOGGED_IN) + { + reset(); + } + else + { + lastLoginTick = client.getTickCount(); + scanScene(); + } + } + + @Subscribe + public void onGameTick(GameTick event) + { + if (!config.treeCooldowns()) + { + return; + } + + Player player = client.getLocalPlayer(); + if (player == null) + { + return; + } + + int currentTick = client.getTickCount(); + boolean isChopping = WOODCUT_ANIMS.contains(player.getAnimation()); + + if (isChopping) + { + TrackedTree tree = findChoppedTree(player); + if (tree != null) + { + tree.choppedTicks++; + tree.lastChoppedTick = currentTick; + activeTree = tree; + } + } + else + { + activeTree = null; + } + + // Depletion regeneration for idle trees + long now = System.currentTimeMillis(); + for (TrackedTree tree : trackedTrees.values()) + { + if (tree.choppedTicks > 0 && tree.lastChoppedTick != currentTick) + { + tree.choppedTicks = Math.max(0, tree.choppedTicks - 1); + } + } + + // Prune expired respawn timers + respawnTimers.values().removeIf(timer -> now >= timer.deadlineMs + 2000L); + } + + @Subscribe + public void onScriptPreFired(ScriptPreFired scriptPreFired) + { + if (!config.treeCooldowns() || scriptPreFired.getScriptId() != ScriptID.ADD_OVERLAYTIMER_LOC) + { + return; + } + + Object[] args = scriptPreFired.getScriptEvent().getArguments(); + if (args.length < 6) + { + return; + } + + int locCoord = (int) args[1]; + int locId = (int) args[2]; + int ticks = (int) args[5]; + + WorldPoint worldPoint = WorldPoint.fromCoord(locCoord); + TreeType treeType = knownStumpIds.get(locId); + if (treeType == null) + { + TrackedTree prev = trackedTrees.get(worldPoint); + treeType = (prev != null) ? prev.treeType : TreeType.fromRespawnTicks(ticks); + } + + long now = System.currentTimeMillis(); + long durationMs = (long) ticks * 600L; + RespawnTimer timer = new RespawnTimer( + treeType, + worldPoint, + now, + now + durationMs, + ticks, + client.getTickCount() + ); + respawnTimers.put(worldPoint, timer); + } + + @Subscribe + public void onGameObjectSpawned(GameObjectSpawned event) + { + if (!config.treeCooldowns()) + { + return; + } + + GameObject go = event.getGameObject(); + if (go == null) + { + return; + } + + TreeType treeType = resolveTreeType(go); + if (treeType != null) + { + TrackedTree tree = new TrackedTree(go, treeType); + trackedTrees.put(go.getWorldLocation(), tree); + // Regrowth: remove any active respawn timer on this tile or within bounds + removeRespawnTimersAt(go.getWorldLocation(), go.sizeX(), go.sizeY()); + return; + } + + TreeType stumpType = resolveStumpType(go); + if (stumpType != null) + { + WorldPoint pt = go.getWorldLocation(); + RespawnTimer timer = respawnTimers.get(pt); + if (timer != null) + { + timer.stumpObject = go; + } + else + { + // Witnessed felling in player's vicinity + Player player = client.getLocalPlayer(); + int ticksSinceLoad = client.getTickCount() - lastLoginTick; + if (player != null && ticksSinceLoad > 2 && player.getWorldLocation().distanceTo(pt) <= 16) + { + TrackedTree prev = trackedTrees.remove(pt); + TreeType actualType = (prev != null) ? prev.treeType : stumpType; + long now = System.currentTimeMillis(); + long durationMs = (long) (actualType.respawnSec * 1000L); + RespawnTimer newTimer = new RespawnTimer( + actualType, + pt, + now, + now + durationMs, + actualType.respawnTicks, + client.getTickCount() + ); + newTimer.stumpObject = go; + respawnTimers.put(pt, newTimer); + } + } + } + } + + @Subscribe + public void onGameObjectDespawned(GameObjectDespawned event) + { + if (!config.treeCooldowns()) + { + return; + } + + GameObject go = event.getGameObject(); + if (go == null) + { + return; + } + + WorldPoint pt = go.getWorldLocation(); + TrackedTree tree = trackedTrees.remove(pt); + if (tree != null) + { + if (!respawnTimers.containsKey(pt)) + { + long now = System.currentTimeMillis(); + long durationMs = (long) (tree.treeType.respawnSec * 1000L); + RespawnTimer timer = new RespawnTimer( + tree.treeType, + pt, + now, + now + durationMs, + tree.treeType.respawnTicks, + client.getTickCount() + ); + respawnTimers.put(pt, timer); + } + } + + // If stump despawned, remove respawn timer + if (resolveStumpType(go) != null) + { + respawnTimers.remove(pt); + } + } + + private void removeRespawnTimersAt(WorldPoint origin, int sizeX, int sizeY) + { + for (int x = 0; x < sizeX; x++) + { + for (int y = 0; y < sizeY; y++) + { + respawnTimers.remove(new WorldPoint(origin.getX() + x, origin.getY() + y, origin.getPlane())); + } + } + } + + private TrackedTree findChoppedTree(Player player) + { + WorldPoint playerLoc = player.getWorldLocation(); + + // Prefer continuing with active tree if still adjacent + if (activeTree != null && isAdjacent(playerLoc, activeTree)) + { + return activeTree; + } + + // Find closest adjacent tree aligned with player orientation + TrackedTree bestTree = null; + int bestScore = Integer.MAX_VALUE; + + int orientation = player.getOrientation(); // 0 = South, 512 = West, 1024 = North, 1536 = East + double facingAngle = orientation * (2.0 * Math.PI / 2048.0); + double facingDx = -Math.sin(facingAngle); + double facingDy = -Math.cos(facingAngle); + + for (TrackedTree tree : trackedTrees.values()) + { + if (tree.worldPoint.getPlane() != playerLoc.getPlane()) + { + continue; + } + + if (isAdjacent(playerLoc, tree)) + { + double centerX = tree.worldPoint.getX() + (tree.sizeX - 1) / 2.0; + double centerY = tree.worldPoint.getY() + (tree.sizeY - 1) / 2.0; + double dx = centerX - playerLoc.getX(); + double dy = centerY - playerLoc.getY(); + + // Dot product with facing direction + double dot = dx * facingDx + dy * facingDy; + int score = (int) Math.round((1.0 - dot) * 100.0); + if (score < bestScore) + { + bestScore = score; + bestTree = tree; + } + } + } + + return bestTree; + } + + private static boolean isAdjacent(WorldPoint playerLoc, TrackedTree tree) + { + int minX = tree.worldPoint.getX(); + int maxX = tree.worldPoint.getX() + tree.sizeX - 1; + int minY = tree.worldPoint.getY(); + int maxY = tree.worldPoint.getY() + tree.sizeY - 1; + + int px = playerLoc.getX(); + int py = playerLoc.getY(); + + int dx = Math.max(0, Math.max(minX - px, px - maxX)); + int dy = Math.max(0, Math.max(minY - py, py - maxY)); + + return dx <= 1 && dy <= 1; + } + + private void scanScene() + { + Tile[][][] tiles = client.getScene().getTiles(); + int plane = client.getPlane(); + if (tiles == null || plane < 0 || plane >= tiles.length) + { + return; + } + + Tile[][] planeTiles = tiles[plane]; + for (Tile[] row : planeTiles) + { + if (row == null) continue; + for (Tile tile : row) + { + if (tile == null) continue; + GameObject[] objects = tile.getGameObjects(); + if (objects != null) + { + for (GameObject go : objects) + { + if (go != null) + { + TreeType treeType = resolveTreeType(go); + if (treeType != null) + { + trackedTrees.put(go.getWorldLocation(), new TrackedTree(go, treeType)); + } + } + } + } + } + } + } + + private TreeType resolveTreeType(GameObject obj) + { + int id = obj.getId(); + if (nonTreeIds.contains(id)) + { + return null; + } + + TreeType known = knownTreeIds.get(id); + if (known != null) + { + return known; + } + + ObjectComposition comp = client.getObjectDefinition(id); + if (comp == null || comp.getName() == null) + { + nonTreeIds.add(id); + return null; + } + + String name = comp.getName().toLowerCase(); + if (name.contains("stump")) + { + return null; + } + + boolean hasChop = false; + String[] actions = comp.getActions(); + if (actions != null) + { + for (String a : actions) + { + if (a != null && a.equalsIgnoreCase("Chop down")) + { + hasChop = true; + break; + } + } + } + + if (!hasChop && !name.contains("tree") && !name.contains("redwood")) + { + nonTreeIds.add(id); + return null; + } + + TreeType matched = matchTreeName(name); + if (matched != null) + { + knownTreeIds.put(id, matched); + return matched; + } + + nonTreeIds.add(id); + return null; + } + + private TreeType resolveStumpType(GameObject obj) + { + int id = obj.getId(); + TreeType known = knownStumpIds.get(id); + if (known != null) + { + return known; + } + + ObjectComposition comp = client.getObjectDefinition(id); + if (comp == null || comp.getName() == null) + { + return null; + } + + String name = comp.getName().toLowerCase(); + if (name.contains("stump")) + { + TreeType matched = matchTreeName(name); + TreeType result = (matched != null) ? matched : TreeType.NORMAL; + knownStumpIds.put(id, result); + return result; + } + + return null; + } + + private static TreeType matchTreeName(String name) + { + if (name.contains("redwood")) return TreeType.REDWOOD; + if (name.contains("magic")) return TreeType.MAGIC; + if (name.contains("yew")) return TreeType.YEW; + if (name.contains("camphor")) return TreeType.CAMPHOR; + if (name.contains("ironwood")) return TreeType.IRONWOOD; + if (name.contains("rosewood")) return TreeType.ROSEWOOD; + if (name.contains("arctic pine")) return TreeType.ARCTIC_PINE; + if (name.contains("mahogany")) return TreeType.MAHOGANY; + if (name.contains("maple")) return TreeType.MAPLE; + if (name.contains("hollow")) return TreeType.HOLLOW; + if (name.contains("teak")) return TreeType.TEAK; + if (name.contains("willow")) return TreeType.WILLOW; + if (name.contains("oak")) return TreeType.OAK; + if (name.contains("tree") || name.contains("evergreen")) return TreeType.NORMAL; + return null; + } + + public Collection getActiveRespawnTimers() + { + return respawnTimers.values(); + } + + public Collection getActiveChopTimers() + { + List result = new ArrayList<>(); + for (TrackedTree tree : trackedTrees.values()) + { + if (tree.treeType.hasChopTimer() && tree.choppedTicks > 0) + { + int totalTicks = tree.treeType.getDespawnTicks(); + int remaining = totalTicks - tree.choppedTicks; + if (remaining > 0) + { + result.add(new ChopTimer(tree.treeType, tree.worldPoint, tree.gameObject, remaining, totalTicks)); + } + } + } + return result; + } +} diff --git a/src/main/java/com/example/essentialsplus/helpers/combat/AnimationData.java b/src/main/java/com/example/essentialsplus/helpers/combat/AnimationData.java new file mode 100644 index 0000000..a9c680c --- /dev/null +++ b/src/main/java/com/example/essentialsplus/helpers/combat/AnimationData.java @@ -0,0 +1,445 @@ +package com.example.essentialsplus.helpers.combat; + +import com.google.common.collect.ImmutableMap; +import com.google.common.collect.ImmutableSet; +import java.security.InvalidParameterException; +import java.util.Arrays; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; +import lombok.Getter; +import net.runelite.api.gameval.AnimationID; +import org.apache.commons.lang3.StringUtils; + +public enum AnimationData +{ + // MELEE + MELEE_VIGGORAS_CHAINMACE(AnimationID.WILD_CAVE_CHAINMACE_CRUSH, AttackStyle.MELEE), + MELEE_DAGGER_SLASH(AnimationID.HUMAN_DDAGGER_LUNGE, AttackStyle.MELEE), + MELEE_SPEAR_STAB(AnimationID.HUMAN_DSPEAR_STAB, AttackStyle.MELEE), + MELEE_SWORD_STAB(AnimationID.HUMAN_SWORD_STAB, AttackStyle.MELEE), + MELEE_SCIM_SLASH(AnimationID.HUMAN_SWORD_SLASH, AttackStyle.MELEE), + + MELEE_LANCE_STAB(AnimationID.HUMAN_DHUNTER_LANCE_ATTACK, AttackStyle.MELEE), + MELEE_LANCE_CRUSH(AnimationID.HUMAN_DHUNTER_LANCE_CRUSH, AttackStyle.MELEE), + MELEE_LANCE_SLASH(AnimationID.HUMAN_DHUNTER_LANCE_SLASH, AttackStyle.MELEE), + + MELEE_FANG_STAB(AnimationID.HUMAN_OSMUMTENS_FANG, AttackStyle.MELEE), + MELEE_FANG_SPEC(AnimationID.OLAF2_BRINE_SABRE_SPECIAL, AttackStyle.MELEE, MetaData.SPECIAL_ATTACK.d), + + MELEE_GENERIC_SLASH(AnimationID.HUMAN_AXE_CHOP, AttackStyle.MELEE), + + MELEE_BATTLEAXE_SLASH(AnimationID.HUMAN_AXE_HACK, AttackStyle.MELEE), + MELEE_MACE_STAB(AnimationID.HUMAN_BLUNT_SPIKE, AttackStyle.MELEE), + MELEE_BATTLEAXE_CRUSH(AnimationID.HUMAN_BLUNT_POUND, AttackStyle.MELEE), + MELEE_2H_CRUSH(AnimationID.HUMAN_DHSWORD_CHOP, AttackStyle.MELEE), + MELEE_2H_SLASH(AnimationID.HUMAN_DHSWORD_SLASH, AttackStyle.MELEE), + MELEE_STAFF_CRUSH_2(AnimationID.HUMAN_STAFF_PUMMEL, AttackStyle.MELEE), + MELEE_STAFF_CRUSH_3(AnimationID.HUMAN_STAFFORB_PUMMEL, AttackStyle.MELEE), + MELEE_PUNCH(AnimationID.HUMAN_UNARMEDPUNCH, AttackStyle.MELEE), + MELEE_KICK(AnimationID.HUMAN_UNARMEDKICK, AttackStyle.MELEE), + MELEE_STAFF_STAB(AnimationID.HUMAN_SPEAR_SPIKE, AttackStyle.MELEE), + MELEE_SPEAR_CRUSH(AnimationID.HUMAN_SPEAR_LUNGE, AttackStyle.MELEE), + MELEE_STAFF_SLASH(AnimationID.HUMAN_SCYTHE_SWEEP, AttackStyle.MELEE), + MELEE_DLONG_SPEC(AnimationID.CLEAVE, AttackStyle.MELEE, MetaData.SPECIAL_ATTACK.d), + MELEE_DRAGON_MACE_SPEC(AnimationID.SHATTER, AttackStyle.MELEE, MetaData.SPECIAL_ATTACK.d), + MELEE_DRAGON_DAGGER_SPEC(AnimationID.PUNCTURE, AttackStyle.MELEE, MetaData.SPECIAL_ATTACK.d), + MELEE_DRAGON_WARHAMMER_SPEC(AnimationID.DRAGON_WARHAMMER_SA_PLAYER, AttackStyle.MELEE, MetaData.SPECIAL_ATTACK.d), + MELEE_ABYSSAL_WHIP(AnimationID.SLAYER_ABYSSAL_WHIP_ATTACK, AttackStyle.MELEE), + MELEE_GRANITE_MAUL(AnimationID.SLAYER_GRANITE_MAUL_ATTACK, AttackStyle.MELEE), + MELEE_GRANITE_MAUL_SPEC(AnimationID.SLAYER_GRANITE_MAUL_SPECIAL_ATTACK, AttackStyle.MELEE, MetaData.SPECIAL_ATTACK.d), + MELEE_DHAROKS_GREATAXE_CRUSH(AnimationID.BARROW_DHAROK_SLASH, AttackStyle.MELEE), + MELEE_DHAROKS_GREATAXE_SLASH(AnimationID.BARROW_DHAROK_CRUSH, AttackStyle.MELEE), + MELEE_AHRIMS_STAFF_CRUSH(AnimationID.BARROWS_QUARTERSTAFF_ATTACK, AttackStyle.MELEE), + MELEE_OBBY_MAUL_CRUSH(AnimationID.DRAGON_PICKAXE_ANIM, AttackStyle.MELEE), + MELEE_ABYSSAL_DAGGER_STAB(AnimationID.ABYSSAL_DAGGER_LUNGE, AttackStyle.MELEE), + MELEE_ABYSSAL_BLUDGEON_CRUSH(AnimationID.ABYSSAL_BLUDGEON_CRUSH, AttackStyle.MELEE), + MELEE_ABYSSAL_BLUDGEON_SPEC(AnimationID.ABYSSAL_BLUDGEON_SPECIAL_ATTACK, AttackStyle.MELEE, MetaData.SPECIAL_ATTACK.d), + MELEE_LEAF_BLADED_BATTLEAXE_CRUSH(AnimationID.BATTLEAXE_CRUSH, AttackStyle.MELEE), + MELEE_INQUISITORS_MACE(AnimationID.HUMAN_INQUISITORS_MACE_CRUSH, AttackStyle.MELEE), + MELEE_BARRELCHEST_ANCHOR_CRUSH(AnimationID.BRAIN_PLAYER_ANCHOR_ATTACK, AttackStyle.MELEE), + MELEE_BARRELCHEST_ANCHOR_CRUSH_SPEC(AnimationID.BRAIN_PLAYER_ANCHOR_SPECIAL_ATTACK, AttackStyle.MELEE, MetaData.SPECIAL_ATTACK.d), + MELEE_LEAF_BLADED_BATTLEAXE_SLASH(AnimationID.GODWARS_GODSWORD_ZAMORAK_PLAYER, AttackStyle.MELEE), + MELEE_GODSWORD_SLASH(AnimationID.DH_SWORD_UPDATE_SLASH, AttackStyle.MELEE), + MELEE_GODSWORD_CRUSH(AnimationID.DH_SWORD_UPDATE_SMASH, AttackStyle.MELEE), + MELEE_GODSWORD_DEFENSIVE(AnimationID.DH_SWORD_UPDATE_BLOCK, AttackStyle.MELEE), + MELEE_RUNE_CLAWS_SPEC(AnimationID.IMPALE, AttackStyle.MELEE, MetaData.SPECIAL_ATTACK.d), + MELEE_DRAGON_CLAWS_SPEC(AnimationID.HUMAN_DRAGON_CLAWS_SPEC, AttackStyle.MELEE, MetaData.SPECIAL_ATTACK.d), + MELEE_VLS_SPEC(AnimationID.HUMAN_DRAGON_SWORD_SPEC, AttackStyle.MELEE, MetaData.SPECIAL_ATTACK.d), + MELEE_ELDER_MAUL(AnimationID.HUMAN_ELDER_MAUL_ATTACK, AttackStyle.MELEE), + MELEE_ZAMORAK_GODSWORD_SPEC(AnimationID.ZGS_SPECIAL_PLAYER, AttackStyle.MELEE, MetaData.SPECIAL_ATTACK.d), + MELEE_ELDER_MAUL_SPEC(AnimationID.HUMAN_ELDER_MAUL_SPEC, AttackStyle.MELEE), + MELEE_ZAMORAK_GODSWORD_OR_SPEC(AnimationID.ZGS_SPECIAL_ORNATE_PLAYER, AttackStyle.MELEE, MetaData.SPECIAL_ATTACK.d), + MELEE_SARADOMIN_GODSWORD_SPEC(AnimationID.SGS_SPECIAL_PLAYER, AttackStyle.MELEE, MetaData.SPECIAL_ATTACK.d), + MELEE_SARADOMIN_GODSWORD_OR_SPEC(AnimationID.SGS_SPECIAL_ORNATE_PLAYER, AttackStyle.MELEE, MetaData.SPECIAL_ATTACK.d), + MELEE_BANDOS_GODSWORD_SPEC(AnimationID.BGS_SPECIAL_PLAYER, AttackStyle.MELEE, MetaData.SPECIAL_ATTACK.d), + MELEE_BANDOS_GODSWORD_OR_SPEC(AnimationID.BGS_SPECIAL_ORNATE_PLAYER, AttackStyle.MELEE, MetaData.SPECIAL_ATTACK.d), + MELEE_ARMADYL_GODSWORD_SPEC(AnimationID.AGS_SPECIAL_PLAYER, AttackStyle.MELEE, MetaData.SPECIAL_ATTACK.d), + MELEE_ARMADYL_GODSWORD_OR_SPEC(AnimationID.AGS_SPECIAL_ORNATE_PLAYER, AttackStyle.MELEE, MetaData.SPECIAL_ATTACK.d), + MELEE_SCYTHE(AnimationID.SCYTHE_OF_VITUR_ATTACK, AttackStyle.MELEE), + MELEE_GHAZI_RAPIER_STAB(AnimationID.GHRAZI_RAPIER_ATTACK, AttackStyle.MELEE), + MELEE_ANCIENT_GODSWORD_SPEC(AnimationID.NGS_SPECIAL_PLAYER, AttackStyle.MELEE, MetaData.SPECIAL_ATTACK.d), + MELEE_CRYSTAL_HALBERD_SPEC(AnimationID.DRAGON_HALBERD_SPECIAL_ATTACK, AttackStyle.MELEE, MetaData.SPECIAL_ATTACK.d), + MELEE_SOULREAPER_AXE(AnimationID.ANCIENT_AXE_CRUSH, AttackStyle.MELEE, MetaData.SPECIAL_ATTACK.d), + MELEE_SOULREAPER_AXE_SPEC(AnimationID.ANCIENT_AXE_SPECIAL, AttackStyle.MELEE, MetaData.SPECIAL_ATTACK.d), + MELEE_GUTHANS_LUNGE(AnimationID.BARROWS_WAR_SPEAR_STAB, AttackStyle.MELEE), + MELEE_GUTHANS_SWIPE(AnimationID.BARROWS_WAR_SPEAR_SLASH, AttackStyle.MELEE), + MELEE_GUTHANS_POUNDMA(AnimationID.BARROWS_WAR_SPEAR_CRUSH, AttackStyle.MELEE), + MELEE_TORAG_HAMMERS(AnimationID.BARROW_TORAG_CRUSH, AttackStyle.MELEE), + MELEE_VERACS_FLAIL(AnimationID.BARROW_GUTHAN_CRUSH, AttackStyle.MELEE), + MELEE_BLISTERWOOD_FLAIL_CRUSH(AnimationID.IVANDIS_FLAIL_ATTACK, AttackStyle.MELEE), + MELEE_BONE_DAGGER_SPEC(AnimationID.DTTD_PLAYER_STAB_BONE_DAGGER, AttackStyle.MELEE, MetaData.SPECIAL_ATTACK.d), + MELEE_DUAL_MACUAHUITL(AnimationID.PMOON_MACUAHUITL_CRUSH, AttackStyle.MELEE), + MELEE_BLUE_MOON_SPEAR_SPEC(AnimationID.HUMAN_ZAMORAKSPEAR_LUNGE, AttackStyle.MELEE, MetaData.SPECIAL_ATTACK.d), + MELEE_BLUE_MOON_SPEAR(AnimationID.HUMAN_ZAMORAKSPEAR_STAB, AttackStyle.MELEE), + MELEE_DHINS(AnimationID.HUMAN_DINHS_BULWARK_BASH, AttackStyle.MELEE), + MELEE_URSINE_CHAINMACE_SPEC(AnimationID.HUMAN_SPECIAL02_URSINE, AttackStyle.MELEE, MetaData.SPECIAL_ATTACK.d), + MELEE_ANCIENT_MACE_SPEC(AnimationID.SLICE_PLAYER_MACE_SPECIAL_ATTACK, AttackStyle.MELEE, MetaData.SPECIAL_ATTACK.d), + MELEE_DSCIM_SPEC(AnimationID.SP_ATTACK_DRAGON_SCIMITAR, AttackStyle.MELEE, MetaData.SPECIAL_ATTACK.d), + MELEE_D2H_SPEC(AnimationID.DRAGON_TWO_HANDED_SWORD, AttackStyle.MELEE, MetaData.SPECIAL_ATTACK.d), + MELEE_ARCLIGHT_SPEC(AnimationID.DARK_SPEC_PLAYER, AttackStyle.MELEE, MetaData.SPECIAL_ATTACK.d), + MELEE_SARA_SWORD_SPEC(AnimationID.SARADOMIN_SWORD_SPECIAL_PLAYER, AttackStyle.MELEE, MetaData.SPECIAL_ATTACK.d), + MELEE_RED_KERIS_SPEC(AnimationID.TOA_KERIS_PARTISAN_SPECIAL01, AttackStyle.MELEE, MetaData.SPECIAL_ATTACK.d), + MELEE_SALAMANDER(AnimationID.HUMAN_ATTACK_SALAMANDER, AttackStyle.MELEE), + MELEE_INFERNAL_TECPATL(AnimationID.TECPATL_STAB, AttackStyle.MELEE), + MELEE_HALLOWED_FLAIL(AnimationID.HUMAN_WEAPONS_HALLOWED_FLAIL01_ATTACK01, AttackStyle.MELEE), + MELEE_FELLING_AXE(AnimationID.FORESTRY_2H_AXE_ATTACK, AttackStyle.MELEE), + MELEE_HALLOWFELL(AnimationID.HUMAN_HALLOWFELL_SLASH, AttackStyle.MELEE), + + // RANGED + RANGED_CHINCHOMPA(AnimationID.HUMAN_CHINCHOMPA_ATTACK_PVN, AttackStyle.RANGED), + RANGED_SHORTBOW(AnimationID.HUMAN_BOW, AttackStyle.RANGED, MetaData.STANDARD_BOW_ATTACK.d), + RANGED_RUNE_KNIFE_PVP(AnimationID.HUMAN_STAKE2, AttackStyle.RANGED), + RANGED_MAGIC_SHORTBOW_SPEC(AnimationID.SNAPSHOT, AttackStyle.RANGED, MetaData.STANDARD_BOW_ATTACK.d | MetaData.SPECIAL_ATTACK.d), + RANGED_CROSSBOW_PVP(AnimationID.XBOWS_HUMAN_FIRE_AND_RELOAD, AttackStyle.RANGED), + RANGED_BLOWPIPE(AnimationID.SNAKEBOSS_BLOWPIPE_ATTACK, AttackStyle.RANGED), + RANGED_DARTS(AnimationID.II_HUMAN_DART_THROW_PVN, AttackStyle.RANGED), + RANGED_BALLISTA(AnimationID.BALLISTA_ATTACK, AttackStyle.RANGED), + RANGED_BALLISTA_SPEC(AnimationID.BALLISTA_SPECIAL_ATTACK_PVN, AttackStyle.RANGED, MetaData.SPECIAL_ATTACK.d), + RANGED_RUNE_THROWNAXE_SPEC(AnimationID.CHAINHIT, AttackStyle.RANGED, MetaData.SPECIAL_ATTACK.d), + RANGED_DRAGON_THROWNAXE_SPEC(AnimationID.HUMAN_DRAGON_TAXE_SPEC, AttackStyle.RANGED, MetaData.SPECIAL_ATTACK.d), + RANGED_RUNE_CROSSBOW(AnimationID.XBOWS_HUMAN_FIRE_AND_RELOAD_PVN, AttackStyle.RANGED), + RANGED_RUNE_CROSSBOW_OR(AnimationID.HUMAN_XBOWS_LEAGUE03_ATTACK_PVN, AttackStyle.RANGED), + RANGED_BALLISTA_2(AnimationID.BALLISTA_ATTACK_PVN, AttackStyle.RANGED), + RANGED_RUNE_KNIFE(AnimationID.HUMAN_STAKE2_PVN, AttackStyle.RANGED), + RANGED_DRAGON_KNIFE(AnimationID.HUMAN_DRAGON_KNIFE, AttackStyle.RANGED), + RANGED_DRAGON_KNIFE_SPEC(AnimationID.HUMAN_DRAGON_TKNIVES_SPEC, AttackStyle.RANGED, MetaData.SPECIAL_ATTACK.d), + RANGED_DRAGON_KNIFE_POISONED(AnimationID.HUMAN_DRAGON_KNIFE_P, AttackStyle.RANGED), + RANGED_DRAGON_KNIFE_POISONED_SPEC(AnimationID.HUMAN_DRAGON_TKNIVES_SPEC_POISON, AttackStyle.RANGED, MetaData.SPECIAL_ATTACK.d), + RANGED_ZARYTE_CROSSBOW(AnimationID.ZCB_ATTACK_PVN, AttackStyle.RANGED), + RANGED_ZARYTE_CROSSBOW_PVP(AnimationID.ZCB_ATTACK, AttackStyle.RANGED), + RANGED_BLAZING_BLOWPIPE(AnimationID.SNAKEBOSS_BLOWPIPE_ATTACK_ORNAMENT, AttackStyle.RANGED), + RANGED_VENATOR_BOW(AnimationID.HUMAN_WEAPON_BOW_VENATOR01_SHOOT, AttackStyle.RANGED, MetaData.STANDARD_BOW_ATTACK.d), + RANGED_KARIL_CROSSBOW(AnimationID.BARROWS_REPEATING_CROSSBOW_FIRE, AttackStyle.RANGED), + RANGED_ATLATL(AnimationID.HUMAN_ATLATL_ATTACK_RANGED_01, AttackStyle.RANGED, MetaData.STANDARD_BOW_ATTACK.d), + RANGED_ATLATL_SPEC(AnimationID.HUMAN_SPECIAL_ATLATL_01, AttackStyle.RANGED, MetaData.STANDARD_BOW_ATTACK.d | MetaData.SPECIAL_ATTACK.d), + RANGED_TONALZTICS(AnimationID.HUMAN_GLAIVE_RALOS01_CHARGED_THROW, AttackStyle.RANGED), + RANGED_TONALZTICS_SPEC(AnimationID.HUMAN_GLAIVE_RALOS01_CHARGED_SPECIAL, AttackStyle.RANGED, MetaData.SPECIAL_ATTACK.d), + RANGED_WEBWEAVER_SPEC(AnimationID.HUMAN_SPECIAL01_WEBWEAVER, AttackStyle.RANGED, MetaData.STANDARD_BOW_ATTACK.d | MetaData.SPECIAL_ATTACK.d), + RANGED_BONE_CROSSBOW_SPEC(AnimationID.DTTD_PLAYER_FIRE_BONE_CROSSBOW_PVN, AttackStyle.RANGED, MetaData.SPECIAL_ATTACK.d), + RANGED_SCORCHING_BOW_SPEC(AnimationID.HUMAN_WEAPON_BOW_SCORCHED_01_SPEC, AttackStyle.RANGED, MetaData.STANDARD_BOW_ATTACK.d | MetaData.SPECIAL_ATTACK.d), + + // MAGIC + MAGIC_GOD_SPELL(AnimationID.HUMAN_CASTING, AttackStyle.MAGIC, Spellbook.STANDARD, MetaData.NO_DATA.d), + MAGIC_IBAN_BLAST(AnimationID.HUMAN_CASTIBANBLAST, AttackStyle.MAGIC, Spellbook.STANDARD, MetaData.NO_DATA.d), + MAGIC_SLAYER_DART(AnimationID.SLAYER_MAGICDART_CAST, AttackStyle.MAGIC, Spellbook.STANDARD, MetaData.NO_DATA.d), + MAGIC_STANDARD_BIND(AnimationID.HUMAN_CASTENTANGLE, AttackStyle.MAGIC, Spellbook.STANDARD, MetaData.NO_DATA.d), + MAGIC_STANDARD_BIND_STAFF(AnimationID.HUMAN_CASTENTANGLE_STAFF, AttackStyle.MAGIC, Spellbook.STANDARD, MetaData.NO_DATA.d), + MAGIC_STANDARD_CONFUSE(AnimationID.HUMAN_CASTCONFUSE, AttackStyle.MAGIC, Spellbook.STANDARD, MetaData.NO_DATA.d), + MAGIC_STANDARD_CONFUSE_STAFF(AnimationID.HUMAN_CASTCONFUSE_STAFF, AttackStyle.MAGIC, Spellbook.STANDARD, MetaData.NO_DATA.d), + MAGIC_STANDARD_CONFUSE_STAFF_WALK(AnimationID.HUMAN_CASTCONFUSE_STAFF_WALKMERGE, AttackStyle.MAGIC, Spellbook.STANDARD, MetaData.NO_DATA.d), + MAGIC_STANDARD_CONFUSE_WALK(AnimationID.HUMAN_CASTCONFUSE_WALKMERGE, AttackStyle.MAGIC, Spellbook.STANDARD, MetaData.NO_DATA.d), + MAGIC_STANDARD_CRUMBLE_UNDEAD(AnimationID.HUMAN_CASTCRUMBLEUNDEAD, AttackStyle.MAGIC, Spellbook.STANDARD, MetaData.NO_DATA.d), + MAGIC_STANDARD_CRUMBLE_UNDEAD_HOLDING_STAFF(AnimationID.HUMAN_CASTCRUMBLEUNDEAD_STAFF, AttackStyle.MAGIC, Spellbook.STANDARD, MetaData.NO_DATA.d), + MAGIC_STANDARD_ENFEEBLE(AnimationID.HUMAN_CASTENFEEBLE_STAFF, AttackStyle.MAGIC, Spellbook.STANDARD, MetaData.NO_DATA.d), + MAGIC_STANDARD_STRIKE_BOLT_BLAST(AnimationID.HUMAN_CASTSTRIKE, AttackStyle.MAGIC, Spellbook.STANDARD, MetaData.NO_DATA.d), + MAGIC_STANDARD_STRIKE_BOLT_BLAST_STAFF(AnimationID.HUMAN_CASTSTRIKE_STAFF, AttackStyle.MAGIC, Spellbook.STANDARD, MetaData.NO_DATA.d), + MAGIC_STANDARD_STRIKE_BOLT_BLAST_STAFF_WALK(AnimationID.HUMAN_CASTSTRIKE_STAFF_WALKMERGE, AttackStyle.MAGIC, Spellbook.STANDARD, MetaData.NO_DATA.d), + MAGIC_STANDARD_STRIKE_BOLT_BLAST_WALK(AnimationID.HUMAN_CASTSTRIKE_WALKMERGE, AttackStyle.MAGIC, Spellbook.STANDARD, MetaData.NO_DATA.d), + MAGIC_STANDARD_STUN(AnimationID.HUMAN_CASTSTUN_STAFF, AttackStyle.MAGIC, Spellbook.STANDARD, MetaData.NO_DATA.d), + MAGIC_STANDARD_SURGE(AnimationID.HUMAN_CAST_SURGE, AttackStyle.MAGIC, Spellbook.STANDARD, MetaData.NO_DATA.d), + MAGIC_STANDARD_SURGE_STAFF(AnimationID.HUMAN_CAST_SURGE_FAST, AttackStyle.MAGIC, Spellbook.STANDARD, MetaData.NO_DATA.d), + MAGIC_STANDARD_SURGE_STAFF_WALK(AnimationID.HUMAN_CAST_SURGE_WALKMERGE, AttackStyle.MAGIC, Spellbook.STANDARD, MetaData.NO_DATA.d), + MAGIC_STANDARD_VULNERABILITY_CURSE(AnimationID.HUMAN_CASTCURSE, AttackStyle.MAGIC, Spellbook.STANDARD, MetaData.NO_DATA.d), + MAGIC_STANDARD_VULNERABILITY_CURSE_STAFF(AnimationID.HUMAN_CASTCURSE_STAFF, AttackStyle.MAGIC, Spellbook.STANDARD, MetaData.NO_DATA.d), + MAGIC_STANDARD_VULNERABILITY_CURSE_STAFF_WALK(AnimationID.HUMAN_CASTCURSE_STAFF_WALKMERGE, AttackStyle.MAGIC, Spellbook.STANDARD, MetaData.NO_DATA.d), + MAGIC_STANDARD_VULNERABILITY_CURSE_WALK(AnimationID.HUMAN_CASTCURSE_WALKMERGE, AttackStyle.MAGIC, Spellbook.STANDARD, MetaData.NO_DATA.d), + MAGIC_STANDARD_WAVE(AnimationID.HUMAN_CASTWAVE, AttackStyle.MAGIC, Spellbook.STANDARD, MetaData.NO_DATA.d), + MAGIC_STANDARD_WAVE_STAFF(AnimationID.HUMAN_CASTWAVE_STAFF, AttackStyle.MAGIC, Spellbook.STANDARD, MetaData.NO_DATA.d), + MAGIC_STANDARD_WAVE_STAFF_WALK(AnimationID.HUMAN_CASTWAVE_STAFF_WALKMERGE, AttackStyle.MAGIC, Spellbook.STANDARD, MetaData.NO_DATA.d), + MAGIC_STANDARD_WAVE_WALK(AnimationID.HUMAN_CASTWAVE_WALKMERGE, AttackStyle.MAGIC, Spellbook.STANDARD, MetaData.NO_DATA.d), + MAGIC_STANDARD_WEAKEN(AnimationID.HUMAN_CASTWEAKEN, AttackStyle.MAGIC, Spellbook.STANDARD, MetaData.NO_DATA.d), + MAGIC_STANDARD_WEAKEN_STAFF(AnimationID.HUMAN_CASTWEAKEN_STAFF, AttackStyle.MAGIC, Spellbook.STANDARD, MetaData.NO_DATA.d), + MAGIC_STANDARD_WEAKEN_STAFF_WALK(AnimationID.HUMAN_CASTWEAKEN_STAFF_WALKMERGE, AttackStyle.MAGIC, Spellbook.STANDARD, MetaData.NO_DATA.d), + MAGIC_STANDARD_WEAKEN_WALK(AnimationID.HUMAN_CASTWEAKEN_WALKMERGE, AttackStyle.MAGIC, Spellbook.STANDARD, MetaData.NO_DATA.d), + + MAGIC_ANCIENT_MULTI_TARGET(AnimationID.ZAROS_VERTICAL_CASTING_WALKMERGE, AttackStyle.MAGIC, Spellbook.ANCIENT, MetaData.NO_DATA.d), + MAGIC_ANCIENT_MULTI_TARGET_PVP(AnimationID.ZAROS_VERTICAL_CASTING, AttackStyle.MAGIC, Spellbook.ANCIENT, MetaData.NO_DATA.d), + MAGIC_ANCIENT_SINGLE_TARGET(AnimationID.ZAROS_CASTING_WALKMERGE, AttackStyle.MAGIC, Spellbook.ANCIENT, MetaData.NO_DATA.d), + MAGIC_ANCIENT_SINGLE_TARGET_PVP(AnimationID.ZAROS_CASTING, AttackStyle.MAGIC, Spellbook.ANCIENT, MetaData.NO_DATA.d), + + MAGIC_ARCEUUS_DEMONBANE(AnimationID.HUMAN_SPELLCAST_DEMONBANE, AttackStyle.MAGIC, Spellbook.ARCEUUS, MetaData.NO_DATA.d), + MAGIC_ARCEUUS_GRASP(AnimationID.HUMAN_SPELLCAST_GRASP, AttackStyle.MAGIC, Spellbook.ARCEUUS, MetaData.NO_DATA.d), + + MAGIC_ACCURSED_SCEPTRE_SPEC(AnimationID.HUMAN_SPECIAL_ACCURSED, AttackStyle.MAGIC, MetaData.SPECIAL_ATTACK.d), + MAGIC_EYE_OF_AYAK(AnimationID.HUMAN_EYE_OF_AYAK_NORMAL, AttackStyle.MAGIC, MetaData.NO_DATA.d), + MAGIC_EYE_OF_AYAK_SPEC(AnimationID.HUMAN_EYE_OF_AYAK_SPECIAL, AttackStyle.MAGIC, MetaData.SPECIAL_ATTACK.d), + MAGIC_TUMEKENS_SHADOW(AnimationID.TOA_SOT_CAST_B, AttackStyle.MAGIC, MetaData.NO_DATA.d), + MAGIC_VOLATILE_NIGHTMARE_STAFF_SPEC(AnimationID.NIGHTMARE_STAFF_SPECIAL, AttackStyle.MAGIC, MetaData.SPECIAL_ATTACK.d), + MAGIC_WARPED_SCEPTRE(AnimationID.POG_WARPED_SCEPTRE_ATTACK, AttackStyle.MAGIC, MetaData.NO_DATA.d), + + // Misc Non-attacks + MAGIC_IMBUE(AnimationID.HUMAN_CASTBONESTOBANANAS, AttackStyle.NON_ATTACK), + SPELLBOOK_SWAP(AnimationID.DREAM_PLAYER_SPELLBOOK_SWAP, AttackStyle.NON_ATTACK), + LUNAR_GROUP(AnimationID.QUEST_LUNAR_SPELL_CAST_SPELL_ON_GROUP, AttackStyle.NON_ATTACK), + LUNAR_OTHER(AnimationID.QUEST_LUNAR_PUSHING_MAGIC_ANIMATION, AttackStyle.NON_ATTACK), + NPC_CONTACT(AnimationID.LUNAR_HUMAN_MAGIC_SUMMON2, AttackStyle.NON_ATTACK), + VENGEANCE_1(AnimationID.VENGEANCE_SPELL_ANIM_NOSTALLING, AttackStyle.NON_ATTACK), + VENGEANCE_2(AnimationID.VENGEANCE_SPELL_ANIM_STALLING, AttackStyle.NON_ATTACK), + REANIMATION(AnimationID.ARCEUUS_NECROMANCY_PLAYERANIM, AttackStyle.NON_ATTACK), + DEMONIC_OFFERING(AnimationID.HUMAN_CAST_OFFERING, AttackStyle.NON_ATTACK), + SHADOW_VEIL(AnimationID.HUMAN_SPELLCAST_SHADOWVEIL, AttackStyle.NON_ATTACK), + MARK_OF_DARKNESS(AnimationID.HUMAN_CAST_SELFIMBUE, AttackStyle.NON_ATTACK), + PICK_POCKETING(AnimationID.HUMAN_PICKPOCKET, AttackStyle.NON_ATTACK), + SUMMON_THRALL(AnimationID.HUMAN_SPELLCAST_RESURRECT, AttackStyle.NON_ATTACK), + LUNAR_TELEPORT(AnimationID.HUMAN_TELEPORT_OTHER_IMPACT, AttackStyle.NON_ATTACK), + MONSTER_EXAMINE(AnimationID.DREAM_PLAYER_MONSTEREXAM_STATSPY, AttackStyle.NON_ATTACK), + HUMIDIFY(AnimationID.DREAM_PLAYER_HUMIDIFY_SPELL, AttackStyle.NON_ATTACK), + GEOMANCY(AnimationID.LUNAR_HUMAN_MAGIC_GEOMANCY, AttackStyle.NON_ATTACK), + DREAM(AnimationID.FOSSIL_LOC_CLAM_IDLE_SHUT, AttackStyle.NON_ATTACK), + ROCKSLUG_BAG_OF_SALT(AnimationID.SLAYER_SALT_SPRINKLE, AttackStyle.NON_ATTACK), + DESSET_LIZARD_ICE_COOLER(AnimationID.HUMAN_CHINCHOMPA_ATTACK, AttackStyle.NON_ATTACK), + FLECTHING_KNIFE(AnimationID.HUMAN_FLETCHING, AttackStyle.NON_ATTACK), + FLECTHING_KEBBIT(AnimationID.HUMAN_CRAFTING_SPIKEDVAMBRACES, AttackStyle.NON_ATTACK), + FLECTHING_CHISEL(AnimationID.HUMAN_FLETCHING_HUNTINGBOLTS, AttackStyle.NON_ATTACK), + FLECTHING_DART_TIP_1(AnimationID.HUMAN_FLETCHING_ADD_DART_FEATHERS_BRONZE, AttackStyle.NON_ATTACK), + FLECTHING_DART_TIP_2(AnimationID.HUMAN_FLETCHING_ADD_DART_FEATHERS_IRON, AttackStyle.NON_ATTACK), + FLECTHING_DART_TIP_3(AnimationID.HUMAN_FLETCHING_ADD_DART_FEATHERS_STEEL, AttackStyle.NON_ATTACK), + FLECTHING_DART_TIP_4(AnimationID.HUMAN_FLETCHING_ADD_DART_FEATHERS_MITHRIL, AttackStyle.NON_ATTACK), + FLECTHING_DART_TIP_5(AnimationID.HUMAN_FLETCHING_ADD_DART_FEATHERS_ADAMANT, AttackStyle.NON_ATTACK), + FLECTHING_DART_TIP_6(AnimationID.HUMAN_FLETCHING_ADD_DART_FEATHERS_RUNE, AttackStyle.NON_ATTACK), + FLECTHING_DART_TIP_7(AnimationID.HUMAN_FLETCHING_ADD_DART_FEATHERS_DRAGON, AttackStyle.NON_ATTACK), + FLECTHING_DART_TIP_8(AnimationID.HUMAN_FLETCHING_ADD_DART_FEATHERS_AMETHYST, AttackStyle.NON_ATTACK), + HERB_TAR(AnimationID.HUMAN_SALAMANDER_TAR_GRIND, AttackStyle.NON_ATTACK), + SETUP_HUNTER_TRAP(AnimationID.HUMAN_LAYTRAP, AttackStyle.NON_ATTACK), + RESET_SNARE_TRAP(AnimationID.HUMAN_HUNTING_DISMANTLE_NET, AttackStyle.NON_ATTACK), + RESET_BOX_TRAP(AnimationID.HUNTING_SETTING_TRAP_SMALL, AttackStyle.NON_ATTACK), + DESERT_AMMY(AnimationID.TELEPORT_NARDAH_HUMAN, AttackStyle.NON_ATTACK), + EAT_FOOD_OR_POTION(AnimationID.HUMAN_EAT, AttackStyle.NON_ATTACK), + OVERLOAD_HIT(AnimationID.HUMAN_KILLERWATT_ELECTRICSHOCK, AttackStyle.NON_ATTACK), + TAKING_HIT_1HANDED_UNARMED(AnimationID.HUMAN_AXE_BLOCK, AttackStyle.NON_ATTACK), + TAKING_HIT_2H_SWORD(AnimationID.HUMAN_DHSWORD_BLOCK, AttackStyle.NON_ATTACK), + TAKING_HIT_ANCHOR(AnimationID.BRAIN_PLAYER_ANCHOR_DEFEND, AttackStyle.NON_ATTACK), + TAKING_HIT_BLISTERWOOD_FLAIL(AnimationID.IVANDIS_FLAIL_DEFEND, AttackStyle.NON_ATTACK), + TAKING_HIT_BLOWPIPE(AnimationID.HUMAN_SPEAR_BLOCK, AttackStyle.NON_ATTACK), + TAKING_HIT_BULWARK(AnimationID.HUMAN_DINHS_BULWARK_BLOCK, AttackStyle.NON_ATTACK), + TAKING_HIT_CHAINMACE(AnimationID.WILD_CAVE_CHAINMACE_DEFEND, AttackStyle.NON_ATTACK), + TAKING_HIT_CHIN_CHOMPA(AnimationID.HUMAN_CHINCHOMPA_DEFEND, AttackStyle.NON_ATTACK), + TAKING_HIT_DAGGER(AnimationID.HUMAN_DDAGGER_BLOCK, AttackStyle.NON_ATTACK), + TAKING_HIT_DEFENDER(AnimationID.WARGUILD_PARRY_DEFEND, AttackStyle.NON_ATTACK), + TAKING_HIT_FANG(AnimationID.HUMAN_SWORD_DEF, AttackStyle.NON_ATTACK), + TAKING_HIT_GODSWORD(AnimationID.DH_SWORD_UPDATE_DEFEND, AttackStyle.NON_ATTACK), + TAKING_HIT_KERIS(AnimationID.HUMAN_DSPEAR_BLOCK, AttackStyle.NON_ATTACK), + TAKING_HIT_LARGE_STAFF(AnimationID.HUMAN_STAFFORB_BLOCK, AttackStyle.NON_ATTACK), + TAKING_HIT_MACE(AnimationID.HUMAN_BLUNT_BLOCK, AttackStyle.NON_ATTACK), + TAKING_HIT_OBBY_MAUL(AnimationID.SLAYER_GRANITE_MAUL_DEFEND, AttackStyle.NON_ATTACK), + TAKING_HIT_SCYTHE(AnimationID.HUMAN_SCYTHE_BLOCK, AttackStyle.NON_ATTACK), + TAKING_HIT_SHIELD(AnimationID.HUMAN_SHIELD_DEFENCE, AttackStyle.NON_ATTACK), + TAKING_HIT_SPEAR(AnimationID.HUMAN_ZAMORAKSPEAR_BLOCK, AttackStyle.NON_ATTACK), + TAKING_HIT_STAFF(AnimationID.HUMAN_STAFF_BLOCK, AttackStyle.NON_ATTACK), + TAKING_HIT_UNARMED(AnimationID.HUMAN_UNARMEDBLOCK, AttackStyle.NON_ATTACK), + TAKING_HIT_VERACS_FLAIL(AnimationID.BARROW_GUTHAN_DEFEND, AttackStyle.NON_ATTACK), + TAKING_HIT_WHIP(AnimationID.SLAYER_ABYSSAL_WHIP_DEFEND, AttackStyle.NON_ATTACK), + TAKING_HIT_KISTEN(AnimationID.HUMAN_WEAPONS_CRIMSON_KISTEN_DEF, AttackStyle.NON_ATTACK), + TAKING_HIT_HALLOWFELL(AnimationID.HUMAN_HALLOWFELL_DEFEND, AttackStyle.NON_ATTACK), + LOW_ALCH(AnimationID.HUMAN_CASTLOWLVLALCHEMY, AttackStyle.NON_ATTACK), + HIGH_ALCH(AnimationID.HUMAN_CASTHIGHLVLALCHEMY, AttackStyle.NON_ATTACK); + + private static final Map DATA; + private static final Map> SPELL_BOOK_ANIMATIONS; + private static final Map NOT_ATTACKS; + private static final Set STANDARD_BOW_ATTACKS; + + public final int animationId; + public final long metaData; + @Getter + public final AttackStyle attackStyle; + @Getter + private final Spellbook spellbook; + + AnimationData(int animationId, AttackStyle attackStyle) + { + if (attackStyle == null) + { + throw new InvalidParameterException("Attack Style must be valid for AnimationData"); + } + this.animationId = animationId; + this.attackStyle = attackStyle; + this.metaData = MetaData.NO_DATA.d; + this.spellbook = null; + } + + AnimationData(int animationId, AttackStyle attackStyle, long data) + { + if (attackStyle == null) + { + throw new InvalidParameterException("Attack Style must be valid for AnimationData"); + } + this.animationId = animationId; + this.attackStyle = attackStyle; + this.metaData = data; + this.spellbook = null; + } + + AnimationData(int animationId, AttackStyle attackStyle, Spellbook book, long data) + { + if (attackStyle == null) + { + throw new InvalidParameterException("Attack Style must be valid for AnimationData"); + } + this.animationId = animationId; + this.attackStyle = attackStyle; + this.metaData = data; + this.spellbook = book; + } + + static + { + ImmutableMap.Builder builder = new ImmutableMap.Builder<>(); + ImmutableMap.Builder notAttacksBuilder = new ImmutableMap.Builder<>(); + Map> spellBookBuilder = new HashMap<>(); + ImmutableSet.Builder standardBowBuilder = new ImmutableSet.Builder<>(); + + for (Spellbook s : Spellbook.values()) + { + spellBookBuilder.put(s, new HashSet<>()); + } + + for (AnimationData data : values()) + { + builder.put(data.animationId, data); + + if (data.spellbook != null) + { + if (data.attackStyle != AttackStyle.MAGIC) + { + throw new InvalidParameterException("Spell book should only be magic animations"); + } + spellBookBuilder.get(data.spellbook).add(data); + } + if (data.attackStyle == AttackStyle.NON_ATTACK) + { + notAttacksBuilder.put(data.animationId, data); + } + + if (MetaData.hasFlagSet(data.metaData, MetaData.STANDARD_BOW_ATTACK)) + { + standardBowBuilder.add(data.animationId); + } + } + + DATA = builder.build(); + NOT_ATTACKS = notAttacksBuilder.build(); + SPELL_BOOK_ANIMATIONS = spellBookBuilder; + STANDARD_BOW_ATTACKS = standardBowBuilder.build(); + } + + public static AnimationData fromId(int animationId) + { + return DATA.get(animationId); + } + + public static Set getAnimationsForSpellbook(Spellbook s) + { + return SPELL_BOOK_ANIMATIONS.get(s); + } + + public static boolean isManualCasting(AnimationData animationData) + { + if (animationData != null && animationData.spellbook != null) + { + return animationData.attackStyle == AttackStyle.MAGIC && + SPELL_BOOK_ANIMATIONS.get(animationData.spellbook).contains(animationData); + } + return false; + } + + public static boolean isBlockListAnimation(int animationId) + { + return NOT_ATTACKS.containsKey(animationId); + } + + @Override + public String toString() + { + String[] words = super.toString().toLowerCase().split("_"); + Arrays.stream(words) + .map(StringUtils::capitalize).collect(Collectors.toList()).toArray(words); + + return String.join(" ", words); + } + + public boolean matchesSpellbook(Spellbook s) + { + if (this.spellbook != null) + { + return this.spellbook == s; + } + return false; + } + + public boolean isStandardBowAttack() + { + return STANDARD_BOW_ATTACKS.contains(this.animationId); + } + + public boolean isBlockListAnimation() + { + return NOT_ATTACKS.containsKey(this.animationId); + } + + public enum AttackStyle + { + MELEE, + RANGED, + MAGIC, + NON_ATTACK; + + @Override + public String toString() + { + return StringUtils.capitalize(super.toString().toLowerCase()); + } + } + + public enum MetaData + { + NO_DATA(), + SPECIAL_ATTACK(1), + STANDARD_BOW_ATTACK(2); + + MetaData(int bitshift) + { + this.d = 1 << bitshift; + } + MetaData() + { + this.d = 0; + } + + public static boolean hasFlagSet(long input, MetaData data) + { + return (input & data.d) == data.d; + } + + private final long d; + + @Override + public String toString() + { + return StringUtils.capitalize(super.toString().toLowerCase()); + } + } +} diff --git a/src/main/java/com/example/essentialsplus/helpers/combat/AttackProcedure.java b/src/main/java/com/example/essentialsplus/helpers/combat/AttackProcedure.java new file mode 100644 index 0000000..88a9bd1 --- /dev/null +++ b/src/main/java/com/example/essentialsplus/helpers/combat/AttackProcedure.java @@ -0,0 +1,8 @@ +package com.example.essentialsplus.helpers.combat; + +public enum AttackProcedure +{ + POWERED_STAVE, + MANUAL_AUTO_CAST, + MELEE_OR_RANGE +} diff --git a/src/main/java/com/example/essentialsplus/helpers/combat/AttackSpeed.java b/src/main/java/com/example/essentialsplus/helpers/combat/AttackSpeed.java new file mode 100644 index 0000000..8601299 --- /dev/null +++ b/src/main/java/com/example/essentialsplus/helpers/combat/AttackSpeed.java @@ -0,0 +1,129 @@ +package com.example.essentialsplus.helpers.combat; + +import com.example.essentialsplus.helpers.combat.variablespeed.VariableSpeed; +import com.example.essentialsplus.helpers.combat.variablespeed.state.TickCount; +import com.google.common.collect.ImmutableMap; +import java.util.ArrayDeque; +import java.util.Map; +import lombok.Getter; +import net.runelite.api.Client; +import net.runelite.api.events.FakeXpDrop; +import net.runelite.api.events.SoundEffectPlayed; +import net.runelite.api.events.StatChanged; +import net.runelite.api.gameval.ItemID; +import net.runelite.client.game.ItemManager; + +public class AttackSpeed +{ + private final ArrayDeque specialPercentageEvents = new ArrayDeque<>(); + private final Damage damage = new Damage(); + @Getter + private int dmgDealt = -1; + private final TickCount tickCount; + private final VariableSpeed variableSpeed; + @Getter + private int soundEffectTick = -1; + @Getter + private int soundEffectId = -1; + @Getter + private boolean isUsingMagic = false; + + public AttackSpeed(final TickCount tc, final VariableSpeed vs) + { + this.tickCount = tc; + this.variableSpeed = vs; + } + + public int compute(final Client client, final AnimationData curAnimation, final Spellbook spellbook, final ItemManager itemManager) + { + final int weaponId = CombatUtils.getWeaponId(client); + final PoweredStaves stave = PoweredStaves.getPoweredStaves(weaponId, curAnimation); + final int specDelta = CombatUtils.getLastDelta(specialPercentageEvents); + dmgDealt = damage.compute(tickCount); + if (stave != null && stave.getAnimations() != null && stave.getAnimations().contains(curAnimation)) + { + isUsingMagic = true; + return variableSpeed.compute(client, curAnimation, AttackProcedure.POWERED_STAVE, spellbook, dmgDealt, specDelta, 4); + } + + if (matchesSpellbook(client, curAnimation, spellbook) && ManualCasting.is(client, curAnimation, soundEffectTick, soundEffectId)) + { + isUsingMagic = true; + return variableSpeed.compute(client, curAnimation, AttackProcedure.MANUAL_AUTO_CAST, spellbook, dmgDealt, specDelta, getMagicBaseSpeed(weaponId)); + } + + isUsingMagic = false; + final int aspeed = CombatUtils.getWeaponSpeed(client, itemManager, weaponId); + return variableSpeed.compute(client, curAnimation, AttackProcedure.MELEE_OR_RANGE, spellbook, dmgDealt, specDelta, aspeed); + } + + public void onTick() + { + while (specialPercentageEvents.size() > 5) + { + specialPercentageEvents.removeFirst(); + } + damage.onTick(); + } + + public void varbitSpecialAttackChanged(final int value) + { + specialPercentageEvents.addLast(value); + } + + public boolean onXpDrop(final FakeXpDrop event) + { + return damage.onXpDrop(event, tickCount); + } + + public boolean onXpDrop(final StatChanged event) + { + return damage.onXpDrop(event, tickCount); + } + + public void onSoundEffectPlayed(final Client client, final SoundEffectPlayed event) + { + soundEffectTick = client.getTickCount(); + soundEffectId = event.getSoundId(); + } + + private static final Map NON_STANDARD_MAGIC_WEAPON_SPEEDS = new ImmutableMap.Builder() + .put(ItemID.TWINFLAME_STAFF, 6) + .build(); + + private static int getMagicBaseSpeed(final int weaponId) + { + if (weaponId == ItemID.NIGHTMARE_STAFF_HARMONISED) + { + return 4; + } + return NON_STANDARD_MAGIC_WEAPON_SPEEDS.getOrDefault(weaponId, 5); + } + + private boolean matchesSpellbook(final Client client, final AnimationData curAnimation, final Spellbook currentSpellBook) + { + if (curAnimation != null && curAnimation.getAttackStyle() == AnimationData.AttackStyle.MAGIC && curAnimation.getSpellbook() == null) + { + return false; + } + if (curAnimation != null && curAnimation.matchesSpellbook(currentSpellBook)) + { + return true; + } + if (client.getTickCount() == soundEffectTick) + { + return CastingSoundData.getSpellBookFromId(soundEffectId) == currentSpellBook; + } + return false; + } + + public void reset() + { + dmgDealt = -1; + soundEffectId = -1; + soundEffectTick = -1; + isUsingMagic = false; + specialPercentageEvents.clear(); + damage.reset(); + } +} diff --git a/src/main/java/com/example/essentialsplus/helpers/combat/AttackStyle.java b/src/main/java/com/example/essentialsplus/helpers/combat/AttackStyle.java new file mode 100644 index 0000000..fb0af04 --- /dev/null +++ b/src/main/java/com/example/essentialsplus/helpers/combat/AttackStyle.java @@ -0,0 +1,34 @@ +package com.example.essentialsplus.helpers.combat; + +import lombok.Getter; +import net.runelite.api.Skill; + +public enum AttackStyle +{ + ACCURATE("Accurate", Skill.ATTACK), + AGGRESSIVE("Aggressive", Skill.STRENGTH), + DEFENSIVE("Defensive", Skill.DEFENCE), + CONTROLLED("Controlled", Skill.ATTACK, Skill.STRENGTH, Skill.DEFENCE), + RANGING("Ranging", Skill.RANGED), + LONGRANGE("Longrange", Skill.RANGED, Skill.DEFENCE), + CASTING("Casting", Skill.MAGIC), + DEFENSIVE_CASTING("Defensive Casting", Skill.MAGIC, Skill.DEFENCE), + OTHER("Other"); + + @Getter + private final String name; + @Getter + private final Skill[] skills; + + AttackStyle(String name, Skill... skills) + { + this.name = name; + this.skills = skills; + } + + @Override + public String toString() + { + return this.name; + } +} diff --git a/src/main/java/com/example/essentialsplus/helpers/combat/AttackType.java b/src/main/java/com/example/essentialsplus/helpers/combat/AttackType.java new file mode 100644 index 0000000..c2fa86b --- /dev/null +++ b/src/main/java/com/example/essentialsplus/helpers/combat/AttackType.java @@ -0,0 +1,16 @@ +package com.example.essentialsplus.helpers.combat; + +public enum AttackType +{ + CRUSH, + SLASH, + STAB, + RANGED, + MAGIC, + NONE; + + public boolean IsMelee() + { + return this.equals(CRUSH) || this.equals(SLASH) || this.equals(STAB); + } +} diff --git a/src/main/java/com/example/essentialsplus/helpers/combat/Attacking.java b/src/main/java/com/example/essentialsplus/helpers/combat/Attacking.java new file mode 100644 index 0000000..2acc969 --- /dev/null +++ b/src/main/java/com/example/essentialsplus/helpers/combat/Attacking.java @@ -0,0 +1,118 @@ +package com.example.essentialsplus.helpers.combat; + +import com.google.common.collect.ImmutableSet; +import java.util.Arrays; +import java.util.Set; +import lombok.Getter; +import net.runelite.api.Actor; +import net.runelite.api.Client; +import net.runelite.api.NPC; +import net.runelite.api.Player; +import net.runelite.api.gameval.NpcID; +import net.runelite.api.gameval.SpotanimID; +import net.runelite.client.game.NPCManager; + +public class Attacking +{ + private static final Set UNRELIABLE_ANIMATIONS = new ImmutableSet.Builder() + .add(AnimationData.RANGED_BLOWPIPE).add(AnimationData.RANGED_BLAZING_BLOWPIPE) + .add(AnimationData.MAGIC_EYE_OF_AYAK).add(AnimationData.MAGIC_EYE_OF_AYAK_SPEC).build(); + + private static final Set SPECIAL_NPCS = new ImmutableSet.Builder() + .add(NpcID.POH_COMBAT_DUMMY_UPGRADED_UNDEADSLAYER_NPC, NpcID.POH_COMBAT_DUMMY_UPGRADED_ETHER_NPC, + NpcID.POH_COMBAT_DUMMY_UPGRADED_KQ_NPC, NpcID.POH_COMBAT_DUMMY_UPGRADED_KURASK_NPC, + NpcID.POH_COMBAT_DUMMY_UPGRADED_VAMPIRE_NPC, NpcID.POH_COMBAT_DUMMY_UPGRADED_VORKATH_NPC, + NpcID.POH_COMBAT_DUMMY_NPC, NpcID.POH_COMBAT_DUMMY_UNDEADSLAYER_NPC, + NpcID.POH_COMBAT_DUMMY_UPGRADED_NPC, NpcID.NIGHTMARE_TOTEM_1_READY, NpcID.NIGHTMARE_TOTEM_2_READY, + NpcID.NIGHTMARE_TOTEM_3_READY, NpcID.NIGHTMARE_TOTEM_4_READY) + .build(); + + public static boolean isPlayerAttacking(final Client client, final NPCManager npcManager) + { + final Player localPlayer = client.getLocalPlayer(); + if (localPlayer == null) + { + return false; + } + final int animationId = localPlayer.getAnimation(); + if (AnimationData.isBlockListAnimation(animationId)) + { + return false; + } + + final boolean notWalking = animationId != -1 || getSalamanderAttack(client); + + final Actor target = localPlayer.getInteracting(); + if (target instanceof NPC) + { + final NPC npc = (NPC) target; + final boolean containsAttackOption = npc.getComposition() != null + && npc.getComposition().getActions() != null + && Arrays.asList(npc.getComposition().getActions()).contains("Attack"); + Integer health = null; + if (npcManager != null) + { + try + { + health = npcManager.getHealth(npc.getId()); + } + catch (Exception ignored) + { + } + } + final boolean hasHealthAndLevel = health != null && health > 0 && target.getCombatLevel() > 0; + final boolean attackingNPC = hasHealthAndLevel || SPECIAL_NPCS.contains(npc.getId()) || containsAttackOption; + return attackingNPC && notWalking; + } + if (target instanceof Player) + { + return notWalking; + } + if (target == null) + { + return false; + } + + final AnimationData fromId = AnimationData.fromId(animationId); + if (UNRELIABLE_ANIMATIONS.contains(fromId)) + { + return false; + } + return fromId != null; + } + + public static Attack playerAttack(final Client client) + { + final Player localPlayer = client.getLocalPlayer(); + if (localPlayer == null) + { + return new Attack(-1, false, null); + } + final int animationId = localPlayer.getAnimation(); + final Actor target = localPlayer.getInteracting(); + return new Attack(animationId, getSalamanderAttack(client), target); + } + + private static boolean getSalamanderAttack(final Client client) + { + Player localPlayer = client.getLocalPlayer(); + return localPlayer != null && localPlayer.hasSpotAnim(SpotanimID.FIREBREATH); + } + + public static final class Attack + { + @Getter + private final int animationId; + @Getter + private final boolean salamander; + @Getter + private final Actor target; + + public Attack(final int animationId, final boolean salamander, final Actor target) + { + this.animationId = animationId; + this.salamander = salamander; + this.target = target; + } + } +} diff --git a/src/main/java/com/example/essentialsplus/helpers/combat/CastingSoundData.java b/src/main/java/com/example/essentialsplus/helpers/combat/CastingSoundData.java new file mode 100644 index 0000000..8e5be22 --- /dev/null +++ b/src/main/java/com/example/essentialsplus/helpers/combat/CastingSoundData.java @@ -0,0 +1,85 @@ +package com.example.essentialsplus.helpers.combat; + +import com.google.common.collect.ImmutableMap; + +public enum CastingSoundData +{ + STANDARD_BIND(101, Spellbook.STANDARD), + STANDARD_CONFUSE(119, Spellbook.STANDARD), + STANDARD_CRUMBLE_UNDEAD(122, Spellbook.STANDARD), + STANDARD_CURSE(127, Spellbook.STANDARD), + STANDARD_EARTH_BLAST(128, Spellbook.STANDARD), + STANDARD_EARTH_BOLT(130, Spellbook.STANDARD), + STANDARD_EARTH_STRIKE(132, Spellbook.STANDARD), + STANDARD_EARTH_SURGE(4025, Spellbook.STANDARD), + STANDARD_EARTH_WAVE(134, Spellbook.STANDARD), + STANDARD_ENFEEBLE(148, Spellbook.STANDARD), + STANDARD_ENTANGLE(151, Spellbook.STANDARD), + STANDARD_FIRE_BLAST(155, Spellbook.STANDARD), + STANDARD_FIRE_BOLT(157, Spellbook.STANDARD), + STANDARD_FIRE_STRIKE(160, Spellbook.STANDARD), + STANDARD_FIRE_SURGE(4032, Spellbook.STANDARD), + STANDARD_IBANS_BLAST_FIRE_WAVE(162, Spellbook.STANDARD), + STANDARD_SLAYER_DART(1718, Spellbook.STANDARD), + STANDARD_SNARE(3003, Spellbook.STANDARD), + STANDARD_STUN(3004, Spellbook.STANDARD), + STANDARD_VULNERABILITY(3009, Spellbook.STANDARD), + STANDARD_WATER_BLAST(207, Spellbook.STANDARD), + STANDARD_WATER_BOLT(209, Spellbook.STANDARD), + STANDARD_WATER_STRIKE(221, Spellbook.STANDARD), + STANDARD_WATER_SURGE(4030, Spellbook.STANDARD), + STANDARD_WATER_WAVE(213, Spellbook.STANDARD), + STANDARD_WEAKEN(3011, Spellbook.STANDARD), + STANDARD_WIND_BLAST(216, Spellbook.STANDARD), + STANDARD_WIND_BOLT(218, Spellbook.STANDARD), + STANDARD_WIND_STRIKE(220, Spellbook.STANDARD), + STANDARD_WIND_SURGE(4028, Spellbook.STANDARD), + STANDARD_WIND_WAVE(222, Spellbook.STANDARD), + + ANCIENT_BLOOD_SPELL(106, Spellbook.ANCIENT), + ANCIENT_ICE_SPELL(171, Spellbook.ANCIENT), + ANCIENT_SHADOW_SPELL(178, Spellbook.ANCIENT), + ANCIENT_SMOKE_SPELL(183, Spellbook.ANCIENT), + + ARCEUUS_DARK_DEMONBANE(5053, Spellbook.ARCEUUS), + ARCEUUS_GHOSTLY_GRASP(5042, Spellbook.ARCEUUS), + ARCEUUS_INFERIOR_DEMONBANE(5038, Spellbook.ARCEUUS), + ARCEUUS_SKELETAL_GRASP(5026, Spellbook.ARCEUUS), + ARCEUUS_SUPERIOR_DEMONBANE(5027, Spellbook.ARCEUUS), + ARCEUUS_UNDEAD_GRASP(5030, Spellbook.ARCEUUS); + + private final int id; + private final Spellbook spellbook; + + CastingSoundData(int id, Spellbook b) + { + this.id = id; + this.spellbook = b; + } + + private static final ImmutableMap SOUNDS; + + static + { + ImmutableMap.Builder builder = new ImmutableMap.Builder<>(); + for (CastingSoundData data : values()) + { + builder.put(data.id, data); + } + SOUNDS = builder.build(); + } + + public static boolean isCastingSound(int id) + { + return SOUNDS.containsKey(id); + } + + public static Spellbook getSpellBookFromId(int id) + { + if (!SOUNDS.containsKey(id)) + { + return null; + } + return SOUNDS.get(id).spellbook; + } +} diff --git a/src/main/java/com/example/essentialsplus/helpers/combat/CombatUtils.java b/src/main/java/com/example/essentialsplus/helpers/combat/CombatUtils.java new file mode 100644 index 0000000..443f8ab --- /dev/null +++ b/src/main/java/com/example/essentialsplus/helpers/combat/CombatUtils.java @@ -0,0 +1,196 @@ +package com.example.essentialsplus.helpers.combat; + +import com.google.common.collect.ImmutableMap; +import java.util.ArrayDeque; +import java.util.Map; +import java.util.Set; +import net.runelite.api.Actor; +import net.runelite.api.Client; +import net.runelite.api.EquipmentInventorySlot; +import net.runelite.api.Item; +import net.runelite.api.ItemContainer; +import net.runelite.api.NPC; +import net.runelite.api.WorldView; +import net.runelite.api.coords.LocalPoint; +import net.runelite.api.coords.WorldPoint; +import net.runelite.api.gameval.InventoryID; +import net.runelite.api.gameval.ItemID; +import net.runelite.api.gameval.VarPlayerID; +import net.runelite.api.gameval.VarbitID; +import net.runelite.client.game.ItemEquipmentStats; +import net.runelite.client.game.ItemManager; +import net.runelite.client.game.ItemStats; +import org.apache.commons.lang3.ArrayUtils; + +public class CombatUtils +{ + public static int getItemIdFromContainer(final ItemContainer container, final int slotID) + { + if (container == null) + { + return -1; + } + final Item item = container.getItem(slotID); + return (item != null) ? item.getId() : -1; + } + + public static int getWeaponIdRaw(final Client client) + { + return getItemIdFromContainer(client.getItemContainer(InventoryID.WORN), + EquipmentInventorySlot.WEAPON.getSlotIdx()); + } + + public static WorldPoint getLocalLocation(final Client client) + { + final LocalPoint localPoint = client.getLocalPlayer().getLocalLocation(); + return WorldPoint.fromLocalInstance(client, localPoint); + } + + public static AttackStyle getAttackStyle(final Client client) + { + final AttackStyle[] attackStyles = getWeaponType(client).getAttackStyles(client); + int currentAttackStyleVarbit = client.getVarpValue(VarPlayerID.COM_MODE); + final int castingMode = client.getVarbitValue(VarbitID.AUTOCAST_DEFMODE); + if (currentAttackStyleVarbit < attackStyles.length) + { + if (currentAttackStyleVarbit == 4) + { + currentAttackStyleVarbit += castingMode; + } + return attackStyles[currentAttackStyleVarbit]; + } + return AttackStyle.ACCURATE; + } + + public static WeaponType getWeaponType(final Client client) + { + final int currentEquippedWeaponTypeVarbit = client.getVarbitValue(VarbitID.COMBAT_WEAPON_CATEGORY); + return WeaponType.getWeaponType(currentEquippedWeaponTypeVarbit); + } + + public static AttackType getAttackType(final Client client) + { + final WeaponType weaponType = getWeaponType(client); + final int currentAttackStyleVarbit = client.getVarpValue(VarPlayerID.COM_MODE); + if (currentAttackStyleVarbit < weaponType.getAttackTypes().length) + { + return weaponType.getAttackTypes()[currentAttackStyleVarbit]; + } + return null; + } + + public static int getTargetId(final Client client) + { + final NPC target = getTargetNPC(client); + int targetId = 0; + if (target != null) + { + targetId = target.getId(); + } + return targetId; + } + + public static NPC getTargetNPC(final Client client) + { + final Actor target = client.getLocalPlayer().getInteracting(); + if (target != null && (target instanceof NPC)) + { + return (NPC) target; + } + return null; + } + + public static boolean isInRegionId(final Client client, final int id) + { + final int[] regions = regions(client); + if (regions == null || regions.length == 0) + { + return false; + } + return ArrayUtils.contains(regions, id); + } + + public static boolean isInRegionId(final Client client, final Set ids) + { + final int[] regions = regions(client); + if (regions == null || regions.length == 0) + { + return false; + } + for (final int id : regions) + { + if (ids.contains(id)) + { + return true; + } + } + return false; + } + + private static int[] regions(final Client client) + { + final WorldView wv = client.getTopLevelWorldView(); + if (wv == null) + { + return null; + } + return wv.getMapRegions(); + } + + public static int getLastDelta(final ArrayDeque events) + { + int i = 0, last = -1, secondLast = -1; + final var it = events.descendingIterator(); + while (it.hasNext()) + { + if (i == 0) + { + last = it.next(); + } + else if (i == 1) + { + secondLast = it.next(); + } + else + { + break; + } + i++; + } + return last - secondLast; + } + + private static final Map WEAPON_ID_MAPPING_WORKAROUNDS = new ImmutableMap.Builder() + .put(ItemID.VENATOR_BOW_ORNAMENT, ItemID.VENATOR_BOW) + .build(); + + private static final Map NON_STANDARD_ATTACK_SPEEDS = new ImmutableMap.Builder() + .put(ItemID.HALLOWFELL, 6) + .build(); + + public static int getWeaponId(final Client client) + { + final int weaponId = getWeaponIdRaw(client); + return WEAPON_ID_MAPPING_WORKAROUNDS.getOrDefault(weaponId, weaponId); + } + + public static ItemStats getWeaponStats(final Client client, final ItemManager itemManager, final int weaponId) + { + if (NON_STANDARD_ATTACK_SPEEDS.containsKey(weaponId)) + { + return new ItemStats(true, -1, -1, + ItemEquipmentStats.builder().aspeed(NON_STANDARD_ATTACK_SPEEDS.get(weaponId)).build()); + } + return itemManager.getItemStats(weaponId); + } + + public static int getWeaponSpeed(final Client client, final ItemManager itemManager, final int weaponId) + { + final ItemStats weaponStats = getWeaponStats(client, itemManager, weaponId); + if (weaponStats == null || weaponStats.getEquipment() == null) + { + return 4; + } + return weaponStats.getEquipment().getAspeed(); + } +} diff --git a/src/main/java/com/example/essentialsplus/helpers/combat/Damage.java b/src/main/java/com/example/essentialsplus/helpers/combat/Damage.java new file mode 100644 index 0000000..0df989f --- /dev/null +++ b/src/main/java/com/example/essentialsplus/helpers/combat/Damage.java @@ -0,0 +1,73 @@ +package com.example.essentialsplus.helpers.combat; + +import com.example.essentialsplus.helpers.combat.variablespeed.state.TickCount; +import java.util.ArrayDeque; +import net.runelite.api.Skill; +import net.runelite.api.events.FakeXpDrop; +import net.runelite.api.events.StatChanged; + +public class Damage +{ + private static final double MODIFIER = 1; + private static final double GLOBAL_MODIFIER = 1; + + private final ArrayDeque hpExpEarned = new ArrayDeque<>(); + private final ArrayDeque hpExpEarnedTickCount = new ArrayDeque<>(); + + public boolean onXpDrop(StatChanged event, TickCount tc) + { + if (event.getSkill() != Skill.HITPOINTS) + { + return false; + } + hpExpEarnedTickCount.addLast(tc.get()); + hpExpEarned.addLast(event.getXp()); + return true; + } + + public boolean onXpDrop(FakeXpDrop event, TickCount tc) + { + if (event.getSkill() != Skill.HITPOINTS) + { + return false; + } + hpExpEarnedTickCount.addLast(tc.get()); + hpExpEarnedTickCount.addLast(tc.get()); + hpExpEarned.addLast(0); + hpExpEarned.addLast(event.getXp()); + return true; + } + + public int compute(TickCount tc) + { + if (hpExpEarnedTickCount.isEmpty()) + { + return -1; + } + final int lastTc = hpExpEarnedTickCount.getLast(); + if (!tc.isWithinNTicks(lastTc, 1)) + { + return 0; + } + final double xp = (double) CombatUtils.getLastDelta(hpExpEarned); + return (int) Math.round(xp * (3.0d / 4.0d) * MODIFIER * GLOBAL_MODIFIER); + } + + public void onTick() + { + while (hpExpEarnedTickCount.size() > 5) + { + hpExpEarnedTickCount.removeFirst(); + } + while (hpExpEarned.size() > 5) + { + hpExpEarned.removeFirst(); + } + } + + public void reset() + { + hpExpEarned.clear(); + hpExpEarnedTickCount.clear(); + } +} diff --git a/src/main/java/com/example/essentialsplus/helpers/combat/ManualCasting.java b/src/main/java/com/example/essentialsplus/helpers/combat/ManualCasting.java new file mode 100644 index 0000000..7cf525d --- /dev/null +++ b/src/main/java/com/example/essentialsplus/helpers/combat/ManualCasting.java @@ -0,0 +1,15 @@ +package com.example.essentialsplus.helpers.combat; + +import net.runelite.api.Client; + +public class ManualCasting +{ + public static boolean is(final Client client, final AnimationData curId, final int soundEffectTick, final int soundEffectId) + { + final boolean castingFromSound = client.getTickCount() == soundEffectTick + ? CastingSoundData.isCastingSound(soundEffectId) + : false; + final boolean castingFromAnimation = AnimationData.isManualCasting(curId); + return castingFromSound || castingFromAnimation; + } +} diff --git a/src/main/java/com/example/essentialsplus/helpers/combat/PoweredStaves.java b/src/main/java/com/example/essentialsplus/helpers/combat/PoweredStaves.java new file mode 100644 index 0000000..a8c2697 --- /dev/null +++ b/src/main/java/com/example/essentialsplus/helpers/combat/PoweredStaves.java @@ -0,0 +1,127 @@ +package com.example.essentialsplus.helpers.combat; + +import com.google.common.collect.ImmutableMap; +import com.google.common.collect.ImmutableSet; +import java.util.Arrays; +import java.util.Set; +import java.util.stream.Collectors; +import lombok.Getter; +import net.runelite.api.gameval.ItemID; +import org.apache.commons.lang3.StringUtils; + +public enum PoweredStaves +{ + WEAPON_ACCURSED(Set.of(AnimationData.MAGIC_STANDARD_WAVE_STAFF, AnimationData.MAGIC_STANDARD_WAVE_STAFF_WALK, + AnimationData.MAGIC_ACCURSED_SCEPTRE_SPEC), ItemID.WILD_CAVE_ACCURSED_CHARGED), + WEAPON_GAUNTLET_STAFF(Set.of(AnimationData.MAGIC_STANDARD_WAVE_STAFF, AnimationData.MAGIC_STANDARD_WAVE_STAFF_WALK), + ItemID.GAUNTLET_MAGIC_T1, ItemID.GAUNTLET_MAGIC_T2, ItemID.GAUNTLET_MAGIC_T3, ItemID.GAUNTLET_MAGIC_T1_HM, + ItemID.GAUNTLET_MAGIC_T2_HM, ItemID.GAUNTLET_MAGIC_T3_HM), + WEAPON_BONE_STAFF(AnimationData.MELEE_GENERIC_SLASH, ItemID.RAT_BONE_STAFF), + WEAPON_DAWNBRINGER(Set.of(AnimationData.MAGIC_STANDARD_WAVE_STAFF, AnimationData.MAGIC_STANDARD_WAVE_STAFF_WALK), + ItemID.VERZIK_SPECIAL_WEAPON), + WEAPON_HARM(Set.of(AnimationData.MAGIC_STANDARD_STRIKE_BOLT_BLAST_STAFF, AnimationData.MAGIC_STANDARD_WAVE_STAFF, + AnimationData.MAGIC_STANDARD_SURGE_STAFF, AnimationData.MAGIC_STANDARD_WAVE_STAFF_WALK, + AnimationData.MAGIC_STANDARD_STRIKE_BOLT_BLAST_STAFF_WALK, AnimationData.MAGIC_STANDARD_SURGE_STAFF_WALK), + ItemID.NIGHTMARE_STAFF_HARMONISED), + WEAPON_SANG(Set.of(AnimationData.MAGIC_STANDARD_WAVE_STAFF, AnimationData.MAGIC_STANDARD_WAVE_STAFF_WALK), + ItemID.SANGUINESTI_STAFF, ItemID.SANGUINESTI_STAFF_OR), + WEAPON_STARTER_STAFF(ItemID.DEADMAN_STARTER_STAFF, ItemID.DEADMAN_APOCALYPSE_STAFF), + WEAPON_SWAMP(Set.of(AnimationData.MAGIC_STANDARD_WAVE_STAFF, AnimationData.MAGIC_STANDARD_WAVE_STAFF_WALK), + ItemID.TOXIC_TOTS_CHARGED, ItemID.TOXIC_TOTS_I_CHARGED, ItemID.TOXIC_TOTS_CHARGED_ORN, + ItemID.TOXIC_TOTS_I_CHARGED_ORN), + WEAPON_THAMMARON(Set.of(AnimationData.MAGIC_STANDARD_WAVE_STAFF, AnimationData.MAGIC_STANDARD_WAVE_STAFF_WALK), + ItemID.WILD_CAVE_SCEPTRE_CHARGED), + WEAPON_TRIDENT(Set.of(AnimationData.MAGIC_STANDARD_WAVE_STAFF, AnimationData.MAGIC_STANDARD_WAVE_STAFF_WALK), + ItemID.TOTS, ItemID.TOTS_CHARGED, ItemID.TOTS_I_CHARGED, ItemID.TOTS_CHARGED_ORN, ItemID.TOTS_ORN, + ItemID.TOTS_I_CHARGED_ORN), + WEAPON_WARPED_SCEPTRE(AnimationData.MAGIC_WARPED_SCEPTRE, ItemID.WARPED_SCEPTRE); + + @Getter + private final Set ids; + @Getter + private final Set animations; + + private static Set ListToSet(int... id) + { + ImmutableSet.Builder builder = new ImmutableSet.Builder<>(); + if (id.length == 0) + { + return builder.build(); + } + for (int i : id) + { + builder.add(i); + } + return builder.build(); + } + + PoweredStaves(int... id) + { + this.ids = ListToSet(id); + this.animations = null; + } + + PoweredStaves(AnimationData spell, int... id) + { + this.ids = ListToSet(id); + this.animations = Set.of(spell); + } + + PoweredStaves(Set spell, int... id) + { + this.ids = ListToSet(id); + this.animations = spell; + } + + protected static final int UNKNOWN_SPELL = 0xDEADBEEF; + protected static final ImmutableMap> POWERED_STAVES; + + static + { + ImmutableMap.Builder> builder = new ImmutableMap.Builder<>(); + + for (PoweredStaves p : values()) + { + for (int id : p.ids) + { + ImmutableMap.Builder spellMap = new ImmutableMap.Builder<>(); + if (p.animations == null) + { + spellMap.put(UNKNOWN_SPELL, p); + } + else + { + for (AnimationData spell : p.animations) + { + spellMap.put(spell.animationId, p); + } + } + builder.put(id, spellMap.build()); + } + } + + POWERED_STAVES = builder.build(); + } + + public static PoweredStaves getPoweredStaves(int weaponId, AnimationData animation) + { + ImmutableMap weaponMap = POWERED_STAVES.get(weaponId); + if (weaponMap == null || animation == null) + { + return null; + } + if (weaponMap.containsKey(UNKNOWN_SPELL)) + { + return weaponMap.get(UNKNOWN_SPELL); + } + return weaponMap.get(animation.animationId); + } + + @Override + public String toString() + { + String[] words = super.toString().toLowerCase().split("_"); + Arrays.stream(words).map(StringUtils::capitalize).collect(Collectors.toList()).toArray(words); + return String.join(" ", words); + } +} diff --git a/src/main/java/com/example/essentialsplus/helpers/combat/Spellbook.java b/src/main/java/com/example/essentialsplus/helpers/combat/Spellbook.java new file mode 100644 index 0000000..af0a339 --- /dev/null +++ b/src/main/java/com/example/essentialsplus/helpers/combat/Spellbook.java @@ -0,0 +1,35 @@ +package com.example.essentialsplus.helpers.combat; + +import com.google.common.collect.ImmutableMap; + +public enum Spellbook +{ + STANDARD(0), + ANCIENT(1), + LUNAR(2), + ARCEUUS(3); + + private final int id; + + Spellbook(int id) + { + this.id = id; + } + + private static final ImmutableMap BOOKS; + + static + { + ImmutableMap.Builder builder = new ImmutableMap.Builder<>(); + for (Spellbook data : values()) + { + builder.put(data.id, data); + } + BOOKS = builder.build(); + } + + public static Spellbook fromVarbit(int varbit) + { + return BOOKS.get(varbit); + } +} diff --git a/src/main/java/com/example/essentialsplus/helpers/combat/WeaponType.java b/src/main/java/com/example/essentialsplus/helpers/combat/WeaponType.java new file mode 100644 index 0000000..fb8a1a9 --- /dev/null +++ b/src/main/java/com/example/essentialsplus/helpers/combat/WeaponType.java @@ -0,0 +1,133 @@ +package com.example.essentialsplus.helpers.combat; + +import static com.example.essentialsplus.helpers.combat.AttackStyle.ACCURATE; +import static com.example.essentialsplus.helpers.combat.AttackStyle.AGGRESSIVE; +import static com.example.essentialsplus.helpers.combat.AttackStyle.CASTING; +import static com.example.essentialsplus.helpers.combat.AttackStyle.DEFENSIVE; +import static com.example.essentialsplus.helpers.combat.AttackStyle.DEFENSIVE_CASTING; +import static com.example.essentialsplus.helpers.combat.AttackStyle.OTHER; +import static com.example.essentialsplus.helpers.combat.AttackType.CRUSH; +import static com.example.essentialsplus.helpers.combat.AttackType.MAGIC; +import static com.example.essentialsplus.helpers.combat.AttackType.NONE; +import static com.example.essentialsplus.helpers.combat.AttackType.RANGED; +import static com.example.essentialsplus.helpers.combat.AttackType.SLASH; +import static com.example.essentialsplus.helpers.combat.AttackType.STAB; + +import com.google.common.collect.ImmutableMap; +import java.util.Map; +import lombok.Getter; +import net.runelite.api.Client; +import net.runelite.api.EnumID; +import net.runelite.api.ParamID; +import net.runelite.api.StructComposition; + +public enum WeaponType +{ + UNARMED(CRUSH, CRUSH, null, CRUSH), + AXE(SLASH, SLASH, CRUSH, SLASH), + BLUNT(CRUSH, CRUSH, null, CRUSH), + BOW(RANGED, RANGED, null, RANGED), + CLAW(SLASH, SLASH, STAB, SLASH), + HEAVY_RANGED(RANGED, RANGED, null, RANGED), + SALAMANDER(SLASH, RANGED, MAGIC, null), + CHINCHOMPA(RANGED, RANGED, null, RANGED), + GUN(NONE, CRUSH, null, null), + SLASH_SWORD(SLASH, SLASH, STAB, SLASH), + TWO_HANDED_SWORD(SLASH, SLASH, CRUSH, SLASH), + PICKAXE(STAB, STAB, CRUSH, STAB), + POLEARM(STAB, SLASH, null, STAB), + POLESTAFF(CRUSH, CRUSH, null, CRUSH), + SCYTHE(SLASH, SLASH, CRUSH, SLASH), + SPEAR(STAB, SLASH, CRUSH, STAB), + SPIKED(CRUSH, CRUSH, STAB, CRUSH), + STAB_SWORD(STAB, STAB, SLASH, STAB), + STAFF(CRUSH, CRUSH, null, CRUSH, MAGIC, MAGIC), + THROWN(RANGED, RANGED, null, RANGED), + WHIP(SLASH, SLASH, null, SLASH), + BLADED_STAFF(STAB, SLASH, null, CRUSH, MAGIC, MAGIC), + TWO_HANDED_SWORD_GODSWORD(STAB, SLASH, null, CRUSH, MAGIC, MAGIC), + POWERED_STAFF(SLASH, SLASH, CRUSH, SLASH), + BANNER(MAGIC, MAGIC, null, MAGIC), + POLEARM_CRYSTAL_HALBERD(STAB, SLASH, CRUSH, STAB), + BLUDGEON(STAB, SLASH, null, STAB), + BULWARK(CRUSH, CRUSH, null, CRUSH), + POWERED_WAND(CRUSH, null, null, NONE), + PARTISAN(MAGIC, MAGIC, null, MAGIC), + PARTISAN_2(STAB, STAB, CRUSH, STAB), + PLACE_HOLDER_0(null, null, null), + PLACE_HOLDER_1(null, null, null), + PLACE_HOLDER_2(null, null, null), + PLACE_HOLDER_3(null, null, null), + FLAIL(SLASH, SLASH, null, SLASH); + + private static final Map WEAPON_TYPES; + + @Getter + private final AttackType[] attackTypes; + + static + { + ImmutableMap.Builder builder = new ImmutableMap.Builder<>(); + for (WeaponType weaponType : values()) + { + builder.put(weaponType.ordinal(), weaponType); + } + WEAPON_TYPES = builder.build(); + } + + WeaponType(AttackType... attackTypes) + { + this.attackTypes = attackTypes; + } + + public AttackStyle[] getAttackStyles(Client client) + { + int weaponType = this.ordinal(); + int weaponStyleEnum = client.getEnum(EnumID.WEAPON_STYLES).getIntValue(weaponType); + if (weaponStyleEnum == -1) + { + if (weaponType == 22) + { + return new AttackStyle[]{ + ACCURATE, AGGRESSIVE, null, DEFENSIVE, CASTING, DEFENSIVE_CASTING, + }; + } + + if (weaponType == 30) + { + return new AttackStyle[]{ + ACCURATE, AGGRESSIVE, AGGRESSIVE, DEFENSIVE, + }; + } + return new AttackStyle[0]; + } + int[] weaponStyleStructs = client.getEnum(weaponStyleEnum).getIntVals(); + + AttackStyle[] styles = new AttackStyle[weaponStyleStructs.length]; + int i = 0; + for (int style : weaponStyleStructs) + { + StructComposition attackStyleStruct = client.getStructComposition(style); + String attackStyleName = attackStyleStruct.getStringValue(ParamID.ATTACK_STYLE_NAME); + AttackStyle attackStyle = AttackStyle.valueOf(attackStyleName.toUpperCase()); + if (attackStyle == OTHER) + { + ++i; + continue; + } + + if (i == 5 && attackStyle == DEFENSIVE) + { + attackStyle = DEFENSIVE_CASTING; + } + + styles[i++] = attackStyle; + } + return styles; + } + + public static WeaponType getWeaponType(int id) + { + return WEAPON_TYPES.get(id); + } +} diff --git a/src/main/java/com/example/essentialsplus/helpers/combat/variablespeed/Amoxliatl.java b/src/main/java/com/example/essentialsplus/helpers/combat/variablespeed/Amoxliatl.java new file mode 100644 index 0000000..6a2da3d --- /dev/null +++ b/src/main/java/com/example/essentialsplus/helpers/combat/variablespeed/Amoxliatl.java @@ -0,0 +1,30 @@ +package com.example.essentialsplus.helpers.combat.variablespeed; + +import com.example.essentialsplus.helpers.combat.AnimationData; +import com.example.essentialsplus.helpers.combat.AttackProcedure; +import com.example.essentialsplus.helpers.combat.AttackType; +import com.example.essentialsplus.helpers.combat.CombatUtils; +import com.example.essentialsplus.helpers.combat.Spellbook; +import net.runelite.api.Client; +import net.runelite.api.coords.WorldPoint; +import net.runelite.api.gameval.NpcID; + +public class Amoxliatl implements IVariableSpeed +{ + private static final int AMOXLIATL_REGION_ID = 5446; + + @Override + public int apply(final Client client, final AnimationData curAnimation, final AttackProcedure atkType, + final Spellbook spellbook, final int damageDealt, final int lastSpecDelta, final int baseSpeed, + final int curSpeed) + { + final WorldPoint location = CombatUtils.getLocalLocation(client); + final int targetId = CombatUtils.getTargetId(client); + final AttackType attkType = CombatUtils.getAttackType(client); + if (location.getRegionID() == AMOXLIATL_REGION_ID && targetId == NpcID.AMOXLIATL_ICE_BLOCK && attkType != null && attkType.IsMelee()) + { + return 1; + } + return curSpeed; + } +} diff --git a/src/main/java/com/example/essentialsplus/helpers/combat/variablespeed/BloodMoonSet.java b/src/main/java/com/example/essentialsplus/helpers/combat/variablespeed/BloodMoonSet.java new file mode 100644 index 0000000..4c24399 --- /dev/null +++ b/src/main/java/com/example/essentialsplus/helpers/combat/variablespeed/BloodMoonSet.java @@ -0,0 +1,22 @@ +package com.example.essentialsplus.helpers.combat.variablespeed; + +import com.example.essentialsplus.helpers.combat.AnimationData; +import com.example.essentialsplus.helpers.combat.AttackProcedure; +import com.example.essentialsplus.helpers.combat.Spellbook; +import net.runelite.api.Client; +import net.runelite.api.gameval.SpotanimID; + +public class BloodMoonSet implements IVariableSpeed +{ + @Override + public int apply(final Client client, final AnimationData curAnimation, final AttackProcedure atkType, + final Spellbook spellbook, final int damageDealt, final int lastSpecDelta, final int baseSpeed, + final int curSpeed) + { + if (client.getLocalPlayer() != null && client.getLocalPlayer().hasSpotAnim(SpotanimID.SPECIAL_DUAL_MACUAHUITL_SPOTANIM)) + { + return curSpeed - 1; + } + return curSpeed; + } +} diff --git a/src/main/java/com/example/essentialsplus/helpers/combat/variablespeed/DoomOfMokhaiotl.java b/src/main/java/com/example/essentialsplus/helpers/combat/variablespeed/DoomOfMokhaiotl.java new file mode 100644 index 0000000..a0176da --- /dev/null +++ b/src/main/java/com/example/essentialsplus/helpers/combat/variablespeed/DoomOfMokhaiotl.java @@ -0,0 +1,127 @@ +package com.example.essentialsplus.helpers.combat.variablespeed; + +import com.example.essentialsplus.helpers.combat.AnimationData; +import com.example.essentialsplus.helpers.combat.AttackProcedure; +import com.example.essentialsplus.helpers.combat.Attacking; +import com.example.essentialsplus.helpers.combat.CombatUtils; +import com.example.essentialsplus.helpers.combat.Spellbook; +import com.example.essentialsplus.helpers.combat.variablespeed.state.TickCount; +import com.google.common.collect.ImmutableSet; +import java.util.Set; +import lombok.extern.slf4j.Slf4j; +import net.runelite.api.Client; +import net.runelite.api.NPC; +import net.runelite.api.gameval.AnimationID; +import net.runelite.api.gameval.ItemID; +import net.runelite.api.gameval.NpcID; +import net.runelite.client.game.ItemManager; + +@Slf4j +public class DoomOfMokhaiotl implements IVariableSpeed +{ + private static final Set DOOM_REGION_IDS = new ImmutableSet.Builder() + .add(5269) + .add(13668) + .add(14180) + .build(); + + private static final Set COOLDOWN_OVERWRITES_IDS = new ImmutableSet.Builder() + .add(NpcID.DOM_DEMONIC_ENERGY) + .add(NpcID.DOM_DEMONIC_ENERGY_GIANT_MAGE) + .add(NpcID.DOM_DEMONIC_ENERGY_GIANT_RANGE) + .add(NpcID.DOM_DEMONIC_ENERGY_MAGE) + .add(NpcID.DOM_DEMONIC_ENERGY_RANGE) + .add(NpcID.DOM_DEMONIC_ENERGY_MELEE) + .add(NpcID.DOM_SHOCKWAVE_PATH_NODE) + .build(); + + private static final Set NO_COOLDOWN_WEAPON = new ImmutableSet.Builder() + .add(ItemID.SILVERLIGHT) + .add(ItemID.DARKLIGHT) + .add(ItemID.ARCLIGHT) + .add(ItemID.EMBERLIGHT) + .add(ItemID.BONE_CLAWS) + .add(ItemID.SCORCHING_BOW) + .add(ItemID.HOLY_WATER) + .add(ItemID.EYE_OF_AYAK) + .build(); + + private final TickCount tickCount; + private int larvaeConsumed = -1; + private int shieldConsumed = -1; + + public DoomOfMokhaiotl(final TickCount tc) + { + this.tickCount = tc; + } + + public boolean onRender(final Client client, final ItemManager itemManager, final Spellbook spellbook) + { + if (!CombatUtils.isInRegionId(client, DOOM_REGION_IDS)) + { + return false; + } + + final var atk = Attacking.playerAttack(client); + final AnimationData anim = AnimationData.fromId(atk.getAnimationId()); + if (anim == null || atk.getTarget() == null || !(atk.getTarget() instanceof NPC) || anim.isBlockListAnimation()) + { + return false; + } + + final NPC npc = (NPC) atk.getTarget(); + final int npcId = npc.getId(); + if (COOLDOWN_OVERWRITES_IDS.contains(npcId)) + { + if (tickCount.isWithinNTicks(larvaeConsumed, 1)) + { + return false; + } + final int weaponId = CombatUtils.getWeaponId(client); + final boolean isDemonbaneSpell = spellbook == Spellbook.ARCEUUS && AnimationData.isManualCasting(anim) && anim == AnimationData.MAGIC_ARCEUUS_DEMONBANE; + if (NO_COOLDOWN_WEAPON.contains(weaponId) || isDemonbaneSpell) + { + return false; + } + larvaeConsumed = tickCount.get(); + return true; + } + else if (npcId == NpcID.DOM_BOSS) + { + if (tickCount.isWithinNTicks(shieldConsumed, 30)) + { + return false; + } + final int animId = npc.getAnimation(); + if (animId == AnimationID.DOM_BEAM_CHARGE_LOOP || animId == AnimationID.DOM_BEAM_CHARGE) + { + if (CombatUtils.getAttackType(client) != null && CombatUtils.getAttackType(client).IsMelee()) + { + shieldConsumed = tickCount.get(); + return true; + } + } + return false; + } + return false; + } + + @Override + public int apply(final Client client, final AnimationData curAnimation, final AttackProcedure atkType, + final Spellbook spellbook, final int damageDealt, final int lastSpecDelta, final int baseSpeed, + final int curSpeed) + { + final int targetId = CombatUtils.getTargetId(client); + final boolean inDoom = CombatUtils.isInRegionId(client, DOOM_REGION_IDS); + if (inDoom && COOLDOWN_OVERWRITES_IDS.contains(targetId)) + { + final int weaponId = CombatUtils.getWeaponId(client); + final boolean isDemonbaneSpell = spellbook == Spellbook.ARCEUUS && AnimationData.isManualCasting(curAnimation) && curAnimation == AnimationData.MAGIC_ARCEUUS_DEMONBANE; + if (NO_COOLDOWN_WEAPON.contains(weaponId) || isDemonbaneSpell) + { + return 1; + } + } + return curSpeed; + } +} diff --git a/src/main/java/com/example/essentialsplus/helpers/combat/variablespeed/EyeOfAyak.java b/src/main/java/com/example/essentialsplus/helpers/combat/variablespeed/EyeOfAyak.java new file mode 100644 index 0000000..a8d1fd6 --- /dev/null +++ b/src/main/java/com/example/essentialsplus/helpers/combat/variablespeed/EyeOfAyak.java @@ -0,0 +1,21 @@ +package com.example.essentialsplus.helpers.combat.variablespeed; + +import com.example.essentialsplus.helpers.combat.AnimationData; +import com.example.essentialsplus.helpers.combat.AttackProcedure; +import com.example.essentialsplus.helpers.combat.Spellbook; +import net.runelite.api.Client; + +public class EyeOfAyak implements IVariableSpeed +{ + @Override + public int apply(final Client client, final AnimationData curAnimation, final AttackProcedure atkType, + final Spellbook spellbook, final int damageDealt, final int lastSpecDelta, final int baseSpeed, + final int curSpeed) + { + if (curAnimation == AnimationData.MAGIC_EYE_OF_AYAK_SPEC) + { + return 5; + } + return curSpeed; + } +} diff --git a/src/main/java/com/example/essentialsplus/helpers/combat/variablespeed/IVariableSpeed.java b/src/main/java/com/example/essentialsplus/helpers/combat/variablespeed/IVariableSpeed.java new file mode 100644 index 0000000..4cd1b66 --- /dev/null +++ b/src/main/java/com/example/essentialsplus/helpers/combat/variablespeed/IVariableSpeed.java @@ -0,0 +1,21 @@ +package com.example.essentialsplus.helpers.combat.variablespeed; + +import com.example.essentialsplus.helpers.combat.AnimationData; +import com.example.essentialsplus.helpers.combat.AttackProcedure; +import com.example.essentialsplus.helpers.combat.Spellbook; +import com.example.essentialsplus.helpers.combat.variablespeed.state.IStateTracker; +import net.runelite.api.Client; + +public interface IVariableSpeed extends IStateTracker +{ + int apply( + final Client client, + final AnimationData curAnimation, + final AttackProcedure atkType, + final Spellbook spellbook, + final int damageDealt, + final int lastSpecDelta, + final int baseSpeed, + final int curSpeed + ); +} diff --git a/src/main/java/com/example/essentialsplus/helpers/combat/variablespeed/MaggotKing.java b/src/main/java/com/example/essentialsplus/helpers/combat/variablespeed/MaggotKing.java new file mode 100644 index 0000000..812e76c --- /dev/null +++ b/src/main/java/com/example/essentialsplus/helpers/combat/variablespeed/MaggotKing.java @@ -0,0 +1,67 @@ +package com.example.essentialsplus.helpers.combat.variablespeed; + +import com.example.essentialsplus.helpers.combat.AnimationData; +import com.example.essentialsplus.helpers.combat.Attacking; +import com.example.essentialsplus.helpers.combat.CombatUtils; +import com.example.essentialsplus.helpers.combat.variablespeed.state.TickCount; +import lombok.extern.slf4j.Slf4j; +import net.runelite.api.Client; +import net.runelite.api.NPC; +import net.runelite.api.gameval.AnimationID; +import net.runelite.api.gameval.NpcID; +import net.runelite.client.game.ItemManager; + +@Slf4j +public class MaggotKing +{ + private static final int MAGGOT_KING_REGION_ID = 11645; + + private final TickCount tickCount; + private int consumed = -1; + + public MaggotKing(final TickCount tc) + { + this.tickCount = tc; + } + + public boolean onRender(final Client client, final ItemManager itemManager) + { + if (!CombatUtils.isInRegionId(client, MAGGOT_KING_REGION_ID) || tickCount.isWithinNTicks(consumed, 1)) + { + return false; + } + + final var atk = Attacking.playerAttack(client); + final AnimationData anim = AnimationData.fromId(atk.getAnimationId()); + if (anim == null || atk.getTarget() == null || !(atk.getTarget() instanceof NPC)) + { + return false; + } + + final NPC npc = (NPC) atk.getTarget(); + if (npc.getId() != NpcID.UR_MAGGOT_LARVAE || !anim.isStandardBowAttack() + || npc.getAnimation() != AnimationID.UR_MAGGOT_LARVAE_FLY) + { + return false; + } + consumed = tickCount.get(); + return true; + } + + public int onChatMessage(final Client client, final String message) + { + if (message == null) + { + return 0; + } + if (message.contains("The sticky acid hampers your ability to attack!")) + { + return 1; + } + else if (message.contains("The Maggot King's screech disrupts your concentration!")) + { + return 3; + } + return 0; + } +} diff --git a/src/main/java/com/example/essentialsplus/helpers/combat/variablespeed/PurgingStaffSpec.java b/src/main/java/com/example/essentialsplus/helpers/combat/variablespeed/PurgingStaffSpec.java new file mode 100644 index 0000000..654c746 --- /dev/null +++ b/src/main/java/com/example/essentialsplus/helpers/combat/variablespeed/PurgingStaffSpec.java @@ -0,0 +1,51 @@ +package com.example.essentialsplus.helpers.combat.variablespeed; + +import com.example.essentialsplus.helpers.combat.AnimationData; +import com.example.essentialsplus.helpers.combat.AttackProcedure; +import com.example.essentialsplus.helpers.combat.CombatUtils; +import com.example.essentialsplus.helpers.combat.Spellbook; +import com.example.essentialsplus.helpers.combat.variablespeed.state.Yama; +import net.runelite.api.Client; +import net.runelite.api.NPC; +import net.runelite.api.gameval.ItemID; + +public class PurgingStaffSpec implements IVariableSpeed +{ + private final Yama yama; + public NPC lastTarget; + + public PurgingStaffSpec(Yama yama) + { + this.yama = yama; + } + + @Override + public int apply(final Client client, final AnimationData curAnimation, final AttackProcedure atkType, + final Spellbook spellbook, final int damageDealt, final int lastSpecDelta, final int baseSpeed, + final int curSpeed) + { + if (this.yama == null || !this.yama.inYamaRegion) + { + return curSpeed; + } + final NPC target = CombatUtils.getTargetNPC(client); + final NPC flare = Yama.isEitherVoidFlare(target, lastTarget); + lastTarget = target; + if (flare == null) + { + return curSpeed; + } + + yama.dealVoidFlareDamage(flare, damageDealt); + if (lastSpecDelta != -250 || CombatUtils.getWeaponId(client) != ItemID.PURGING_STAFF || spellbook != Spellbook.ARCEUUS) + { + return curSpeed; + } + + if (yama.getVoidFlareDead(flare)) + { + return curSpeed - 2; + } + return curSpeed; + } +} diff --git a/src/main/java/com/example/essentialsplus/helpers/combat/variablespeed/RapidAttackStyle.java b/src/main/java/com/example/essentialsplus/helpers/combat/variablespeed/RapidAttackStyle.java new file mode 100644 index 0000000..ba45aec --- /dev/null +++ b/src/main/java/com/example/essentialsplus/helpers/combat/variablespeed/RapidAttackStyle.java @@ -0,0 +1,25 @@ +package com.example.essentialsplus.helpers.combat.variablespeed; + +import com.example.essentialsplus.helpers.combat.AnimationData; +import com.example.essentialsplus.helpers.combat.AttackProcedure; +import com.example.essentialsplus.helpers.combat.AttackStyle; +import com.example.essentialsplus.helpers.combat.CombatUtils; +import com.example.essentialsplus.helpers.combat.Spellbook; +import net.runelite.api.Client; +import net.runelite.api.gameval.VarPlayerID; + +public class RapidAttackStyle implements IVariableSpeed +{ + @Override + public int apply(final Client client, final AnimationData curAnimation, final AttackProcedure atkType, + final Spellbook spellbook, final int damageDealt, final int lastSpecDelta, final int baseSpeed, + final int curSpeed) + { + final boolean isRapid = client.getVarpValue(VarPlayerID.COM_MODE) == 1; + if (atkType == AttackProcedure.MELEE_OR_RANGE && CombatUtils.getAttackStyle(client) == AttackStyle.RANGING && isRapid) + { + return curSpeed - 1; + } + return curSpeed; + } +} diff --git a/src/main/java/com/example/essentialsplus/helpers/combat/variablespeed/RedKerisSpec.java b/src/main/java/com/example/essentialsplus/helpers/combat/variablespeed/RedKerisSpec.java new file mode 100644 index 0000000..fa8833a --- /dev/null +++ b/src/main/java/com/example/essentialsplus/helpers/combat/variablespeed/RedKerisSpec.java @@ -0,0 +1,25 @@ +package com.example.essentialsplus.helpers.combat.variablespeed; + +import com.example.essentialsplus.helpers.combat.AnimationData; +import com.example.essentialsplus.helpers.combat.AttackProcedure; +import com.example.essentialsplus.helpers.combat.Spellbook; +import net.runelite.api.Client; + +public class RedKerisSpec implements IVariableSpeed +{ + @Override + public int apply(final Client client, final AnimationData curAnimation, final AttackProcedure atkType, + final Spellbook spellbook, final int damageDealt, final int lastSpecDelta, final int baseSpeed, + final int curSpeed) + { + if (lastSpecDelta != -750 || curAnimation != AnimationData.MELEE_RED_KERIS_SPEC) + { + return curSpeed; + } + if (damageDealt > 0) + { + return curSpeed + 4; + } + return curSpeed; + } +} diff --git a/src/main/java/com/example/essentialsplus/helpers/combat/variablespeed/RoyalTitans.java b/src/main/java/com/example/essentialsplus/helpers/combat/variablespeed/RoyalTitans.java new file mode 100644 index 0000000..688eb4c --- /dev/null +++ b/src/main/java/com/example/essentialsplus/helpers/combat/variablespeed/RoyalTitans.java @@ -0,0 +1,174 @@ +package com.example.essentialsplus.helpers.combat.variablespeed; + +import com.example.essentialsplus.helpers.combat.AnimationData; +import com.example.essentialsplus.helpers.combat.AttackProcedure; +import com.example.essentialsplus.helpers.combat.CombatUtils; +import com.example.essentialsplus.helpers.combat.Spellbook; +import com.google.common.collect.ImmutableSet; +import java.util.HashSet; +import java.util.Set; +import net.runelite.api.Client; +import net.runelite.api.NPC; +import net.runelite.api.coords.WorldPoint; +import net.runelite.api.events.GameTick; +import net.runelite.api.events.NpcDespawned; +import net.runelite.api.events.NpcSpawned; +import net.runelite.api.gameval.ItemID; +import net.runelite.api.gameval.NpcID; + +public class RoyalTitans implements IVariableSpeed +{ + private static final int ROYAL_TITANS_REGION_ID = 11669; + private static final int HP_FUDGE = 1; + private static final int ELEMENTAL_HP = 40 - HP_FUDGE; + + private final Set iceElementals = new HashSet<>(); + private final Set fireElementals = new HashSet<>(); + private boolean removeDead = false; + + private static final Set ONE_SHOT_SPELLS = new ImmutableSet.Builder() + .add(AnimationData.MAGIC_STANDARD_STRIKE_BOLT_BLAST) + .add(AnimationData.MAGIC_STANDARD_STRIKE_BOLT_BLAST_STAFF) + .add(AnimationData.MAGIC_STANDARD_STRIKE_BOLT_BLAST_STAFF_WALK) + .add(AnimationData.MAGIC_STANDARD_STRIKE_BOLT_BLAST_WALK) + .add(AnimationData.MAGIC_STANDARD_SURGE) + .add(AnimationData.MAGIC_STANDARD_SURGE_STAFF) + .add(AnimationData.MAGIC_STANDARD_SURGE_STAFF_WALK) + .add(AnimationData.MAGIC_STANDARD_WAVE) + .add(AnimationData.MAGIC_STANDARD_WAVE_STAFF) + .add(AnimationData.MAGIC_STANDARD_WAVE_STAFF_WALK) + .add(AnimationData.MAGIC_STANDARD_WAVE_WALK) + .build(); + + @Override + public int apply(final Client client, final AnimationData curAnimation, final AttackProcedure atkType, + final Spellbook spellbook, final int damageDealt, final int lastSpecDelta, final int baseSpeed, + final int curSpeed) + { + if (earlyExit(client, atkType, damageDealt, spellbook)) + { + return curSpeed; + } + final int targetId = CombatUtils.getTargetId(client); + if (spellbook != Spellbook.STANDARD || !isElemental(targetId) || !ONE_SHOT_SPELLS.contains(curAnimation)) + { + return curSpeed; + } + final NPC target = CombatUtils.getTargetNPC(client); + if (target == null) + { + return curSpeed; + } + + final boolean wieldingTwinflame = CombatUtils.getWeaponId(client) == ItemID.TWINFLAME_STAFF; + final int computedDamage = wieldingTwinflame ? damageDealt + ((damageDealt * 4) / 10) : damageDealt; + + if (computedDamage < ELEMENTAL_HP) + { + return curSpeed; + } + + final var set = targetId == NpcID.RT_SUMMON_ELEMENTAL_FIRE ? fireElementals : iceElementals; + int count = 1; + final var reference = target.getWorldLocation(); + for (final NPC elemental : set) + { + if (elemental == target) + { + continue; + } + final WorldPoint worldLocation = elemental.getWorldLocation(); + final int distanceTo2D = reference.distanceTo2D(worldLocation); + if (distanceTo2D <= 1) + { + count++; + } + } + + final boolean isTwinflameKillOnSecondProjectile = wieldingTwinflame && + curAnimation == AnimationData.MAGIC_STANDARD_STRIKE_BOLT_BLAST_STAFF; + final int extraHitOffset = isTwinflameKillOnSecondProjectile ? 1 : 0; + + final WorldPoint playerLoc = client.getLocalPlayer().getWorldLocation(); + final int distance = playerLoc.distanceTo2D(reference); + final int hitDelay = 2 + (distance / 3) + extraHitOffset; + + final int remainingAtImpact = Math.max(0, curSpeed - hitDelay); + final int remainingAfterReduction = Math.max(0, remainingAtImpact - count); + final int finalSpeed = Math.min(curSpeed, hitDelay + remainingAfterReduction); + + removeDead = true; + return finalSpeed; + } + + private static boolean earlyExit(final Client client, final AttackProcedure atkType, final int damageDealt, + final Spellbook spellbook) + { + return notInRegion(client) + || atkType != AttackProcedure.MANUAL_AUTO_CAST + || damageDealt <= 0 + || spellbook != Spellbook.STANDARD; + } + + @Override + public void onGameTick(Client client, GameTick tick) + { + if (removeDead) + { + boolean removed = iceElementals.removeIf(NPC::isDead); + removed |= fireElementals.removeIf(NPC::isDead); + if (removed) + { + removeDead = false; + } + } + } + + @Override + public void onNpcSpawned(final Client client, final NpcSpawned npcSpawned) + { + if (notInRegion(client)) + { + return; + } + final NPC npc = npcSpawned.getNpc(); + final int id = npc.getId(); + if (id == NpcID.RT_SUMMON_ELEMENTAL_ICE) + { + iceElementals.add(npc); + } + else if (id == NpcID.RT_SUMMON_ELEMENTAL_FIRE) + { + fireElementals.add(npc); + } + } + + @Override + public void onNpcDespawned(final Client client, final NpcDespawned npcDespawned) + { + if (notInRegion(client)) + { + return; + } + final NPC npc = npcDespawned.getNpc(); + final int id = npc.getId(); + if (id == NpcID.RT_SUMMON_ELEMENTAL_ICE) + { + iceElementals.remove(npc); + } + else if (id == NpcID.RT_SUMMON_ELEMENTAL_FIRE) + { + fireElementals.remove(npc); + } + } + + private static boolean isElemental(int id) + { + return id == NpcID.RT_SUMMON_ELEMENTAL_FIRE || id == NpcID.RT_SUMMON_ELEMENTAL_ICE; + } + + private static boolean notInRegion(final Client client) + { + return CombatUtils.getLocalLocation(client).getRegionID() != ROYAL_TITANS_REGION_ID; + } +} diff --git a/src/main/java/com/example/essentialsplus/helpers/combat/variablespeed/Scurrius.java b/src/main/java/com/example/essentialsplus/helpers/combat/variablespeed/Scurrius.java new file mode 100644 index 0000000..fb2159c --- /dev/null +++ b/src/main/java/com/example/essentialsplus/helpers/combat/variablespeed/Scurrius.java @@ -0,0 +1,44 @@ +package com.example.essentialsplus.helpers.combat.variablespeed; + +import com.example.essentialsplus.helpers.combat.AnimationData; +import com.example.essentialsplus.helpers.combat.AttackProcedure; +import com.example.essentialsplus.helpers.combat.CombatUtils; +import com.example.essentialsplus.helpers.combat.Spellbook; +import net.runelite.api.Client; +import net.runelite.api.coords.WorldPoint; +import net.runelite.api.gameval.ItemID; +import net.runelite.api.gameval.NpcID; + +public class Scurrius implements IVariableSpeed +{ + private static final int SCURRIUS_REGION_ID = 13210; + private static final int SCURRIUS_MIN_X = 3290; + private static final int SCURRIUS_MAX_X = 3307; + private static final int SCURRIUS_MIN_Y = 9859; + private static final int SCURRIUS_MAX_Y = 9876; + + private static boolean attackingGiantRatWithBoneWeapon(final int equipped, final int regionId, final int x, + final int y, final int target) + { + final boolean correctWeapon = equipped == ItemID.RAT_BONE_STAFF || equipped == ItemID.RAT_BONE_MACE || equipped == ItemID.RAT_BONE_BOW; + final boolean correctCoords = x >= SCURRIUS_MIN_X && x <= SCURRIUS_MAX_X && y >= SCURRIUS_MIN_Y && y <= SCURRIUS_MAX_Y; + final boolean correctRegion = regionId == SCURRIUS_REGION_ID; + final boolean correctEnemy = target == NpcID.RAT_BOSS_GIANT_RAT; + return correctWeapon && correctCoords && correctRegion && correctEnemy; + } + + @Override + public int apply(final Client client, final AnimationData curAnimation, final AttackProcedure atkType, + final Spellbook spellbook, final int damageDealt, final int lastSpecDelta, final int baseSpeed, + final int curSpeed) + { + final WorldPoint location = CombatUtils.getLocalLocation(client); + final int weaponId = CombatUtils.getWeaponId(client); + final int targetId = CombatUtils.getTargetId(client); + if (attackingGiantRatWithBoneWeapon(weaponId, location.getRegionID(), location.getX(), location.getY(), targetId)) + { + return 1; + } + return curSpeed; + } +} diff --git a/src/main/java/com/example/essentialsplus/helpers/combat/variablespeed/ShadowCrash.java b/src/main/java/com/example/essentialsplus/helpers/combat/variablespeed/ShadowCrash.java new file mode 100644 index 0000000..8dfbdb5 --- /dev/null +++ b/src/main/java/com/example/essentialsplus/helpers/combat/variablespeed/ShadowCrash.java @@ -0,0 +1,257 @@ +package com.example.essentialsplus.helpers.combat.variablespeed; + +import com.example.essentialsplus.helpers.combat.variablespeed.state.MarkOfDarkness; +import com.example.essentialsplus.helpers.combat.variablespeed.state.TickCount; +import com.example.essentialsplus.helpers.combat.variablespeed.state.Yama; +import com.example.essentialsplus.helpers.combat.variablespeed.state.YamaPhase; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.Map; +import java.util.Set; +import lombok.extern.slf4j.Slf4j; +import net.runelite.api.Client; +import net.runelite.api.GraphicsObject; +import net.runelite.api.Player; +import net.runelite.api.WorldView; +import net.runelite.api.coords.LocalPoint; +import net.runelite.api.coords.WorldPoint; + +@Slf4j +public class ShadowCrash +{ + private static final int FIREBALL_ID = 3262; + private static final int TILE = 128; + + private static final int HV_GAP_1 = 3; + private static final int HV_GAP_2 = 6; + private static final int HV_DELTA_1 = HV_GAP_1 * TILE; + private static final int HV_DELTA_2 = HV_GAP_2 * TILE; + private static final int D_GAP_1 = 2; + private static final int D_GAP_2 = 4; + private static final int D_DELTA_1 = D_GAP_1 * TILE; + private static final int D_DELTA_2 = D_GAP_2 * TILE; + + private final TickCount tickCount; + private final Yama yama; + private final MarkOfDarkness mod; + + private int consumed = -1; + private final Map fireballs; + private ArrayList lines; + private WorldPoint player; + + public ShadowCrash(Yama yama, MarkOfDarkness mod, TickCount tc) + { + this.yama = yama; + this.mod = mod; + this.tickCount = tc; + this.fireballs = new HashMap<>(); + this.lines = new ArrayList<>(); + } + + public int onRender(final Client client, final int attackDelayHoldoffTicks, final boolean isUsingMagic) + { + if (yama == null || !yama.inYamaRegion || yama.phase() != YamaPhase.P3 || !mod.isActive()) + { + return 0; + } + if (!tickCount.isWithinNTicks(consumed, 2) && shadowCrashSweetSpot(client)) + { + consumed = tickCount.get(); + if (attackDelayHoldoffTicks <= 1 && isUsingMagic) + { + return 0; + } + return -1; + } + return 0; + } + + private boolean shadowCrashSweetSpot(final Client client) + { + getState(client); + if (lines.isEmpty()) + { + return false; + } + + for (var line : lines) + { + if (line.isActive()) + { + return line.inSweetSpot(client, player); + } + } + return false; + } + + private void getState(final Client client) + { + fireballs.clear(); + lines.clear(); + final WorldView tlwv = client.getTopLevelWorldView(); + final Player localPlayer = client.getLocalPlayer(); + if (localPlayer == null) + { + return; + } + final WorldView playerWv = localPlayer.getWorldView(); + if (tlwv != null) + { + getFireballs(tlwv); + } + if (playerWv != null && playerWv != tlwv) + { + getFireballs(playerWv); + } + if (fireballs.size() < 3) + { + return; + } + player = localPlayer.getWorldLocation(); + lines = getLines(); + } + + private void getFireballs(final WorldView wv) + { + for (GraphicsObject graphicsObject : wv.getGraphicsObjects()) + { + if (graphicsObject.getId() == FIREBALL_ID) + { + fireballs.put(graphicsObject.getLocation(), graphicsObject); + } + } + } + + private ArrayList getLines() + { + final var result = new ArrayList(); + + while (fireballs.size() >= 3) + { + LocalPoint first = null; + final Set fireballsLocations = fireballs.keySet(); + for (var location : fireballsLocations) + { + if (first == null) + { + first = location; + } + else + { + if (first.getX() > location.getX()) + { + first = location; + } + else if (first.getX() == location.getX()) + { + if (first.getY() > location.getY()) + { + first = location; + } + } + } + } + if (first == null) + { + return result; + } + + var centre = first.plus(D_DELTA_1, D_DELTA_1); + var end = first.plus(D_DELTA_2, D_DELTA_2); + if (fireballsLocations.contains(centre) && fireballsLocations.contains(end)) + { + result.add(new FireballLine(LineType.D2, centre, fireballs.get(centre))); + removeLine(first, centre, end); + continue; + } + + centre = first.plus(HV_DELTA_1, 0); + end = first.plus(HV_DELTA_2, 0); + if (fireballsLocations.contains(centre) && fireballsLocations.contains(end)) + { + result.add(new FireballLine(LineType.H, centre, fireballs.get(centre))); + removeLine(first, centre, end); + continue; + } + + centre = first.plus(0, HV_DELTA_1); + end = first.plus(0, HV_DELTA_2); + if (fireballsLocations.contains(centre) && fireballsLocations.contains(end)) + { + result.add(new FireballLine(LineType.V, centre, fireballs.get(centre))); + removeLine(first, centre, end); + continue; + } + + centre = first.plus(D_DELTA_1, -D_DELTA_1); + end = first.plus(D_DELTA_2, -D_DELTA_2); + if (fireballsLocations.contains(centre) && fireballsLocations.contains(end)) + { + result.add(new FireballLine(LineType.D1, centre, fireballs.get(centre))); + removeLine(first, centre, end); + continue; + } + return result; + } + return result; + } + + private void removeLine(final LocalPoint a, final LocalPoint b, final LocalPoint c) + { + fireballs.remove(a); + fireballs.remove(b); + fireballs.remove(c); + } + + public static boolean eq(final WorldPoint t, final int dx, final int dy, final WorldPoint other) + { + if (t == null || other == null) + { + return false; + } + return t.getX() + dx == other.getX() && t.getY() + dy == other.getY(); + } + + static class FireballLine + { + private final LineType type; + private final LocalPoint centre; + private final GraphicsObject cObject; + + FireballLine(final LineType type, final LocalPoint centre, final GraphicsObject cObject) + { + this.centre = centre; + this.cObject = cObject; + this.type = type; + } + + public boolean isActive() + { + final int animationFrame = cObject.getAnimationFrame(); + return animationFrame >= 30 && animationFrame <= 40; + } + + public boolean inSweetSpot(final Client client, final WorldPoint lp) + { + final WorldPoint wp = WorldPoint.fromLocal(client, centre); + switch (this.type) + { + case V: + return eq(wp, 1, 0, lp) || eq(wp, -1, 0, lp); + case H: + return eq(wp, 0, 1, lp) || eq(wp, 0, -1, lp); + case D1: + return eq(wp, 1, 1, lp) || eq(wp, -1, -1, lp); + case D2: + return eq(wp, -1, 1, lp) || eq(wp, 1, -1, lp); + } + return false; + } + } + + enum LineType + { + V, H, D1, D2 + } +} diff --git a/src/main/java/com/example/essentialsplus/helpers/combat/variablespeed/TombsOfAmascut.java b/src/main/java/com/example/essentialsplus/helpers/combat/variablespeed/TombsOfAmascut.java new file mode 100644 index 0000000..898c234 --- /dev/null +++ b/src/main/java/com/example/essentialsplus/helpers/combat/variablespeed/TombsOfAmascut.java @@ -0,0 +1,26 @@ +package com.example.essentialsplus.helpers.combat.variablespeed; + +import com.example.essentialsplus.helpers.combat.AnimationData; +import com.example.essentialsplus.helpers.combat.AttackProcedure; +import com.example.essentialsplus.helpers.combat.AttackType; +import com.example.essentialsplus.helpers.combat.CombatUtils; +import com.example.essentialsplus.helpers.combat.Spellbook; +import net.runelite.api.Client; +import net.runelite.api.gameval.NpcID; + +public class TombsOfAmascut implements IVariableSpeed +{ + @Override + public int apply(final Client client, final AnimationData curAnimation, final AttackProcedure atkType, + final Spellbook spellbook, final int damageDealt, final int lastSpecDelta, final int baseSpeed, + final int curSpeed) + { + final int targetId = CombatUtils.getTargetId(client); + final AttackType attkType = CombatUtils.getAttackType(client); + if (targetId == NpcID.WARDENS_P3_ORB_BLUE && attkType != null && attkType.IsMelee()) + { + return 1; + } + return curSpeed; + } +} diff --git a/src/main/java/com/example/essentialsplus/helpers/combat/variablespeed/TormentedDemons.java b/src/main/java/com/example/essentialsplus/helpers/combat/variablespeed/TormentedDemons.java new file mode 100644 index 0000000..9dfc3dd --- /dev/null +++ b/src/main/java/com/example/essentialsplus/helpers/combat/variablespeed/TormentedDemons.java @@ -0,0 +1,184 @@ +package com.example.essentialsplus.helpers.combat.variablespeed; + +import com.example.essentialsplus.helpers.combat.AnimationData; +import com.example.essentialsplus.helpers.combat.AttackProcedure; +import com.example.essentialsplus.helpers.combat.AttackType; +import com.example.essentialsplus.helpers.combat.CombatUtils; +import com.example.essentialsplus.helpers.combat.Spellbook; +import com.example.essentialsplus.helpers.combat.WeaponType; +import com.example.essentialsplus.helpers.combat.variablespeed.state.TickCount; +import java.util.HashMap; +import java.util.Map; +import net.runelite.api.Client; +import net.runelite.api.NPC; +import net.runelite.api.events.GameTick; +import net.runelite.api.events.NpcDespawned; +import net.runelite.api.events.NpcSpawned; +import net.runelite.api.gameval.NpcID; +import net.runelite.api.gameval.SpotanimID; + +public class TormentedDemons implements IVariableSpeed +{ + private final TickCount tickCount; + private final Map tormentedDemons = new HashMap<>(); + + public TormentedDemons(TickCount tc) + { + this.tickCount = tc; + } + + @Override + public int apply(final Client client, final AnimationData curAnimation, final AttackProcedure atkType, + final Spellbook spellbook, final int damageDealt, final int lastSpecDelta, final int baseSpeed, + final int curSpeed) + { + final int targetId = CombatUtils.getTargetId(client); + if (!isTormentedDemon(targetId)) + { + return curSpeed; + } + final NPC target = CombatUtils.getTargetNPC(client); + if (!tormentedDemons.containsKey(target)) + { + return curSpeed; + } + final DemonData targetDemon = tormentedDemons.get(target); + if (!targetDemon.isVulnerable(tickCount.get())) + { + return curSpeed; + } + if (targetDemon.vulnConsumed(tickCount.get())) + { + return curSpeed; + } + switch (atkType) + { + case POWERED_STAVE: + return curSpeed; + case MANUAL_AUTO_CAST: + break; + case MELEE_OR_RANGE: + final WeaponType wep = CombatUtils.getWeaponType(client); + final AttackType attkType = CombatUtils.getAttackType(client); + if (wep != WeaponType.HEAVY_RANGED && (attkType == null || attkType != AttackType.CRUSH)) + { + return curSpeed; + } + break; + } + if (curSpeed > 4) + { + return 4; + } + return curSpeed; + } + + private static boolean isTormentedDemon(int targetId) + { + return targetId == NpcID.TORMENTED_DEMON_1 || targetId == NpcID.TORMENTED_DEMON_2; + } + + @Override + public void onGameTick(Client client, GameTick tick) + { + for (final Map.Entry td : tormentedDemons.entrySet()) + { + final boolean isVulnerable = td.getKey().hasSpotAnim(SpotanimID.LUC2_UNDEAD_DEMON_EXPLOSION_FIRE_SPOT); + td.getValue().update(tickCount.get(), isVulnerable); + } + } + + @Override + public void onNpcSpawned(final Client client, final NpcSpawned npcSpawned) + { + final NPC npc = npcSpawned.getNpc(); + maybeInsertDemon(npc); + } + + private void maybeInsertDemon(final NPC npc) + { + if (isTormentedDemon(npc.getId())) + { + final boolean isVulnerable = npc.hasSpotAnim(SpotanimID.LUC2_UNDEAD_DEMON_EXPLOSION_FIRE_SPOT); + tormentedDemons.put(npc, new DemonData(tickCount.get(), isVulnerable)); + } + } + + @Override + public void onNpcDespawned(final Client client, final NpcDespawned npcDespawned) + { + final NPC npc = npcDespawned.getNpc(); + if (isTormentedDemon(npc.getId())) + { + tormentedDemons.remove(npc); + } + } + + static class DemonData + { + private static final int VULN_TICKS_AFTER_END = 10; + private Integer vulnerableStart; + private Integer vulnerableFinish; + private int attacked; + + DemonData(int tick, boolean vuln) + { + this.update(tick, vuln); + this.attacked = -1; + } + + void update(int tick, boolean vuln) + { + if (vuln && this.vulnerableStart == null) + { + this.vulnerableStart = tick; + this.vulnerableFinish = tick; + } + else if (vuln && this.vulnerableStart != null) + { + this.vulnerableFinish = tick; + } + + if (!vuln) + { + this.vulnerableStart = null; + } + if (this.attacked != -1 && this.attacked + 1 >= tick) + { + this.attacked = -1; + } + } + + boolean isVulnerable(int tick) + { + if (this.vulnerableFinish == null) + { + return false; + } + return (this.vulnerableFinish + VULN_TICKS_AFTER_END) > tick; + } + + boolean vulnConsumed(int tick) + { + if (this.attacked == tick || this.attacked == -1) + { + this.consumeVuln(tick); + return false; + } + else if (this.attacked > tick + VULN_TICKS_AFTER_END) + { + return true; + } + else + { + this.consumeVuln(tick); + return false; + } + } + + void consumeVuln(int tick) + { + this.attacked = tick; + } + } +} diff --git a/src/main/java/com/example/essentialsplus/helpers/combat/variablespeed/VariableSpeed.java b/src/main/java/com/example/essentialsplus/helpers/combat/variablespeed/VariableSpeed.java new file mode 100644 index 0000000..96bb6fa --- /dev/null +++ b/src/main/java/com/example/essentialsplus/helpers/combat/variablespeed/VariableSpeed.java @@ -0,0 +1,117 @@ +package com.example.essentialsplus.helpers.combat.variablespeed; + +import com.example.essentialsplus.helpers.combat.AnimationData; +import com.example.essentialsplus.helpers.combat.AttackProcedure; +import com.example.essentialsplus.helpers.combat.Spellbook; +import com.example.essentialsplus.helpers.combat.variablespeed.state.IStateTracker; +import com.example.essentialsplus.helpers.combat.variablespeed.state.MarkOfDarkness; +import com.example.essentialsplus.helpers.combat.variablespeed.state.TickCount; +import com.example.essentialsplus.helpers.combat.variablespeed.state.Yama; +import net.runelite.api.Client; +import net.runelite.api.events.ChatMessage; +import net.runelite.api.events.GameTick; +import net.runelite.api.events.NpcDespawned; +import net.runelite.api.events.NpcSpawned; + +public class VariableSpeed +{ + private final TickCount tickCount; + private final Yama yama; + private final MarkOfDarkness markOfDarkness; + + public final ShadowCrash shadowCrash; + public final MaggotKing maggotKing; + public final DoomOfMokhaiotl doomOfMokhaiotl; + + private final IStateTracker[] toTrack; + private final IVariableSpeed[] toApply; + + public VariableSpeed(TickCount tc) + { + this.tickCount = tc; + this.yama = new Yama(); + this.markOfDarkness = new MarkOfDarkness(); + this.shadowCrash = new ShadowCrash(yama, markOfDarkness, tc); + this.maggotKing = new MaggotKing(tc); + this.doomOfMokhaiotl = new DoomOfMokhaiotl(tc); + + this.toTrack = new IStateTracker[]{ + tc, + yama, + markOfDarkness + }; + + this.toApply = new IVariableSpeed[]{ + new BloodMoonSet(), + new RapidAttackStyle(), + new RedKerisSpec(), + new PurgingStaffSpec(yama), + new EyeOfAyak(), + new TormentedDemons(tc), + new RoyalTitans(), + new Amoxliatl(), + new Scurrius(), + new TombsOfAmascut(), + doomOfMokhaiotl + }; + } + + public int compute(final Client client, final AnimationData curAnimation, final AttackProcedure atkType, + final Spellbook spellbook, final int damageDealt, final int lastSpecDelta, final int baseSpeed) + { + int newSpeed = baseSpeed; + for (final IVariableSpeed i : toApply) + { + newSpeed = i.apply(client, curAnimation, atkType, spellbook, damageDealt, lastSpecDelta, baseSpeed, newSpeed); + } + return newSpeed; + } + + public void onGameTick(final Client client, final GameTick tick) + { + for (final IStateTracker i : toTrack) + { + i.onGameTick(client, tick); + } + for (final IStateTracker i : toApply) + { + i.onGameTick(client, tick); + } + } + + public void onChatMessage(final Client client, final ChatMessage event) + { + for (final IStateTracker i : toTrack) + { + i.onChatMessage(client, event); + } + for (final IStateTracker i : toApply) + { + i.onChatMessage(client, event); + } + } + + public void onNpcSpawned(final Client client, final NpcSpawned npcSpawned) + { + for (final IStateTracker i : toTrack) + { + i.onNpcSpawned(client, npcSpawned); + } + for (final IStateTracker i : toApply) + { + i.onNpcSpawned(client, npcSpawned); + } + } + + public void onNpcDespawned(final Client client, final NpcDespawned npcDespawned) + { + for (final IStateTracker i : toTrack) + { + i.onNpcDespawned(client, npcDespawned); + } + for (final IStateTracker i : toApply) + { + i.onNpcDespawned(client, npcDespawned); + } + } +} diff --git a/src/main/java/com/example/essentialsplus/helpers/combat/variablespeed/state/IStateTracker.java b/src/main/java/com/example/essentialsplus/helpers/combat/variablespeed/state/IStateTracker.java new file mode 100644 index 0000000..8c16f06 --- /dev/null +++ b/src/main/java/com/example/essentialsplus/helpers/combat/variablespeed/state/IStateTracker.java @@ -0,0 +1,26 @@ +package com.example.essentialsplus.helpers.combat.variablespeed.state; + +import net.runelite.api.Client; +import net.runelite.api.events.ChatMessage; +import net.runelite.api.events.GameTick; +import net.runelite.api.events.NpcDespawned; +import net.runelite.api.events.NpcSpawned; + +public interface IStateTracker +{ + default void onGameTick(final Client client, final GameTick tick) + { + } + + default void onChatMessage(final Client client, final ChatMessage event) + { + } + + default void onNpcSpawned(final Client client, final NpcSpawned npcSpawned) + { + } + + default void onNpcDespawned(final Client client, final NpcDespawned npcDespawned) + { + } +} diff --git a/src/main/java/com/example/essentialsplus/helpers/combat/variablespeed/state/MarkOfDarkness.java b/src/main/java/com/example/essentialsplus/helpers/combat/variablespeed/state/MarkOfDarkness.java new file mode 100644 index 0000000..20ced21 --- /dev/null +++ b/src/main/java/com/example/essentialsplus/helpers/combat/variablespeed/state/MarkOfDarkness.java @@ -0,0 +1,47 @@ +package com.example.essentialsplus.helpers.combat.variablespeed.state; + +import com.example.essentialsplus.helpers.combat.CombatUtils; +import java.time.Duration; +import java.time.Instant; +import net.runelite.api.Client; +import net.runelite.api.Skill; +import net.runelite.api.events.ChatMessage; +import net.runelite.api.gameval.ItemID; +import net.runelite.client.util.RSTimeUnit; + +public class MarkOfDarkness implements IStateTracker +{ + private static final String MARK_OF_DARKNESS_MESSAGE = "You have placed a Mark of Darkness upon yourself."; + + private Instant modStartTime = Instant.now(); + private Instant modEndTime = Instant.now(); + + @Override + public void onChatMessage(Client client, ChatMessage event) + { + final String message = event.getMessage(); + if (message != null && message.endsWith(MARK_OF_DARKNESS_MESSAGE)) + { + final Duration duration = getMarkOfDarknessDuration(client); + this.modStartTime = Instant.now(); + this.modEndTime = modStartTime.plus(duration); + } + } + + public boolean isActive() + { + final Instant n = Instant.now(); + return this.modStartTime.isBefore(n) && this.modEndTime.isAfter(n); + } + + private static Duration getMarkOfDarknessDuration(Client client) + { + final int magicLevel = client.getRealSkillLevel(Skill.MAGIC); + final Duration markOfDarknessDuration = Duration.of((long) magicLevel * 3, RSTimeUnit.GAME_TICKS); + if (CombatUtils.getWeaponId(client) == ItemID.PURGING_STAFF) + { + return markOfDarknessDuration.multipliedBy(5); + } + return markOfDarknessDuration; + } +} diff --git a/src/main/java/com/example/essentialsplus/helpers/combat/variablespeed/state/TickCount.java b/src/main/java/com/example/essentialsplus/helpers/combat/variablespeed/state/TickCount.java new file mode 100644 index 0000000..707d021 --- /dev/null +++ b/src/main/java/com/example/essentialsplus/helpers/combat/variablespeed/state/TickCount.java @@ -0,0 +1,30 @@ +package com.example.essentialsplus.helpers.combat.variablespeed.state; + +import net.runelite.api.Client; +import net.runelite.api.events.GameTick; + +public class TickCount implements IStateTracker +{ + private int tickCount = 0; + + public int get() + { + return tickCount; + } + + @Override + public void onGameTick(Client client, GameTick tick) + { + tickCount++; + } + + public boolean isWithinNTicks(int toCheckAgainst, int n) + { + return this.tickCount <= toCheckAgainst + n && this.tickCount >= toCheckAgainst; + } + + public void reset() + { + tickCount = 0; + } +} diff --git a/src/main/java/com/example/essentialsplus/helpers/combat/variablespeed/state/Yama.java b/src/main/java/com/example/essentialsplus/helpers/combat/variablespeed/state/Yama.java new file mode 100644 index 0000000..95649b1 --- /dev/null +++ b/src/main/java/com/example/essentialsplus/helpers/combat/variablespeed/state/Yama.java @@ -0,0 +1,177 @@ +package com.example.essentialsplus.helpers.combat.variablespeed.state; + +import com.example.essentialsplus.helpers.combat.CombatUtils; +import java.util.HashMap; +import java.util.Map; +import lombok.NonNull; +import net.runelite.api.Client; +import net.runelite.api.NPC; +import net.runelite.api.events.ChatMessage; +import net.runelite.api.events.GameTick; +import net.runelite.api.events.NpcSpawned; +import net.runelite.api.gameval.NpcID; + +public class Yama implements IStateTracker +{ + private static final int HP_FUDGE = 2; + private static final int VOID_FLARE_HP_P1_P2 = 140 - HP_FUDGE; + private static final int VOID_FLARE_HP_P3 = 71 - HP_FUDGE; + private static final int YAMA_REGION_ID = 6045; + private static final int YAMA_PHASE_TRANSITION_ANIMATION_ID = 12147; + + public YamaData yama; + public boolean inYamaRegion; + public NPC lastTarget; + + @Override + public void onGameTick(Client client, GameTick tick) + { + inYamaRegion = CombatUtils.isInRegionId(client, YAMA_REGION_ID); + if (!inYamaRegion) + { + yama = null; + return; + } + if (yama != null) + { + yama.determineYamaPhase(); + } + } + + @Override + public void onChatMessage(Client client, ChatMessage event) + { + if (!inYamaRegion || yama == null) + { + return; + } + if (event.getMessage().startsWith("Your Yama success count is:")) + { + yama.killed(); + } + } + + @Override + public void onNpcSpawned(final Client client, final NpcSpawned npcSpawned) + { + if (!inYamaRegion || yama != null) + { + return; + } + final NPC npc = npcSpawned.getNpc(); + if (npc.getId() == NpcID.YAMA) + { + yama = new YamaData(npc); + } + } + + public static NPC isEitherVoidFlare(NPC a, NPC b) + { + if (a != null && a.getId() == NpcID.YAMA_VOIDFLARE) + { + return a; + } + if (b != null && b.getId() == NpcID.YAMA_VOIDFLARE) + { + return b; + } + return null; + } + + public void dealVoidFlareDamage(NPC target, int damageDealt) + { + if (yama == null) + { + return; + } + this.yama.dealVoidFlareDamage(target, damageDealt); + } + + public boolean getVoidFlareDead(NPC target) + { + if (yama == null) + { + return false; + } + return this.yama.getVoidFlareDead(target); + } + + public YamaPhase phase() + { + if (yama == null) + { + return YamaPhase.P1; + } + return this.yama.phase; + } + + public static class YamaData + { + @NonNull + private final NPC yama; + private YamaPhase phase; + private int phaseChangeCooldown; + private final Map voidFlares; + + YamaData(@NonNull NPC yama) + { + this.yama = yama; + this.phase = YamaPhase.P1; + this.phaseChangeCooldown = 0; + this.voidFlares = new HashMap<>(); + } + + public boolean getVoidFlareDead(NPC target) + { + if (!this.voidFlares.containsKey(target)) + { + return false; + } + return this.voidFlares.get(target) <= 0; + } + + public void dealVoidFlareDamage(NPC target, int damageDealt) + { + if (this.voidFlares.containsKey(target)) + { + this.voidFlares.put(target, this.voidFlares.get(target) - damageDealt); + } + else + { + final int hp = this.getVoidFlareHp() - damageDealt; + this.voidFlares.put(target, hp); + } + } + + private void determineYamaPhase() + { + if (phaseChangeCooldown == 0 && this.yama.getAnimation() == YAMA_PHASE_TRANSITION_ANIMATION_ID) + { + this.phaseChangeCooldown = 20; + this.phase = this.phase.nextPhase(); + } + if (this.phaseChangeCooldown > 0) + { + this.phaseChangeCooldown--; + } + } + + private int getVoidFlareHp() + { + switch (this.phase) + { + case P1: + case P2: + return VOID_FLARE_HP_P1_P2; + default: + return VOID_FLARE_HP_P3; + } + } + + private void killed() + { + this.phase = YamaPhase.P1; + this.voidFlares.clear(); + } + } +} diff --git a/src/main/java/com/example/essentialsplus/helpers/combat/variablespeed/state/YamaPhase.java b/src/main/java/com/example/essentialsplus/helpers/combat/variablespeed/state/YamaPhase.java new file mode 100644 index 0000000..8f75e2f --- /dev/null +++ b/src/main/java/com/example/essentialsplus/helpers/combat/variablespeed/state/YamaPhase.java @@ -0,0 +1,20 @@ +package com.example.essentialsplus.helpers.combat.variablespeed.state; + +public enum YamaPhase +{ + P1, P2, P3; + + public YamaPhase nextPhase() + { + switch (this) + { + case P1: + return P2; + case P2: + return P3; + case P3: + return P3; + } + return P1; + } +} diff --git a/src/main/java/com/example/essentialsplus/loot/LootEvent.java b/src/main/java/com/example/essentialsplus/loot/LootEvent.java new file mode 100644 index 0000000..04f5dab --- /dev/null +++ b/src/main/java/com/example/essentialsplus/loot/LootEvent.java @@ -0,0 +1,27 @@ +package com.example.essentialsplus.loot; + +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * Kills and drops of one source within a small time bucket. Recent events are kept per minute, + * older ones are merged into hourly and daily buckets (see {@link LootRecord#compactEvents}). + */ +public class LootEvent +{ + /** bucket start (epoch millis) */ + public long t; + /** kills in this bucket */ + public int k; + /** item id -> quantity */ + public Map i = new LinkedHashMap<>(); + + public LootEvent() + { + } + + public LootEvent(long t) + { + this.t = t; + } +} diff --git a/src/main/java/com/example/essentialsplus/loot/LootRecord.java b/src/main/java/com/example/essentialsplus/loot/LootRecord.java new file mode 100644 index 0000000..acc3c0b --- /dev/null +++ b/src/main/java/com/example/essentialsplus/loot/LootRecord.java @@ -0,0 +1,246 @@ +package com.example.essentialsplus.loot; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import lombok.Data; +import lombok.NoArgsConstructor; +import net.runelite.client.game.ItemManager; + +@Data +@NoArgsConstructor +public class LootRecord +{ + private String name; + private String type; // "NPC", "CHEST", "CLUE", "EVENT", "PLAYER" + private int kills; + private long firstSeen; + private long lastSeen; + private Map items = new LinkedHashMap<>(); + private com.example.essentialsplus.loot.combat.CombatStats combatStats; + /** Time-bucketed kills/drops, used for time range filtering. */ + private List events = new ArrayList<>(); + + private static final long MINUTE = 60_000L; + private static final long HOUR = 60 * MINUTE; + private static final long DAY = 24 * HOUR; + + public boolean hasCombatStats() + { + return combatStats != null && combatStats.getFightsCount() > 0; + } + + public synchronized void addCombatFight(com.example.essentialsplus.loot.combat.FightSession session) + { + addCombatFight(session, 0); + } + + public synchronized void addCombatFight(com.example.essentialsplus.loot.combat.FightSession session, long lootValue) + { + if (combatStats == null) + { + combatStats = new com.example.essentialsplus.loot.combat.CombatStats(); + } + combatStats.addFight(session, lootValue); + } + + public LootRecord(String name, String type) + { + this.name = name; + this.type = type != null ? type : "NPC"; + this.kills = 0; + this.firstSeen = System.currentTimeMillis(); + this.lastSeen = this.firstSeen; + } + + public synchronized void addDrop(int itemId, String itemName, int quantity, int gePrice) + { + this.lastSeen = System.currentTimeMillis(); + currentEvent(lastSeen).i.merge(itemId, (long) quantity, Long::sum); + TrackedLootItem item = items.computeIfAbsent(itemId, id -> new TrackedLootItem(id, itemName, 0, gePrice)); + item.addQuantity(quantity); + if (gePrice > 0) + { + item.setGePrice(gePrice); + } + if (itemName != null && !itemName.isEmpty()) + { + item.setName(itemName); + } + } + + public synchronized void addKills(int count) + { + this.kills += count; + this.lastSeen = System.currentTimeMillis(); + currentEvent(lastSeen).k += count; + } + + public synchronized long getTotalValue() + { + long total = 0; + for (TrackedLootItem item : items.values()) + { + total += item.getTotalPrice(); + } + return total; + } + + public synchronized long getTotalValue(ItemManager itemManager) + { + return getTotalValue(); + } + + public synchronized List getSortedItems() + { + List list = new ArrayList<>(items.values()); + list.sort((a, b) -> Long.compare(b.getTotalPrice(), a.getTotalPrice())); + return list; + } + + public synchronized List getSortedItems(ItemManager itemManager) + { + return getSortedItems(); + } + + public synchronized int getDistinctItemCount() + { + return items.size(); + } + + public synchronized long getTotalItemQuantity() + { + long sum = 0; + for (TrackedLootItem item : items.values()) + { + sum += item.getQuantity(); + } + return sum; + } + + public synchronized List getEvents() + { + if (events == null) + { + events = new ArrayList<>(); + } + return events; + } + + private LootEvent currentEvent(long now) + { + List list = getEvents(); + long bucket = now - now % MINUTE; + LootEvent last = list.isEmpty() ? null : list.get(list.size() - 1); + if (last == null || last.t != bucket) + { + last = new LootEvent(bucket); + list.add(last); + } + return last; + } + + /** Merges events older than 2 days into hours and older than 60 days into days. */ + public synchronized void compactEvents(long now) + { + List list = getEvents(); + if (list.size() < 2) + { + return; + } + List out = new ArrayList<>(list.size()); + for (LootEvent e : list) + { + long age = now - e.t; + long size = age > 60 * DAY ? DAY : age > 2 * DAY ? HOUR : MINUTE; + long bucket = e.t - e.t % size; + LootEvent last = out.isEmpty() ? null : out.get(out.size() - 1); + if (last != null && last.t == bucket) + { + last.k += e.k; + e.i.forEach((id, q) -> last.i.merge(id, q, Long::sum)); + } + else + { + LootEvent copy = new LootEvent(bucket); + copy.k = e.k; + copy.i.putAll(e.i); + out.add(copy); + } + } + events = out; + } + + /** The part of this record in the filter's range; for "Now" only the current streak of kills/drops. */ + public synchronized LootRecord view(com.example.essentialsplus.ui.TimeFilter filter) + { + if (!filter.isCurrent()) + { + return since(filter.start()); + } + List list = getEvents(); + long start = com.example.essentialsplus.ui.TimeFilter.streakStart(list.size(), i -> list.get(i).t, i -> true, System.currentTimeMillis()); + return since(start == Long.MAX_VALUE ? Long.MAX_VALUE / 2 : start); + } + + /** + * A view of this record limited to kills, drops and fights at or after {@code start}. + * Returns this record itself for {@code start <= 0} (all time). + */ + public synchronized LootRecord since(long start) + { + if (start <= 0) + { + return this; + } + LootRecord r = new LootRecord(); + r.name = name; + r.type = type; + r.firstSeen = 0; + r.lastSeen = 0; + for (LootEvent e : getEvents()) + { + if (e.t + MINUTE <= start) + { + continue; + } + r.kills += e.k; + if (r.firstSeen == 0) + { + r.firstSeen = e.t; + } + r.lastSeen = Math.max(r.lastSeen, e.t); + for (Map.Entry d : e.i.entrySet()) + { + TrackedLootItem base = items.get(d.getKey()); + String itemName = base != null ? base.getName() : "Item " + d.getKey(); + long price = base != null ? base.getGePrice() : 0; + r.items.computeIfAbsent(d.getKey(), id -> new TrackedLootItem(id, itemName, 0, price)).addQuantity(d.getValue()); + } + } + if (combatStats != null) + { + List fights = new ArrayList<>(); + for (com.example.essentialsplus.loot.combat.SingleFightRecord f : combatStats.getFightHistory()) + { + if (f.getTimestamp() >= start) + { + fights.add(f); + } + } + if (!fights.isEmpty()) + { + r.combatStats = com.example.essentialsplus.loot.combat.CombatStats.fromFights(fights); + r.lastSeen = Math.max(r.lastSeen, fights.get(fights.size() - 1).getTimestamp()); + } + } + return r; + } + + /** True if nothing was recorded (e.g. a filtered view outside the range). */ + public synchronized boolean isEmpty() + { + return kills <= 0 && items.isEmpty() && !hasCombatStats(); + } +} diff --git a/src/main/java/com/example/essentialsplus/loot/LootTrackerStore.java b/src/main/java/com/example/essentialsplus/loot/LootTrackerStore.java new file mode 100644 index 0000000..5c26154 --- /dev/null +++ b/src/main/java/com/example/essentialsplus/loot/LootTrackerStore.java @@ -0,0 +1,456 @@ +package com.example.essentialsplus.loot; + +import com.example.essentialsplus.WealthStore; +import com.google.gson.Gson; +import com.google.gson.reflect.TypeToken; +import java.io.File; +import java.io.Reader; +import java.io.Writer; +import java.lang.reflect.Type; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.StandardCopyOption; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; +import javax.inject.Inject; +import javax.inject.Singleton; +import javax.swing.SwingUtilities; +import lombok.extern.slf4j.Slf4j; +import net.runelite.api.ItemComposition; +import net.runelite.client.game.ItemManager; +import net.runelite.client.game.ItemStack; + +@Slf4j +@Singleton +public class LootTrackerStore +{ + private static final String FILE_NAME = "loot_tracker.json"; + private static final Type RECORD_MAP_TYPE = new TypeToken>() {}.getType(); + + private final Gson gson; + private final ScheduledExecutorService executor; + private final Map records = new ConcurrentHashMap<>(); + private final List listeners = new CopyOnWriteArrayList<>(); + + private final Map recentDropHashes = Collections.synchronizedMap(new LinkedHashMap() + { + @Override + protected boolean removeEldestEntry(Map.Entry eldest) + { + return size() > 128; + } + }); + + private ScheduledFuture pendingSaveTask; + + @Inject + public LootTrackerStore(Gson gson, ScheduledExecutorService executor) + { + this.gson = gson.newBuilder().setPrettyPrinting().disableHtmlEscaping().create(); + this.executor = executor; + } + + public void load() + { + File file = new File(WealthStore.DIR, FILE_NAME); + if (!file.exists()) + { + return; + } + + try (Reader reader = Files.newBufferedReader(file.toPath(), StandardCharsets.UTF_8)) + { + Map loaded = gson.fromJson(reader, RECORD_MAP_TYPE); + if (loaded != null) + { + records.clear(); + records.putAll(loaded); + + // Clean up orphaned Doom minions so they do not clutter the overview + LootRecord earth = records.remove("Volatile earth"); + LootRecord larva = records.remove("Demonic larva"); + if (earth != null || larva != null) + { + log.info("Cleaned orphaned Doom minions (Volatile earth / Demonic larva) from records"); + saveDebounced(); + } + + notifyListeners(); + } + } + catch (Exception e) + { + log.warn("Failed to load loot tracker records from {}", file, e); + } + } + + public synchronized void save() + { + try + { + Files.createDirectories(WealthStore.DIR.toPath()); + File target = new File(WealthStore.DIR, FILE_NAME); + File tmp = new File(WealthStore.DIR, FILE_NAME + ".tmp"); + + long now = System.currentTimeMillis(); + for (LootRecord record : records.values()) + { + record.compactEvents(now); + } + + try (Writer writer = Files.newBufferedWriter(tmp.toPath(), StandardCharsets.UTF_8)) + { + gson.toJson(records, RECORD_MAP_TYPE, writer); + } + + Files.move(tmp.toPath(), target.toPath(), StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); + } + catch (Exception e) + { + log.error("Failed to save loot tracker records", e); + } + } + + public synchronized void saveDebounced() + { + if (pendingSaveTask != null && !pendingSaveTask.isDone()) + { + return; + } + pendingSaveTask = executor.schedule(this::save, 3, TimeUnit.SECONDS); + } + + public boolean isDuplicate(int tick, String source, Collection items) + { + long hash = ((long) tick << 32) ^ ((long) source.hashCode() & 0xFFFFFFFFL); + if (items != null) + { + for (ItemStack item : items) + { + if (item != null) + { + hash = hash * 31 + ((long) item.getId() * 10007L + item.getQuantity()); + } + } + } + return recentDropHashes.put(hash, Boolean.TRUE) != null; + } + + public void addLoot(String source, String type, Collection items, int amount, ItemManager itemManager, int currentTick) + { + if (source == null || source.trim().isEmpty() || items == null || items.isEmpty()) + { + return; + } + + if (isDuplicate(currentTick, source, items)) + { + return; + } + + String cleanSource = com.example.essentialsplus.loot.combat.CombatTracker.getCanonicalEncounterName(source.trim()); + LootRecord record = records.computeIfAbsent(cleanSource, s -> new LootRecord(s, type)); + // amount < 0: loot without a kill (e.g. Doom reward claim; kills come from CombatTracker) + if (amount >= 0) + { + record.addKills(Math.max(1, amount)); + } + + for (ItemStack item : items) + { + if (item == null || item.getId() <= 0 || item.getQuantity() <= 0) + { + continue; + } + int id = item.getId(); + int qty = item.getQuantity(); + + String name = "Item " + id; + int gePrice = 0; + if (itemManager != null) + { + ItemComposition comp = itemManager.getItemComposition(id); + if (comp != null && comp.getName() != null) + { + name = comp.getName(); + } + gePrice = itemManager.getItemPrice(id); + } + + record.addDrop(id, name, qty, gePrice); + } + + saveDebounced(); + notifyListeners(); + } + + public void deleteRecord(String source) + { + if (source != null && records.remove(source) != null) + { + saveDebounced(); + notifyListeners(); + } + } + + public void clearAll() + { + records.clear(); + save(); + notifyListeners(); + } + + public Collection getRecords() + { + return records.values(); + } + + public LootRecord getRecord(String source) + { + if (source == null) + { + return null; + } + String canonical = com.example.essentialsplus.loot.combat.CombatTracker.getCanonicalEncounterName(source.trim()); + LootRecord r = records.get(canonical); + if (r != null) + { + return r; + } + for (Map.Entry entry : records.entrySet()) + { + if (entry.getKey().equalsIgnoreCase(canonical)) + { + return entry.getValue(); + } + } + return null; + } + + public LootRecord getOrCreateRecord(String source, String type) + { + if (source == null || source.trim().isEmpty()) + { + return null; + } + String clean = com.example.essentialsplus.loot.combat.CombatTracker.getCanonicalEncounterName(source.trim()); + LootRecord existing = getRecord(clean); + if (existing != null) + { + return existing; + } + LootRecord r = new LootRecord(clean, type != null ? type : "NPC"); + records.put(clean, r); + return r; + } + + /** Records limited to {@code start} (0 = all time); sources without activity in the range are left out. */ + public List getRecords(com.example.essentialsplus.ui.TimeFilter filter) + { + if (!filter.isCurrent()) + { + return getRecords(filter.start()); + } + List out = new ArrayList<>(); + for (LootRecord record : records.values()) + { + LootRecord view = record.view(filter); + if (!view.isEmpty()) + { + out.add(view); + } + } + return out; + } + + public List getRecords(long start) + { + List out = new ArrayList<>(); + for (LootRecord record : records.values()) + { + LootRecord view = record.since(start); + if (start <= 0 || !view.isEmpty()) + { + out.add(view); + } + } + return out; + } + + /** Totals over a set of (possibly filtered) records. */ + public static long totalValue(Collection list) + { + long total = 0; + for (LootRecord record : list) + { + total += record.getTotalValue(); + } + return total; + } + + public static int totalKills(Collection list) + { + int sum = 0; + for (LootRecord record : list) + { + sum += record.getKills(); + } + return sum; + } + + public static long totalSuppliesCost(Collection list) + { + long sum = 0; + for (LootRecord record : list) + { + if (record.hasCombatStats()) + { + sum += record.getCombatStats().getTotalSuppliesCost(); + } + } + return sum; + } + + public static double overallEfficiency(Collection list) + { + long totalDuration = 0; + long totalIdle = 0; + for (LootRecord record : list) + { + if (record.hasCombatStats()) + { + totalDuration += record.getCombatStats().getTotalDurationTicks(); + totalIdle += record.getCombatStats().getTotalIdleTicks(); + } + } + if (totalDuration <= 0) + { + return 100.0; + } + return Math.max(0.0, Math.min(100.0, (1.0 - (double) totalIdle / totalDuration) * 100.0)); + } + + public long getTotalValue() + { + long total = 0; + for (LootRecord record : records.values()) + { + total += record.getTotalValue(); + } + return total; + } + + public long getTotalValue(ItemManager itemManager) + { + return getTotalValue(); + } + + public int getTotalKills() + { + int sum = 0; + for (LootRecord record : records.values()) + { + sum += record.getKills(); + } + return sum; + } + + public long getTotalSuppliesCost() + { + long sum = 0; + for (LootRecord record : records.values()) + { + if (record.hasCombatStats()) + { + sum += record.getCombatStats().getTotalSuppliesCost(); + } + } + return sum; + } + + public long getTotalNetProfit() + { + return getTotalValue() - getTotalSuppliesCost(); + } + + public double getOverallEfficiency() + { + long totalDuration = 0; + long totalIdle = 0; + for (LootRecord record : records.values()) + { + if (record.hasCombatStats()) + { + totalDuration += record.getCombatStats().getTotalDurationTicks(); + totalIdle += record.getCombatStats().getTotalIdleTicks(); + } + } + if (totalDuration <= 0) + { + return 100.0; + } + return Math.max(0.0, Math.min(100.0, (1.0 - (double) totalIdle / totalDuration) * 100.0)); + } + + public List getTopOverallItems(int limit) + { + Map merged = new LinkedHashMap<>(); + for (LootRecord r : records.values()) + { + for (TrackedLootItem item : r.getItems().values()) + { + TrackedLootItem existing = merged.computeIfAbsent(item.getId(), id -> new TrackedLootItem(id, item.getName(), 0, item.getGePrice())); + existing.addQuantity(item.getQuantity()); + } + } + List list = new ArrayList<>(merged.values()); + list.sort((a, b) -> Long.compare(b.getTotalPrice(), a.getTotalPrice())); + if (list.size() > limit) + { + list = new ArrayList<>(list.subList(0, limit)); + } + return list; + } + + public List getTopOverallItems(ItemManager itemManager, int limit) + { + return getTopOverallItems(limit); + } + + public void addChangeListener(Runnable r) + { + listeners.add(r); + } + + public void removeChangeListener(Runnable r) + { + listeners.remove(r); + } + + public void notifyListeners() + { + SwingUtilities.invokeLater(() -> + { + for (Runnable r : listeners) + { + try + { + r.run(); + } + catch (Exception e) + { + log.warn("Error running loot tracker listener", e); + } + } + }); + } +} diff --git a/src/main/java/com/example/essentialsplus/loot/TrackedLootItem.java b/src/main/java/com/example/essentialsplus/loot/TrackedLootItem.java new file mode 100644 index 0000000..bdc4c06 --- /dev/null +++ b/src/main/java/com/example/essentialsplus/loot/TrackedLootItem.java @@ -0,0 +1,32 @@ +package com.example.essentialsplus.loot; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import net.runelite.client.game.ItemManager; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class TrackedLootItem +{ + private int id; + private String name; + private long quantity; + private long gePrice; + + public void addQuantity(long qty) + { + this.quantity += qty; + } + + public long getTotalPrice() + { + return gePrice * quantity; + } + + public long getTotalPrice(ItemManager itemManager) + { + return getTotalPrice(); + } +} diff --git a/src/main/java/com/example/essentialsplus/loot/combat/CombatStats.java b/src/main/java/com/example/essentialsplus/loot/combat/CombatStats.java new file mode 100644 index 0000000..922430f --- /dev/null +++ b/src/main/java/com/example/essentialsplus/loot/combat/CombatStats.java @@ -0,0 +1,342 @@ +package com.example.essentialsplus.loot.combat; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +public class CombatStats +{ + // Cumulative totals across all kills of this enemy + private int fightsCount; + private long totalAttacksDealt; + private long totalDamageDealt; + private long totalHitsReceived; + private long totalDamageTaken; + private long totalDurationTicks; + private long totalIdleTicks; + private long totalTilesMoved; + private long totalHpHealed; + private long totalSuppliesCost; + private Map totalFoodUsed = new LinkedHashMap<>(); + private Map totalPotionsUsed = new LinkedHashMap<>(); + private Map totalAmmoUsed = new LinkedHashMap<>(); + + // Cumulative Prayer & Weapon Metrics + private long totalPrayerBlockedHits; + private long totalPrayerMissedHits; + private long totalOffPrayerDamageTaken; + private long totalOnPrayerAttacks; + private long totalOffPrayerAttacks; + private long totalCorrectWeaponAttacks; + private long totalIncorrectWeaponAttacks; + + // Delve metrics + private int maxDelve; + private int lastDelve; + + // Most recent fight snapshot + private int lastAttacksDealt; + private int lastDamageDealt; + private int lastHitsReceived; + private int lastDamageTaken; + private int lastDurationTicks; + private int lastIdleTicks; + private int lastTilesMoved; + private int lastHpHealed; + private long lastSuppliesCost; + private double lastEfficiency; + private double lastPerformanceScore; + private Map lastFoodUsed = new LinkedHashMap<>(); + private Map lastPotionsUsed = new LinkedHashMap<>(); + private Map lastAmmoUsed = new LinkedHashMap<>(); + + private int lastPrayerBlockedHits; + private int lastPrayerMissedHits; + private int lastOffPrayerDamageTaken; + private int lastOnPrayerAttacks; + private int lastOffPrayerAttacks; + private int lastCorrectWeaponAttacks; + private int lastIncorrectWeaponAttacks; + + /** Individual fights kept per enemy; also bounds how far back time range filters see fights. */ + private static final int MAX_HISTORY = 1000; + + // List of all individual fights recorded + private List fightHistory = new ArrayList<>(); + + public synchronized void addFight(FightSession session) + { + addFight(session, 0); + } + + public synchronized void addFight(FightSession session, long lootValue) + { + this.fightsCount++; + + this.totalAttacksDealt += session.getAttacksDealt(); + this.totalDamageDealt += session.getDamageDealt(); + this.totalHitsReceived += session.getHitsReceived(); + this.totalDamageTaken += session.getDamageTaken(); + this.totalDurationTicks += session.getDurationTicks(); + this.totalIdleTicks += session.getIdleTicks(); + this.totalTilesMoved += session.getTilesMoved(); + this.totalHpHealed += session.getHpHealed(); + this.totalSuppliesCost += session.getSuppliesCost(); + + session.getFoodUsed().forEach((k, v) -> this.totalFoodUsed.merge(k, v, Integer::sum)); + session.getPotionsUsed().forEach((k, v) -> this.totalPotionsUsed.merge(k, v, Integer::sum)); + session.getAmmoUsed().forEach((k, v) -> this.totalAmmoUsed.merge(k, v, Integer::sum)); + + // Prayer & Weapon totals + this.totalPrayerBlockedHits += session.getPrayerBlockedHits(); + this.totalPrayerMissedHits += session.getPrayerMissedHits(); + this.totalOffPrayerDamageTaken += session.getOffPrayerDamageTaken(); + this.totalOnPrayerAttacks += session.getOnPrayerAttacks(); + this.totalOffPrayerAttacks += session.getOffPrayerAttacks(); + this.totalCorrectWeaponAttacks += session.getCorrectWeaponAttacks(); + this.totalIncorrectWeaponAttacks += session.getIncorrectWeaponAttacks(); + + // Store last fight + this.lastAttacksDealt = session.getAttacksDealt(); + this.lastDamageDealt = session.getDamageDealt(); + this.lastHitsReceived = session.getHitsReceived(); + this.lastDamageTaken = session.getDamageTaken(); + this.lastDurationTicks = session.getDurationTicks(); + this.lastIdleTicks = session.getIdleTicks(); + this.lastTilesMoved = session.getTilesMoved(); + this.lastHpHealed = session.getHpHealed(); + this.lastSuppliesCost = session.getSuppliesCost(); + this.lastEfficiency = session.getEfficiencyPct(); + this.lastPerformanceScore = session.getPerformanceScore(); + this.lastFoodUsed = new LinkedHashMap<>(session.getFoodUsed()); + this.lastPotionsUsed = new LinkedHashMap<>(session.getPotionsUsed()); + this.lastAmmoUsed = new LinkedHashMap<>(session.getAmmoUsed()); + + this.lastPrayerBlockedHits = session.getPrayerBlockedHits(); + this.lastPrayerMissedHits = session.getPrayerMissedHits(); + this.lastOffPrayerDamageTaken = session.getOffPrayerDamageTaken(); + this.lastOnPrayerAttacks = session.getOnPrayerAttacks(); + this.lastOffPrayerAttacks = session.getOffPrayerAttacks(); + this.lastCorrectWeaponAttacks = session.getCorrectWeaponAttacks(); + this.lastIncorrectWeaponAttacks = session.getIncorrectWeaponAttacks(); + + int delve = session.getDelve(); + if (delve > this.maxDelve) + { + this.maxDelve = delve; + } + this.lastDelve = delve; + + // Create SingleFightRecord for history + SingleFightRecord single = new SingleFightRecord( + this.fightsCount, + System.currentTimeMillis(), + session.getDurationTicks(), + session.getIdleTicks(), + session.getEfficiencyPct(), + session.getGrade(), + session.getAttacksDealt(), + session.getDamageDealt(), + session.getHitsReceived(), + session.getDamageTaken(), + session.getHpHealed(), + session.getTilesMoved(), + session.getSuppliesCost(), + lootValue, + lootValue - session.getSuppliesCost(), + new LinkedHashMap<>(session.getFoodUsed()), + new LinkedHashMap<>(session.getPotionsUsed()), + new LinkedHashMap<>(session.getAmmoUsed()), + session.getPrayerBlockedHits(), + session.getPrayerMissedHits(), + session.getOffPrayerDamageTaken(), + session.getOnPrayerAttacks(), + session.getOffPrayerAttacks(), + session.getCorrectWeaponAttacks(), + session.getIncorrectWeaponAttacks(), + session.getPerformanceScore(), + delve + ); + + if (this.fightHistory == null) + { + this.fightHistory = new ArrayList<>(); + } + this.fightHistory.add(single); + if (this.fightHistory.size() > MAX_HISTORY) + { + this.fightHistory.remove(0); + } + } + + /** Rebuilds cumulative stats from a subset of fights (used for time range filtering). */ + public static CombatStats fromFights(List fights) + { + CombatStats cs = new CombatStats(); + for (SingleFightRecord f : fights) + { + cs.fightsCount++; + cs.totalAttacksDealt += f.getAttacksDealt(); + cs.totalDamageDealt += f.getDamageDealt(); + cs.totalHitsReceived += f.getHitsReceived(); + cs.totalDamageTaken += f.getDamageTaken(); + cs.totalDurationTicks += f.getDurationTicks(); + cs.totalIdleTicks += f.getIdleTicks(); + cs.totalTilesMoved += f.getTilesMoved(); + cs.totalHpHealed += f.getHpHealed(); + cs.totalSuppliesCost += f.getSuppliesCost(); + if (f.getFoodUsed() != null) + { + f.getFoodUsed().forEach((k, v) -> cs.totalFoodUsed.merge(k, v, Integer::sum)); + } + if (f.getPotionsUsed() != null) + { + f.getPotionsUsed().forEach((k, v) -> cs.totalPotionsUsed.merge(k, v, Integer::sum)); + } + if (f.getAmmoUsed() != null) + { + f.getAmmoUsed().forEach((k, v) -> cs.totalAmmoUsed.merge(k, v, Integer::sum)); + } + cs.totalPrayerBlockedHits += f.getPrayerBlockedHits(); + cs.totalPrayerMissedHits += f.getPrayerMissedHits(); + cs.totalOffPrayerDamageTaken += f.getOffPrayerDamageTaken(); + cs.totalOnPrayerAttacks += f.getOnPrayerAttacks(); + cs.totalOffPrayerAttacks += f.getOffPrayerAttacks(); + cs.totalCorrectWeaponAttacks += f.getCorrectWeaponAttacks(); + cs.totalIncorrectWeaponAttacks += f.getIncorrectWeaponAttacks(); + cs.maxDelve = Math.max(cs.maxDelve, f.getDelve()); + + cs.lastAttacksDealt = f.getAttacksDealt(); + cs.lastDamageDealt = f.getDamageDealt(); + cs.lastHitsReceived = f.getHitsReceived(); + cs.lastDamageTaken = f.getDamageTaken(); + cs.lastDurationTicks = f.getDurationTicks(); + cs.lastIdleTicks = f.getIdleTicks(); + cs.lastTilesMoved = f.getTilesMoved(); + cs.lastHpHealed = f.getHpHealed(); + cs.lastSuppliesCost = f.getSuppliesCost(); + cs.lastEfficiency = f.getEfficiency(); + cs.lastPerformanceScore = f.getPerformanceScore(); + cs.lastFoodUsed = f.getFoodUsed() == null ? new LinkedHashMap<>() : new LinkedHashMap<>(f.getFoodUsed()); + cs.lastPotionsUsed = f.getPotionsUsed() == null ? new LinkedHashMap<>() : new LinkedHashMap<>(f.getPotionsUsed()); + cs.lastAmmoUsed = f.getAmmoUsed() == null ? new LinkedHashMap<>() : new LinkedHashMap<>(f.getAmmoUsed()); + cs.lastPrayerBlockedHits = f.getPrayerBlockedHits(); + cs.lastPrayerMissedHits = f.getPrayerMissedHits(); + cs.lastOffPrayerDamageTaken = f.getOffPrayerDamageTaken(); + cs.lastOnPrayerAttacks = f.getOnPrayerAttacks(); + cs.lastOffPrayerAttacks = f.getOffPrayerAttacks(); + cs.lastCorrectWeaponAttacks = f.getCorrectWeaponAttacks(); + cs.lastIncorrectWeaponAttacks = f.getIncorrectWeaponAttacks(); + cs.lastDelve = f.getDelve(); + } + cs.fightHistory = new ArrayList<>(fights); + return cs; + } + + public List getFightHistory() + { + if (fightHistory == null) + { + fightHistory = new ArrayList<>(); + } + return fightHistory; + } + + public double getAvgEfficiency() + { + if (totalDurationTicks <= 0) + { + return 100.0; + } + double idleRatio = (double) totalIdleTicks / totalDurationTicks; + return Math.max(0.0, Math.min(100.0, (1.0 - idleRatio) * 100.0)); + } + + public double getAvgPrayerDefensePct() + { + long total = totalPrayerBlockedHits + totalPrayerMissedHits; + if (total <= 0) + { + return 100.0; + } + return ((double) totalPrayerBlockedHits / total) * 100.0; + } + + public double getAvgOffPrayerAttackPct() + { + long total = totalOnPrayerAttacks + totalOffPrayerAttacks; + if (total <= 0) + { + return 100.0; + } + return ((double) totalOffPrayerAttacks / total) * 100.0; + } + + public double getAvgCorrectWeaponPct() + { + long total = totalCorrectWeaponAttacks + totalIncorrectWeaponAttacks; + if (total <= 0) + { + return 100.0; + } + return ((double) totalCorrectWeaponAttacks / total) * 100.0; + } + + public double getAvgPerformanceScore() + { + double uptime = getAvgEfficiency(); + long totalIncoming = totalPrayerBlockedHits + totalPrayerMissedHits; + boolean defenseTested = (totalIncoming > 0); + double defScore = getAvgPrayerDefensePct(); + + long totalOverheadAttacks = totalOnPrayerAttacks + totalOffPrayerAttacks; + boolean overheadTested = (totalOverheadAttacks > 0 && totalOnPrayerAttacks > 0); + double offScore = getAvgOffPrayerAttackPct(); + + long totalWep = totalCorrectWeaponAttacks + totalIncorrectWeaponAttacks; + boolean wepTested = (totalWep > 0 && totalIncorrectWeaponAttacks > 0); + double wepScore = getAvgCorrectWeaponPct(); + + if (defenseTested && (overheadTested || wepTested)) + { + double offense = (offScore * 0.5) + (wepScore * 0.5); + return Math.max(0.0, Math.min(100.0, (0.40 * uptime) + (0.35 * defScore) + (0.25 * offense))); + } + else if (defenseTested) + { + return Math.max(0.0, Math.min(100.0, (0.60 * uptime) + (0.40 * defScore))); + } + else if (overheadTested || wepTested) + { + double offense = (offScore * 0.5) + (wepScore * 0.5); + return Math.max(0.0, Math.min(100.0, (0.60 * uptime) + (0.40 * offense))); + } + + return uptime; + } + + public String getLastGrade() + { + return getGrade(lastPerformanceScore > 0 ? lastPerformanceScore : lastEfficiency); + } + + public String getAvgGrade() + { + return getGrade(getAvgPerformanceScore()); + } + + public static String getGrade(double score) + { + if (score >= 95.0) return "S+"; + if (score >= 90.0) return "A+"; + if (score >= 80.0) return "A"; + if (score >= 70.0) return "B"; + if (score >= 55.0) return "C"; + return "D"; + } +} diff --git a/src/main/java/com/example/essentialsplus/loot/combat/CombatTracker.java b/src/main/java/com/example/essentialsplus/loot/combat/CombatTracker.java new file mode 100644 index 0000000..6373c60 --- /dev/null +++ b/src/main/java/com/example/essentialsplus/loot/combat/CombatTracker.java @@ -0,0 +1,1172 @@ +package com.example.essentialsplus.loot.combat; + +import com.example.essentialsplus.loot.LootRecord; +import com.example.essentialsplus.loot.LootTrackerStore; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import javax.inject.Inject; +import javax.inject.Singleton; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; +import net.runelite.api.Actor; +import net.runelite.api.Client; +import net.runelite.api.EquipmentInventorySlot; +import net.runelite.api.Hitsplat; +import net.runelite.api.Item; +import net.runelite.api.ItemComposition; +import net.runelite.api.ItemContainer; +import net.runelite.api.NPC; +import net.runelite.api.Player; +import net.runelite.api.Prayer; +import net.runelite.api.Projectile; +import net.runelite.api.coords.WorldPoint; +import net.runelite.api.events.ActorDeath; +import net.runelite.api.events.AnimationChanged; +import net.runelite.api.events.GameTick; +import net.runelite.api.events.HitsplatApplied; +import net.runelite.api.events.ItemContainerChanged; +import net.runelite.api.events.NpcDespawned; +import net.runelite.api.events.ProjectileMoved; +import net.runelite.api.gameval.InventoryID; +import net.runelite.client.game.ItemManager; +import net.runelite.client.game.ItemStats; +import net.runelite.client.util.Text; + +@Slf4j +@Singleton +public class CombatTracker +{ + private final Client client; + private final ItemManager itemManager; + private final LootTrackerStore lootStore; + + @Getter + private FightSession activeSession; + + // Pending session awaiting potential ground loot or timeout + private FightSession pendingDeathSession; + private int pendingDeathTick = -1; + + public static final String DOOM_OF_MOKHAIOTL = "Doom of Mokhaiotl"; + + private Map lastInventorySnapshot; + private Map lastEquipmentSnapshot; + private Map lastQuiverSnapshot; + private int lastPlayerAnimation = -1; + + // Projectile tracking for defensive prayer detection + private int lastIncomingProjectileTick = -1; + private boolean lastIncomingProjectileIsMagic = false; + + public enum AttackCategory + { + MELEE_STAB, + MELEE_SLASH, + MELEE_CRUSH, + RANGED, + MAGIC, + UNKNOWN; + + public boolean isMelee() + { + return this == MELEE_STAB || this == MELEE_SLASH || this == MELEE_CRUSH; + } + } + + public enum OverheadProtection + { + NONE, + MELEE, + RANGED, + MAGIC, + RANGE_MAGE, + ALL + } + + @Inject + public CombatTracker(Client client, ItemManager itemManager, LootTrackerStore lootStore) + { + this.client = client; + this.itemManager = itemManager; + this.lootStore = lootStore; + } + + public static boolean isDoomEncounter(String name) + { + if (name == null) + { + return false; + } + String lower = name.toLowerCase(Locale.ENGLISH); + return lower.contains("mokhaiotl") + || lower.contains("demonic larva") + || lower.contains("volatile earth"); + } + + public static boolean isDoomMinion(String name) + { + if (name == null) + { + return false; + } + String lower = name.toLowerCase(Locale.ENGLISH); + return lower.contains("demonic larva") || lower.contains("volatile earth"); + } + + public static String getCanonicalEncounterName(String npcName) + { + if (npcName == null) + { + return null; + } + if (isDoomEncounter(npcName)) + { + return DOOM_OF_MOKHAIOTL; + } + return npcName.trim(); + } + + public void onHitsplatApplied(HitsplatApplied event) + { + Actor actor = event.getActor(); + Hitsplat hitsplat = event.getHitsplat(); + if (actor == null || hitsplat == null) + { + return; + } + + Player localPlayer = client.getLocalPlayer(); + if (localPlayer == null) + { + return; + } + + // 1. Damage taken by local player -> Evaluate Defensive Prayer (Blocked vs Missed) + if (actor == localPlayer) + { + if (activeSession != null) + { + int amount = hitsplat.getAmount(); + activeSession.recordHitReceived(amount); + activeSession.setLastCombatTick(client.getTickCount()); + + evaluateIncomingHitPrayer(hitsplat, localPlayer); + } + return; + } + + // 2. Damage dealt to an NPC -> Track Attack & Offensive Accuracy + if (actor instanceof NPC && hitsplat.isMine()) + { + NPC npc = (NPC) actor; + String rawNpcName = Text.removeTags(npc.getName()); + if (rawNpcName == null || rawNpcName.isEmpty()) + { + return; + } + + String canonicalName = getCanonicalEncounterName(rawNpcName); + int tick = client.getTickCount(); + + if (activeSession != null && isDoomEncounter(canonicalName) && isDoomEncounter(activeSession.getTargetName())) + { + activeSession.setTargetIndex(npc.getIndex()); + activeSession.setLastCombatTick(tick); + } + else if (activeSession == null || activeSession.getTargetIndex() != npc.getIndex()) + { + activeSession = new FightSession(canonicalName, npc.getIndex(), tick, localPlayer.getWorldLocation(), getEquippedWeaponSpeed(), npc.getCombatLevel()); + } + + // Attacking confirms activity! Reset idle ticks immediately, even on 0 damage / shield / splash! + activeSession.resetTicksSinceLastAttack(); + + if (activeSession.getAttacksDealt() == 0 || (tick - activeSession.getLastAttackTick() >= activeSession.getWeaponSpeed())) + { + activeSession.recordAttack(getEquippedWeaponSpeed(), tick); + recordAttackStyleAndOverhead(npc); + } + + activeSession.recordDamageDealt(hitsplat.getAmount()); + activeSession.setLastCombatTick(tick); + } + } + + public void onAnimationChanged(AnimationChanged event) + { + Player localPlayer = client.getLocalPlayer(); + if (event.getActor() != localPlayer || localPlayer == null) + { + return; + } + + int anim = localPlayer.getAnimation(); + if (isAttackAnimation(anim)) + { + int tick = client.getTickCount(); + Actor interacting = localPlayer.getInteracting(); + String interactingName = (interacting instanceof NPC) ? Text.removeTags(interacting.getName()) : null; + String canonicalInteracting = getCanonicalEncounterName(interactingName); + + if (activeSession != null) + { + if (isDoomEncounter(canonicalInteracting) && isDoomEncounter(activeSession.getTargetName())) + { + if (interacting instanceof NPC) + { + activeSession.setTargetIndex(((NPC) interacting).getIndex()); + } + } + activeSession.resetTicksSinceLastAttack(); + activeSession.recordAttack(getEquippedWeaponSpeed(), tick); + activeSession.setLastCombatTick(tick); + + NPC targetNpc = findActiveNpc(); + if (targetNpc != null) + { + recordAttackStyleAndOverhead(targetNpc); + } + } + else if (interacting instanceof NPC) + { + NPC npc = (NPC) interacting; + activeSession = new FightSession(canonicalInteracting != null ? canonicalInteracting : interactingName, + npc.getIndex(), tick, localPlayer.getWorldLocation(), getEquippedWeaponSpeed(), npc.getCombatLevel()); + activeSession.recordAttack(getEquippedWeaponSpeed(), tick); + recordAttackStyleAndOverhead(npc); + } + } + lastPlayerAnimation = anim; + } + + public void onProjectileMoved(ProjectileMoved event) + { + Projectile p = event.getProjectile(); + if (p == null) + { + return; + } + + Player local = client.getLocalPlayer(); + if (local != null && p.getInteracting() == local) + { + lastIncomingProjectileTick = client.getTickCount(); + lastIncomingProjectileIsMagic = isMagicProjectileId(p.getId()); + } + } + + public void onActorDeath(ActorDeath event) + { + Actor actor = event.getActor(); + if (actor instanceof NPC) + { + handleNpcDeath((NPC) actor); + } + } + + public void onNpcDespawned(NpcDespawned event) + { + NPC npc = event.getNpc(); + if (npc != null && npc.isDead()) + { + handleNpcDeath(npc); + } + } + + public void handleNpcDeath(NPC npc) + { + if (activeSession == null || npc == null) + { + return; + } + + String deadNpcName = Text.removeTags(npc.getName()); + if (deadNpcName == null) + { + return; + } + + // Minions/adds in Doom (Demonic larva, Volatile earth) dying do not end the Doom fight session! + if (isDoomMinion(deadNpcName)) + { + log.debug("Doom minion {} died, continuing main fight session", deadNpcName); + return; + } + + String target = activeSession.getTargetName(); + boolean match = (npc.getIndex() == activeSession.getTargetIndex()); + if (!match && target != null) + { + String dLower = deadNpcName.toLowerCase(Locale.ENGLISH); + String tLower = target.toLowerCase(Locale.ENGLISH); + match = dLower.equals(tLower) || dLower.contains(tLower) || tLower.contains(dLower) + || (isDoomEncounter(tLower) && isDoomEncounter(dLower)); + } + + if (!match) + { + return; + } + + FightSession completed = activeSession; + activeSession = null; + + // If monster never drops ground loot (e.g. Barrows Brothers, Jad, Zuk), finalize immediately! + if (isZeroGroundLootMonster(target)) + { + finalizeSession(completed, 0, true); + } + else + { + // Otherwise store as pending death session. If loot drops within 4 ticks, onLootReceivedForEnemy will finalize with loot value. + // If no loot drops after 4 ticks, onGameTick will finalize with 0 loot value. + pendingDeathSession = completed; + pendingDeathTick = client.getTickCount(); + } + } + + public void onGameTick(GameTick event) + { + Player localPlayer = client.getLocalPlayer(); + if (localPlayer == null) + { + return; + } + + int currentTick = client.getTickCount(); + + // Finalize pending death session if 4 ticks elapsed without loot (monster dropped nothing) + if (pendingDeathSession != null) + { + if (currentTick - pendingDeathTick > 4) + { + finalizeSession(pendingDeathSession, 0, true); + pendingDeathSession = null; + } + } + + if (activeSession != null) + { + // Check if combat timed out (35 ticks = 21s of no combat activity) + if (currentTick - activeSession.getLastCombatTick() > 35) + { + activeSession = null; + return; + } + + // If localPlayer is actively interacting and in attack animation with target or encounter target, reset idle timer + Actor interacting = localPlayer.getInteracting(); + int anim = localPlayer.getAnimation(); + if (isAttackAnimation(anim) && isAttackingEncounterTarget(interacting)) + { + activeSession.resetTicksSinceLastAttack(); + } + + activeSession.onGameTick(localPlayer.getWorldLocation()); + } + + // Update inventory & equipment snapshot + ItemContainer inv = client.getItemContainer(InventoryID.INV); + if (inv != null) + { + lastInventorySnapshot = copyContainer(inv); + } + ItemContainer equip = client.getItemContainer(InventoryID.WORN); + if (equip != null) + { + lastEquipmentSnapshot = copyContainer(equip); + } + ItemContainer quiver = client.getItemContainer(InventoryID.DIZANAS_QUIVER_AMMO); + if (quiver != null) + { + lastQuiverSnapshot = copyContainer(quiver); + } + } + + private boolean isAttackingEncounterTarget(Actor interacting) + { + if (activeSession == null || !(interacting instanceof NPC)) + { + return false; + } + NPC npc = (NPC) interacting; + if (npc.getIndex() == activeSession.getTargetIndex()) + { + return true; + } + String target = activeSession.getTargetName(); + String npcName = Text.removeTags(npc.getName()); + if (isDoomEncounter(target) && isDoomEncounter(npcName)) + { + return true; + } + return false; + } + + public void onItemContainerChanged(ItemContainerChanged event) + { + if (activeSession == null && pendingDeathSession == null) + { + return; + } + + int cid = event.getContainerId(); + if (cid != InventoryID.INV && cid != InventoryID.WORN && cid != InventoryID.DIZANAS_QUIVER_AMMO) + { + return; + } + + ItemContainer currentContainer = event.getItemContainer(); + if (currentContainer == null) + { + return; + } + + Map snapshot; + if (cid == InventoryID.INV) + { + snapshot = lastInventorySnapshot; + } + else if (cid == InventoryID.WORN) + { + snapshot = lastEquipmentSnapshot; + } + else + { + snapshot = lastQuiverSnapshot; + } + + if (snapshot == null) + { + if (cid == InventoryID.INV) + { + lastInventorySnapshot = copyContainer(currentContainer); + } + else if (cid == InventoryID.WORN) + { + lastEquipmentSnapshot = copyContainer(currentContainer); + } + else + { + lastQuiverSnapshot = copyContainer(currentContainer); + } + return; + } + + Map currentMap = copyContainer(currentContainer); + for (Map.Entry entry : snapshot.entrySet()) + { + int id = entry.getKey(); + int prevQty = entry.getValue(); + int currentQty = currentMap.getOrDefault(id, 0); + + if (prevQty > currentQty) + { + int consumed = prevQty - currentQty; + categorizeAndRecordSupply(id, consumed); + } + } + + if (cid == InventoryID.INV) + { + lastInventorySnapshot = currentMap; + } + else if (cid == InventoryID.WORN) + { + lastEquipmentSnapshot = currentMap; + } + else + { + lastQuiverSnapshot = currentMap; + } + } + + private void recordAttackStyleAndOverhead(NPC npc) + { + if (activeSession == null || npc == null) + { + return; + } + + AttackCategory playerStyle = getPlayerAttackCategory(); + OverheadProtection npcProt = getNpcOverheadProtection(npc); + + boolean isOnPrayer = isAttackOnPrayer(playerStyle, npcProt); + boolean isCorrectWep = isWeaponCorrect(npc.getName(), playerStyle, isOnPrayer); + + activeSession.recordAttackAgainstEnemy(isOnPrayer, isCorrectWep); + } + + private void evaluateIncomingHitPrayer(Hitsplat hitsplat, Player localPlayer) + { + if (activeSession == null || localPlayer == null) + { + return; + } + + boolean protMelee = client.isPrayerActive(Prayer.PROTECT_FROM_MELEE) || client.isPrayerActive(Prayer.RP_DAMPEN_MELEE); + boolean protRange = client.isPrayerActive(Prayer.PROTECT_FROM_MISSILES) || client.isPrayerActive(Prayer.RP_DAMPEN_RANGED); + boolean protMage = client.isPrayerActive(Prayer.PROTECT_FROM_MAGIC) || client.isPrayerActive(Prayer.RP_DAMPEN_MAGIC); + boolean anyProt = protMelee || protRange || protMage; + + int amount = hitsplat.getAmount(); + boolean isShieldBlock = (amount == 0); + + NPC npc = findActiveNpc(); + String name = activeSession.getTargetName().toLowerCase(Locale.ENGLISH); + + int currentTick = client.getTickCount(); + boolean recentProjectile = (currentTick - lastIncomingProjectileTick <= 3); + + boolean isMeleeAttack = false; + boolean isRangedAttack = false; + boolean isMagicAttack = false; + + if (recentProjectile) + { + if (lastIncomingProjectileIsMagic) + { + isMagicAttack = true; + } + else + { + isRangedAttack = true; + } + } + else if (npc != null && localPlayer.getWorldLocation() != null && npc.getWorldLocation() != null + && localPlayer.getWorldLocation().distanceTo(npc.getWorldLocation()) <= 2) + { + isMeleeAttack = true; + } + else if (name.contains("cow") || name.contains("brutus") || name.contains("horror") || name.contains("abyssal") || name.contains("gargoyle") + || name.contains("dharok") || name.contains("guthan") || name.contains("torag") || name.contains("verac")) + { + isMeleeAttack = true; + } + else if (name.contains("karil") || name.contains("ranger")) + { + isRangedAttack = true; + } + else if (name.contains("ahrim") || name.contains("kraken") || name.contains("wizard")) + { + isMagicAttack = true; + } + + boolean blocked; + if (isMeleeAttack) + { + blocked = protMelee; + } + else if (isRangedAttack) + { + blocked = protRange; + } + else if (isMagicAttack) + { + blocked = protMage; + } + else + { + blocked = anyProt && isShieldBlock; + } + + if (blocked) + { + activeSession.recordPrayerBlock(); + } + else if (amount > 0) + { + // a 0 off prayer is fine (maybe on purpose); only count hits that actually did damage + activeSession.recordPrayerMiss(amount); + } + } + + private AttackCategory getPlayerAttackCategory() + { + ItemContainer equip = client.getItemContainer(InventoryID.WORN); + if (equip == null) + { + return AttackCategory.MELEE_CRUSH; + } + + Item weapon = equip.getItem(EquipmentInventorySlot.WEAPON.getSlotIdx()); + if (weapon == null || weapon.getId() <= 0) + { + return AttackCategory.MELEE_CRUSH; + } + + ItemStats stats = itemManager.getItemStats(weapon.getId()); + ItemComposition comp = itemManager.getItemComposition(weapon.getId()); + String name = (comp != null && comp.getName() != null) ? comp.getName().toLowerCase(Locale.ENGLISH) : ""; + + if (stats != null && stats.getEquipment() != null) + { + var eq = stats.getEquipment(); + if (name.contains("bow") || name.contains("blowpipe") || name.contains("dart") + || name.contains("knife") || name.contains("chinchompa") || name.contains("ballista") + || name.contains("javelin") || eq.getArange() > 20) + { + return AttackCategory.RANGED; + } + if (name.contains("staff") || name.contains("wand") || name.contains("trident") + || name.contains("shadow") || name.contains("sanguinesti") || eq.getAmagic() > 20) + { + return AttackCategory.MAGIC; + } + + if (eq.getAstab() >= eq.getAslash() && eq.getAstab() >= eq.getAcrush()) + { + return AttackCategory.MELEE_STAB; + } + if (eq.getAslash() >= eq.getAcrush()) + { + return AttackCategory.MELEE_SLASH; + } + return AttackCategory.MELEE_CRUSH; + } + + if (name.contains("bow") || name.contains("dart") || name.contains("knife") || name.contains("blowpipe")) + { + return AttackCategory.RANGED; + } + if (name.contains("staff") || name.contains("wand")) + { + return AttackCategory.MAGIC; + } + return AttackCategory.MELEE_SLASH; + } + + private OverheadProtection getNpcOverheadProtection(NPC npc) + { + if (npc == null) + { + return OverheadProtection.NONE; + } + + short[] spriteIds = npc.getOverheadSpriteIds(); + if (spriteIds != null && spriteIds.length > 0) + { + short sprite = spriteIds[0]; + if (sprite == 0 || sprite == 12) return OverheadProtection.MELEE; + if (sprite == 1 || sprite == 13) return OverheadProtection.RANGED; + if (sprite == 2 || sprite == 14) return OverheadProtection.MAGIC; + if (sprite == 6) return OverheadProtection.RANGE_MAGE; + if (sprite == 7 || sprite == 8 || sprite == 9) return OverheadProtection.ALL; + } + + int id = npc.getId(); + if (id == 7144) return OverheadProtection.MELEE; + if (id == 7145) return OverheadProtection.RANGED; + if (id == 7146) return OverheadProtection.MAGIC; + + if (id == 13597) return OverheadProtection.MELEE; + if (id == 13598) return OverheadProtection.RANGED; + if (id == 13599) return OverheadProtection.MAGIC; + + if (id == 11790) return OverheadProtection.MELEE; + if (id == 11791) return OverheadProtection.RANGED; + if (id == 11792) return OverheadProtection.MAGIC; + + if (id == 9022 || id == 9036) return OverheadProtection.MAGIC; + if (id == 9023 || id == 9037) return OverheadProtection.RANGED; + if (id == 9024 || id == 9038) return OverheadProtection.MELEE; + + if (id == 1158 || id == 6500 || id == 6501) return OverheadProtection.RANGE_MAGE; + if (id == 1160 || id == 6502 || id == 6503) return OverheadProtection.MELEE; + + return OverheadProtection.NONE; + } + + private boolean isAttackOnPrayer(AttackCategory style, OverheadProtection protection) + { + if (protection == OverheadProtection.NONE) + { + return false; + } + if (protection == OverheadProtection.ALL) + { + return true; + } + if (style.isMelee() && (protection == OverheadProtection.MELEE)) + { + return true; + } + if (style == AttackCategory.RANGED && (protection == OverheadProtection.RANGED || protection == OverheadProtection.RANGE_MAGE)) + { + return true; + } + if (style == AttackCategory.MAGIC && (protection == OverheadProtection.MAGIC || protection == OverheadProtection.RANGE_MAGE)) + { + return true; + } + return false; + } + + private boolean isWeaponCorrect(String npcName, AttackCategory style, boolean isOnPrayer) + { + if (isOnPrayer) + { + return false; // Attacking into overhead prayer is never correct + } + + if (npcName == null) + { + return true; + } + String lower = npcName.toLowerCase(Locale.ENGLISH); + + if (lower.contains("brutus") || lower.contains("gargoyle")) + { + return style == AttackCategory.MELEE_CRUSH; + } + if (lower.contains("dagannoth rex")) + { + return style == AttackCategory.MAGIC; + } + if (lower.contains("dagannoth prime")) + { + return style == AttackCategory.RANGED; + } + if (lower.contains("dagannoth supreme")) + { + return style.isMelee(); + } + if (lower.contains("corporeal beast")) + { + return style == AttackCategory.MELEE_STAB; + } + if (lower.contains("whisperer")) + { + return style == AttackCategory.MAGIC; + } + if (lower.contains("vardorvis") || lower.contains("duke sucellus")) + { + return style == AttackCategory.MELEE_SLASH; + } + if (lower.contains("leviathan")) + { + return style == AttackCategory.RANGED || style == AttackCategory.MAGIC; + } + if (lower.contains("ahrim")) + { + return style == AttackCategory.RANGED || style.isMelee(); + } + if (lower.contains("dharok") || lower.contains("guthan") || lower.contains("torag") || lower.contains("verac") || lower.contains("karil")) + { + return style == AttackCategory.MAGIC; + } + + return true; + } + + public static boolean isZeroGroundLootMonster(String name) + { + if (name == null) + { + return false; + } + String lower = name.toLowerCase(Locale.ENGLISH); + + // Barrows brothers: never drop ground loot! + if (lower.contains("dharok") || lower.contains("ahrim") || lower.contains("karil") + || lower.contains("guthan") || lower.contains("torag") || lower.contains("verac")) + { + return true; + } + + // Fight Caves & Inferno wave monsters & bosses + if (lower.contains("tztok-jad") || lower.contains("tzkal-zuk") || lower.contains("yt-hurkot") + || lower.contains("jal-") || lower.contains("tz-kih") || lower.contains("tz-kek")) + { + return true; + } + + // Fortis Colosseum wave monsters & bosses + if (lower.contains("fremennik warband") || lower.contains("serpent shaman") + || lower.contains("jaguar warrior") || lower.contains("sol heredit") + || lower.contains("manticore") || lower.contains("minotaur")) + { + return true; + } + + // Quest bosses / special encounters with no ground drops + if (lower.contains("galvek") || lower.contains("seren") || lower.contains("glough") + || lower.contains("vanstrom klause")) + { + return true; + } + + return false; + } + + private void finalizeSession(FightSession session, long lootValue, boolean incrementKills) + { + if (session == null) + { + return; + } + + String enemyName = session.getTargetName().trim(); + LootRecord record = lootStore.getOrCreateRecord(enemyName, "NPC"); + if (record != null) + { + if (incrementKills) + { + record.addKills(1); + } + record.addCombatFight(session, lootValue); + lootStore.saveDebounced(); + lootStore.notifyListeners(); + } + } + + private NPC findActiveNpc() + { + if (activeSession == null) + { + return null; + } + Player localPlayer = client.getLocalPlayer(); + if (localPlayer != null && localPlayer.getInteracting() instanceof NPC) + { + NPC npc = (NPC) localPlayer.getInteracting(); + if (npc.getIndex() == activeSession.getTargetIndex()) + { + return npc; + } + } + for (NPC npc : client.getNpcs()) + { + if (npc != null && npc.getIndex() == activeSession.getTargetIndex()) + { + return npc; + } + } + return null; + } + + private static boolean isMagicProjectileId(int id) + { + // Common magic projectile IDs: spells, tridents, shadow, barrages, elemental + return (id >= 93 && id <= 131) || id == 1040 || id == 1042 || id == 1251 || id == 1252 + || id == 1539 || id == 2147 || id == 1707 || id == 1046 || (id >= 360 && id <= 382); + } + + private void categorizeAndRecordSupply(int itemId, int quantity) + { + FightSession session = activeSession != null ? activeSession : pendingDeathSession; + if (session == null || quantity <= 0) + { + return; + } + + ItemComposition comp = itemManager.getItemComposition(itemId); + if (comp == null || comp.getName() == null) + { + return; + } + + String name = comp.getName(); + String lower = name.toLowerCase(Locale.ENGLISH); + int price = Math.max(0, itemManager.getItemPrice(itemId)); + + // Check inventory actions for "Eat" or "Drink" + String[] actions = comp.getInventoryActions(); + boolean hasEat = false; + boolean hasDrink = false; + if (actions != null) + { + for (String act : actions) + { + if ("Eat".equalsIgnoreCase(act)) + { + hasEat = true; + } + if ("Drink".equalsIgnoreCase(act)) + { + hasDrink = true; + } + } + } + + // 1. Food + int heal = getFoodHeal(lower); + if (hasEat || heal > 0) + { + if (heal <= 0) heal = 16; + long totalCost = (long) price * quantity; + session.recordFood(name, quantity, heal * quantity, totalCost); + return; + } + + // 2. Potions + if (hasDrink || lower.contains("potion") || lower.contains("brew") || lower.contains("restore") + || lower.contains("combat") || lower.contains("ranging") || lower.contains("stamina") + || lower.contains("antidote") || lower.contains("anti-venom") || lower.contains("sanfew") + || lower.contains("super defence") || lower.contains("super attack") || lower.contains("super strength") + || lower.contains("menaphite") || lower.contains("remedy") || lower.contains("guthix rest") + || lower.contains("serum") || lower.contains("elixir") || lower.contains("mix")) + { + int doses = 1; + if (lower.contains("(4)")) doses = 4; + else if (lower.contains("(3)")) doses = 3; + else if (lower.contains("(2)")) doses = 2; + long costPerDose = (long) (price / doses) * quantity; + + String baseName = name.replaceAll("\\(\\d\\)", "").trim(); + session.recordPotion(baseName, quantity, costPerDose); + + if (lower.contains("brew")) + { + session.addHpHealed(16 * quantity); + } + return; + } + + // 3. Ammo / Runes / Charges + if (lower.contains("dart") || lower.contains("arrow") || lower.contains("bolt") + || lower.contains("rune") || lower.contains("scale") || lower.contains("chinchompa") + || lower.contains("knife") || lower.contains("thrownaxe") || lower.contains("javelin")) + { + long totalCost = (long) price * quantity; + session.recordAmmo(name, quantity, totalCost); + } + } + + public void setDoomDelve(int delve) + { + if (delve <= 0) + { + return; + } + + if (activeSession != null && isDoomEncounter(activeSession.getTargetName())) + { + activeSession.setDelve(delve); + } + if (pendingDeathSession != null && isDoomEncounter(pendingDeathSession.getTargetName())) + { + pendingDeathSession.setDelve(delve); + } + attachDelveToLastDoomRecord(delve, -1); + } + + public void onDoomDelveCompleted(int delve, long lootValue) + { + FightSession session = activeSession != null ? activeSession : pendingDeathSession; + if (session != null && isDoomEncounter(session.getTargetName())) + { + if (delve > 0) + { + session.setDelve(delve); + } + finalizeSession(session, Math.max(0, lootValue), true); + activeSession = null; + pendingDeathSession = null; + } + else + { + attachDelveToLastDoomRecord(delve, lootValue); + } + } + + private void attachDelveToLastDoomRecord(int delve, long lootValue) + { + LootRecord record = lootStore.getRecord(DOOM_OF_MOKHAIOTL); + if (record != null && record.hasCombatStats()) + { + List history = record.getCombatStats().getFightHistory(); + if (!history.isEmpty()) + { + SingleFightRecord last = history.get(history.size() - 1); + boolean changed = false; + if (delve > 0 && (last.getDelve() <= 0 || last.getDelve() != delve)) + { + last.setDelve(delve); + record.getCombatStats().setLastDelve(delve); + if (delve > record.getCombatStats().getMaxDelve()) + { + record.getCombatStats().setMaxDelve(delve); + } + changed = true; + } + if (lootValue > 0 && last.getLootValue() == 0 && (System.currentTimeMillis() - last.getTimestamp() < 180_000)) + { + last.setLootValue(lootValue); + last.setNetProfit(lootValue - last.getSuppliesCost()); + changed = true; + } + if (changed) + { + lootStore.saveDebounced(); + lootStore.notifyListeners(); + } + } + } + } + + public void onLootReceivedForEnemy(String enemyName) + { + onLootReceivedForEnemy(enemyName, 0, 0); + } + + public void onLootReceivedForEnemy(String enemyName, long lootValue) + { + onLootReceivedForEnemy(enemyName, lootValue, 0); + } + + public void onLootReceivedForEnemy(String enemyName, long lootValue, int delve) + { + if (enemyName == null) + { + return; + } + + String lootEnemy = enemyName.trim().toLowerCase(Locale.ENGLISH); + if (isDoomEncounter(lootEnemy)) + { + lootEnemy = DOOM_OF_MOKHAIOTL.toLowerCase(Locale.ENGLISH); + if (delve > 0) + { + setDoomDelve(delve); + } + } + + // 1. Check if pendingDeathSession matches this loot + if (pendingDeathSession != null) + { + String target = pendingDeathSession.getTargetName().trim().toLowerCase(Locale.ENGLISH); + if (target.equals(lootEnemy) || target.contains(lootEnemy) || lootEnemy.contains(target) + || (target.contains("doom") && lootEnemy.contains("doom")) + || (target.contains("mokhaiotl") && lootEnemy.contains("mokhaiotl"))) + { + if (delve > 0) + { + pendingDeathSession.setDelve(delve); + } + finalizeSession(pendingDeathSession, lootValue, false); + pendingDeathSession = null; + return; + } + } + + // 2. Check if activeSession matches this loot + if (activeSession != null) + { + String target = activeSession.getTargetName().trim().toLowerCase(Locale.ENGLISH); + if (target.equals(lootEnemy) || target.contains(lootEnemy) || lootEnemy.contains(target) + || (target.contains("doom") && lootEnemy.contains("doom")) + || (target.contains("mokhaiotl") && lootEnemy.contains("mokhaiotl"))) + { + if (delve > 0) + { + activeSession.setDelve(delve); + } + finalizeSession(activeSession, lootValue, false); + activeSession = null; + return; + } + } + + // 3. For delayed reward chests (e.g. Doom of Mokhaiotl): + // If a fight record was already finalized recently with 0 lootValue, attach the chest loot! + String canonical = isDoomEncounter(enemyName) ? DOOM_OF_MOKHAIOTL : enemyName.trim(); + LootRecord record = lootStore.getRecord(canonical); + if (record != null && record.hasCombatStats()) + { + List history = record.getCombatStats().getFightHistory(); + if (!history.isEmpty()) + { + SingleFightRecord last = history.get(history.size() - 1); + boolean changed = false; + if (delve > 0 && (last.getDelve() <= 0 || last.getDelve() != delve)) + { + last.setDelve(delve); + record.getCombatStats().setLastDelve(delve); + if (delve > record.getCombatStats().getMaxDelve()) + { + record.getCombatStats().setMaxDelve(delve); + } + changed = true; + } + if (lootValue > 0 && last.getLootValue() == 0 && (System.currentTimeMillis() - last.getTimestamp() < 180_000)) + { + last.setLootValue(lootValue); + last.setNetProfit(lootValue - last.getSuppliesCost()); + changed = true; + } + if (changed) + { + lootStore.saveDebounced(); + lootStore.notifyListeners(); + } + } + } + } + + private int getEquippedWeaponSpeed() + { + ItemContainer equip = client.getItemContainer(InventoryID.WORN); + if (equip != null) + { + Item weapon = equip.getItem(EquipmentInventorySlot.WEAPON.getSlotIdx()); + if (weapon != null && weapon.getId() > 0) + { + ItemStats stats = itemManager.getItemStats(weapon.getId()); + if (stats != null && stats.getEquipment() != null) + { + int speed = stats.getEquipment().getAspeed(); + if (speed > 0) + { + return speed; + } + } + } + } + return 4; + } + + private boolean isAttackAnimation(int anim) + { + if (anim <= 0 || anim == 808 || anim == 819 || anim == 824 || anim == 829 || anim == 830) + { + return false; + } + Player localPlayer = client.getLocalPlayer(); + return localPlayer != null && (localPlayer.getInteracting() instanceof NPC || activeSession != null); + } + + private static int getFoodHeal(String lowerName) + { + if (lowerName.contains("anglerfish")) return 22; + if (lowerName.contains("manta ray")) return 22; + if (lowerName.contains("tuna potato")) return 22; + if (lowerName.contains("dark crab")) return 22; + if (lowerName.contains("sea turtle")) return 21; + if (lowerName.contains("shark")) return 20; + if (lowerName.contains("karambwan")) return 18; + if (lowerName.contains("monkfish")) return 16; + if (lowerName.contains("swordfish")) return 14; + if (lowerName.contains("bass")) return 13; + if (lowerName.contains("lobster")) return 12; + if (lowerName.contains("meat") || lowerName.contains("beef") || lowerName.contains("chicken")) return 3; + if (lowerName.contains("bread")) return 5; + if (lowerName.contains("cake")) return 4; + return 0; + } + + private static Map copyContainer(ItemContainer container) + { + Map map = new HashMap<>(); + for (Item item : container.getItems()) + { + if (item != null && item.getId() > 0 && item.getQuantity() > 0) + { + map.merge(item.getId(), item.getQuantity(), Integer::sum); + } + } + return map; + } +} diff --git a/src/main/java/com/example/essentialsplus/loot/combat/FightSession.java b/src/main/java/com/example/essentialsplus/loot/combat/FightSession.java new file mode 100644 index 0000000..4b79349 --- /dev/null +++ b/src/main/java/com/example/essentialsplus/loot/combat/FightSession.java @@ -0,0 +1,279 @@ +package com.example.essentialsplus.loot.combat; + +import java.util.LinkedHashMap; +import java.util.Map; +import lombok.Getter; +import lombok.Setter; +import net.runelite.api.coords.WorldPoint; + +@Getter +public class FightSession +{ + private final String targetName; + @Setter + private int targetIndex; + private final int startTick; + private int targetCombatLevel; + + @Setter + private int lastCombatTick; + + @Setter + private int delve = 0; + + private int attacksDealt; + private int damageDealt; + private int hitsReceived; + private int damageTaken; + private int durationTicks; + private int idleTicks; + private int tilesMoved; + private int hpHealed; + private long suppliesCost; + + private int weaponSpeed = 4; + private int ticksSinceLastAttack = 0; + private int lastAttackTick = -1; + private WorldPoint lastLocation; + + private final Map foodUsed = new LinkedHashMap<>(); + private final Map potionsUsed = new LinkedHashMap<>(); + private final Map ammoUsed = new LinkedHashMap<>(); + + // Prayer & Weapon Performance metrics + private int prayerBlockedHits; + private int prayerMissedHits; + private int offPrayerDamageTaken; + private int onPrayerAttacks; + private int offPrayerAttacks; + private int correctWeaponAttacks; + private int incorrectWeaponAttacks; + + public FightSession(String targetName, int targetIndex, int startTick, WorldPoint initialLocation, int initialWeaponSpeed) + { + this(targetName, targetIndex, startTick, initialLocation, initialWeaponSpeed, 0); + } + + public FightSession(String targetName, int targetIndex, int startTick, WorldPoint initialLocation, int initialWeaponSpeed, int targetCombatLevel) + { + this.targetName = targetName; + this.targetIndex = targetIndex; + this.startTick = startTick; + this.lastCombatTick = startTick; + this.lastLocation = initialLocation; + this.weaponSpeed = Math.max(2, initialWeaponSpeed); + this.ticksSinceLastAttack = 0; + this.targetCombatLevel = targetCombatLevel; + } + + public void recordAttack(int attackSpeed, int currentTick) + { + if (currentTick == lastAttackTick && currentTick > 0) + { + return; // Avoid duplicate attack recording on the exact same tick + } + this.lastAttackTick = currentTick; + this.attacksDealt++; + this.weaponSpeed = Math.max(2, attackSpeed); + this.ticksSinceLastAttack = 0; + } + + public void recordAttackAgainstEnemy(boolean isOnPrayer, boolean isCorrectWeapon) + { + if (isOnPrayer) + { + this.onPrayerAttacks++; + } + else + { + this.offPrayerAttacks++; + } + + if (isCorrectWeapon) + { + this.correctWeaponAttacks++; + } + else + { + this.incorrectWeaponAttacks++; + } + } + + public void recordDamageDealt(int amount) + { + if (amount > 0) + { + this.damageDealt += amount; + } + } + + public void recordHitReceived(int amount) + { + this.hitsReceived++; + if (amount > 0) + { + this.damageTaken += amount; + } + } + + public void recordPrayerBlock() + { + this.prayerBlockedHits++; + } + + public void recordPrayerMiss(int damageAmount) + { + this.prayerMissedHits++; + if (damageAmount > 0) + { + this.offPrayerDamageTaken += damageAmount; + } + } + + public void resetTicksSinceLastAttack() + { + this.ticksSinceLastAttack = 0; + } + + public void recordFood(String foodName, int heal, long cost) + { + recordFood(foodName, 1, heal, cost); + } + + public void recordFood(String foodName, int quantity, int heal, long cost) + { + int q = Math.max(1, quantity); + this.foodUsed.merge(foodName, q, Integer::sum); + this.hpHealed += heal; + this.suppliesCost += cost; + } + + public void recordPotion(String potionName, long costPerDose) + { + recordPotion(potionName, 1, costPerDose); + } + + public void recordPotion(String potionName, int quantity, long costPerDose) + { + int q = Math.max(1, quantity); + this.potionsUsed.merge(potionName, q, Integer::sum); + this.suppliesCost += costPerDose; + } + + public void recordAmmo(String ammoName, int count, long totalCost) + { + this.ammoUsed.merge(ammoName, count, Integer::sum); + this.suppliesCost += totalCost; + } + + public void addHpHealed(int amount) + { + this.hpHealed += amount; + } + + public void onGameTick(WorldPoint currentLocation) + { + this.durationTicks++; + + if (lastLocation != null && currentLocation != null) + { + int dist = currentLocation.distanceTo(lastLocation); + if (dist > 0 && dist < 15) + { + this.tilesMoved += dist; + } + } + this.lastLocation = currentLocation; + + // Tick elapsed + this.ticksSinceLastAttack++; + + // If weapon takes 4 ticks, off cooldown starting at tick 5: + // When ticksSinceLastAttack > weaponSpeed (e.g. > 4), player was off cooldown and did not attack -> idle tick! + if (this.ticksSinceLastAttack > this.weaponSpeed) + { + this.idleTicks++; + } + } + + public double getEfficiencyPct() + { + if (durationTicks <= 0) + { + return 100.0; + } + double idleRatio = (double) idleTicks / durationTicks; + return Math.max(0.0, Math.min(100.0, (1.0 - idleRatio) * 100.0)); + } + + public double getPrayerDefensePct() + { + int total = prayerBlockedHits + prayerMissedHits; + if (total <= 0) + { + return 100.0; + } + return ((double) prayerBlockedHits / total) * 100.0; + } + + public double getOffPrayerAttackPct() + { + int total = onPrayerAttacks + offPrayerAttacks; + if (total <= 0) + { + return 100.0; + } + return ((double) offPrayerAttacks / total) * 100.0; + } + + public double getCorrectWeaponPct() + { + int total = correctWeaponAttacks + incorrectWeaponAttacks; + if (total <= 0) + { + return 100.0; + } + return ((double) correctWeaponAttacks / total) * 100.0; + } + + /** + * Calculates composite performance score taking uptime, defensive prayer, and offensive weapon/prayer into account. + */ + public double getPerformanceScore() + { + double uptime = getEfficiencyPct(); + int totalIncoming = prayerBlockedHits + prayerMissedHits; + boolean defenseTested = (totalIncoming > 0); + double defScore = getPrayerDefensePct(); + + int totalOverheadAttacks = onPrayerAttacks + offPrayerAttacks; + boolean overheadTested = (totalOverheadAttacks > 0 && onPrayerAttacks > 0); + double offScore = getOffPrayerAttackPct(); + + int totalWep = correctWeaponAttacks + incorrectWeaponAttacks; + boolean wepTested = (totalWep > 0 && incorrectWeaponAttacks > 0); + double wepScore = getCorrectWeaponPct(); + + if (defenseTested && (overheadTested || wepTested)) + { + double offense = (offScore * 0.5) + (wepScore * 0.5); + return Math.max(0.0, Math.min(100.0, (0.40 * uptime) + (0.35 * defScore) + (0.25 * offense))); + } + else if (defenseTested) + { + return Math.max(0.0, Math.min(100.0, (0.60 * uptime) + (0.40 * defScore))); + } + else if (overheadTested || wepTested) + { + double offense = (offScore * 0.5) + (wepScore * 0.5); + return Math.max(0.0, Math.min(100.0, (0.60 * uptime) + (0.40 * offense))); + } + + return uptime; + } + + public String getGrade() + { + return CombatStats.getGrade(getPerformanceScore()); + } +} diff --git a/src/main/java/com/example/essentialsplus/loot/combat/SingleFightRecord.java b/src/main/java/com/example/essentialsplus/loot/combat/SingleFightRecord.java new file mode 100644 index 0000000..195edda --- /dev/null +++ b/src/main/java/com/example/essentialsplus/loot/combat/SingleFightRecord.java @@ -0,0 +1,45 @@ +package com.example.essentialsplus.loot.combat; + +import java.util.LinkedHashMap; +import java.util.Map; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class SingleFightRecord +{ + private int fightNumber; + private long timestamp; + private int durationTicks; + private int idleTicks; + private double efficiency; + private String grade; + private int attacksDealt; + private int damageDealt; + private int hitsReceived; + private int damageTaken; + private int hpHealed; + private int tilesMoved; + private long suppliesCost; + private long lootValue; + private long netProfit; + private Map foodUsed = new LinkedHashMap<>(); + private Map potionsUsed = new LinkedHashMap<>(); + private Map ammoUsed = new LinkedHashMap<>(); + + // Prayer & Weapon Performance metrics + private int prayerBlockedHits; + private int prayerMissedHits; + private int offPrayerDamageTaken; + private int onPrayerAttacks; + private int offPrayerAttacks; + private int correctWeaponAttacks; + private int incorrectWeaponAttacks; + private double performanceScore; + + // Delve level (if Doom of Mokhaiotl or delve encounter) + private int delve; +} diff --git a/src/main/java/com/example/essentialsplus/loot/droptable/DropLine.java b/src/main/java/com/example/essentialsplus/loot/droptable/DropLine.java new file mode 100644 index 0000000..ef21da5 --- /dev/null +++ b/src/main/java/com/example/essentialsplus/loot/droptable/DropLine.java @@ -0,0 +1,61 @@ +package com.example.essentialsplus.loot.droptable; + +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** One row of a wiki drop table ("dropsline" bucket). */ +public class DropLine +{ + private static final Pattern FRACTION = Pattern.compile("([\\d.]+)\\s*/\\s*([\\d.]+)"); + + public final String source; + public final String item; + public final String rarityText; + /** chance per roll, NaN when the wiki gives no number (e.g. "Varies") */ + public final double chance; + public final int rolls; + public final int qtyLow; + public final int qtyHigh; + + public DropLine(String source, String item, String rarityText, int rolls, int qtyLow, int qtyHigh) + { + this.source = source == null ? "" : source; + this.item = item == null ? "" : item; + this.rarityText = rarityText == null ? "" : rarityText; + this.chance = parseRarity(this.rarityText); + this.rolls = Math.max(1, rolls); + this.qtyLow = Math.max(0, qtyLow); + this.qtyHigh = Math.max(this.qtyLow, qtyHigh); + } + + /** Expected number of times this line is hit per kill. */ + public double hitsPerKill() + { + return Double.isNaN(chance) ? Double.NaN : Math.min(chance * rolls, rolls); + } + + /** "Always" -> 1, "3/128" or "~1/1,024" -> fraction, anything else -> NaN. */ + static double parseRarity(String text) + { + String t = text.replace(",", "").trim(); + if (t.equalsIgnoreCase("always")) + { + return 1; + } + Matcher m = FRACTION.matcher(t); + if (!m.find()) + { + return Double.NaN; + } + try + { + double num = Double.parseDouble(m.group(1)); + double den = Double.parseDouble(m.group(2)); + return den > 0 ? Math.min(1, num / den) : Double.NaN; + } + catch (NumberFormatException e) + { + return Double.NaN; + } + } +} diff --git a/src/main/java/com/example/essentialsplus/loot/droptable/DropTable.java b/src/main/java/com/example/essentialsplus/loot/droptable/DropTable.java new file mode 100644 index 0000000..deedd56 --- /dev/null +++ b/src/main/java/com/example/essentialsplus/loot/droptable/DropTable.java @@ -0,0 +1,159 @@ +package com.example.essentialsplus.loot.droptable; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +/** The wiki drop table of one page, split into its variants (e.g. "Standard", "Wilderness Slayer Cave"). */ +public class DropTable +{ + public final String page; + public final List variants; + + public static final class Variant + { + public final String name; + public final List lines; + + Variant(String name, List lines) + { + this.name = name; + this.lines = lines; + } + + @Override + public String toString() + { + return name; + } + } + + /** Combined wiki rate of one item. */ + public static final class Rate + { + public final String item; + public final String text; + /** expected hits per kill (sum over all lines of the item), NaN if unknown */ + public final double perKill; + + Rate(String item, String text, double perKill) + { + this.item = item; + this.text = text; + this.perKill = perKill; + } + } + + public DropTable(String page, List lines) + { + this.page = page; + this.variants = buildVariants(page, lines); + } + + public boolean isEmpty() + { + return variants.isEmpty(); + } + + /** + * Groups lines by their "Dropped from" section. Small sections (a few extra drops such as Catacombs + * shards) are added on top of the main table; large ones are separate tables. + */ + static List buildVariants(String page, List lines) + { + Map> groups = new LinkedHashMap<>(); + for (DropLine l : lines) + { + groups.computeIfAbsent(l.source, k -> new ArrayList<>()).add(l); + } + List out = new ArrayList<>(); + if (groups.isEmpty()) + { + return out; + } + int max = groups.values().stream().mapToInt(List::size).max().orElse(0); + List mains = new ArrayList<>(); + List extras = new ArrayList<>(); + for (Map.Entry> g : groups.entrySet()) + { + boolean base = !g.getKey().contains("#"); + (base || g.getValue().size() * 3 >= max ? mains : extras).add(g.getKey()); + } + // a base section without '#' always comes first + mains.sort(Comparator.comparing(s -> s.contains("#"))); + if (mains.isEmpty()) + { + mains.add(extras.remove(0)); + } + for (String m : mains) + { + out.add(new Variant(label(page, m), groups.get(m))); + } + String first = mains.get(0); + for (String e : extras) + { + List combined = new ArrayList<>(groups.get(first)); + combined.addAll(groups.get(e)); + out.add(new Variant(label(page, first) + " + " + label(page, e), combined)); + } + return out; + } + + private static String label(String page, String source) + { + int hash = source.indexOf('#'); + if (hash >= 0 && hash + 1 < source.length()) + { + return source.substring(hash + 1); + } + return source.isEmpty() ? page : "Standard"; + } + + /** Drop rate per item of the variant, keyed by lower-case item name. */ + public static Map rates(Variant variant) + { + Map> byItem = new LinkedHashMap<>(); + for (DropLine l : variant.lines) + { + if (!l.item.isEmpty() && !l.item.equalsIgnoreCase("Nothing")) + { + byItem.computeIfAbsent(key(l.item), k -> new ArrayList<>()).add(l); + } + } + Map out = new LinkedHashMap<>(); + for (Map.Entry> e : byItem.entrySet()) + { + List ls = e.getValue(); + double hits = 0; + for (DropLine l : ls) + { + hits += l.hitsPerKill(); + } + String text = ls.size() == 1 || Double.isNaN(hits) ? ls.get(0).rarityText : formatChance(hits); + out.put(e.getKey(), new Rate(ls.get(0).item, text, hits)); + } + return out; + } + + static String formatChance(double perKill) + { + if (perKill >= 1) + { + return "Always"; + } + if (perKill <= 0) + { + return "?"; + } + double inv = 1 / perKill; + return inv >= 100 ? String.format(Locale.ROOT, "1/%,d", Math.round(inv)) : String.format(Locale.ROOT, "1/%.1f", inv); + } + + public static String key(String name) + { + return name.trim().toLowerCase(Locale.ROOT); + } +} diff --git a/src/main/java/com/example/essentialsplus/loot/gear/CombatChoice.java b/src/main/java/com/example/essentialsplus/loot/gear/CombatChoice.java new file mode 100644 index 0000000..85aef2e --- /dev/null +++ b/src/main/java/com/example/essentialsplus/loot/gear/CombatChoice.java @@ -0,0 +1,320 @@ +package com.example.essentialsplus.loot.gear; + +import java.util.Locale; + +/** How an attack is made: attack type, stance and (for magic) the spell or powered staff. */ +public final class CombatChoice +{ + public enum Type + { + STAB("Stab"), SLASH("Slash"), CRUSH("Crush"), RANGED("Ranged"), MAGIC("Magic"); + + public final String label; + + Type(String label) + { + this.label = label; + } + + public boolean melee() + { + return this == STAB || this == SLASH || this == CRUSH; + } + } + + public enum Stance + { + ACCURATE("Accurate"), AGGRESSIVE("Aggressive"), CONTROLLED("Controlled"), DEFENSIVE("Defensive"), + RAPID("Rapid"), LONGRANGE("Longrange"), CASTING("Autocast"), MANUAL("Manual cast"); + + public final String label; + + Stance(String label) + { + this.label = label; + } + } + + /** A castable spell: base max hit (-1 when it scales with the magic level) and element. */ + public static final class Spell + { + public final String name; + public final int base; + public final String element; + + Spell(String name, int base, String element) + { + this.name = name; + this.base = base; + this.element = element; + } + } + + public final Type type; + public final Stance stance; + /** null when not magic or when a powered staff attacks on its own */ + public final Spell spell; + + public CombatChoice(Type type, Stance stance, Spell spell) + { + this.type = type; + this.stance = stance; + this.spell = spell; + } + + public String label() + { + if (type == Type.MAGIC) + { + return spell != null ? spell.name : "Powered staff"; + } + return type.label + " · " + stance.label; + } + + // COM_MODE button -> stance per weapon category (varbit COMBAT_WEAPON_CATEGORY), null = no button + private static final Stance A = Stance.ACCURATE, G = Stance.AGGRESSIVE, C = Stance.CONTROLLED, D = Stance.DEFENSIVE, + R = Stance.RAPID, L = Stance.LONGRANGE; + private static final Type ST = Type.STAB, SL = Type.SLASH, CR = Type.CRUSH, RA = Type.RANGED; + + private static final Object[][][] CATEGORIES = { + /* 0 unarmed */ {{CR, A}, {CR, G}, null, {CR, D}}, + /* 1 axe */ {{SL, A}, {SL, G}, {CR, G}, {SL, D}}, + /* 2 blunt */ {{CR, A}, {CR, G}, null, {CR, D}}, + /* 3 bow */ {{RA, A}, {RA, R}, null, {RA, L}}, + /* 4 claw */ {{SL, A}, {SL, G}, {ST, C}, {SL, D}}, + /* 5 crossbow */ {{RA, A}, {RA, R}, null, {RA, L}}, + /* 6 salamander */ {{SL, G}, {RA, A}, null, null}, + /* 7 chinchompa */ {{RA, A}, {RA, R}, null, {RA, L}}, + /* 8 gun */ null, + /* 9 slash sword */ {{SL, A}, {SL, G}, {ST, C}, {SL, D}}, + /* 10 2h sword */ {{SL, A}, {SL, G}, {CR, G}, {SL, D}}, + /* 11 pickaxe */ {{ST, A}, {ST, G}, {CR, G}, {ST, D}}, + /* 12 halberd */ {{ST, C}, {SL, G}, null, {ST, D}}, + /* 13 polestaff */ {{CR, A}, {CR, G}, null, {CR, D}}, + /* 14 scythe */ {{SL, A}, {SL, G}, {CR, G}, {SL, D}}, + /* 15 spear */ {{ST, C}, {SL, C}, {CR, C}, {ST, D}}, + /* 16 spiked */ {{CR, A}, {CR, G}, {ST, C}, {CR, D}}, + /* 17 stab sword */ {{ST, A}, {ST, G}, {SL, G}, {ST, D}}, + /* 18 staff */ {{CR, A}, {CR, G}, null, {CR, D}}, + /* 19 thrown */ {{RA, A}, {RA, R}, null, {RA, L}}, + /* 20 whip */ {{SL, A}, {SL, C}, null, {SL, D}}, + /* 21 bladed staff */ {{ST, A}, {SL, G}, null, {CR, D}}, + /* 22 */ null, + /* 23 powered staff */ null, + /* 24 banner */ {{ST, A}, {SL, G}, {CR, C}, {ST, D}}, + /* 25 polearm */ {{ST, C}, {SL, G}, null, {ST, D}}, + /* 26 bludgeon */ {{CR, G}, {CR, G}, null, {CR, G}}, + /* 27 bulwark */ {{CR, A}, null, null, null}, + /* 28 */ null, + /* 29 partisan */ {{ST, A}, {ST, G}, {CR, G}, {ST, D}}, + }; + + /** The attack style the player has selected for the worn weapon, or null if the category is not known. */ + public static CombatChoice fromVarbits(int category, int comMode, int autocastSpell, int magicLevel, GearItem weapon) + { + if (category == 23 || isPoweredStaff(weapon)) + { + return new CombatChoice(Type.MAGIC, Stance.CASTING, null); + } + if ((category == 18 || category == 21) && comMode >= 4) + { + Spell spell = spell(autocastSpell); + return new CombatChoice(Type.MAGIC, Stance.CASTING, spell != null ? spell : bestStandardSpell(magicLevel)); + } + if (category < 0 || category >= CATEGORIES.length || CATEGORIES[category] == null + || comMode < 0 || comMode > 3 || CATEGORIES[category][comMode] == null) + { + return null; + } + Object[] entry = CATEGORIES[category][comMode]; + return new CombatChoice((Type) entry[0], (Stance) entry[1], null); + } + + /** Best guess for a weapon that is not worn: its main attack type with the damage stance. */ + public static CombatChoice infer(GearItem weapon, int magicLevel, int autocastSpell) + { + switch (weaponClass(weapon)) + { + case RANGED: + return new CombatChoice(Type.RANGED, Stance.RAPID, null); + case MAGIC: + if (isPoweredStaff(weapon)) + { + return new CombatChoice(Type.MAGIC, Stance.CASTING, null); + } + Spell s = spell(autocastSpell); + return new CombatChoice(Type.MAGIC, Stance.CASTING, s != null ? s : bestStandardSpell(magicLevel)); + default: + return new CombatChoice(bestMeleeType(weapon), Stance.AGGRESSIVE, null); + } + } + + /** Melee choice of a weapon for one attack type (optimizer): aggressive when possible. */ + public static CombatChoice melee(Type type) + { + return new CombatChoice(type, Stance.AGGRESSIVE, null); + } + + public static Type bestMeleeType(GearItem w) + { + if (w == null) + { + return Type.CRUSH; + } + if (w.astab >= w.aslash && w.astab >= w.acrush) + { + return Type.STAB; + } + return w.aslash >= w.acrush ? Type.SLASH : Type.CRUSH; + } + + /** RANGED, MAGIC or melee (STAB) class of a weapon. */ + public static Type weaponClass(GearItem w) + { + if (w == null) + { + return Type.STAB; + } + if (isPoweredStaff(w)) + { + return Type.MAGIC; + } + boolean rangedName = w.has("bow") || w.has("dart") || w.has("knife") || w.has("thrownaxe") || w.has("chinchompa") + || w.has("ballista") || w.has("blowpipe") || w.has("javelin") || w.has("atlatl") || w.has("tonalztics"); + int meleeMax = Math.max(w.astab, Math.max(w.aslash, w.acrush)); + if (rangedName && w.arange > 0 || w.arange > 0 && w.arange > meleeMax && w.amagic <= 0) + { + return Type.RANGED; + } + boolean magicName = w.has("staff") || w.has("wand") || w.has("sceptre") || w.has("trident"); + if (magicName && w.amagic >= meleeMax) + { + return Type.MAGIC; + } + return Type.STAB; + } + + public static boolean isPoweredStaff(GearItem w) + { + return poweredBase(w, 99) != Integer.MIN_VALUE; + } + + /** Base max hit of a powered staff at the given (boosted) magic level, MIN_VALUE if not one. */ + public static int poweredBase(GearItem w, int magic) + { + if (w == null) + { + return Integer.MIN_VALUE; + } + String n = w.lower; + if (n.startsWith("trident of the seas")) + { + return magic / 3 - 5; + } + if (n.startsWith("trident of the swamp")) + { + return magic / 3 - 2; + } + if (n.startsWith("sanguinesti staff") || n.startsWith("holy sanguinesti staff")) + { + return magic / 3 - 1; + } + if (n.startsWith("tumeken's shadow")) + { + return magic / 3 + 1; + } + if (n.startsWith("thammaron's sceptre")) + { + return magic / 3 - 8; + } + if (n.startsWith("accursed sceptre")) + { + return magic / 3 - 6; + } + if (n.startsWith("warped sceptre")) + { + return (8 * magic + 96) / 37; + } + if (n.startsWith("crystal staff (basic)") || n.startsWith("corrupted staff (basic)")) + { + return 23; + } + if (n.startsWith("crystal staff (attuned)") || n.startsWith("corrupted staff (attuned)")) + { + return 31; + } + if (n.startsWith("crystal staff (perfected)") || n.startsWith("corrupted staff (perfected)")) + { + return 39; + } + return Integer.MIN_VALUE; + } + + /** Spell of varbit AUTOCAST_SPELL, null for none or unknown. */ + public static Spell spell(int autocast) + { + switch (autocast) + { + case 1: return new Spell("Wind Strike", 2, "Air"); + case 2: return new Spell("Water Strike", 4, "Water"); + case 3: return new Spell("Earth Strike", 6, "Earth"); + case 4: return new Spell("Fire Strike", 8, "Fire"); + case 5: return new Spell("Wind Bolt", 9, "Air"); + case 6: return new Spell("Water Bolt", 10, "Water"); + case 7: return new Spell("Earth Bolt", 11, "Earth"); + case 8: return new Spell("Fire Bolt", 12, "Fire"); + case 9: return new Spell("Wind Blast", 13, "Air"); + case 10: return new Spell("Water Blast", 14, "Water"); + case 11: return new Spell("Earth Blast", 15, "Earth"); + case 12: return new Spell("Fire Blast", 16, "Fire"); + case 13: return new Spell("Wind Wave", 17, "Air"); + case 14: return new Spell("Water Wave", 18, "Water"); + case 15: return new Spell("Earth Wave", 19, "Earth"); + case 16: return new Spell("Fire Wave", 20, "Fire"); + case 17: return new Spell("Crumble Undead", 15, null); + case 18: return new Spell("Magic Dart", -1, null); + case 19: return new Spell("Claws of Guthix", 20, null); + case 20: return new Spell("Flames of Zamorak", 20, null); + case 21: return new Spell("Saradomin Strike", 20, null); + case 31: return new Spell("Smoke Rush", 13, null); + case 32: return new Spell("Shadow Rush", 14, null); + case 33: return new Spell("Blood Rush", 15, null); + case 34: return new Spell("Ice Rush", 16, null); + case 35: return new Spell("Smoke Burst", 17, null); + case 36: return new Spell("Shadow Burst", 18, null); + case 37: return new Spell("Blood Burst", 21, null); + case 38: return new Spell("Ice Burst", 22, null); + case 39: return new Spell("Smoke Blitz", 23, null); + case 40: return new Spell("Shadow Blitz", 24, null); + case 41: return new Spell("Blood Blitz", 25, null); + case 42: return new Spell("Ice Blitz", 26, null); + case 43: return new Spell("Smoke Barrage", 27, null); + case 44: return new Spell("Shadow Barrage", 28, null); + case 45: return new Spell("Blood Barrage", 29, null); + case 46: return new Spell("Ice Barrage", 30, null); + case 47: return new Spell("Iban Blast", 25, null); + case 48: return new Spell("Wind Surge", 21, "Air"); + case 49: return new Spell("Water Surge", 22, "Water"); + case 50: return new Spell("Earth Surge", 23, "Earth"); + case 51: return new Spell("Fire Surge", 24, "Fire"); + default: return null; + } + } + + /** Strongest fire spell of the standard spellbook at a magic level. */ + public static Spell bestStandardSpell(int magic) + { + if (magic >= 95) return spell(51); + if (magic >= 75) return spell(16); + if (magic >= 59) return spell(12); + if (magic >= 35) return spell(8); + if (magic >= 13) return spell(4); + return spell(1); + } + + @Override + public String toString() + { + return label().toLowerCase(Locale.ENGLISH); + } +} diff --git a/src/main/java/com/example/essentialsplus/loot/gear/DpsCalc.java b/src/main/java/com/example/essentialsplus/loot/gear/DpsCalc.java new file mode 100644 index 0000000..07e6b3f --- /dev/null +++ b/src/main/java/com/example/essentialsplus/loot/gear/DpsCalc.java @@ -0,0 +1,333 @@ +package com.example.essentialsplus.loot.gear; + +import com.example.essentialsplus.loot.gear.CombatChoice.Stance; +import com.example.essentialsplus.loot.gear.CombatChoice.Type; + +/** + * Max hit, accuracy and DPS with the standard OSRS formulas. Covers prayers, stances, void, salve, slayer helm, + * dragon hunter, arclight, twisted bow, crystal armour, powered staves and elemental weaknesses; other special + * effects (special attacks, set effects, multi-hit weapons) are left out, so it is an estimate. + */ +public final class DpsCalc +{ + private DpsCalc() + { + } + + public static final class Result + { + public final CombatChoice choice; + public final int maxHit; + public final double hitChance; + public final double dps; + public final int speed; + public final long attackRoll; + public final long defenceRoll; + + Result(CombatChoice choice, int maxHit, double hitChance, int speed, long attackRoll, long defenceRoll) + { + this.choice = choice; + this.maxHit = Math.max(0, maxHit); + this.hitChance = hitChance; + this.speed = Math.max(1, speed); + this.attackRoll = attackRoll; + this.defenceRoll = defenceRoll; + this.dps = hitChance * this.maxHit / 2.0 / (this.speed * 0.6); + } + + /** Expected seconds to kill {@code hp}, NaN if no damage. */ + public double secondsToKill(int hp) + { + return dps > 0 && hp > 0 ? hp / dps : Double.NaN; + } + } + + /** Uses the selected style when the worn weapon is still worn, otherwise the weapon's likely style. */ + public static CombatChoice choiceFor(Loadout loadout, Loadout worn, PlayerSetup p) + { + GearItem w = loadout.weapon(); + if (loadout.id(GearItem.WEAPON) == worn.id(GearItem.WEAPON)) + { + CombatChoice live = CombatChoice.fromVarbits(p.weaponCategory, p.comMode, p.autocastSpell, p.magic, w); + if (live != null) + { + return live; + } + } + return CombatChoice.infer(w, p.magic, p.autocastSpell); + } + + public static Result calc(Loadout l, PlayerSetup p, Monster m, CombatChoice c) + { + switch (c.type) + { + case RANGED: + return ranged(l, p, m, c); + case MAGIC: + return magic(l, p, m, c); + default: + return melee(l, p, m, c); + } + } + + static double hitChance(long att, long def) + { + if (att <= 0) + { + return 0; + } + if (att > def) + { + return 1.0 - (def + 2.0) / (2.0 * (att + 1.0)); + } + return att / (2.0 * (def + 1.0)); + } + + private static long defenceRoll(Monster m, int level, int bonus) + { + return (long) (level + 9) * (bonus + 64); + } + + private static int weaponSpeed(Loadout l) + { + GearItem w = l.weapon(); + return w == null || w.aspeed <= 0 ? 4 : w.aspeed; + } + + private static Result melee(Loadout l, PlayerSetup p, Monster m, CombatChoice c) + { + int[] b = l.bonuses(); + int attBonus = c.type == Type.STAB ? b[0] : c.type == Type.SLASH ? b[1] : b[2]; + int stanceAtt = c.stance == Stance.ACCURATE ? 3 : c.stance == Stance.CONTROLLED ? 1 : 0; + int stanceStr = c.stance == Stance.AGGRESSIVE ? 3 : c.stance == Stance.CONTROLLED ? 1 : 0; + + int effAtt = (int) Math.floor(p.attack * p.meleeAcc) + stanceAtt + 8; + int effStr = (int) Math.floor(p.strength * p.meleeStr) + stanceStr + 8; + if (hasVoid(l, "melee")) + { + effAtt = effAtt * 11 / 10; + effStr = effStr * 11 / 10; + } + long att = (long) effAtt * (attBonus + 64); + int max = (effStr * (b[10] + 64) + 320) / 640; + + // salve and slayer helm do not stack; salve wins + if (m.hasAttribute("undead") && l.slotHas(GearItem.AMULET, "salve amulet")) + { + boolean e = l.slotHas(GearItem.AMULET, "(e") ; + att = e ? att * 6 / 5 : att * 7 / 6; + max = e ? max * 6 / 5 : max * 7 / 6; + } + else if (p.onSlayerTask && (l.slotHas(GearItem.HEAD, "slayer helmet") || l.slotHas(GearItem.HEAD, "black mask"))) + { + att = att * 7 / 6; + max = max * 7 / 6; + } + GearItem w = l.weapon(); + if (w != null && m.hasAttribute("dragon") && w.has("dragon hunter lance")) + { + att = att * 6 / 5; + max = max * 6 / 5; + } + if (w != null && m.hasAttribute("demon") && (w.has("arclight") || w.has("emberlight"))) + { + att = att * 17 / 10; + max = max * 17 / 10; + } + + int defBonus = c.type == Type.STAB ? m.dstab : c.type == Type.SLASH ? m.dslash : m.dcrush; + long def = defenceRoll(m, m.defenceLevel, defBonus); + return new Result(c, max, hitChance(att, def), weaponSpeed(l), att, def); + } + + /** Ammo type the weapon fires from the ammo slot: "arrow", "bolt", "javelin" or null (no ammo used). */ + static String ammoFor(GearItem w) + { + if (w == null) + { + return null; + } + if (w.has("ballista")) + { + return "javelin"; + } + if (w.has("crossbow") || w.has("karil")) + { + return w.has("karil") ? "bolt rack" : "bolt"; + } + if (w.has("bow") && !w.has("crystal bow") && !w.has("bow of faerdhinen") && !w.has("blowpipe")) + { + return "arrow"; + } + return null; + } + + private static Result ranged(Loadout l, PlayerSetup p, Monster m, CombatChoice c) + { + int[] b = l.bonuses(); + GearItem w = l.weapon(); + GearItem ammo = l.get(GearItem.AMMO); + int rstr = b[11]; + if (ammo != null) + { + String wanted = ammoFor(w); + if (wanted == null || !ammo.has(wanted)) + { + // ammo the weapon cannot fire adds no ranged strength + rstr -= ammo.rstr; + } + } + + int stance = c.stance == Stance.ACCURATE ? 3 : 0; + int effAtt = (int) Math.floor(p.ranged * p.rangedAcc) + stance + 8; + int effStr = (int) Math.floor(p.ranged * p.rangedStr) + stance + 8; + if (hasVoid(l, "ranger")) + { + effAtt = effAtt * 11 / 10; + effStr = isEliteVoid(l) ? effStr * 9 / 8 : effStr * 11 / 10; + } + long att = (long) effAtt * (b[4] + 64); + int max = (effStr * (rstr + 64) + 320) / 640; + + if (w != null && (w.has("crystal bow") || w.has("bow of faerdhinen"))) + { + int accPct = 0; + int dmgPct = 0; + if (l.slotHas(GearItem.HEAD, "crystal helm")) + { + accPct += 5; + dmgPct += 5; + } + if (l.slotHas(GearItem.BODY, "crystal body")) + { + accPct += 15; + dmgPct += 15; + } + if (l.slotHas(GearItem.LEGS, "crystal legs")) + { + accPct += 10; + dmgPct += 10; + } + att = att * (200 + accPct) / 200; + max = max * (200 + dmgPct) / 200; + } + + boolean imbued = l.slotHas(GearItem.AMULET, "(i)") || l.slotHas(GearItem.AMULET, "(ei)"); + if (m.hasAttribute("undead") && l.slotHas(GearItem.AMULET, "salve amulet") && imbued) + { + boolean e = l.slotHas(GearItem.AMULET, "(ei)"); + att = e ? att * 6 / 5 : att * 7 / 6; + max = e ? max * 6 / 5 : max * 7 / 6; + } + else if (p.onSlayerTask && (l.slotHas(GearItem.HEAD, "slayer helmet (i)") || l.slotHas(GearItem.HEAD, "black mask (i)"))) + { + att = att * 23 / 20; + max = max * 23 / 20; + } + + if (w != null && w.has("twisted bow")) + { + int mg = Math.min(250, Math.max(m.magicLevel, m.magicAttack)); + int accPct = Math.min(140, 140 + (3 * mg - 10) / 100 - (int) Math.pow(3 * mg / 10 - 100, 2) / 100); + int dmgPct = Math.min(250, 250 + (3 * mg - 14) / 100 - (int) Math.pow(3 * mg / 10 - 140, 2) / 100); + att = att * Math.max(0, accPct) / 100; + max = max * Math.max(0, dmgPct) / 100; + } + if (w != null && m.hasAttribute("dragon") && w.has("dragon hunter crossbow")) + { + att = att * 13 / 10; + max = max * 5 / 4; + } + + int defBonus; + if (w != null && (w.has("crossbow") || w.has("ballista") || w.has("karil"))) + { + defBonus = m.dheavy; + } + else if (w != null && (w.has("dart") || w.has("knife") || w.has("thrownaxe") || w.has("blowpipe") || w.has("chinchompa"))) + { + defBonus = m.dlight; + } + else + { + defBonus = m.dstandard; + } + long def = defenceRoll(m, m.defenceLevel, defBonus); + int speed = weaponSpeed(l) - (c.stance == Stance.RAPID ? 1 : 0); + return new Result(c, max, hitChance(att, def), speed, att, def); + } + + private static Result magic(Loadout l, PlayerSetup p, Monster m, CombatChoice c) + { + int[] b = l.bonuses(); + GearItem w = l.weapon(); + int base; + int speed; + String element = null; + if (c.spell == null) + { + base = CombatChoice.poweredBase(w, p.magic); + speed = weaponSpeed(l); + } + else + { + base = c.spell.base >= 0 ? c.spell.base : 10 + p.magic / 10; + element = c.spell.element; + speed = 5; + } + if (base == Integer.MIN_VALUE || base <= 0) + { + return new Result(c, 0, 0, speed, 0, 0); + } + + boolean shadow = w != null && w.has("tumeken's shadow") && c.spell == null; + int magicAttack = shadow ? b[3] * 3 : b[3]; + double dmgPct = (shadow ? l.magicDamage() * 3 : l.magicDamage()) + p.magicDmgPct; + + int effAtt = (int) Math.floor(p.magic * p.magicAcc) + 9; + if (hasVoid(l, "mage")) + { + effAtt = effAtt * 29 / 20; + if (isEliteVoid(l)) + { + dmgPct += 2.5; + } + } + long att = (long) effAtt * (magicAttack + 64); + int max = (int) Math.floor(base * (1 + dmgPct / 100.0)); + + boolean imbued = l.slotHas(GearItem.AMULET, "(i)") || l.slotHas(GearItem.AMULET, "(ei)"); + if (m.hasAttribute("undead") && l.slotHas(GearItem.AMULET, "salve amulet") && imbued) + { + boolean e = l.slotHas(GearItem.AMULET, "(ei)"); + att = e ? att * 6 / 5 : att * 23 / 20; + max = e ? max * 6 / 5 : max * 23 / 20; + } + else if (p.onSlayerTask && (l.slotHas(GearItem.HEAD, "slayer helmet (i)") || l.slotHas(GearItem.HEAD, "black mask (i)"))) + { + att = att * 23 / 20; + max = max * 23 / 20; + } + if (element != null && m.elementalWeakness != null && element.equalsIgnoreCase(m.elementalWeakness) && m.elementalWeaknessPct > 0) + { + att = att * (100 + m.elementalWeaknessPct) / 100; + max = max + base * m.elementalWeaknessPct / 100; + } + + long def = defenceRoll(m, m.magicLevel, m.dmagic); + return new Result(c, max, hitChance(att, def), speed, att, def); + } + + private static boolean hasVoid(Loadout l, String helm) + { + return l.slotHas(GearItem.HEAD, "void " + helm + " helm") + && (l.slotHas(GearItem.BODY, "void knight top") || l.slotHas(GearItem.BODY, "elite void top")) + && (l.slotHas(GearItem.LEGS, "void knight robe") || l.slotHas(GearItem.LEGS, "elite void robe")) + && l.slotHas(GearItem.GLOVES, "void knight gloves"); + } + + private static boolean isEliteVoid(Loadout l) + { + return l.slotHas(GearItem.BODY, "elite void top") && l.slotHas(GearItem.LEGS, "elite void robe"); + } +} diff --git a/src/main/java/com/example/essentialsplus/loot/gear/FightService.java b/src/main/java/com/example/essentialsplus/loot/gear/FightService.java new file mode 100644 index 0000000..2af689e --- /dev/null +++ b/src/main/java/com/example/essentialsplus/loot/gear/FightService.java @@ -0,0 +1,492 @@ +package com.example.essentialsplus.loot.gear; + +import com.example.essentialsplus.helpers.AttackStyles; +import com.example.essentialsplus.helpers.CombatStyle; +import com.example.essentialsplus.loot.combat.CombatTracker; +import com.example.essentialsplus.loot.combat.FightSession; +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.Executor; +import java.util.function.Consumer; +import java.util.function.Supplier; +import net.runelite.api.Actor; +import net.runelite.api.Client; +import net.runelite.api.GameState; +import net.runelite.api.Item; +import net.runelite.api.ItemComposition; +import net.runelite.api.ItemContainer; +import net.runelite.api.NPC; +import net.runelite.api.Player; +import net.runelite.api.Prayer; +import net.runelite.api.Skill; +import net.runelite.api.gameval.InventoryID; +import net.runelite.api.gameval.VarPlayerID; +import net.runelite.api.gameval.VarbitID; +import net.runelite.client.game.ItemManager; +import net.runelite.client.game.ItemStats; +import net.runelite.client.util.Text; + +/** + * Builds {@link FightSnapshot}s on the client thread: worn gear, owned equipment (worn, inventory, bank), + * levels/prayers/style, the current target with its wiki stats and the running fight session. + * The best-in-bank search runs on the background executor whenever gear or target change. + */ +public class FightService +{ + private final Client client; + private final ItemManager itemManager; + private final CombatTracker combatTracker; + private final AttackStyles attackStyles; + private final MonsterService monsters; + private final Executor executor; + /** saved bank of the logged-in account (item id -> quantity), may be null */ + private final Supplier> bank; + private final List> listeners = new CopyOnWriteArrayList<>(); + + /** base item (source WORN) per canonical id, null value = not equipable */ + private final Map itemCache = new java.util.HashMap<>(); + + private volatile boolean slayerTask; + private volatile boolean monsterLoaded; + private volatile boolean clearRequested; + private boolean containersChanged = true; + + private PlayerSetup lastSetup; + private int[] lastWornIds; + private Loadout lastWorn; + private List pool = Collections.emptyList(); + private long poolHash; + private int targetNpcIndex = -1; + private FightSnapshot.Target lastTarget; + private Monster lastMonster; + private volatile long gearVersion; + private volatile List best = Collections.emptyList(); + private volatile long bestVersion = -1; + private volatile boolean bestReady; + private FightSnapshot last = FightSnapshot.LOGGED_OUT; + + public FightService(Client client, ItemManager itemManager, CombatTracker combatTracker, AttackStyles attackStyles, + MonsterService monsters, Executor executor, Supplier> bank) + { + this.client = client; + this.itemManager = itemManager; + this.combatTracker = combatTracker; + this.attackStyles = attackStyles; + this.monsters = monsters; + this.executor = executor; + this.bank = bank; + } + + public void addListener(Consumer l) + { + listeners.add(l); + l.accept(last); + } + + public void removeListener(Consumer l) + { + listeners.remove(l); + } + + public FightSnapshot last() + { + return last; + } + + public boolean isSlayerTask() + { + return slayerTask; + } + + /** Slayer helmet / black mask bonus applies (set from the panel). */ + public void setSlayerTask(boolean task) + { + slayerTask = task; + lastSetup = null; + } + + /** Called from the wiki thread when monster stats arrive. */ + public void onMonsterLoaded() + { + monsterLoaded = true; + } + + public void onContainerChanged() + { + containersChanged = true; + } + + /** Forgets the last target (panel "clear"). Any thread. */ + public void clearTarget() + { + clearRequested = true; + } + + public void reset() + { + lastSetup = null; + lastWornIds = null; + lastWorn = null; + lastTarget = null; + lastMonster = null; + targetNpcIndex = -1; + pool = Collections.emptyList(); + poolHash = 0; + containersChanged = true; + itemCache.clear(); + publish(FightSnapshot.LOGGED_OUT); + } + + public void onGameTick() + { + Player me = client.getLocalPlayer(); + if (client.getGameState() != GameState.LOGGED_IN || me == null) + { + if (last.loggedIn) + { + reset(); + } + return; + } + + boolean gearChanged = false; + + PlayerSetup setup = readSetup(); + if (lastSetup == null || !sameSetup(setup, lastSetup)) + { + lastSetup = setup; + gearChanged = true; + } + + Loadout worn = lastWorn; + if (containersChanged || worn == null) + { + worn = readWorn(); + int[] ids = new int[GearItem.SLOTS]; + for (int s = 0; s < GearItem.SLOTS; s++) + { + ids[s] = worn.id(s); + } + if (lastWornIds == null || !java.util.Arrays.equals(ids, lastWornIds)) + { + lastWornIds = ids; + gearChanged = true; + } + lastWorn = worn; + + long hash = ownedHash(); + if (hash != poolHash || gearChanged) + { + poolHash = hash; + pool = readPool(worn); + gearChanged = true; + } + containersChanged = false; + } + + if (clearRequested) + { + clearRequested = false; + lastTarget = null; + targetNpcIndex = -1; + gearChanged = true; + } + + FightSnapshot.Target target = readTarget(me); + Monster monster = target != null ? target.monster : null; + if (monster != lastMonster || monsterLoaded) + { + monsterLoaded = false; + lastMonster = monster; + gearChanged = true; + } + + if (gearChanged) + { + gearVersion++; + startSearch(gearVersion, worn, setup, target); + } + + FightSession s = combatTracker.getActiveSession(); + FightSnapshot.Session session = s != null ? new FightSnapshot.Session(s) : null; + + boolean emit = gearChanged || bestReady || target != null && (target.live || last.target == null || last.target.live) + || session != null || last.session != null || target == null && last.target != null; + bestReady = false; + if (emit) + { + List b = bestVersion == gearVersion ? best : last.best; + publish(new FightSnapshot(true, setup, worn, pool, target, session, b, gearVersion)); + } + } + + private void publish(FightSnapshot snap) + { + last = snap; + for (Consumer l : listeners) + { + l.accept(snap); + } + } + + private void startSearch(long version, Loadout worn, PlayerSetup setup, FightSnapshot.Target target) + { + List items = pool; + Monster m = target != null ? target.calcMonster() : null; + if (m == null || items.isEmpty()) + { + best = Collections.emptyList(); + bestVersion = version; + return; + } + executor.execute(() -> + { + List result = GearOptimizer.bestPerStyle(items, worn, setup, m); + if (version == gearVersion) + { + best = result; + bestVersion = version; + bestReady = true; + } + }); + } + + // ---------------------------------------------------------------- reading game state + + private PlayerSetup readSetup() + { + double meleeAcc = pick(1.20, Prayer.PIETY, 1.15, Prayer.CHIVALRY, 1.15, Prayer.INCREDIBLE_REFLEXES, + 1.10, Prayer.IMPROVED_REFLEXES, 1.05, Prayer.CLARITY_OF_THOUGHT); + double meleeStr = pick(1.23, Prayer.PIETY, 1.18, Prayer.CHIVALRY, 1.15, Prayer.ULTIMATE_STRENGTH, + 1.10, Prayer.SUPERHUMAN_STRENGTH, 1.05, Prayer.BURST_OF_STRENGTH); + double rangedAcc = pick(1.20, Prayer.RIGOUR, 1.18, Prayer.DEADEYE, 1.15, Prayer.EAGLE_EYE, + 1.10, Prayer.HAWK_EYE, 1.05, Prayer.SHARP_EYE); + double rangedStr = pick(1.23, Prayer.RIGOUR, 1.18, Prayer.DEADEYE, 1.15, Prayer.EAGLE_EYE, + 1.10, Prayer.HAWK_EYE, 1.05, Prayer.SHARP_EYE); + double magicAcc = pick(1.25, Prayer.AUGURY, 1.18, Prayer.MYSTIC_VIGOUR, 1.15, Prayer.MYSTIC_MIGHT, + 1.10, Prayer.MYSTIC_LORE, 1.05, Prayer.MYSTIC_WILL); + int magicDmg = client.isPrayerActive(Prayer.AUGURY) ? 4 : client.isPrayerActive(Prayer.MYSTIC_VIGOUR) ? 3 + : client.isPrayerActive(Prayer.MYSTIC_MIGHT) ? 2 : client.isPrayerActive(Prayer.MYSTIC_LORE) ? 1 : 0; + return new PlayerSetup( + client.getBoostedSkillLevel(Skill.ATTACK), + client.getBoostedSkillLevel(Skill.STRENGTH), + client.getBoostedSkillLevel(Skill.DEFENCE), + client.getBoostedSkillLevel(Skill.RANGED), + client.getBoostedSkillLevel(Skill.MAGIC), + client.getBoostedSkillLevel(Skill.HITPOINTS), + meleeAcc, meleeStr, rangedAcc, rangedStr, magicAcc, magicDmg, + client.getVarbitValue(VarbitID.COMBAT_WEAPON_CATEGORY), + client.getVarpValue(VarPlayerID.COM_MODE), + client.getVarbitValue(VarbitID.AUTOCAST_SPELL), + slayerTask); + } + + /** Multiplier of the first active prayer in (value, prayer) pairs, 1 if none. */ + private double pick(Object... pairs) + { + for (int i = 0; i + 1 < pairs.length; i += 2) + { + if (client.isPrayerActive((Prayer) pairs[i + 1])) + { + return ((Number) pairs[i]).doubleValue(); + } + } + return 1.0; + } + + private static boolean sameSetup(PlayerSetup a, PlayerSetup b) + { + return a.attack == b.attack && a.strength == b.strength && a.defence == b.defence && a.ranged == b.ranged + && a.magic == b.magic && a.meleeAcc == b.meleeAcc && a.meleeStr == b.meleeStr && a.rangedAcc == b.rangedAcc + && a.rangedStr == b.rangedStr && a.magicAcc == b.magicAcc && a.magicDmgPct == b.magicDmgPct + && a.weaponCategory == b.weaponCategory && a.comMode == b.comMode && a.autocastSpell == b.autocastSpell + && a.onSlayerTask == b.onSlayerTask; + } + + private Loadout readWorn() + { + GearItem[] items = new GearItem[GearItem.SLOTS]; + ItemContainer c = client.getItemContainer(InventoryID.WORN); + if (c != null) + { + Item[] arr = c.getItems(); + for (int s = 0; s < Math.min(arr.length, GearItem.SLOTS); s++) + { + if (arr[s] != null && arr[s].getId() > 0) + { + GearItem g = gearItem(arr[s].getId()); + if (g == null) + { + // no bonuses known (e.g. new or cosmetic item): still show it + ItemComposition comp = itemManager.getItemComposition(arr[s].getId()); + g = new GearItem(arr[s].getId(), comp.getName(), s, false, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, GearItem.Source.WORN); + } + items[s] = g; + } + } + } + return new Loadout(items); + } + + private long ownedHash() + { + long h = 17; + ItemContainer inv = client.getItemContainer(InventoryID.INV); + if (inv != null) + { + for (Item i : inv.getItems()) + { + h = h * 31 + i.getId(); + } + } + Map b = bank.get(); + if (b != null) + { + h = h * 31 + b.size(); + for (Map.Entry e : b.entrySet()) + { + h += e.getKey() * 1_000_003L ^ (e.getValue() > 0 ? 1 : 0); + } + } + return h; + } + + private List readPool(Loadout worn) + { + Map out = new LinkedHashMap<>(); + for (int s : GearItem.ALL_SLOTS) + { + GearItem g = worn.get(s); + if (g != null) + { + out.putIfAbsent(g.id, g.withSource(GearItem.Source.WORN)); + } + } + ItemContainer inv = client.getItemContainer(InventoryID.INV); + if (inv != null) + { + for (Item i : inv.getItems()) + { + addOwned(out, i.getId(), GearItem.Source.INVENTORY); + } + } + Map b = bank.get(); + if (b != null) + { + for (Map.Entry e : b.entrySet()) + { + if (e.getValue() != null && e.getValue() > 0) + { + addOwned(out, e.getKey(), GearItem.Source.BANK); + } + } + } + return Collections.unmodifiableList(new ArrayList<>(out.values())); + } + + private void addOwned(Map out, int id, GearItem.Source source) + { + if (id <= 0) + { + return; + } + GearItem g = gearItem(id); + if (g != null && !out.containsKey(g.id)) + { + out.put(g.id, g.withSource(source)); + } + } + + private GearItem gearItem(int rawId) + { + int id = itemManager.canonicalize(rawId); + if (itemCache.containsKey(id)) + { + return itemCache.get(id); + } + GearItem g = null; + ItemStats stats = itemManager.getItemStats(id); + if (stats != null && stats.isEquipable() && stats.getEquipment() != null && stats.getEquipment().getSlot() >= 0 + && stats.getEquipment().getSlot() < GearItem.SLOTS) + { + ItemComposition comp = itemManager.getItemComposition(id); + g = GearItem.of(id, comp.getName(), stats.getEquipment(), GearItem.Source.WORN); + } + itemCache.put(id, g); + return g; + } + + private FightSnapshot.Target readTarget(Player me) + { + NPC npc = null; + Actor interacting = me.getInteracting(); + if (interacting instanceof NPC && ((NPC) interacting).getCombatLevel() > 0) + { + npc = (NPC) interacting; + } + FightSession session = combatTracker.getActiveSession(); + if (npc == null && session != null) + { + npc = byIndex(session.getTargetIndex()); + } + if (npc == null) + { + for (NPC n : client.getNpcs()) + { + if (n != null && n.getInteracting() == me && n.getCombatLevel() > 0 && !n.isDead()) + { + npc = n; + break; + } + } + } + + if (npc == null) + { + if (lastTarget == null) + { + return null; + } + NPC old = byIndex(targetNpcIndex); + boolean dead = lastTarget.dead || old == null || old.isDead(); + if (lastTarget.live || lastTarget.monster == null) + { + Monster m = lastTarget.monster != null ? lastTarget.monster : monsters.get(lastTarget.npcId, lastTarget.combatLevel); + lastTarget = new FightSnapshot.Target(lastTarget.npcId, lastTarget.name, lastTarget.combatLevel, + dead ? 0 : lastTarget.healthRatio, lastTarget.healthScale, m, m == null && monsters.isLoading(lastTarget.npcId), + false, dead, null); + } + return lastTarget; + } + + targetNpcIndex = npc.getIndex(); + Monster m = monsters.get(npc.getId(), npc.getCombatLevel()); + CombatStyle style = attackStyles.current().get(npc); + String name = Text.removeTags(npc.getName()); + lastTarget = new FightSnapshot.Target(npc.getId(), name == null ? "Unknown" : name, npc.getCombatLevel(), + npc.getHealthScale() > 0 ? npc.getHealthRatio() : -1, npc.getHealthScale(), m, + m == null && monsters.isLoading(npc.getId()), true, npc.isDead(), style); + return lastTarget; + } + + private NPC byIndex(int index) + { + if (index < 0) + { + return null; + } + for (NPC n : client.getNpcs()) + { + if (n != null && n.getIndex() == index) + { + return n; + } + } + return null; + } +} diff --git a/src/main/java/com/example/essentialsplus/loot/gear/FightSnapshot.java b/src/main/java/com/example/essentialsplus/loot/gear/FightSnapshot.java new file mode 100644 index 0000000..3e09c82 --- /dev/null +++ b/src/main/java/com/example/essentialsplus/loot/gear/FightSnapshot.java @@ -0,0 +1,111 @@ +package com.example.essentialsplus.loot.gear; + +import com.example.essentialsplus.helpers.CombatStyle; +import com.example.essentialsplus.loot.combat.FightSession; +import java.util.Collections; +import java.util.List; + +/** Everything the fight views show, captured on the client thread (immutable). */ +public final class FightSnapshot +{ + public static final FightSnapshot LOGGED_OUT = new FightSnapshot(false, PlayerSetup.DEFAULT, Loadout.EMPTY, + Collections.emptyList(), null, null, Collections.emptyList(), 0); + + public static final class Target + { + public final int npcId; + public final String name; + public final int combatLevel; + /** -1 when no health bar is shown */ + public final int healthRatio; + public final int healthScale; + /** null while loading or when the wiki has no stats */ + public final Monster monster; + public final boolean loading; + /** being fought right now (false: last target, kept for planning) */ + public final boolean live; + public final boolean dead; + /** attack style the NPC last used on the player, null if unknown */ + public final CombatStyle attackStyle; + + public Target(int npcId, String name, int combatLevel, int healthRatio, int healthScale, Monster monster, + boolean loading, boolean live, boolean dead, CombatStyle attackStyle) + { + this.npcId = npcId; + this.name = name; + this.combatLevel = combatLevel; + this.healthRatio = healthRatio; + this.healthScale = healthScale; + this.monster = monster; + this.loading = loading; + this.live = live; + this.dead = dead; + this.attackStyle = attackStyle; + } + + /** Monster to calculate against: wiki stats or a dummy. */ + public Monster calcMonster() + { + return monster != null ? monster : Monster.DUMMY; + } + } + + /** Copy of the running fight session. */ + public static final class Session + { + public final int durationTicks, idleTicks, attacks, damageDealt, hitsReceived, damageTaken, hpHealed; + public final int prayerBlocked, prayerMissed, onPrayerAttacks, offPrayerAttacks, correctWeapon, incorrectWeapon; + public final long suppliesCost; + public final double efficiency, performance, prayerDefencePct, offPrayerPct, correctWeaponPct; + public final String grade; + + public Session(FightSession s) + { + durationTicks = s.getDurationTicks(); + idleTicks = s.getIdleTicks(); + attacks = s.getAttacksDealt(); + damageDealt = s.getDamageDealt(); + hitsReceived = s.getHitsReceived(); + damageTaken = s.getDamageTaken(); + hpHealed = s.getHpHealed(); + prayerBlocked = s.getPrayerBlockedHits(); + prayerMissed = s.getPrayerMissedHits(); + onPrayerAttacks = s.getOnPrayerAttacks(); + offPrayerAttacks = s.getOffPrayerAttacks(); + correctWeapon = s.getCorrectWeaponAttacks(); + incorrectWeapon = s.getIncorrectWeaponAttacks(); + suppliesCost = s.getSuppliesCost(); + efficiency = s.getEfficiencyPct(); + performance = s.getPerformanceScore(); + prayerDefencePct = s.getPrayerDefensePct(); + offPrayerPct = s.getOffPrayerAttackPct(); + correctWeaponPct = s.getCorrectWeaponPct(); + grade = s.getGrade(); + } + } + + public final boolean loggedIn; + public final PlayerSetup setup; + public final Loadout worn; + /** Equipable items owned (worn, inventory, bank), one per item id */ + public final List pool; + public final Target target; + public final Session session; + /** Best setup per style against the target, highest DPS first */ + public final List best; + /** changes whenever gear, pool, levels or target stats change (not on hp / session updates) */ + public final long gearVersion; + + public FightSnapshot(boolean loggedIn, PlayerSetup setup, Loadout worn, List pool, Target target, + Session session, List best, long gearVersion) + { + this.loggedIn = loggedIn; + this.setup = setup; + this.worn = worn; + this.pool = pool; + this.target = target; + this.session = session; + this.best = best; + this.gearVersion = gearVersion; + } +} diff --git a/src/main/java/com/example/essentialsplus/loot/gear/GearItem.java b/src/main/java/com/example/essentialsplus/loot/gear/GearItem.java new file mode 100644 index 0000000..1de539e --- /dev/null +++ b/src/main/java/com/example/essentialsplus/loot/gear/GearItem.java @@ -0,0 +1,121 @@ +package com.example.essentialsplus.loot.gear; + +import java.util.Locale; +import net.runelite.client.game.ItemEquipmentStats; + +/** An equipable item with its bonuses (immutable, safe on any thread). */ +public final class GearItem +{ + public static final int HEAD = 0; + public static final int CAPE = 1; + public static final int AMULET = 2; + public static final int WEAPON = 3; + public static final int BODY = 4; + public static final int SHIELD = 5; + public static final int LEGS = 7; + public static final int GLOVES = 9; + public static final int BOOTS = 10; + public static final int RING = 12; + public static final int AMMO = 13; + public static final int SLOTS = 14; + + public static final int[] ALL_SLOTS = {HEAD, CAPE, AMULET, WEAPON, BODY, SHIELD, LEGS, GLOVES, BOOTS, RING, AMMO}; + + /** Where the item was found. */ + public enum Source + { + WORN("Worn"), + INVENTORY("Inventory"), + BANK("Bank"); + + public final String label; + + Source(String label) + { + this.label = label; + } + } + + public final int id; + public final String name; + public final String lower; + public final int slot; + public final boolean twoHanded; + public final int astab, aslash, acrush, amagic, arange; + public final int dstab, dslash, dcrush, dmagic, drange; + public final int str, rstr, prayer, aspeed; + /** magic damage in percent */ + public final double mdmg; + public final Source source; + + public GearItem(int id, String name, int slot, boolean twoHanded, + int astab, int aslash, int acrush, int amagic, int arange, + int dstab, int dslash, int dcrush, int dmagic, int drange, + int str, int rstr, double mdmg, int prayer, int aspeed, Source source) + { + this.id = id; + this.name = name == null ? "Item " + id : name; + this.lower = this.name.toLowerCase(Locale.ENGLISH); + this.slot = slot; + this.twoHanded = twoHanded; + this.astab = astab; + this.aslash = aslash; + this.acrush = acrush; + this.amagic = amagic; + this.arange = arange; + this.dstab = dstab; + this.dslash = dslash; + this.dcrush = dcrush; + this.dmagic = dmagic; + this.drange = drange; + this.str = str; + this.rstr = rstr; + this.mdmg = mdmg; + this.prayer = prayer; + this.aspeed = aspeed; + this.source = source; + } + + public static GearItem of(int id, String name, ItemEquipmentStats e, Source source) + { + return new GearItem(id, name, e.getSlot(), e.isTwoHanded(), + e.getAstab(), e.getAslash(), e.getAcrush(), e.getAmagic(), e.getArange(), + e.getDstab(), e.getDslash(), e.getDcrush(), e.getDmagic(), e.getDrange(), + e.getStr(), e.getRstr(), e.getMdmg(), e.getPrayer(), e.getAspeed(), source); + } + + public GearItem withSource(Source s) + { + return s == source ? this : new GearItem(id, name, slot, twoHanded, astab, aslash, acrush, amagic, arange, + dstab, dslash, dcrush, dmagic, drange, str, rstr, mdmg, prayer, aspeed, s); + } + + public int totalDefence() + { + return dstab + dslash + dcrush + dmagic + drange; + } + + public boolean has(String part) + { + return lower.contains(part); + } + + public static String slotName(int slot) + { + switch (slot) + { + case HEAD: return "Head"; + case CAPE: return "Cape"; + case AMULET: return "Neck"; + case WEAPON: return "Weapon"; + case BODY: return "Body"; + case SHIELD: return "Shield"; + case LEGS: return "Legs"; + case GLOVES: return "Hands"; + case BOOTS: return "Feet"; + case RING: return "Ring"; + case AMMO: return "Ammo"; + default: return "Slot " + slot; + } + } +} diff --git a/src/main/java/com/example/essentialsplus/loot/gear/GearOptimizer.java b/src/main/java/com/example/essentialsplus/loot/gear/GearOptimizer.java new file mode 100644 index 0000000..95799c1 --- /dev/null +++ b/src/main/java/com/example/essentialsplus/loot/gear/GearOptimizer.java @@ -0,0 +1,158 @@ +package com.example.essentialsplus.loot.gear; + +import com.example.essentialsplus.loot.gear.CombatChoice.Type; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Comparator; +import java.util.List; + +/** Picks the highest DPS items per combat style from the items the player owns. */ +public final class GearOptimizer +{ + private static final int WEAPONS_PER_STYLE = 8; + private static final int PASSES = 2; + + private GearOptimizer() + { + } + + public static final class Option + { + public final String label; + public final Loadout loadout; + public final DpsCalc.Result result; + + Option(String label, Loadout loadout, DpsCalc.Result result) + { + this.label = label; + this.loadout = loadout; + this.result = result; + } + } + + /** Best setup of each style (melee, ranged, magic) that has a weapon, highest DPS first. */ + public static List

" + escape(text) + "
"); + l.setFont(FontManager.getRunescapeSmallFont()); + l.setForeground(new Color(200, 170, 110)); + l.setBorder(BorderFactory.createCompoundBorder( + BorderFactory.createMatteBorder(0, 2, 0, 0, Ui.WARN), + BorderFactory.createEmptyBorder(3, 6, 3, 4))); + return l; + } + + private static String escape(String s) + { + return s.replace("&", "&").replace("<", "<").replace(">", ">"); + } +} diff --git a/src/main/java/com/example/essentialsplus/progress/ui/ProgressRow.java b/src/main/java/com/example/essentialsplus/progress/ui/ProgressRow.java new file mode 100644 index 0000000..b9e5b26 --- /dev/null +++ b/src/main/java/com/example/essentialsplus/progress/ui/ProgressRow.java @@ -0,0 +1,135 @@ +package com.example.essentialsplus.progress.ui; + +import com.example.essentialsplus.ui.Ui; +import java.awt.Cursor; +import java.awt.Dimension; +import java.awt.Font; +import java.awt.FontMetrics; +import java.awt.Graphics; +import java.awt.Graphics2D; +import java.awt.Image; +import java.awt.event.MouseAdapter; +import java.awt.event.MouseEvent; +import java.util.function.Supplier; +import javax.swing.JComponent; +import javax.swing.SwingUtilities; +import net.runelite.client.ui.FontManager; +import net.runelite.client.ui.PluginPanel; + +/** Clickable list row: icon, name, subtitle, "done / total" and a completion bar. */ +class ProgressRow extends JComponent +{ + private static final int ICON = 32; + + private final Supplier icon; + private final String title; + private final String subtitle; + private final int done; + private final int total; + private final boolean clickable; + private boolean hover; + + ProgressRow(Supplier icon, String title, String subtitle, int done, int total, Runnable onClick) + { + this.icon = icon; + this.title = title; + this.subtitle = subtitle; + this.done = done; + this.total = total; + this.clickable = onClick != null; + setToolTipText(title); + if (clickable) + { + setCursor(Cursor.getPredefinedCursor(Cursor.HAND_CURSOR)); + addMouseListener(new MouseAdapter() + { + @Override + public void mouseEntered(MouseEvent e) + { + hover = true; + repaint(); + } + + @Override + public void mouseExited(MouseEvent e) + { + hover = false; + repaint(); + } + + @Override + public void mousePressed(MouseEvent e) + { + if (SwingUtilities.isLeftMouseButton(e)) + { + onClick.run(); + } + } + }); + } + } + + @Override + public Dimension getPreferredSize() + { + return new Dimension(PluginPanel.PANEL_WIDTH - 24, 48); + } + + @Override + protected void paintComponent(Graphics g0) + { + Graphics2D g = Ui.prepare(g0); + int w = getWidth(); + int h = getHeight(); + boolean complete = total > 0 && done >= total; + g.setColor(hover ? Ui.CARD_HOVER : Ui.CARD); + g.fillRoundRect(0, 0, w, h, 8, 8); + if (complete) + { + g.setColor(Ui.UP); + g.fillRoundRect(0, 0, 3, h, 3, 3); + } + + int pad = 8; + int x = pad; + if (icon != null) + { + ProgressUi.icon(g, icon.get(), x - 2, (h - ICON) / 2, ICON, false); + x += ICON + 4; + } + int right = w - pad - (clickable ? 10 : 0); + if (clickable) + { + g.setFont(FontManager.getRunescapeBoldFont().deriveFont(14f)); + g.setColor(hover ? Ui.ACCENT : Ui.FAINT); + Ui.drawRight(g, "›", w - pad + 1, h / 2 + 5); + } + + Font small = FontManager.getRunescapeSmallFont(); + g.setFont(small); + FontMetrics sfm = g.getFontMetrics(); + String count = total > 0 ? done + "/" + total : ""; + int countW = sfm.stringWidth(count); + + g.setFont(FontManager.getRunescapeBoldFont()); + FontMetrics fm = g.getFontMetrics(); + int base = 6 + fm.getAscent(); + g.setColor(complete ? Ui.UP : Ui.TEXT); + g.drawString(ProgressUi.ellipsize(fm, title, right - x - countW - 6), x, base); + g.setFont(small); + g.setColor(Ui.MUTED); + Ui.drawRight(g, count, right, base); + + int subBase = base + sfm.getAscent() + 3; + String pct = total > 0 ? ProgressUi.pct(done, total) : ""; + g.setColor(complete ? Ui.UP : Ui.TEXT); + Ui.drawRight(g, pct, right, subBase); + if (subtitle != null) + { + g.setColor(Ui.FAINT); + g.drawString(ProgressUi.ellipsize(sfm, subtitle, right - x - sfm.stringWidth(pct) - 6), x, subBase); + } + ProgressUi.bar(g, x, h - 9, right - x, 4, ProgressUi.ratio(done, total), ProgressUi.progressColor(done, total)); + g.dispose(); + } +} diff --git a/src/main/java/com/example/essentialsplus/progress/ui/ProgressUi.java b/src/main/java/com/example/essentialsplus/progress/ui/ProgressUi.java new file mode 100644 index 0000000..01017c8 --- /dev/null +++ b/src/main/java/com/example/essentialsplus/progress/ui/ProgressUi.java @@ -0,0 +1,137 @@ +package com.example.essentialsplus.progress.ui; + +import com.example.essentialsplus.ui.Ui; +import java.awt.BasicStroke; +import java.awt.Color; +import java.awt.FontMetrics; +import java.awt.Graphics2D; +import java.awt.Image; +import java.util.Locale; + +/** Colours and painting helpers shared by the progress views. */ +final class ProgressUi +{ + static final Color TRACK = new Color(255, 255, 255, 22); + static final Color DONE_BG = new Color(34, 48, 38); + static final Color[] CA_TIER_COLORS = { + new Color(152, 206, 110), new Color(96, 176, 232), new Color(232, 164, 64), + new Color(206, 110, 214), new Color(236, 96, 96), new Color(255, 210, 74), + }; + static final Color[] DIARY_TIER_COLORS = {CA_TIER_COLORS[0], CA_TIER_COLORS[1], CA_TIER_COLORS[2], CA_TIER_COLORS[3]}; + + private ProgressUi() + { + } + + static double ratio(int done, int total) + { + return total > 0 ? Math.max(0, Math.min(1, (double) done / total)) : 0; + } + + static String pct(int done, int total) + { + if (total <= 0) + { + return "-"; + } + double p = 100.0 * done / total; + return (p >= 100 || p == 0 ? String.format(Locale.US, "%.0f", p) : String.format(Locale.US, "%.1f", p)) + "%"; + } + + /** Gold while in progress, green when complete. */ + static Color progressColor(int done, int total) + { + return total > 0 && done >= total ? Ui.UP : Ui.GOLD; + } + + static void bar(Graphics2D g, int x, int y, int w, int h, double ratio, Color color) + { + g.setColor(TRACK); + g.fillRoundRect(x, y, w, h, h, h); + int fill = (int) Math.round(w * ratio); + if (fill > 0) + { + g.setColor(color); + g.fillRoundRect(x, y, Math.max(fill, h), h, h, h); + } + } + + /** Round check mark: filled green when done, empty ring otherwise. */ + static void check(Graphics2D g, int x, int y, int size, boolean done) + { + if (done) + { + g.setColor(Ui.UP); + g.fillOval(x, y, size, size); + g.setColor(new Color(20, 32, 24)); + g.setStroke(new BasicStroke(2f, BasicStroke.CAP_ROUND, BasicStroke.JOIN_ROUND)); + g.drawPolyline(new int[]{x + size * 27 / 100, x + size * 44 / 100, x + size * 74 / 100}, + new int[]{y + size * 52 / 100, y + size * 68 / 100, y + size * 34 / 100}, 3); + } + else + { + g.setColor(Ui.FAINT); + g.setStroke(new BasicStroke(1.5f)); + g.drawOval(x + 1, y + 1, size - 2, size - 2); + } + } + + /** Small rounded label, e.g. a tier name. */ + static int chip(Graphics2D g, String text, int x, int baseline, Color color) + { + FontMetrics fm = g.getFontMetrics(); + int w = fm.stringWidth(text) + 8; + int h = fm.getAscent() + 4; + g.setColor(Ui.alpha(color, 40)); + g.fillRoundRect(x, baseline - fm.getAscent() - 2, w, h, 6, 6); + g.setColor(color); + g.drawString(text, x + 4, baseline); + return w; + } + + /** Draws an icon image centered in a square box; nothing if not loaded yet. */ + static void icon(Graphics2D g, Image img, int x, int y, int box, boolean faded) + { + if (img == null) + { + return; + } + int w = img.getWidth(null); + int h = img.getHeight(null); + if (w <= 0 || h <= 0) + { + return; + } + double scale = Math.min(1.0, Math.min((double) box / w, (double) box / h)); + int dw = (int) (w * scale); + int dh = (int) (h * scale); + java.awt.Composite old = g.getComposite(); + if (faded) + { + g.setComposite(java.awt.AlphaComposite.getInstance(java.awt.AlphaComposite.SRC_OVER, 0.28f)); + } + g.drawImage(img, x + (box - dw) / 2, y + (box - dh) / 2, dw, dh, null); + g.setComposite(old); + } + + static String ellipsize(FontMetrics fm, String s, int max) + { + if (s == null) + { + return ""; + } + if (fm.stringWidth(s) <= max) + { + return s; + } + for (int i = s.length() - 1; i > 0; i--) + { + String t = s.substring(0, i) + ".."; + if (fm.stringWidth(t) <= max) + { + return t; + } + } + return ""; + } +} diff --git a/src/main/java/com/example/essentialsplus/progress/ui/TaskRow.java b/src/main/java/com/example/essentialsplus/progress/ui/TaskRow.java new file mode 100644 index 0000000..1d27acf --- /dev/null +++ b/src/main/java/com/example/essentialsplus/progress/ui/TaskRow.java @@ -0,0 +1,152 @@ +package com.example.essentialsplus.progress.ui; + +import com.example.essentialsplus.ui.Ui; +import java.awt.BorderLayout; +import java.awt.Color; +import java.awt.Cursor; +import java.awt.Dimension; +import java.awt.Graphics; +import java.awt.Graphics2D; +import java.awt.event.MouseAdapter; +import java.awt.event.MouseEvent; +import java.util.List; +import javax.swing.BorderFactory; +import javax.swing.JComponent; +import javax.swing.JLabel; +import javax.swing.JPanel; +import javax.swing.SwingUtilities; +import net.runelite.client.ui.FontManager; +import net.runelite.client.ui.PluginPanel; + +/** + * A single task: check mark, name, description and a tier chip. Rows with details (requirements, notes) + * expand on click. + */ +class TaskRow extends JPanel +{ + /** text column width for html wrapping */ + private static final int TEXT_WIDTH = PluginPanel.PANEL_WIDTH - 24 - 44; + private static final int CHECK = 14; + + private final boolean done; + private boolean partial; + private boolean hover; + + /** + * @param chips (text, color) pairs drawn under the name, may be empty + * @param details lines shown when expanded; no expansion when empty + */ + TaskRow(String name, String description, List chips, List details, boolean done, + boolean expanded, Runnable onToggle) + { + super(new BorderLayout(0, 0)); + this.done = done; + setOpaque(false); + setBorder(BorderFactory.createEmptyBorder(6, 8 + CHECK + 8, 6, 8)); + + StringBuilder html = new StringBuilder("
"); + html.append("").append(escape(name)).append(""); + if (description != null && !description.isEmpty()) + { + html.append("
").append(escape(description)).append(""); + } + if (!chips.isEmpty()) + { + html.append("
"); + for (Object[] chip : chips) + { + html.append("").append(escape((String) chip[0])).append("  "); + } + } + boolean expandable = !details.isEmpty(); + if (expandable && expanded) + { + html.append("
"); + for (String d : details) + { + html.append("
").append(escape(d)).append(""); + } + } + if (expandable) + { + html.append("
").append(expanded ? "▲ less" : "▼ requirements").append(""); + } + JLabel label = new JLabel(html.append("
").toString()); + label.setFont(FontManager.getRunescapeSmallFont()); + add(label, BorderLayout.CENTER); + + if (expandable || onToggle != null) + { + setCursor(Cursor.getPredefinedCursor(Cursor.HAND_CURSOR)); + MouseAdapter mouse = new MouseAdapter() + { + @Override + public void mouseEntered(MouseEvent e) + { + hover = true; + repaint(); + } + + @Override + public void mouseExited(MouseEvent e) + { + hover = false; + repaint(); + } + + @Override + public void mousePressed(MouseEvent e) + { + if (SwingUtilities.isLeftMouseButton(e) && onToggle != null) + { + onToggle.run(); + } + } + }; + addMouseListener(mouse); + label.addMouseListener(mouse); + } + } + + /** Started but not finished: half-filled check. */ + void setPartial(boolean partial) + { + this.partial = partial; + } + + @Override + public Dimension getMaximumSize() + { + return new Dimension(Integer.MAX_VALUE, getPreferredSize().height); + } + + @Override + protected void paintComponent(Graphics g0) + { + Graphics2D g = Ui.prepare(g0); + g.setColor(done ? ProgressUi.DONE_BG : hover ? Ui.CARD_HOVER : Ui.CARD); + g.fillRoundRect(0, 0, getWidth(), getHeight(), 8, 8); + if (partial && !done) + { + g.setColor(Ui.WARN); + g.setStroke(new java.awt.BasicStroke(1.5f)); + g.drawOval(9, 9, CHECK - 2, CHECK - 2); + g.fillArc(9, 9, CHECK - 2, CHECK - 2, 90, 180); + } + else + { + ProgressUi.check(g, 8, 8, CHECK, done); + } + g.dispose(); + } + + private static String hex(Color c) + { + return String.format("#%02x%02x%02x", c.getRed(), c.getGreen(), c.getBlue()); + } + + private static String escape(String s) + { + return s.replace("&", "&").replace("<", "<").replace(">", ">"); + } +} diff --git a/src/main/java/com/example/essentialsplus/skills/SkillGoals.java b/src/main/java/com/example/essentialsplus/skills/SkillGoals.java new file mode 100644 index 0000000..bb6bf22 --- /dev/null +++ b/src/main/java/com/example/essentialsplus/skills/SkillGoals.java @@ -0,0 +1,130 @@ +package com.example.essentialsplus.skills; + +import java.util.HashMap; +import java.util.Map; +import net.runelite.api.Experience; +import net.runelite.client.config.ConfigManager; + +/** Level goal per skill and character, with the xp the skill had when the goal was set. Stored in the config. */ +public class SkillGoals +{ + private static final String GROUP = com.example.essentialsplus.EssentialsPlusConfig.GROUP; + + /** A target level and the xp at the moment it was set (start of the progress bar). */ + public static final class Goal + { + public final int level; + public final int startXp; + + Goal(int level, int startXp) + { + this.level = level; + this.startXp = startXp; + } + + public int targetXp() + { + return Experience.getXpForLevel(level); + } + + /** Progress from the start xp to the goal, 0..1. */ + public double progress(int xp) + { + int target = targetXp(); + if (xp >= target) + { + return 1; + } + int span = target - startXp; + return span <= 0 ? 0 : Math.max(0, (double) (xp - startXp) / span); + } + + public int xpLeft(int xp) + { + return Math.max(0, targetXp() - xp); + } + } + + private final ConfigManager configManager; + private final Map> cache = new HashMap<>(); + + public SkillGoals(ConfigManager configManager) + { + this.configManager = configManager; + } + + public synchronized Goal get(String account, String skill) + { + return account == null ? null : load(account).get(skill); + } + + public synchronized void set(String account, String skill, int level, int currentXp) + { + if (account == null) + { + return; + } + Map goals = load(account); + Goal old = goals.get(skill); + // keep the original start when only the target changes, unless the old goal was already reached + int start = old != null && currentXp < old.targetXp() ? Math.min(old.startXp, currentXp) : currentXp; + goals.put(skill, new Goal(level, start)); + save(account, goals); + } + + public synchronized void clear(String account, String skill) + { + if (account == null) + { + return; + } + Map goals = load(account); + if (goals.remove(skill) != null) + { + save(account, goals); + } + } + + private Map load(String account) + { + return cache.computeIfAbsent(account, a -> + { + Map goals = new HashMap<>(); + String raw = configManager.getConfiguration(GROUP, configKey(a)); + if (raw != null) + { + for (String entry : raw.split(",")) + { + String[] kv = entry.split("[=:]"); + try + { + goals.put(kv[0], new Goal(Integer.parseInt(kv[1]), Integer.parseInt(kv[2]))); + } + catch (RuntimeException ignored) + { + } + } + } + return goals; + }); + } + + private void save(String account, Map goals) + { + StringBuilder sb = new StringBuilder(); + goals.forEach((skill, g) -> sb.append(sb.length() == 0 ? "" : ",").append(skill).append('=').append(g.level).append(':').append(g.startXp)); + if (sb.length() == 0) + { + configManager.unsetConfiguration(GROUP, configKey(account)); + } + else + { + configManager.setConfiguration(GROUP, configKey(account), sb.toString()); + } + } + + private static String configKey(String account) + { + return "skillGoals_" + account.replaceAll("[^A-Za-z0-9_-]", "_"); + } +} diff --git a/src/main/java/com/example/essentialsplus/skills/SkillHistory.java b/src/main/java/com/example/essentialsplus/skills/SkillHistory.java new file mode 100644 index 0000000..344c8d8 --- /dev/null +++ b/src/main/java/com/example/essentialsplus/skills/SkillHistory.java @@ -0,0 +1,372 @@ +package com.example.essentialsplus.skills; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import com.example.essentialsplus.ui.TimeFilter; +import com.example.essentialsplus.ui.TimeRange; +import net.runelite.api.Experience; +import net.runelite.api.Skill; + +/** Experience timeline of one character. */ +public class SkillHistory +{ + public static final Skill[] SKILLS = Arrays.stream(Skill.values()).filter(s -> s != Skill.OVERALL).toArray(Skill[]::new); + + private static final long MINUTE = 60_000L; + /** gaps between samples up to this long count as active training time */ + private static final long ACTIVE_GAP = 5 * MINUTE; + + public String key; + public String name; + /** skill names in the order of {@link XpSample#x}, so files survive new skills being added */ + public List skills = new ArrayList<>(); + public List samples = new ArrayList<>(); + + public long resetSessionAt; + public long resetNowAt; + + public transient boolean dirty; + + public SkillHistory() + { + } + + public SkillHistory(String key, String name) + { + this.key = key; + this.name = name; + this.skills = currentNames(); + } + + static List currentNames() + { + List names = new ArrayList<>(); + for (Skill s : SKILLS) + { + names.add(s.name()); + } + return names; + } + + /** Maps arrays of older files onto the current skill order. */ + void normalize() + { + if (samples == null) + { + samples = new ArrayList<>(); + } + samples.removeIf(s -> s == null || s.x == null); + List current = currentNames(); + if (skills == null || skills.isEmpty()) + { + skills = current; + } + if (skills.equals(current)) + { + return; + } + int[] map = new int[current.size()]; + for (int i = 0; i < map.length; i++) + { + map[i] = skills.indexOf(current.get(i)); + } + for (XpSample s : samples) + { + int[] x = new int[map.length]; + for (int i = 0; i < map.length; i++) + { + x[i] = map[i] >= 0 && map[i] < s.x.length ? s.x[map[i]] : 0; + } + s.x = x; + } + skills = current; + dirty = true; + } + + public synchronized XpSample latest() + { + return samples.isEmpty() ? null : samples.get(samples.size() - 1); + } + + /** + * Adds a sample if anything changed. Within the same minute the newest sample is overwritten + * (xp only grows, so the minute's last value is all that matters). + */ + synchronized boolean record(int[] xp, long now) + { + XpSample last = latest(); + if (last != null && Arrays.equals(last.x, xp)) + { + return false; + } + if (last != null) + { + for (int i = 0; i < xp.length; i++) + { + if (xp[i] < last.get(i)) + { + // xp never drops - stats are still loading after login/hop + return false; + } + } + } + if (last != null && samples.size() > 1 && last.t / MINUTE == now / MINUTE) + { + last.t = now; + last.x = xp; + } + else + { + samples.add(new XpSample(now, xp)); + } + dirty = true; + return true; + } + + /** Baseline at {@code start}: last sample at or before it, else the first sample. */ + public synchronized XpSample at(long start) + { + XpSample base = null; + for (XpSample s : samples) + { + if (s.t <= start) + { + base = s; + } + else + { + break; + } + } + return base != null ? base : samples.isEmpty() ? null : samples.get(0); + } + + /** Samples strictly after {@code start}, preceded by the baseline. */ + public synchronized List range(long start) + { + List out = new ArrayList<>(); + XpSample base = at(start); + if (base == null) + { + return out; + } + out.add(base); + for (XpSample s : samples) + { + if (s.t > start && s != base) + { + out.add(s); + } + } + return out; + } + + public synchronized void reset(TimeRange range) + { + if (range == TimeRange.SESSION) + { + resetSessionAt = System.currentTimeMillis(); + dirty = true; + } + else if (range == TimeRange.CURRENT) + { + resetNowAt = System.currentTimeMillis(); + dirty = true; + } + } + + /** Start of the current training streak of a skill (or the total for {@code skill < 0}); {@link Long#MAX_VALUE} when idle. */ + public synchronized long currentStart(int skill, long now) + { + return TimeFilter.streakStart(samples.size(), i -> samples.get(i).t, + i -> i > 0 && samples.get(i).value(skill) > samples.get(i - 1).value(skill), now); + } + + /** Start of the filter's range per skill: the same for every skill, except per training streak for "Now". */ + public synchronized long[] starts(TimeFilter filter) + { + long[] out = new long[SKILLS.length]; + long start = filter.start(); + long now = System.currentTimeMillis(); + for (int i = 0; i < out.length; i++) + { + out[i] = filter.isCurrent() ? currentStart(i, now) : start; + } + return out; + } + + /** Common start of the filter's range: the earliest active streak for "Now" (now when idle). */ + public synchronized long start(TimeFilter filter) + { + if (!filter.isCurrent()) + { + return filter.start(); + } + long now = System.currentTimeMillis(); + long start = currentStart(-1, now); + return start == Long.MAX_VALUE ? now : start; + } + + /** Start of the rate calculation range per skill, clamped by Reset Hourly markers. */ + public synchronized long[] rateStarts(TimeFilter filter) + { + long[] out = new long[SKILLS.length]; + long now = System.currentTimeMillis(); + for (int i = 0; i < out.length; i++) + { + if (filter.isCurrent()) + { + long streak = currentStart(i, now); + out[i] = streak == Long.MAX_VALUE ? Long.MAX_VALUE : Math.max(streak, resetNowAt); + } + else if (filter.getRange() == TimeRange.SESSION) + { + out[i] = Math.max(filter.start(), resetSessionAt); + } + else + { + out[i] = filter.start(); + } + } + return out; + } + + /** Common start of the rate calculation range, clamped by Reset Hourly markers. */ + public synchronized long rateStart(TimeFilter filter) + { + if (filter.isCurrent()) + { + long now = System.currentTimeMillis(); + long start = currentStart(-1, now); + long base = start == Long.MAX_VALUE ? now : start; + return Math.max(base, resetNowAt); + } + if (filter.getRange() == TimeRange.SESSION) + { + return Math.max(filter.start(), resetSessionAt); + } + return filter.start(); + } + + /** Xp gained per skill in the filter's range; skills idle for "Now" count as nothing. */ + public synchronized long[] gained(TimeFilter filter) + { + if (!filter.isCurrent()) + { + return gained(filter.start()); + } + long[] starts = starts(filter); + long[] g = new long[SKILLS.length]; + XpSample last = latest(); + for (int i = 0; i < g.length; i++) + { + XpSample base = starts[i] == Long.MAX_VALUE ? null : at(starts[i]); + g[i] = base == null || last == null ? 0 : Math.max(0, last.get(i) - base.get(i)); + } + return g; + } + + /** Xp gained in a single skill since {@code start}. */ + public synchronized long gainedSince(long start, int skill) + { + if (start == Long.MAX_VALUE || skill < 0 || skill >= SKILLS.length) + { + return 0; + } + XpSample base = at(start); + XpSample last = latest(); + return base == null || last == null ? 0 : Math.max(0, last.get(skill) - base.get(skill)); + } + + /** Total xp gained across all skills since {@code start}. */ + public synchronized long gainedTotalSince(long start) + { + if (start == Long.MAX_VALUE) + { + return 0; + } + XpSample base = at(start); + XpSample last = latest(); + return base == null || last == null ? 0 : Math.max(0, last.total() - base.total()); + } + + /** Xp gained per skill since {@code start}. */ + public synchronized long[] gained(long start) + { + long[] g = new long[SKILLS.length]; + XpSample base = at(start); + XpSample last = latest(); + if (base == null || last == null) + { + return g; + } + for (int i = 0; i < g.length; i++) + { + g[i] = Math.max(0, last.get(i) - base.get(i)); + } + return g; + } + + /** Time spent gaining xp since {@code start} (sum of short gaps between xp changes). */ + public synchronized long activeMillis(long start, int skill) + { + List list = range(start); + long active = 0; + for (int i = 1; i < list.size(); i++) + { + XpSample a = list.get(i - 1); + XpSample b = list.get(i); + long gap = b.t - Math.max(a.t, start); + if (gap > 0 && gap <= ACTIVE_GAP && b.value(skill) > a.value(skill)) + { + active += gap; + } + } + return active; + } + + /** Level-ups since {@code start}, oldest first. */ + public synchronized List levelUps(long start) + { + List out = new ArrayList<>(); + List list = range(start); + for (int i = 1; i < list.size(); i++) + { + XpSample a = list.get(i - 1); + XpSample b = list.get(i); + for (int s = 0; s < SKILLS.length; s++) + { + int from = level(a.get(s)); + int to = level(b.get(s)); + if (to > from) + { + out.add(new LevelUp(b.t, s, from, to)); + } + } + } + return out; + } + + public static int level(int xp) + { + return Experience.getLevelForXp(Math.max(0, xp)); + } + + /** One or more levels gained in a skill between two samples. */ + public static final class LevelUp + { + public final long t; + public final int skill; + public final int from; + public final int to; + + LevelUp(long t, int skill, int from, int to) + { + this.t = t; + this.skill = skill; + this.from = from; + this.to = to; + } + } +} diff --git a/src/main/java/com/example/essentialsplus/skills/SkillStore.java b/src/main/java/com/example/essentialsplus/skills/SkillStore.java new file mode 100644 index 0000000..e8a7ffe --- /dev/null +++ b/src/main/java/com/example/essentialsplus/skills/SkillStore.java @@ -0,0 +1,164 @@ +package com.example.essentialsplus.skills; + +import com.example.essentialsplus.WealthStore; +import com.example.essentialsplus.ui.TimeRange; +import com.google.gson.Gson; +import java.io.File; +import java.io.Reader; +import java.io.Writer; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.StandardCopyOption; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import javax.inject.Inject; +import javax.inject.Singleton; +import lombok.extern.slf4j.Slf4j; + +/** Loads and saves {@link SkillHistory} files (one per character) in {@code essentials-plus/skills}. */ +@Slf4j +@Singleton +public class SkillStore +{ + public static final File DIR = new File(WealthStore.DIR, "skills"); + + private static final long HOUR = 3_600_000L; + private static final long DAY = 24 * HOUR; + + private final Gson gson; + private final Map histories = new ConcurrentHashMap<>(); + + @Inject + public SkillStore(Gson gson) + { + this.gson = gson.newBuilder().disableHtmlEscaping().create(); + } + + public void load() + { + File[] files = DIR.listFiles((d, n) -> n.endsWith(".json")); + if (files == null) + { + return; + } + for (File f : files) + { + try (Reader reader = Files.newBufferedReader(f.toPath(), StandardCharsets.UTF_8)) + { + SkillHistory h = gson.fromJson(reader, SkillHistory.class); + if (h != null && h.key != null) + { + h.normalize(); + histories.putIfAbsent(h.key, h); + } + } + catch (Exception e) + { + log.warn("Skills: could not read {}", f, e); + } + } + } + + public SkillHistory get(String key) + { + return key == null ? null : histories.get(key); + } + + public void resetHourly(String key, TimeRange range) + { + SkillHistory h = get(key); + if (h != null) + { + h.reset(range); + save(h); + } + } + + /** All characters, most recently active first. */ + public List all() + { + List list = new ArrayList<>(histories.values()); + list.removeIf(h -> h.latest() == null); + list.sort((a, b) -> Long.compare(b.latest().t, a.latest().t)); + return list; + } + + /** @return true if a new data point was stored */ + public boolean record(String key, String name, int[] xp, long now) + { + SkillHistory h = histories.computeIfAbsent(key, k -> new SkillHistory(k, name)); + h.name = name; + return h.record(xp, now); + } + + public void saveDirty() + { + for (SkillHistory h : histories.values()) + { + if (h.dirty) + { + save(h); + } + } + } + + public synchronized void save(SkillHistory h) + { + String json; + synchronized (h) + { + compact(h, System.currentTimeMillis()); + json = gson.toJson(h); + h.dirty = false; + } + try + { + Files.createDirectories(DIR.toPath()); + File target = new File(DIR, h.key.replaceAll("[^A-Za-z0-9 _()\\-]", "_") + ".json"); + File tmp = new File(DIR, target.getName() + ".tmp"); + try (Writer w = Files.newBufferedWriter(tmp.toPath(), StandardCharsets.UTF_8)) + { + w.write(json); + } + Files.move(tmp.toPath(), target.toPath(), StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); + } + catch (Exception e) + { + h.dirty = true; + log.warn("Skills: could not save {}", h.key, e); + } + } + + /** + * Samples older than 7 days keep only the last value per hour, older than 90 days per day. + * The very first sample is kept as baseline for "All". + */ + static void compact(SkillHistory h, long now) + { + List in = h.samples; + if (in.size() < 3) + { + return; + } + List out = new ArrayList<>(in.size()); + out.add(in.get(0)); + for (int i = 1; i < in.size(); i++) + { + XpSample s = in.get(i); + long age = now - s.t; + long size = age > 90 * DAY ? DAY : age > 7 * DAY ? HOUR : 0; + XpSample prev = out.get(out.size() - 1); + if (size > 0 && out.size() > 1 && prev.t / size == s.t / size) + { + out.set(out.size() - 1, s); + } + else + { + out.add(s); + } + } + h.samples = out; + } +} diff --git a/src/main/java/com/example/essentialsplus/skills/SkillsHudOverlay.java b/src/main/java/com/example/essentialsplus/skills/SkillsHudOverlay.java new file mode 100644 index 0000000..64aa80d --- /dev/null +++ b/src/main/java/com/example/essentialsplus/skills/SkillsHudOverlay.java @@ -0,0 +1,224 @@ +package com.example.essentialsplus.skills; + +import com.example.essentialsplus.EssentialsPlusConfig; +import com.example.essentialsplus.EssentialsPlusPlugin; +import com.example.essentialsplus.ui.TimeFilter; +import com.example.essentialsplus.ui.Ui; +import java.awt.Color; +import java.awt.Dimension; +import java.awt.Graphics2D; +import java.awt.Point; +import java.awt.image.BufferedImage; +import java.util.ArrayList; +import java.util.EnumMap; +import java.util.List; +import java.util.Map; +import javax.inject.Inject; +import java.util.Locale; +import net.runelite.api.Experience; +import net.runelite.api.MenuAction; +import net.runelite.api.Skill; +import net.runelite.client.game.SkillIconManager; +import net.runelite.client.ui.overlay.OverlayPanel; +import net.runelite.client.ui.overlay.OverlayPosition; +import net.runelite.client.ui.overlay.components.ComponentOrientation; +import net.runelite.client.ui.overlay.components.ImageComponent; +import net.runelite.client.ui.overlay.components.LineComponent; +import net.runelite.client.ui.overlay.components.ProgressBarComponent; +import net.runelite.client.ui.overlay.components.SplitComponent; +import net.runelite.client.ui.overlay.components.TitleComponent; +import net.runelite.client.util.ImageUtil; + +/** In-game panel listing the xp gained per skill in the selected time range. */ +public class SkillsHudOverlay extends OverlayPanel +{ + private static final int ICON = 14; + + private final EssentialsPlusPlugin plugin; + private final EssentialsPlusConfig config; + private final SkillIconManager iconManager; + private final Map icons = new EnumMap<>(Skill.class); + + @Inject + SkillsHudOverlay(EssentialsPlusPlugin plugin, EssentialsPlusConfig config, SkillIconManager iconManager) + { + super(plugin); + this.plugin = plugin; + this.config = config; + this.iconManager = iconManager; + setPosition(OverlayPosition.TOP_LEFT); + panelComponent.setPreferredSize(new Dimension(170, 0)); + addMenuEntry(MenuAction.RUNELITE_OVERLAY, "Reset Hourly", "Skills"); + } + + @Override + public Dimension render(Graphics2D graphics) + { + if (!config.showSkillsHud()) + { + return null; + } + SkillHistory history = plugin.getSkillStore().get(plugin.getSkillKey()); + if (history == null) + { + return null; + } + boolean showLevel = config.skillsHudLevel(); + boolean showPercent = config.skillsHudPercent(); + boolean showXpLeft = config.skillsHudXpLeft(); + boolean showTime = config.skillsHudTimeToLevel(); + boolean showXpHour = config.skillsHudXpHour(); + boolean showBar = config.skillsHudProgressBar(); + boolean details = showLevel || showPercent || showXpLeft || showTime || showXpHour; + panelComponent.setPreferredSize(new Dimension(details ? 200 : 170, 0)); + + TimeFilter filter = plugin.getTimeFilter(); + long[] starts = history.starts(filter); + long[] rateStarts = history.rateStarts(filter); + long[] gained = history.gained(filter); + + List order = new ArrayList<>(); + long total = 0; + for (int i = 0; i < gained.length; i++) + { + if (gained[i] > 0) + { + order.add(i); + total += gained[i]; + } + } + order.sort((a, b) -> Long.compare(gained[b], gained[a])); + + panelComponent.getChildren().add(TitleComponent.builder() + .text("XP gained (" + filter.getLabel() + ")") + .color(Ui.ACCENT) + .build()); + + if (order.isEmpty()) + { + panelComponent.getChildren().add(LineComponent.builder() + .left(filter.isCurrent() ? "Not training" : "No xp yet") + .leftColor(Ui.MUTED) + .build()); + return super.render(graphics); + } + + XpSample last = history.latest(); + int limit = Math.max(1, config.skillsHudMax()); + for (int n = 0; n < Math.min(limit, order.size()); n++) + { + int i = order.get(n); + Skill skill = SkillHistory.SKILLS[i]; + int levels = SkillHistory.level(last.get(i)) - SkillHistory.level(history.at(starts[i]).get(i)); + String right = "+" + Ui.gp(gained[i]) + (levels > 0 ? " (+" + levels + " lvl)" : ""); + LineComponent line = LineComponent.builder() + .left(skill.getName()) + .right(right) + .rightColor(levels > 0 ? Ui.UP : Color.WHITE) + .build(); + BufferedImage icon = icon(skill); + if (icon == null) + { + panelComponent.getChildren().add(line); + } + else + { + panelComponent.getChildren().add(SplitComponent.builder() + .first(new ImageComponent(icon)) + .second(line) + .orientation(ComponentOrientation.HORIZONTAL) + .gap(new Point(4, 0)) + .build()); + } + + int xp = last.get(i); + int lvl = SkillHistory.level(xp); + boolean maxed = lvl >= Experience.MAX_VIRT_LEVEL; + int lo = Experience.getXpForLevel(lvl); + int hi = maxed ? lo : Experience.getXpForLevel(lvl + 1); + double progress = maxed ? 1 : Math.max(0, Math.min(1, (xp - lo) / (double) (hi - lo))); + long xpLeft = maxed ? 0 : hi - xp; + if (details) + { + long active = showTime || showXpHour ? history.activeMillis(rateStarts[i], i) : 0; + long rateGained = history.gainedSince(rateStarts[i], i); + long xph = active >= Ui.MINUTE ? rateGained * Ui.HOUR / active : 0; + List leftParts = new ArrayList<>(); + List rightParts = new ArrayList<>(); + if (showLevel) + { + leftParts.add("Lvl " + lvl); + } + if (showPercent) + { + leftParts.add(maxed ? "max" : String.format(Locale.US, "%.1f%%", progress * 100)); + } + if (showXpHour) + { + leftParts.add(xph > 0 ? Ui.gp(xph) + "/h" : "-/h"); + } + if (showXpLeft && !maxed) + { + rightParts.add(Ui.gp(xpLeft) + " left"); + } + if (showTime && !maxed) + { + rightParts.add(xph > 0 ? duration(xpLeft * Ui.HOUR / xph) : "-"); + } + panelComponent.getChildren().add(LineComponent.builder() + .left(String.join(" \u00b7 ", leftParts)) + .leftColor(Ui.MUTED) + .right(String.join(" \u00b7 ", rightParts)) + .rightColor(Ui.MUTED) + .build()); + } + if (showBar) + { + ProgressBarComponent bar = new ProgressBarComponent(); + bar.setMinimum(0); + bar.setMaximum(100); + bar.setValue(progress * 100); + bar.setLabelDisplayMode(ProgressBarComponent.LabelDisplayMode.TEXT_ONLY); + bar.setCenterLabel(""); + bar.setForegroundColor(Ui.alpha(Ui.UP, 190)); + bar.setBackgroundColor(new Color(0, 0, 0, 120)); + bar.setPreferredSize(new Dimension(0, 8)); + panelComponent.getChildren().add(bar); + } + } + if (order.size() > 1) + { + panelComponent.getChildren().add(LineComponent.builder() + .left("Total") + .leftColor(Ui.GOLD) + .right("+" + Ui.gp(total)) + .rightColor(Ui.GOLD) + .build()); + } + return super.render(graphics); + } + + private static String duration(long ms) + { + long minutes = Math.max(1, ms / Ui.MINUTE); + if (minutes < 60) + { + return minutes + "m"; + } + long hours = minutes / 60; + if (hours < 24) + { + return hours + "h " + (minutes % 60) + "m"; + } + return (hours / 24) + "d " + (hours % 24) + "h"; + } + + private BufferedImage icon(Skill skill) + { + return icons.computeIfAbsent(skill, s -> + { + BufferedImage img = iconManager.getSkillImage(s, true); + return img == null ? null : ImageUtil.resizeImage(img, ICON, ICON); + }); + } +} diff --git a/src/main/java/com/example/essentialsplus/skills/XpSample.java b/src/main/java/com/example/essentialsplus/skills/XpSample.java new file mode 100644 index 0000000..9af33bf --- /dev/null +++ b/src/main/java/com/example/essentialsplus/skills/XpSample.java @@ -0,0 +1,42 @@ +package com.example.essentialsplus.skills; + +/** Experience of every skill at one moment ({@link SkillHistory#SKILLS} order). */ +public class XpSample +{ + public long t; + public int[] x; + + public XpSample() + { + } + + public XpSample(long t, int[] x) + { + this.t = t; + this.x = x; + } + + public long total() + { + long sum = 0; + if (x != null) + { + for (int v : x) + { + sum += v; + } + } + return sum; + } + + public int get(int skill) + { + return x != null && skill >= 0 && skill < x.length ? x[skill] : 0; + } + + /** Skill xp, or the total for {@code skill < 0}. */ + public long value(int skill) + { + return skill < 0 ? total() : get(skill); + } +} diff --git a/src/main/java/com/example/essentialsplus/ui/AccountCard.java b/src/main/java/com/example/essentialsplus/ui/AccountCard.java new file mode 100644 index 0000000..edca26e --- /dev/null +++ b/src/main/java/com/example/essentialsplus/ui/AccountCard.java @@ -0,0 +1,404 @@ +package com.example.essentialsplus.ui; + +import com.example.essentialsplus.data.Snapshot; +import java.awt.BasicStroke; +import java.awt.Color; +import java.awt.Cursor; +import java.awt.Dimension; +import java.awt.Font; +import java.awt.FontMetrics; +import java.awt.GradientPaint; +import java.awt.Graphics; +import java.awt.Graphics2D; +import java.awt.Polygon; +import java.awt.event.MouseAdapter; +import java.awt.event.MouseEvent; +import java.awt.geom.Path2D; +import java.util.Collections; +import java.util.List; +import javax.swing.JComponent; +import javax.swing.JMenuItem; +import javax.swing.JPopupMenu; +import javax.swing.SwingUtilities; +import net.runelite.client.ui.FontManager; + +/** + * Card for one character or - in summary style - the total of all characters. + * Shows value, change over the selected range, status and a sparkline. + */ +class AccountCard extends JComponent +{ + private final boolean summary; + private String title = ""; + private String subtitle = ""; + private long value; + private boolean hasValue; + private long delta; + private double deltaRatio = Double.NaN; + private String rangeLabel = ""; + private String status = ""; + private Color statusColor = Ui.MUTED; + private boolean statusDot; + private List spark = Collections.emptyList(); + private boolean selected; + private boolean hover; + /** summary style options (overview right-click menu) */ + private boolean showSpark = true; + private boolean showPct = true; + private String rightText = ""; + private Runnable onResetHourly; + + AccountCard(boolean summary, Runnable onClick, Runnable onDelete) + { + this(summary, onClick, onDelete, null); + } + + AccountCard(boolean summary, Runnable onClick, Runnable onDelete, Runnable onResetHourly) + { + this.summary = summary; + this.onResetHourly = onResetHourly; + setCursor(Cursor.getPredefinedCursor(Cursor.HAND_CURSOR)); + addMouseListener(new MouseAdapter() + { + @Override + public void mouseEntered(MouseEvent e) + { + hover = true; + repaint(); + } + + @Override + public void mouseExited(MouseEvent e) + { + hover = false; + repaint(); + } + + @Override + public void mousePressed(MouseEvent e) + { + if (e.isPopupTrigger() || SwingUtilities.isRightMouseButton(e)) + { + showMenu(e, onDelete); + } + else if (SwingUtilities.isLeftMouseButton(e)) + { + onClick.run(); + } + } + + @Override + public void mouseReleased(MouseEvent e) + { + if (e.isPopupTrigger()) + { + showMenu(e, onDelete); + } + } + }); + } + + void setOnResetHourly(Runnable onResetHourly) + { + this.onResetHourly = onResetHourly; + } + + private void showMenu(MouseEvent e, Runnable onDelete) + { + if (onDelete == null && onResetHourly == null) + { + return; + } + JPopupMenu menu = new JPopupMenu(); + if (onResetHourly != null) + { + JMenuItem reset = new JMenuItem("Reset Hourly"); + reset.addActionListener(a -> onResetHourly.run()); + menu.add(reset); + } + if (onDelete != null) + { + JMenuItem delete = new JMenuItem("Delete data of " + title + "..."); + delete.addActionListener(a -> onDelete.run()); + menu.add(delete); + } + menu.show(this, e.getX(), e.getY()); + } + + void update(String title, String subtitle, Long value, long delta, double deltaRatio, String rangeLabel, + String status, Color statusColor, boolean statusDot, List spark, boolean selected) + { + update(title, subtitle, value, delta, deltaRatio, rangeLabel, status, statusColor, statusDot, spark, selected, ""); + } + + void update(String title, String subtitle, Long value, long delta, double deltaRatio, String rangeLabel, + String status, Color statusColor, boolean statusDot, List spark, boolean selected, String rightText) + { + this.title = title; + this.subtitle = subtitle; + this.hasValue = value != null; + this.value = value == null ? 0 : value; + this.delta = delta; + this.deltaRatio = deltaRatio; + this.rangeLabel = rangeLabel; + this.status = status; + this.statusColor = statusColor; + this.statusDot = statusDot; + this.spark = spark; + this.selected = selected; + this.rightText = rightText == null ? "" : rightText; + String tip = hasValue ? title + ": " + Ui.exact(this.value) : null; + if (tip != null && !this.rightText.isEmpty()) + { + tip += " (" + this.rightText + ")"; + } + setToolTipText(tip); + repaint(); + } + + void setOptions(boolean showSpark, boolean showPct) + { + this.showSpark = showSpark; + this.showPct = showPct; + revalidate(); + repaint(); + } + + void copyFrom(AccountCard other) + { + if (other == null) + { + return; + } + this.title = other.title; + this.subtitle = other.subtitle; + this.hasValue = other.hasValue; + this.value = other.value; + this.delta = other.delta; + this.deltaRatio = other.deltaRatio; + this.rangeLabel = other.rangeLabel; + this.status = other.status; + this.statusColor = other.statusColor; + this.statusDot = other.statusDot; + this.spark = other.spark; + this.rightText = other.rightText; + this.onResetHourly = other.onResetHourly; + setToolTipText(other.getToolTipText()); + repaint(); + } + + @Override + public Dimension getPreferredSize() + { + return new Dimension(net.runelite.client.ui.PluginPanel.PANEL_WIDTH - 16, summary ? (showSpark ? 125 : 84) : 60); + } + + @Override + public Dimension getMaximumSize() + { + return new Dimension(Integer.MAX_VALUE, summary ? (showSpark ? 130 : 84) : 60); + } + + @Override + public Dimension getMinimumSize() + { + return new Dimension(10, summary ? (showSpark ? 110 : 80) : 50); + } + + @Override + protected void paintComponent(Graphics g0) + { + Graphics2D g = Ui.prepare(g0); + int w = getWidth(); + int h = getHeight(); + + g.setColor(selected ? Ui.CARD_SELECTED : hover ? Ui.CARD_HOVER : Ui.CARD); + g.fillRoundRect(0, 0, w, h, 8, 8); + if (selected) + { + g.setColor(Ui.ACCENT); + g.fillRoundRect(0, 0, 3, h, 3, 3); + } + + int pad = 9; + int right = w - pad; + Font bold = FontManager.getRunescapeBoldFont(); + Font small = FontManager.getRunescapeSmallFont(); + + if (summary) + { + g.setFont(bold); + FontMetrics fm = g.getFontMetrics(); + g.setColor(Ui.ACCENT); + g.drawString(title, pad, 6 + fm.getAscent()); + + g.setFont(bold.deriveFont(14f)); + g.setColor(hover ? Ui.ACCENT : Ui.FAINT); + Ui.drawRight(g, "›", right, 6 + fm.getAscent()); + + if (!subtitle.isEmpty()) + { + g.setFont(small); + g.setColor(Ui.MUTED); + FontMetrics sfm = g.getFontMetrics(); + int titleW = fm.stringWidth(title); + int subW = sfm.stringWidth(subtitle); + int maxSubX = right - 14; + if (maxSubX - subW > pad + titleW + 8) + { + Ui.drawRight(g, subtitle, maxSubX, 6 + fm.getAscent()); + } + } + + Font big = bold.deriveFont(26f); + g.setFont(big); + FontMetrics bfm = g.getFontMetrics(); + int valueBase = 22 + bfm.getAscent(); + g.setColor(hasValue ? Ui.GOLD : Ui.FAINT); + g.drawString(hasValue ? Ui.gp(value) : "-", pad, valueBase); + + g.setFont(small); + FontMetrics sfm = g.getFontMetrics(); + int line = valueBase + sfm.getAscent() + 3; + int x = paintDelta(g, sfm, pad, line); + if (!rangeLabel.isEmpty() && hasValue) + { + g.setColor(Ui.FAINT); + g.drawString(rangeLabel, x + 4, line); + } + if (!status.isEmpty()) + { + g.setColor(statusColor); + Ui.drawRight(g, Ui.ellipsize(sfm, status, w / 2 - pad), right, line); + } + else if (!rightText.isEmpty()) + { + g.setColor(Ui.deltaColor(delta)); + Ui.drawRight(g, rightText, right, line); + } + int sparkH = Math.min(28, Math.max(0, h - line - 10)); + if (sparkH > 6 && showSpark) + { + paintSpark(g, pad, h - sparkH - 6, w - pad * 2, sparkH); + } + } + else + { + g.setFont(bold); + FontMetrics fm = g.getFontMetrics(); + int base1 = 5 + fm.getAscent(); + String valueText = hasValue ? Ui.gp(value) : "-"; + int valueW = fm.stringWidth(valueText); + g.setColor(Ui.TEXT); + g.drawString(Ui.ellipsize(fm, title, w - pad * 2 - valueW - 8), pad, base1); + g.setColor(hasValue ? Ui.GOLD : Ui.FAINT); + Ui.drawRight(g, valueText, right, base1); + + g.setFont(small); + FontMetrics sfm = g.getFontMetrics(); + int base2 = base1 + sfm.getAscent() + 3; + int sx = pad; + if (statusDot) + { + g.setColor(Ui.alpha(statusColor, 70)); + g.fillOval(sx - 1, base2 - sfm.getAscent() / 2 - 4, 8, 8); + g.setColor(statusColor); + g.fillOval(sx + 1, base2 - sfm.getAscent() / 2 - 2, 4, 4); + sx += 10; + } + String deltaText = hasValue && delta != 0 ? Ui.signedGp(delta) : ""; + int deltaW = deltaText.isEmpty() ? 0 : sfm.stringWidth(deltaText) + 9; + g.setColor(statusColor); + g.drawString(Ui.ellipsize(sfm, status, right - sx - deltaW - 6), sx, base2); + if (!deltaText.isEmpty()) + { + paintDelta(g, sfm, right - deltaW, base2); + } + paintSpark(g, pad, base2 + 4, w - pad * 2, h - base2 - 8); + } + Ui.paintDragOutline(g, this); + g.dispose(); + } + + /** Draws "▲ +1.2M (+3.4%)" with a painted triangle. Returns x after the text. */ + private int paintDelta(Graphics2D g, FontMetrics fm, int x, int baseline) + { + if (!hasValue) + { + return x; + } + Color c = Ui.deltaColor(delta); + g.setColor(c); + int cy = baseline - fm.getAscent() / 2 + 1; + if (delta != 0) + { + Polygon tri = delta > 0 + ? new Polygon(new int[]{x, x + 6, x + 3}, new int[]{cy + 2, cy + 2, cy - 3}, 3) + : new Polygon(new int[]{x, x + 6, x + 3}, new int[]{cy - 3, cy - 3, cy + 2}, 3); + g.fillPolygon(tri); + x += 9; + } + String s = Ui.signedGp(delta); + if (summary && showPct && !Double.isNaN(deltaRatio) && delta != 0) + { + s += " (" + Ui.pct(deltaRatio) + ")"; + } + if (!summary && delta == 0) + { + return x; + } + g.drawString(s, x, baseline); + return x + fm.stringWidth(s); + } + + private void paintSpark(Graphics2D g, int x, int y, int w, int h) + { + if (spark.size() < 2 || h < 4 || w < 10) + { + return; + } + long lo = Long.MAX_VALUE; + long hi = Long.MIN_VALUE; + for (Snapshot s : spark) + { + lo = Math.min(lo, s.total()); + hi = Math.max(hi, s.total()); + } + long t0 = spark.get(0).t; + long t1 = Math.max(t0 + 1, spark.get(spark.size() - 1).t); + double span = Math.max(1, hi - lo); + + Path2D line = new Path2D.Double(); + double prevY = 0; + for (int i = 0; i < spark.size(); i++) + { + Snapshot s = spark.get(i); + double px = x + (double) (s.t - t0) / (t1 - t0) * w; + double py = hi == lo ? y + h / 2.0 : y + h - (s.total() - lo) / span * h; + if (i == 0) + { + line.moveTo(px, py); + } + else + { + line.lineTo(px, prevY); + line.lineTo(px, py); + } + prevY = py; + } + Color c = Ui.deltaColor(delta); + if (delta == 0) + { + c = Ui.GOLD; + } + Path2D area = new Path2D.Double(line); + area.lineTo(x + w, y + h); + area.lineTo(x, y + h); + area.closePath(); + g.setPaint(new GradientPaint(0, y, Ui.alpha(c, 60), 0, y + h, Ui.alpha(c, 0))); + g.fill(area); + g.setStroke(new BasicStroke(1.2f)); + g.setColor(Ui.alpha(c, 200)); + g.draw(line); + } +} diff --git a/src/main/java/com/example/essentialsplus/ui/Analysis.java b/src/main/java/com/example/essentialsplus/ui/Analysis.java new file mode 100644 index 0000000..2bf4fcb --- /dev/null +++ b/src/main/java/com/example/essentialsplus/ui/Analysis.java @@ -0,0 +1,419 @@ +package com.example.essentialsplus.ui; + +import com.example.essentialsplus.data.ItemLedger; +import com.example.essentialsplus.data.ItemSnapshot; +import com.example.essentialsplus.data.Snapshot; +import java.time.DayOfWeek; +import java.time.Instant; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.time.temporal.ChronoUnit; +import java.time.temporal.TemporalAdjusters; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** Derived figures: candles, daily changes, statistics and item movers. */ +public final class Analysis +{ + private static final long[] BUCKETS = { + Ui.MINUTE, 5 * Ui.MINUTE, 15 * Ui.MINUTE, 30 * Ui.MINUTE, Ui.HOUR, 2 * Ui.HOUR, 3 * Ui.HOUR, 6 * Ui.HOUR, 12 * Ui.HOUR, Ui.DAY, 7 * Ui.DAY, 30 * Ui.DAY + }; + + private Analysis() + { + } + + // ------------------------------------------------------------------ candles + + static final class Candle + { + long t0; + long t1; + long o; + long h; + long l; + long c; + boolean empty; + + long change() + { + return c - o; + } + } + + /** Smallest bucket size yielding at most {@code maxCount} candles. */ + static long pickBucket(long span, int maxCount) + { + for (long b : BUCKETS) + { + if (span / b <= maxCount) + { + return b; + } + } + return BUCKETS[BUCKETS.length - 1]; + } + + static long bucketStart(long t, long bucket) + { + ZonedDateTime z = Instant.ofEpochMilli(t).atZone(ZoneId.systemDefault()); + if (bucket >= 30 * Ui.DAY) + { + return z.truncatedTo(ChronoUnit.DAYS).withDayOfMonth(1).toInstant().toEpochMilli(); + } + if (bucket >= 7 * Ui.DAY) + { + return z.truncatedTo(ChronoUnit.DAYS).with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)).toInstant().toEpochMilli(); + } + if (bucket >= Ui.DAY) + { + return z.truncatedTo(ChronoUnit.DAYS).toInstant().toEpochMilli(); + } + if (bucket < Ui.HOUR) + { + int minutes = (int) (bucket / Ui.MINUTE); + ZonedDateTime m = z.truncatedTo(ChronoUnit.MINUTES); + return m.withMinute(m.getMinute() - m.getMinute() % minutes).toInstant().toEpochMilli(); + } + int hours = (int) (bucket / Ui.HOUR); + ZonedDateTime h = z.truncatedTo(ChronoUnit.HOURS); + return h.withHour(h.getHour() - h.getHour() % hours).toInstant().toEpochMilli(); + } + + static long bucketEnd(long bucketStart, long bucket) + { + ZonedDateTime z = Instant.ofEpochMilli(bucketStart).atZone(ZoneId.systemDefault()); + if (bucket >= 30 * Ui.DAY) + { + return z.plusMonths(1).toInstant().toEpochMilli(); + } + if (bucket >= 7 * Ui.DAY) + { + return z.plusWeeks(1).toInstant().toEpochMilli(); + } + if (bucket >= Ui.DAY) + { + return z.plusDays(1).toInstant().toEpochMilli(); + } + return bucketStart + bucket; + } + + /** + * OHLC candles over [start, end]. A candle opens at the previous close; buckets without data + * become flat candles once a value is known. + */ + static List candles(List pts, long start, long end, long bucket) + { + List out = new ArrayList<>(); + if (pts.isEmpty()) + { + return out; + } + int i = 0; + Long prevClose = null; + for (long b0 = bucketStart(start, bucket); b0 <= end; ) + { + long b1 = bucketEnd(b0, bucket); + Candle c = new Candle(); + c.t0 = b0; + c.t1 = b1; + boolean any = false; + long h = Long.MIN_VALUE; + long l = Long.MAX_VALUE; + long first = 0; + long last = 0; + while (i < pts.size() && (pts.get(i).t < b1 || (b1 > end && pts.get(i).t <= end))) + { + Snapshot p = pts.get(i++); + if (!any) + { + first = p.open(); + } + any = true; + h = Math.max(h, p.high()); + l = Math.min(l, p.low()); + last = p.close(); + } + if (any) + { + c.o = prevClose != null ? prevClose : first; + c.h = Math.max(h, c.o); + c.l = Math.min(l, c.o); + c.c = last; + prevClose = last; + out.add(c); + } + else if (prevClose != null) + { + c.o = c.h = c.l = c.c = prevClose; + c.empty = true; + out.add(c); + } + b0 = b1; + } + return out; + } + + static long changeBucket(long span) + { + if (span <= 3 * Ui.HOUR) + { + return 5 * Ui.MINUTE; + } + if (span <= 2 * Ui.DAY) + { + return Ui.HOUR; + } + if (span <= 400 * Ui.DAY) + { + return Ui.DAY; + } + return 7 * Ui.DAY; + } + + // ------------------------------------------------------------------ statistics + + public static final class Stats + { + public boolean has; + public long change; + public double ratio = Double.NaN; + public long perDay; + public long peak; + public long peakT; + public long low; + public long lowT; + public Candle best; + public Candle worst; + public int up; + public int down; + public int periods; + public double maxDrawdown; + public double volatility = Double.NaN; + public long bucket; + } + + static Stats stats(List pts) + { + Stats s = new Stats(); + if (pts.isEmpty()) + { + return s; + } + s.has = true; + long first = pts.get(0).total(); + long last = pts.get(pts.size() - 1).total(); + s.change = last - first; + s.ratio = first != 0 ? (double) s.change / first : Double.NaN; + + long t0 = pts.get(0).t; + long t1 = pts.get(pts.size() - 1).t; + s.perDay = Math.round(s.change / Math.max(1.0, (t1 - t0) / (double) Ui.DAY)); + + s.peak = Long.MIN_VALUE; + s.low = Long.MAX_VALUE; + double peakSoFar = 0; + for (Snapshot p : pts) + { + if (p.high() > s.peak) + { + s.peak = p.high(); + s.peakT = p.t; + } + if (p.low() < s.low) + { + s.low = p.low(); + s.lowT = p.t; + } + if (peakSoFar > 0) + { + s.maxDrawdown = Math.max(s.maxDrawdown, (peakSoFar - p.low()) / peakSoFar); + } + peakSoFar = Math.max(peakSoFar, p.high()); + } + + s.bucket = changeBucket(t1 - t0); + List periods = candles(pts, t0, t1, s.bucket); + List returns = new ArrayList<>(); + for (Candle c : periods) + { + long ch = c.change(); + if (ch > 0) + { + s.up++; + } + else if (ch < 0) + { + s.down++; + } + if (ch != 0 && (s.best == null || ch > s.best.change())) + { + s.best = c; + } + if (ch != 0 && (s.worst == null || ch < s.worst.change())) + { + s.worst = c; + } + if (c.o > 0) + { + returns.add((double) ch / c.o); + } + } + if (s.worst != null && s.worst.change() >= 0) + { + s.worst = null; + } + if (s.best != null && s.best.change() <= 0) + { + s.best = null; + } + s.periods = periods.size(); + if (returns.size() >= 2) + { + double mean = returns.stream().mapToDouble(d -> d).average().orElse(0); + double var = returns.stream().mapToDouble(d -> (d - mean) * (d - mean)).sum() / (returns.size() - 1); + s.volatility = Math.sqrt(var); + } + return s; + } + + // ------------------------------------------------------------------ item movers + + static final class Mover + { + int id; + long q0; + long q1; + long v0; + long v1; + long priceEffect; + long qtyEffect; + boolean ignorePrices; + + long delta() + { + return ignorePrices ? qtyEffect : v1 - v0; + } + + double priceChange() + { + long p0 = q0 > 0 ? v0 / q0 : 0; + long p1 = q1 > 0 ? v1 / q1 : 0; + return p0 > 0 && p1 > 0 ? (double) (p1 - p0) / p0 : Double.NaN; + } + } + + static final class Movers + { + boolean available; + /** price movements left out: only quantity changes count */ + boolean ignorePrices; + long since; + long priceEffect; + long qtyEffect; + List gainers = new ArrayList<>(); + List losers = new ArrayList<>(); + } + + /** + * Compares holdings at {@code start} with the latest holdings and splits every item's value change + * into a market effect (old quantity x price change) and an activity effect (quantity change x new price). + */ + static Movers movers(List ledgers, long start, int limit, boolean ignorePrices) + { + Movers m = new Movers(); + m.ignorePrices = ignorePrices; + Map byId = new HashMap<>(); + m.since = Long.MAX_VALUE; + long idleSince = Long.MAX_VALUE; + for (ItemLedger ledger : ledgers) + { + ItemSnapshot end = ledger.last(); + ItemSnapshot begin = ledger.at(start); + if (end == null || begin == null) + { + continue; + } + m.available = true; + if (begin == end) + { + // unchanged characters (e.g. not played) must not push "since" back to their last login + idleSince = Math.min(idleSince, begin.t); + continue; + } + m.since = Math.min(m.since, begin.t); + + Map a = toMap(begin); + Map b = toMap(end); + java.util.Set ids = new java.util.HashSet<>(a.keySet()); + ids.addAll(b.keySet()); + for (int id : ids) + { + long[] x = a.getOrDefault(id, new long[]{0, 0}); + long[] y = b.getOrDefault(id, new long[]{0, 0}); + long p0 = x[1] > 0 ? x[1] : y[1]; + long p1 = y[1] > 0 ? y[1] : x[1]; + Mover mv = byId.computeIfAbsent(id, k -> + { + Mover n = new Mover(); + n.id = k; + n.ignorePrices = ignorePrices; + return n; + }); + mv.q0 += x[0]; + mv.q1 += y[0]; + mv.v0 += x[0] * x[1]; + mv.v1 += y[0] * y[1]; + mv.priceEffect += x[0] * (p1 - p0); + mv.qtyEffect += (y[0] - x[0]) * p1; + } + } + + if (m.since == Long.MAX_VALUE) + { + m.since = idleSince; + } + List all = new ArrayList<>(); + for (Mover mv : byId.values()) + { + if (ignorePrices) + { + mv.priceEffect = 0; + } + m.priceEffect += mv.priceEffect; + m.qtyEffect += mv.qtyEffect; + if (mv.delta() != 0) + { + all.add(mv); + } + } + all.sort((x, y) -> Long.compare(y.delta(), x.delta())); + for (Mover mv : all) + { + if (mv.delta() > 0 && m.gainers.size() < limit) + { + m.gainers.add(mv); + } + } + for (int i = all.size() - 1; i >= 0 && m.losers.size() < limit; i--) + { + if (all.get(i).delta() < 0) + { + m.losers.add(all.get(i)); + } + } + return m; + } + + private static Map toMap(ItemSnapshot s) + { + Map map = new HashMap<>(); + for (int i = 0; i < s.ids.length; i++) + { + map.put(s.ids[i], new long[]{s.q[i], s.p[i]}); + } + return map; + } +} diff --git a/src/main/java/com/example/essentialsplus/ui/BreakdownView.java b/src/main/java/com/example/essentialsplus/ui/BreakdownView.java new file mode 100644 index 0000000..5a5c897 --- /dev/null +++ b/src/main/java/com/example/essentialsplus/ui/BreakdownView.java @@ -0,0 +1,300 @@ +package com.example.essentialsplus.ui; + +import com.example.essentialsplus.WealthCategory; +import com.example.essentialsplus.data.SourceValue; +import java.awt.Dimension; +import java.awt.FontMetrics; +import java.awt.Graphics; +import java.awt.Graphics2D; +import java.awt.event.MouseEvent; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import javax.swing.JComponent; +import net.runelite.client.ui.FontManager; + +/** Current value per category with share, change over the range, share bar and individual sources. */ +class BreakdownView extends JComponent +{ + private static final int ROW = 40; + private static final int SUB = 15; + + private static final class Row + { + WealthCategory category; + long value; + long start; + boolean hasStart; + List subs = new ArrayList<>(); + int y; + } + + private final List rows = new ArrayList<>(); + private final List hits = new ArrayList<>(); + private long total; + private int hoverY = -1; + private java.util.function.BiConsumer onOpen; + + BreakdownView() + { + setToolTipText(""); + java.awt.event.MouseAdapter mouse = new java.awt.event.MouseAdapter() + { + @Override + public void mouseMoved(MouseEvent e) + { + hoverY = e.getY(); + Object[] hit = hitAt(e.getY()); + setCursor(java.awt.Cursor.getPredefinedCursor(hit != null ? java.awt.Cursor.HAND_CURSOR : java.awt.Cursor.DEFAULT_CURSOR)); + repaint(); + } + + @Override + public void mouseExited(MouseEvent e) + { + hoverY = -1; + repaint(); + } + + @Override + public void mousePressed(MouseEvent e) + { + Object[] hit = hitAt(e.getY()); + if (hit != null && onOpen != null) + { + onOpen.accept((String) hit[2], (String) hit[3]); + } + } + }; + addMouseListener(mouse); + addMouseMotionListener(mouse); + } + + /** Called with (view, label) when a row is clicked. */ + void setOnOpen(java.util.function.BiConsumer onOpen) + { + this.onOpen = onOpen; + } + + private Object[] hitAt(int y) + { + for (Object[] h : hits) + { + if (y >= (int) h[0] && y < (int) h[1]) + { + return h; + } + } + return null; + } + + static String viewOf(WealthCategory c) + { + switch (c) + { + case BANK: + return "bank"; + case INVENTORY: + return "inventory"; + case EQUIPMENT: + return "equipment"; + case STORAGE: + return "storage"; + case COFFERS: + return "coffers"; + case GRAND_EXCHANGE: + return "ge"; + default: + return null; + } + } + + private static String viewOf(String sourceName, WealthCategory c) + { + switch (sourceName) + { + case "Looting bag": + return "lootingBag"; + case "Seed vault": + return "seedVault"; + case "Rune pouch": + return "runePouch"; + default: + return viewOf(c); + } + } + + /** + * @param start breakdown at the start of the range, or null if unknown + */ + void update(long[] current, List sources, long[] start) + { + rows.clear(); + total = 0; + if (current != null) + { + for (WealthCategory c : WealthCategory.values()) + { + long v = c.ordinal() < current.length ? current[c.ordinal()] : 0; + long s = start != null && c.ordinal() < start.length ? start[c.ordinal()] : 0; + total += v; + if (v == 0 && s == 0) + { + continue; + } + Row r = new Row(); + r.category = c; + r.value = v; + r.start = s; + r.hasStart = start != null && c != WealthCategory.IMPORTED && (start.length <= WealthCategory.IMPORTED.ordinal() || start[WealthCategory.IMPORTED.ordinal()] == 0); + + Map merged = new LinkedHashMap<>(); + for (SourceValue sv : sources) + { + if (sv.category == c.ordinal() && sv.value > 0) + { + merged.merge(sv.name, sv.value, Long::sum); + } + } + boolean onlySelf = merged.size() == 1 && merged.containsKey(c.getLabel()); + if (!onlySelf) + { + merged.forEach((n, v2) -> r.subs.add(new SourceValue(n, c.ordinal(), v2))); + r.subs.sort((a, b) -> Long.compare(b.value, a.value)); + } + rows.add(r); + } + } + rows.removeIf(r -> r.value == 0 && r.category == WealthCategory.IMPORTED); + rows.sort((a, b) -> Long.compare(b.value, a.value)); + revalidate(); + repaint(); + } + + @Override + public String getToolTipText(MouseEvent e) + { + for (Row r : rows) + { + if (e.getY() >= r.y && e.getY() < r.y + ROW) + { + String s = "" + r.category.getLabel() + "
" + Ui.exact(r.value); + if (r.hasStart) + { + s += "
Start of range: " + Ui.exact(r.start) + "
Change: " + Ui.exact(r.value - r.start); + } + return s + ""; + } + } + return null; + } + + @Override + public Dimension getPreferredSize() + { + int h = 8; + for (Row r : rows) + { + h += ROW + r.subs.size() * SUB; + } + return new Dimension(10, rows.isEmpty() ? 40 : h); + } + + @Override + protected void paintComponent(Graphics g0) + { + Graphics2D g = Ui.prepare(g0); + int w = getWidth(); + g.setColor(Ui.CARD); + g.fillRoundRect(0, 0, w, getHeight(), 8, 8); + + g.setFont(FontManager.getRunescapeSmallFont()); + FontMetrics sfm = g.getFontMetrics(); + if (rows.isEmpty()) + { + g.setColor(Ui.MUTED); + String s = "No data yet"; + g.drawString(s, (w - sfm.stringWidth(s)) / 2, 20 + sfm.getAscent() / 2); + g.dispose(); + return; + } + + int pad = 8; + int y = 4; + hits.clear(); + for (Row r : rows) + { + r.y = y; + String view = viewOf(r.category); + if (view != null) + { + hits.add(new Object[]{y, y + ROW, view, r.category.getLabel()}); + if (hoverY >= y && hoverY < y + ROW) + { + g.setColor(Ui.CARD_HOVER.brighter()); + g.fillRoundRect(3, y, w - 6, ROW, 6, 6); + } + } + double share = total > 0 ? (double) r.value / total : 0; + + g.setFont(FontManager.getRunescapeFont()); + FontMetrics fm = g.getFontMetrics(); + int base = y + 3 + fm.getAscent(); + g.setColor(r.category.getColor()); + g.fillRoundRect(pad, base - fm.getAscent() + 3, 8, 8, 3, 3); + g.setColor(Ui.TEXT); + g.drawString(r.category.getLabel(), pad + 13, base); + g.setColor(Ui.GOLD); + Ui.drawRight(g, Ui.gp(r.value), w - pad, base); + + g.setFont(FontManager.getRunescapeSmallFont()); + int base2 = base + sfm.getAscent() + 1; + g.setColor(Ui.MUTED); + g.drawString(String.format(Locale.US, "%.1f%%", share * 100), pad + 13, base2); + if (r.hasStart) + { + long d = r.value - r.start; + g.setColor(Ui.deltaColor(d)); + String ds = d == 0 ? "±0" : Ui.signedGp(d); + if (d != 0 && r.start > 0) + { + ds += " (" + Ui.pct((double) d / r.start) + ")"; + } + Ui.drawRight(g, ds, w - pad, base2); + } + + int barY = y + ROW - 7; + int barW = w - pad * 2; + g.setColor(Ui.alpha(java.awt.Color.WHITE, 14)); + g.fillRoundRect(pad, barY, barW, 4, 4, 4); + g.setColor(r.category.getColor()); + g.fillRoundRect(pad, barY, Math.max(3, (int) Math.round(barW * share)), 4, 4, 4); + y += ROW; + + for (SourceValue s : r.subs) + { + String subView = viewOf(s.name, r.category); + if (subView != null) + { + hits.add(new Object[]{y, y + SUB, subView, s.name}); + if (hoverY >= y && hoverY < y + SUB) + { + g.setColor(Ui.CARD_HOVER.brighter()); + g.fillRoundRect(3, y, w - 6, SUB, 4, 4); + } + } + int sb = y + sfm.getAscent(); + g.setColor(Ui.FAINT); + g.fillOval(pad + 14, sb - sfm.getAscent() / 2 - 1, 3, 3); + String val = Ui.gp(s.value); + g.setColor(Ui.MUTED); + g.drawString(Ui.ellipsize(sfm, s.name, w - pad * 2 - 24 - sfm.stringWidth(val) - 6), pad + 22, sb); + Ui.drawRight(g, val, w - pad, sb); + y += SUB; + } + } + g.dispose(); + } +} diff --git a/src/main/java/com/example/essentialsplus/ui/CardDrag.java b/src/main/java/com/example/essentialsplus/ui/CardDrag.java new file mode 100644 index 0000000..73539b1 --- /dev/null +++ b/src/main/java/com/example/essentialsplus/ui/CardDrag.java @@ -0,0 +1,138 @@ +package com.example.essentialsplus.ui; + +import java.awt.Component; +import java.awt.Container; +import java.awt.Cursor; +import java.awt.Point; +import java.awt.event.MouseAdapter; +import java.awt.event.MouseEvent; +import java.util.function.Supplier; +import javax.swing.JComponent; +import javax.swing.JPopupMenu; +import javax.swing.SwingUtilities; + +/** + * Overview card mouse handling: left click opens, left drag moves the card within its container + * (live, other cards make room), right click shows the card's options. + */ +final class CardDrag extends MouseAdapter +{ + private static final int THRESHOLD = 5; + + private final JComponent card; + private final Container container; + /** components before this index (the title) never move */ + private final int firstIndex; + private final Runnable onClick; + private final Supplier menu; + private final Runnable onDrop; + private Point pressed; + private boolean dragging; + + private CardDrag(JComponent card, Container container, int firstIndex, Runnable onClick, Supplier menu, Runnable onDrop) + { + this.card = card; + this.container = container; + this.firstIndex = firstIndex; + this.onClick = onClick; + this.menu = menu; + this.onDrop = onDrop; + } + + static void install(JComponent card, Container container, int firstIndex, Runnable onClick, Supplier menu, Runnable onDrop) + { + CardDrag d = new CardDrag(card, container, firstIndex, onClick, menu, onDrop); + card.addMouseListener(d); + card.addMouseMotionListener(d); + } + + /** True while a card is being dragged: cards skip their hover look. */ + static boolean isDragging(JComponent card) + { + return Boolean.TRUE.equals(card.getClientProperty("cardDragging")); + } + + @Override + public void mousePressed(MouseEvent e) + { + if (e.isPopupTrigger() || SwingUtilities.isRightMouseButton(e)) + { + JPopupMenu m = menu.get(); + if (m != null) + { + m.show(card, e.getX(), e.getY()); + } + return; + } + if (SwingUtilities.isLeftMouseButton(e)) + { + pressed = e.getLocationOnScreen(); + dragging = false; + } + } + + @Override + public void mouseDragged(MouseEvent e) + { + if (pressed == null || !SwingUtilities.isLeftMouseButton(e)) + { + return; + } + if (!dragging) + { + if (Math.abs(e.getYOnScreen() - pressed.y) < THRESHOLD && Math.abs(e.getXOnScreen() - pressed.x) < THRESHOLD) + { + return; + } + dragging = true; + card.putClientProperty("cardDragging", true); + card.setCursor(Cursor.getPredefinedCursor(Cursor.MOVE_CURSOR)); + card.repaint(); + } + int y = SwingUtilities.convertPoint(card, e.getPoint(), container).y; + int target = firstIndex; + int current = -1; + Component[] comps = container.getComponents(); + for (int i = firstIndex; i < comps.length; i++) + { + if (comps[i] == card) + { + current = i; + continue; + } + if (comps[i].getY() + comps[i].getHeight() / 2 < y) + { + target++; + } + } + if (current >= 0 && target != current) + { + container.remove(card); + container.add(card, target); + container.revalidate(); + container.repaint(); + } + } + + @Override + public void mouseReleased(MouseEvent e) + { + if (pressed == null) + { + return; + } + pressed = null; + if (dragging) + { + dragging = false; + card.putClientProperty("cardDragging", false); + card.setCursor(Cursor.getPredefinedCursor(Cursor.HAND_CURSOR)); + card.repaint(); + onDrop.run(); + } + else if (SwingUtilities.isLeftMouseButton(e) && card.contains(e.getPoint())) + { + onClick.run(); + } + } +} diff --git a/src/main/java/com/example/essentialsplus/ui/EssentialsPlusPanel.java b/src/main/java/com/example/essentialsplus/ui/EssentialsPlusPanel.java new file mode 100644 index 0000000..d4d9f34 --- /dev/null +++ b/src/main/java/com/example/essentialsplus/ui/EssentialsPlusPanel.java @@ -0,0 +1,806 @@ +package com.example.essentialsplus.ui; + +import com.example.essentialsplus.EssentialsPlusPlugin; +import com.example.essentialsplus.WealthStore; +import com.example.essentialsplus.skills.SkillHistory; +import com.example.essentialsplus.currency.ui.CurrencyPanel; +import com.example.essentialsplus.loot.ui.LootSummaryCard; +import com.example.essentialsplus.loot.ui.LootTrackerPanel; +import com.example.essentialsplus.progress.ui.ProgressCard; +import com.example.essentialsplus.progress.ui.ProgressPanel; +import com.example.essentialsplus.wiki.ui.WikiCard; +import com.example.essentialsplus.wiki.ui.WikiPanel; +import java.awt.BorderLayout; +import java.awt.CardLayout; +import java.awt.Cursor; +import java.awt.Dimension; +import java.awt.GridLayout; +import javax.swing.BorderFactory; +import javax.swing.JButton; +import javax.swing.JCheckBoxMenuItem; +import javax.swing.JComponent; +import javax.swing.JMenu; +import javax.swing.JMenuItem; +import javax.swing.JPopupMenu; +import javax.swing.JRadioButtonMenuItem; +import javax.swing.JScrollPane; +import javax.swing.ScrollPaneConstants; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import javax.swing.JLabel; +import javax.swing.JPanel; +import javax.swing.SwingUtilities; +import net.runelite.client.ui.ColorScheme; +import net.runelite.client.ui.FontManager; +import net.runelite.client.ui.PluginPanel; +import net.runelite.client.util.LinkBrowser; + +/** + * Root side-panel for the Essentials Plus suite. + * Hosts the Feature Overview and sub-feature views (Wealth Tracker, Loot Tracker, Skills, Achievements, Wiki) plus a footer + * with the global time range and utilities that stays visible in every view. + */ +public class EssentialsPlusPanel extends PluginPanel +{ + private static final String VIEW_OVERVIEW = "OVERVIEW"; + private static final String VIEW_WEALTH = "WEALTH"; + private static final String VIEW_LOOT = "LOOT"; + private static final String VIEW_SKILLS = "SKILLS"; + private static final String VIEW_WIKI = "WIKI"; + private static final String VIEW_PROGRESS = "PROGRESS"; + private static final String VIEW_CURRENCY = "CURRENCY"; + + private final CardLayout cardLayout = new CardLayout(); + private final JPanel container = new JPanel(cardLayout); + + private final EssentialsPlusPlugin plugin; + private final OverviewPanel overviewPanel; + private final WealthPanel wealthPanel; + private final LootTrackerPanel lootTrackerPanel; + private final SkillsPanel skillsPanel; + private final WikiPanel wikiPanel; + private final ProgressPanel progressPanel; + private final CurrencyPanel currencyPanel; + private final Runnable currencyListener; + /** collection log / diary / combat achievement changes, from the client thread */ + private final Runnable progressListener; + private final Segmented rangeBar; + private final Runnable rangeListener; + /** "Now" and "Sess" move with the clock; loot views only refresh on new loot otherwise */ + private final javax.swing.Timer clockTicker; + + public EssentialsPlusPanel(EssentialsPlusPlugin plugin) + { + super(false); + this.plugin = plugin; + + setLayout(new BorderLayout()); + setBackground(Ui.BG); + + overviewPanel = new OverviewPanel(this, plugin); + wealthPanel = new WealthPanel(plugin, this::showOverview); + lootTrackerPanel = new LootTrackerPanel(plugin.getLootTrackerStore(), plugin.getItemManager(), plugin.getWikiApi(), plugin.getWikiExecutor(), + plugin.getCollectionLog(), plugin.getTimeFilter(), plugin.getFightService(), plugin::openWikiPage, this::showOverview); + skillsPanel = new SkillsPanel(plugin, this::showOverview); + wikiPanel = new WikiPanel(plugin, this::showOverview); + progressPanel = new ProgressPanel(plugin, this::showOverview); + currencyPanel = new CurrencyPanel(plugin.getCurrencyTracker(), plugin.getItemManager(), plugin.getTimeFilter(), this::showOverview); + currencyListener = currencyPanel::refreshLater; + plugin.getCurrencyTracker().addListener(currencyListener); + overviewPanel.attach(currencyPanel, wealthPanel, progressPanel); + progressListener = () -> SwingUtilities.invokeLater(overviewPanel::refreshProgress); + plugin.getCollectionLog().addListener(progressListener); + plugin.getProgress().addListener(progressListener); + + // Synchronize the summary card on the overview panel with WealthPanel's live data + wealthPanel.setOnSummaryUpdated(overviewPanel::updateSummaryCard); + + container.setOpaque(false); + container.add(overviewPanel, VIEW_OVERVIEW); + container.add(wealthPanel.getWrappedPanel(), VIEW_WEALTH); + container.add(lootTrackerPanel, VIEW_LOOT); + container.add(skillsPanel.getWrappedPanel(), VIEW_SKILLS); + container.add(wikiPanel, VIEW_WIKI); + container.add(progressPanel, VIEW_PROGRESS); + container.add(currencyPanel, VIEW_CURRENCY); + + add(container, BorderLayout.CENTER); + rangeBar = Segmented.ranges(plugin.getTimeFilter().getRange(), plugin.getTimeFilter()::set); + add(buildFooter(), BorderLayout.SOUTH); + + TimeFilter filter = plugin.getTimeFilter(); + rangeListener = () -> SwingUtilities.invokeLater(() -> + { + rangeBar.setSelected(filter.getRange().ordinal()); + overviewPanel.refreshSkills(); + currencyPanel.refresh(); + }); + filter.addListener(rangeListener); + clockTicker = new javax.swing.Timer(10_000, e -> + { + if (filter.isCurrent()) + { + lootTrackerPanel.rebuild(); + overviewPanel.refreshLoot(); + overviewPanel.refreshSkills(); + } + // Keep wealth and currency overview cards fresh + wealthPanel.refreshLater(); + currencyPanel.refresh(); + }); + clockTicker.start(); + + cardLayout.show(container, VIEW_OVERVIEW); + } + + /** Global footer: time range for every view + refresh / data folder. */ + private JPanel buildFooter() + { + JPanel footer = new JPanel(new BorderLayout(0, 4)); + footer.setBackground(Ui.BG); + footer.setBorder(BorderFactory.createCompoundBorder( + BorderFactory.createMatteBorder(1, 0, 0, 0, ColorScheme.DARKER_GRAY_COLOR), + BorderFactory.createEmptyBorder(6, 8, 8, 8))); + + rangeBar.setPreferredSize(new Dimension(10, 24)); + rangeBar.setToolTipText("Time range for Wealth, Loot, Skills and the xp HUD"); + footer.add(rangeBar, BorderLayout.NORTH); + + JPanel buttons = new JPanel(new GridLayout(1, 2, 6, 0)); + buttons.setOpaque(false); + buttons.add(createSmallButton("Refresh", () -> + { + plugin.requestUpdate(); + refreshLater(); + skillsPanel.refreshLater(); + lootTrackerPanel.rebuild(); + })); + buttons.add(createSmallButton("Data Folder", () -> + { + WealthStore.DIR.mkdirs(); + LinkBrowser.open(WealthStore.DIR.getAbsolutePath()); + })); + footer.add(buttons, BorderLayout.CENTER); + return footer; + } + + public void showOverview() + { + cardLayout.show(container, VIEW_OVERVIEW); + wealthPanel.refreshLater(); + overviewPanel.refreshLoot(); + overviewPanel.refreshSkills(); + overviewPanel.refreshWiki(); + } + + public void showWealthTracker() + { + cardLayout.show(container, VIEW_WEALTH); + wealthPanel.refreshLater(); + } + + public void showLootTracker() + { + cardLayout.show(container, VIEW_LOOT); + lootTrackerPanel.rebuild(); + } + + public void showSkills() + { + cardLayout.show(container, VIEW_SKILLS); + skillsPanel.refreshLater(); + } + + public void showProgress() + { + cardLayout.show(container, VIEW_PROGRESS); + progressPanel.onShow(); + } + + public void showCurrencies() + { + cardLayout.show(container, VIEW_CURRENCY); + currencyPanel.refresh(); + } + + public void showWiki() + { + cardLayout.show(container, VIEW_WIKI); + wikiPanel.onShow(); + } + + /** Helper switches changed in the config. */ + public void refreshHelpersLater() + { + SwingUtilities.invokeLater(overviewPanel::refreshHelpers); + } + + /** A wiki page was opened in-game: update the "last viewed" line of the overview card. */ + public void refreshWikiLater() + { + SwingUtilities.invokeLater(overviewPanel::refreshWiki); + } + + public void refreshLater() + { + wealthPanel.refreshLater(); + overviewPanel.refreshLoot(); + refreshSkillsLater(); + } + + /** Xp changed: coalesced refresh of the skills tab and overview card. */ + public void refreshSkillsLater() + { + SwingUtilities.invokeLater(() -> + { + skillsPanel.refreshLater(); + overviewPanel.refreshSkillsThrottled(); + }); + } + + public void dispose() + { + clockTicker.stop(); + wealthPanel.dispose(); + skillsPanel.dispose(); + plugin.getTimeFilter().removeListener(rangeListener); + plugin.getCollectionLog().removeListener(progressListener); + plugin.getProgress().removeListener(progressListener); + plugin.getCurrencyTracker().removeListener(currencyListener); + } + + public WealthPanel getWealthPanel() + { + return wealthPanel; + } + + public void refreshWealthLater() + { + if (wealthPanel != null) + { + wealthPanel.refreshLater(); + } + } + + private static JButton createSmallButton(String text, Runnable action) + { + JButton b = new JButton(text); + b.setFont(FontManager.getRunescapeSmallFont()); + b.setForeground(Ui.MUTED); + b.setFocusPainted(false); + b.setPreferredSize(new Dimension(b.getPreferredSize().width, 24)); + b.setMargin(new java.awt.Insets(2, 4, 2, 4)); + b.setCursor(Cursor.getPredefinedCursor(Cursor.HAND_CURSOR)); + b.addActionListener(e -> action.run()); + return b; + } + + /** + * Main Overview view listing the features available in Essentials Plus. Cards can be dragged into a new order + * (left mouse) and have display options (right mouse); both are saved in the config. + */ + static final class OverviewPanel extends JPanel + { + private static final String CURRENCY = "currency"; + private static final String WEALTH = "wealth"; + private static final String LOOT = "loot"; + private static final String SKILLS = "skills"; + private static final String PROGRESS = "progress"; + private static final String WIKI = "wiki"; + private static final String HELPERS = "helpers"; + private static final List DEFAULT_ORDER = Arrays.asList(CURRENCY, WEALTH, LOOT, SKILLS, PROGRESS, WIKI, HELPERS); + private static final Map NAMES = new HashMap<>(); + + static + { + NAMES.put(CURRENCY, "Points & Marks"); + NAMES.put(WEALTH, "Total wealth"); + NAMES.put(LOOT, "Fights & Loot"); + NAMES.put(SKILLS, "Skills"); + NAMES.put(PROGRESS, "Achievements"); + NAMES.put(WIKI, "Wiki"); + NAMES.put(HELPERS, "Helpers"); + } + + private final EssentialsPlusPanel parent; + private final EssentialsPlusPlugin plugin; + private final AccountCard totalWealthCard; + private final LootSummaryCard lootSummaryCard; + private final SkillsCard skillsCard; + private final WikiCard wikiCard; + private final ProgressCard progressCard; + private final HelpersCard helpersCard; + private final JPanel topContainer; + /** card id -> component, in default order */ + private final Map cards = new LinkedHashMap<>(); + private ProgressPanel progressPanel; + private CurrencyPanel currencyPanel; + private WealthPanel wealthPanel; + private long lastSkillsPaint; + + OverviewPanel(EssentialsPlusPanel parent, EssentialsPlusPlugin plugin) + { + this.parent = parent; + this.plugin = plugin; + + setBackground(Ui.BG); + setLayout(new BorderLayout()); + + topContainer = new JPanel(new net.runelite.client.ui.DynamicGridLayout(0, 1, 0, 8)); + topContainer.setOpaque(false); + topContainer.setBorder(BorderFactory.createEmptyBorder(10, 8, 8, 8)); + + // Header title banner (clean, bold title only) + JPanel header = new JPanel(new BorderLayout()); + header.setOpaque(false); + header.setBorder(BorderFactory.createEmptyBorder(2, 2, 4, 2)); + + JLabel title = new JLabel("Essentials Plus"); + title.setFont(FontManager.getRunescapeBoldFont().deriveFont(16f)); + title.setForeground(ColorScheme.BRAND_ORANGE); + title.setToolTipText("Drag cards to reorder, right-click a card for options"); + header.add(title, BorderLayout.WEST); + header.addMouseListener(new java.awt.event.MouseAdapter() + { + @Override + public void mousePressed(java.awt.event.MouseEvent e) + { + if (e.isPopupTrigger() || SwingUtilities.isRightMouseButton(e)) + { + JPopupMenu menu = new JPopupMenu(); + addLayoutItems(menu); + menu.show(header, e.getX(), e.getY()); + } + } + }); + + topContainer.add(header); + + // cards open their tab through CardDrag (on release, so a drag does not open it) + Runnable none = () -> { }; + totalWealthCard = new AccountCard(true, none, null); + lootSummaryCard = new LootSummaryCard(plugin.getLootTrackerStore(), plugin.getItemManager(), plugin.getTimeFilter(), null); + plugin.getFightService().addListener(snap -> SwingUtilities.invokeLater(() -> lootSummaryCard.setFight(snap))); + skillsCard = new SkillsCard(none); + progressCard = new ProgressCard(none); + wikiCard = new WikiCard(none); + helpersCard = new HelpersCard(plugin); + refreshSkills(); + refreshWiki(); + + JPanel listWrap = new JPanel(new BorderLayout()); + listWrap.setOpaque(false); + listWrap.add(topContainer, BorderLayout.NORTH); + + JScrollPane scroll = new JScrollPane(listWrap); + scroll.setBorder(null); + scroll.setOpaque(false); + scroll.getViewport().setOpaque(false); + scroll.setHorizontalScrollBarPolicy(ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER); + scroll.setVerticalScrollBarPolicy(ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED); + scroll.getVerticalScrollBar().setUnitIncrement(16); + scroll.getVerticalScrollBar().setPreferredSize(new Dimension(8, 0)); + + add(scroll, BorderLayout.CENTER); + } + + /** Second step once all tabs exist: registers the cards, applies the saved options and order. */ + void attach(CurrencyPanel currencyPanel, WealthPanel wealthPanel, ProgressPanel progressPanel) + { + this.currencyPanel = currencyPanel; + this.wealthPanel = wealthPanel; + this.progressPanel = progressPanel; + register(CURRENCY, currencyPanel.createOverviewCard(() -> { }), parent::showCurrencies); + register(WEALTH, totalWealthCard, parent::showWealthTracker); + register(LOOT, lootSummaryCard, parent::showLootTracker); + register(SKILLS, skillsCard, parent::showSkills); + register(PROGRESS, progressCard, parent::showProgress); + register(WIKI, wikiCard, parent::showWiki); + register(HELPERS, helpersCard, none()); + applyOptions(); + layoutCards(); + refreshProgress(); + } + + private static Runnable none() + { + return () -> { }; + } + + private void register(String id, JComponent card, Runnable open) + { + cards.put(id, card); + CardDrag.install(card, topContainer, 1, open, () -> menu(id), this::saveOrder); + } + + // ---- settings + + private String get(String name) + { + String v = plugin.getOverviewSetting(name); + return v == null ? "" : v; + } + + private boolean flag(String name, boolean def) + { + String v = plugin.getOverviewSetting(name); + return v == null || v.isEmpty() ? def : Boolean.parseBoolean(v); + } + + private int mode(String name) + { + try + { + return Integer.parseInt(get(name)); + } + catch (NumberFormatException e) + { + return 0; + } + } + + private void set(String name, Object value) + { + plugin.setOverviewSetting(name, value == null ? null : String.valueOf(value)); + applyOptions(); + } + + private static List split(String raw) + { + List out = new ArrayList<>(); + for (String part : raw.split(",")) + { + if (!part.trim().isEmpty()) + { + out.add(part.trim()); + } + } + return out; + } + + private void applyOptions() + { + currencyPanel.setOverviewOptions(com.example.essentialsplus.currency.Currency.byName(get("currency.pin")), + flag("currency.tracked", true)); + totalWealthCard.setOptions(flag("wealth.chart", true), flag("wealth.pct", true)); + wealthPanel.setOverviewKeys(new HashSet<>(split(get("wealth.characters")))); + lootSummaryCard.setMode(mode("loot.mode")); + lootSummaryCard.setFightOptions(flag("loot.target", true), flag("loot.last", false), flag("loot.hp", true), + flag("loot.dps", true), flag("loot.session", true), flag("loot.kills", true)); + skillsCard.setOptions(mode("skills.mode"), flag("skills.chart", true)); + skillsCard.setTopSkills(mode("skills.top"), s -> plugin.getSkillIconManager().getSkillImage(s, true)); + progressCard.setCounts(flag("progress.counts", false)); + wikiCard.setShowLast(flag("wiki.last", true)); + topContainer.revalidate(); + topContainer.repaint(); + } + + /** Saved order first (unknown ids dropped), then cards added since; hidden cards left out. */ + private void layoutCards() + { + List order = new ArrayList<>(); + for (String id : split(get("order"))) + { + if (cards.containsKey(id) && !order.contains(id)) + { + order.add(id); + } + } + for (String id : DEFAULT_ORDER) + { + if (!order.contains(id)) + { + order.add(Math.min(DEFAULT_ORDER.indexOf(id), order.size()), id); + } + } + List hidden = split(get("hidden")); + while (topContainer.getComponentCount() > 1) + { + topContainer.remove(1); + } + for (String id : order) + { + if (!hidden.contains(id)) + { + topContainer.add(cards.get(id)); + } + } + topContainer.revalidate(); + topContainer.repaint(); + } + + /** After a drag: visible cards in their new order, hidden ones keep their place at the end. */ + private void saveOrder() + { + List order = new ArrayList<>(); + for (java.awt.Component c : topContainer.getComponents()) + { + for (Map.Entry e : cards.entrySet()) + { + if (e.getValue() == c) + { + order.add(e.getKey()); + } + } + } + for (String id : cards.keySet()) + { + if (!order.contains(id)) + { + order.add(id); + } + } + plugin.setOverviewSetting("order", String.join(",", order)); + } + + private void setHidden(String id, boolean hide) + { + List hidden = split(get("hidden")); + hidden.remove(id); + if (hide) + { + hidden.add(id); + } + plugin.setOverviewSetting("hidden", String.join(",", hidden)); + layoutCards(); + } + + // ---- right-click menus + + private JPopupMenu menu(String id) + { + JPopupMenu menu = new JPopupMenu(); + if (!HELPERS.equals(id)) + { + JMenuItem open = new JMenuItem("Open " + NAMES.get(id)); + open.addActionListener(a -> openCard(id)); + menu.add(open); + menu.addSeparator(); + } + switch (id) + { + case CURRENCY: + currencyItems(menu); + break; + case WEALTH: + wealthItems(menu); + break; + case LOOT: + radios(menu, "loot.mode", "Total value", "Latest drop", "Top source"); + menu.addSeparator(); + check(menu, "Show kill count", "loot.kills", true); + check(menu, "Show target while fighting", "loot.target", true); + JMenu fightMenu = new JMenu("Target display"); + check(fightMenu, "Keep last target after the fight", "loot.last", false); + check(fightMenu, "Health bar", "loot.hp", true); + check(fightMenu, "Max hit / DPS / hit chance", "loot.dps", true); + check(fightMenu, "Fight time / damage", "loot.session", true); + menu.add(fightMenu); + break; + case SKILLS: + radios(menu, "skills.mode", "Xp gained", "Total xp", "Total level"); + menu.addSeparator(); + check(menu, "Show chart", "skills.chart", true); + JMenu top = new JMenu("Top skills"); + int topCount = mode("skills.top"); + for (int n = 0; n <= 3; n++) + { + int value = n; + top.add(radio(n == 0 ? "Off" : "Top " + n, topCount == n, () -> set("skills.top", value == 0 ? null : value))); + } + menu.add(top); + menu.addSeparator(); + JMenuItem reset = new JMenuItem("Reset Hourly"); + reset.addActionListener(e -> + { + SkillHistory h = SkillsPanel.resolve(plugin.getSkillStore(), null, plugin.getSkillKey()); + if (h != null) + { + plugin.resetSkillsHourly(h.key); + refreshSkills(); + } + }); + menu.add(reset); + break; + case PROGRESS: + { + boolean counts = flag("progress.counts", false); + menu.add(radio("Percent complete", !counts, () -> set("progress.counts", null))); + menu.add(radio("Done / total", counts, () -> set("progress.counts", true))); + break; + } + case WIKI: + check(menu, "Show last viewed page", "wiki.last", true); + break; + default: + break; + } + menu.addSeparator(); + JMenuItem hide = new JMenuItem("Hide card"); + hide.addActionListener(a -> setHidden(id, true)); + menu.add(hide); + addLayoutItems(menu); + return menu; + } + + private void openCard(String id) + { + switch (id) + { + case CURRENCY: + parent.showCurrencies(); + break; + case WEALTH: + parent.showWealthTracker(); + break; + case LOOT: + parent.showLootTracker(); + break; + case SKILLS: + parent.showSkills(); + break; + case PROGRESS: + parent.showProgress(); + break; + default: + parent.showWiki(); + break; + } + } + + private void addLayoutItems(JPopupMenu menu) + { + List hidden = split(get("hidden")); + if (!hidden.isEmpty()) + { + JMenu show = new JMenu("Show hidden card"); + for (String h : hidden) + { + if (NAMES.containsKey(h)) + { + JMenuItem item = new JMenuItem(NAMES.get(h)); + item.addActionListener(a -> setHidden(h, false)); + show.add(item); + } + } + menu.add(show); + } + JMenuItem reset = new JMenuItem("Reset layout"); + reset.addActionListener(a -> + { + plugin.setOverviewSetting("order", null); + plugin.setOverviewSetting("hidden", null); + layoutCards(); + }); + menu.add(reset); + } + + private void currencyItems(JPopupMenu menu) + { + String pin = get("currency.pin"); + menu.add(radio("Auto: what I'm playing", pin.isEmpty(), () -> set("currency.pin", null))); + List tracked = currencyPanel.tracked(); + if (!tracked.isEmpty()) + { + JMenu pinMenu = new JMenu("Always show"); + for (com.example.essentialsplus.currency.Currency c : tracked) + { + pinMenu.add(radio(c.label, c.name().equals(pin), () -> set("currency.pin", c.name()))); + } + menu.add(pinMenu); + } + menu.addSeparator(); + check(menu, "Show tracked count", "currency.tracked", true); + } + + private void wealthItems(JPopupMenu menu) + { + boolean isHourly = wealthPanel != null ? wealthPanel.isHourly() : (plugin.getTimeFilter().isCurrent() || plugin.getTimeFilter().getRange() == TimeRange.SESSION); + if (isHourly) + { + JMenuItem reset = new JMenuItem("Reset Hourly"); + reset.addActionListener(e -> plugin.resetWealthHourly(null)); + menu.add(reset); + menu.addSeparator(); + } + Set chosen = new HashSet<>(split(get("wealth.characters"))); + menu.add(radio("All characters", chosen.isEmpty(), () -> set("wealth.characters", null))); + for (com.example.essentialsplus.data.AccountData a : plugin.getStore().all()) + { + String key = a.key; + JCheckBoxMenuItem item = new JCheckBoxMenuItem(a.name == null ? key : a.name, chosen.contains(key)); + item.addActionListener(e -> + { + Set next = new HashSet<>(split(get("wealth.characters"))); + if (!next.remove(key)) + { + next.add(key); + } + set("wealth.characters", next.isEmpty() ? null : String.join(",", next)); + }); + menu.add(item); + } + menu.addSeparator(); + check(menu, "Show chart", "wealth.chart", true); + check(menu, "Show % change", "wealth.pct", true); + } + + private void radios(JPopupMenu menu, String name, String... labels) + { + int current = mode(name); + for (int i = 0; i < labels.length; i++) + { + int value = i; + menu.add(radio(labels[i], current == i, () -> set(name, value == 0 ? null : value))); + } + } + + private void check(JComponent menu, String label, String name, boolean def) + { + boolean on = flag(name, def); + JCheckBoxMenuItem item = new JCheckBoxMenuItem(label, on); + item.addActionListener(e -> set(name, !on == def ? null : !on)); + menu.add(item); + } + + private static JRadioButtonMenuItem radio(String label, boolean selected, Runnable action) + { + JRadioButtonMenuItem item = new JRadioButtonMenuItem(label, selected); + item.addActionListener(e -> action.run()); + return item; + } + + // ---- data + + void updateSummaryCard(AccountCard source) + { + totalWealthCard.copyFrom(source); + } + + void refreshLoot() + { + lootSummaryCard.refresh(); + } + + void refreshProgress() + { + if (progressPanel != null) + { + progressPanel.fillCard(progressCard); + } + } + + void refreshHelpers() + { + helpersCard.refresh(); + } + + void refreshWiki() + { + java.util.List recent = plugin.getWikiRecent(); + wikiCard.setLastPage(recent.isEmpty() ? null : recent.get(0)); + } + + void refreshSkillsThrottled() + { + if (System.currentTimeMillis() - lastSkillsPaint >= 1000) + { + refreshSkills(); + } + } + + void refreshSkills() + { + lastSkillsPaint = System.currentTimeMillis(); + SkillsPanel.fillCard(skillsCard, + SkillsPanel.resolve(plugin.getSkillStore(), null, plugin.getSkillKey()), + plugin.getTimeFilter(), "Skills"); + } + } +} diff --git a/src/main/java/com/example/essentialsplus/ui/GeWindow.java b/src/main/java/com/example/essentialsplus/ui/GeWindow.java new file mode 100644 index 0000000..0bac0c1 --- /dev/null +++ b/src/main/java/com/example/essentialsplus/ui/GeWindow.java @@ -0,0 +1,428 @@ +package com.example.essentialsplus.ui; + +import com.example.essentialsplus.data.GeRow; +import com.example.essentialsplus.data.GeSlot; +import java.awt.BasicStroke; +import java.awt.BorderLayout; +import java.awt.Color; +import java.awt.Dimension; +import java.awt.FontMetrics; +import java.awt.Graphics; +import java.awt.Graphics2D; +import java.awt.Rectangle; +import java.awt.RenderingHints; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Locale; +import javax.swing.BorderFactory; +import javax.swing.JComponent; +import javax.swing.JFrame; +import javax.swing.JLabel; +import javax.swing.JPanel; +import javax.swing.JScrollPane; +import javax.swing.Scrollable; +import javax.swing.WindowConstants; +import net.runelite.client.ui.FontManager; + +/** Grand Exchange slots in detail: progress, escrow, collection box, value, age, speed. */ +class GeWindow extends JFrame +{ + private static final int CARD_W = 470; + private static final int CARD_H = 205; + private static final int GAP = 10; + private static final int HEADER_H = 28; + private static final Color BUY = new Color(72, 208, 130); + private static final Color SELL = new Color(255, 170, 60); + + private final ItemIcons icons; + private final JLabel title = new JLabel(); + private final JLabel subtitle = new JLabel(); + private final Slots slots = new Slots(); + long lastLoad; + String selection; + + GeWindow(ItemIcons icons, Runnable onClose) + { + this.icons = icons; + setDefaultCloseOperation(WindowConstants.DISPOSE_ON_CLOSE); + addWindowListener(new java.awt.event.WindowAdapter() + { + @Override + public void windowClosed(java.awt.event.WindowEvent e) + { + onClose.run(); + } + }); + + JPanel root = new JPanel(new BorderLayout(0, 10)); + root.setBackground(Ui.BG); + root.setBorder(BorderFactory.createEmptyBorder(12, 12, 12, 12)); + JPanel titles = new JPanel(new BorderLayout(0, 2)); + titles.setOpaque(false); + title.setFont(FontManager.getRunescapeBoldFont().deriveFont(24f)); + title.setForeground(Ui.ACCENT); + subtitle.setFont(FontManager.getRunescapeFont()); + subtitle.setForeground(Ui.MUTED); + titles.add(title, BorderLayout.NORTH); + titles.add(subtitle, BorderLayout.SOUTH); + root.add(titles, BorderLayout.NORTH); + + JScrollPane scroll = new JScrollPane(slots); + scroll.setBorder(null); + scroll.getViewport().setBackground(Ui.BG); + scroll.getVerticalScrollBar().setUnitIncrement(24); + root.add(scroll, BorderLayout.CENTER); + + setContentPane(root); + setSize(CARD_W * 2 + GAP + 60, 980); + setMinimumSize(new Dimension(CARD_W + 60, 400)); + } + + void setTitleText(String who) + { + setTitle("Wealth Tracker - Grand Exchange - " + who); + title.setText("Grand Exchange - " + who); + } + + void setRows(List rows) + { + lastLoad = System.currentTimeMillis(); + int used = 0; + int characters = 0; + long value = 0; + long readyItems = 0; + long readyCoins = 0; + String lastChar = null; + for (GeRow r : rows) + { + if (!r.character.equals(lastChar)) + { + characters++; + lastChar = r.character; + } + if (r.offer != null) + { + used++; + value += slotValue(r); + readyItems += r.offer.collectItems; + readyCoins += r.offer.collectCoins; + } + } + String s = used + " / " + characters * 8 + " slots in use - " + Ui.exact(value) + " in offers"; + if (readyItems > 0 || readyCoins > 0) + { + s += " - ready to collect: " + (readyItems > 0 ? String.format(Locale.US, "%,d items", readyItems) : "") + + (readyItems > 0 && readyCoins > 0 ? " + " : "") + (readyCoins > 0 ? Ui.gp(readyCoins) + " gp" : ""); + } + subtitle.setText(s); + slots.setRows(rows, characters > 1); + } + + private static long slotValue(GeRow r) + { + GeSlot s = r.offer; + return (s.escrowItems + s.collectItems) * (long) r.marketPrice + s.escrowCoins + s.collectCoins; + } + + static String duration(long ms) + { + long m = Math.max(0, ms) / 60_000; + if (m < 60) + { + return m + "m"; + } + long h = m / 60; + if (h < 24) + { + return h + "h " + (m % 60) + "m"; + } + return (h / 24) + "d " + (h % 24) + "h"; + } + + private final class Slots extends JComponent implements Scrollable + { + private List rows = Collections.emptyList(); + private boolean grouped; + + void setRows(List rows, boolean grouped) + { + this.rows = rows; + this.grouped = grouped; + revalidate(); + repaint(); + } + + private int columns() + { + int w = getParent() != null ? getParent().getWidth() : getWidth(); + return Math.max(1, (w + GAP) / (CARD_W + GAP)); + } + + @Override + public Dimension getPreferredSize() + { + int cols = columns(); + int h = 0; + for (List group : groups()) + { + h += (grouped ? HEADER_H : 0) + ((group.size() + cols - 1) / cols) * (CARD_H + GAP); + } + return new Dimension(cols * (CARD_W + GAP), h); + } + + private List> groups() + { + List> out = new ArrayList<>(); + String last = null; + for (GeRow r : rows) + { + if (!r.character.equals(last)) + { + out.add(new ArrayList<>()); + last = r.character; + } + out.get(out.size() - 1).add(r); + } + return out; + } + + @Override + public Dimension getPreferredScrollableViewportSize() + { + return getPreferredSize(); + } + + @Override + public int getScrollableUnitIncrement(Rectangle visibleRect, int orientation, int direction) + { + return 24; + } + + @Override + public int getScrollableBlockIncrement(Rectangle visibleRect, int orientation, int direction) + { + return visibleRect.height - 40; + } + + @Override + public boolean getScrollableTracksViewportWidth() + { + return true; + } + + @Override + public boolean getScrollableTracksViewportHeight() + { + return false; + } + + @Override + protected void paintComponent(Graphics g0) + { + Graphics2D g = Ui.prepare(g0); + g.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR); + int cols = columns(); + int y = 0; + for (List group : groups()) + { + if (grouped) + { + g.setFont(FontManager.getRunescapeBoldFont()); + g.setColor(Ui.TEXT); + g.drawString(group.get(0).character, 2, y + 18); + y += HEADER_H; + } + for (int i = 0; i < group.size(); i++) + { + int x = (i % cols) * (CARD_W + GAP); + int cy = y + (i / cols) * (CARD_H + GAP); + paintCard(g, group.get(i), x, cy); + } + y += ((group.size() + cols - 1) / cols) * (CARD_H + GAP); + } + g.dispose(); + } + + private void paintCard(Graphics2D g, GeRow r, int x, int y) + { + GeSlot s = r.offer; + if (s == null) + { + g.setColor(new Color(34, 34, 34)); + g.fillRoundRect(x, y, CARD_W, CARD_H, 10, 10); + g.setStroke(new BasicStroke(1f, BasicStroke.CAP_BUTT, BasicStroke.JOIN_MITER, 10f, new float[]{4f, 4f}, 0f)); + g.setColor(new Color(70, 70, 70)); + g.drawRoundRect(x + 1, y + 1, CARD_W - 2, CARD_H - 2, 10, 10); + g.setFont(FontManager.getRunescapeFont()); + FontMetrics fm = g.getFontMetrics(); + g.setColor(Ui.FAINT); + String t = "Slot " + (r.slot + 1) + " - empty"; + g.drawString(t, x + (CARD_W - fm.stringWidth(t)) / 2, y + CARD_H / 2 + 4); + return; + } + + long now = System.currentTimeMillis(); + boolean cancelled = s.state != null && s.state.startsWith("CANCELLED"); + boolean complete = s.done >= s.total || "BOUGHT".equals(s.state) || "SOLD".equals(s.state); + Color side = s.buy ? BUY : SELL; + + g.setColor(Ui.CARD); + g.fillRoundRect(x, y, CARD_W, CARD_H, 10, 10); + g.setColor(side); + g.fillRoundRect(x, y, 4, CARD_H, 4, 4); + + int pad = 14; + g.drawImage(icons.icon(s.item, this), x + pad, y + 10, 36, 32, null); + + g.setFont(FontManager.getRunescapeBoldFont()); + FontMetrics bfm = g.getFontMetrics(); + g.setColor(Ui.TEXT); + String name = r.name != null ? r.name : "Item " + s.item; + g.drawString(Ui.ellipsize(bfm, name, CARD_W - 220), x + pad + 46, y + 24); + + // badge + g.setFont(FontManager.getRunescapeSmallFont()); + FontMetrics sfm = g.getFontMetrics(); + String badge = s.buy ? "BUY" : "SELL"; + int bw = sfm.stringWidth(badge) + 12; + int bx = x + pad + 46; + g.setColor(Ui.alpha(side, 50)); + g.fillRoundRect(bx, y + 30, bw, 15, 6, 6); + g.setColor(side); + g.drawString(badge, bx + 6, y + 30 + sfm.getAscent() + 1); + + String stateText = cancelled ? "Cancelled" : complete ? "Completed" : s.buy ? "Buying" : "Selling"; + Color stateColor = cancelled ? Ui.DOWN : complete ? Ui.UP : Ui.MUTED; + g.setFont(FontManager.getRunescapeFont()); + FontMetrics fm = g.getFontMetrics(); + g.setColor(stateColor); + Ui.drawRight(g, stateText, x + CARD_W - pad, y + 24); + g.setFont(FontManager.getRunescapeSmallFont()); + g.setColor(Ui.FAINT); + Ui.drawRight(g, "Slot " + (r.slot + 1), x + CARD_W - pad, y + 42); + + // progress + int barX = x + pad; + int barY = y + 52; + int barW = CARD_W - pad * 2; + int barH = 18; + double ratio = s.total > 0 ? Math.min(1, (double) s.done / s.total) : 0; + g.setColor(new Color(20, 20, 20)); + g.fillRoundRect(barX, barY, barW, barH, 6, 6); + g.setColor(cancelled ? new Color(170, 60, 50) : complete ? new Color(40, 150, 70) : new Color(220, 130, 20)); + g.fillRoundRect(barX, barY, (int) Math.round(barW * ratio), barH, 6, 6); + g.setFont(FontManager.getRunescapeBoldFont()); + FontMetrics pfm = g.getFontMetrics(); + String prog = String.format(Locale.US, "%s %,d / %,d (%.1f%%)", s.buy ? "Bought" : "Sold", s.done, s.total, ratio * 100); + g.setColor(Color.WHITE); + g.drawString(prog, barX + (barW - pfm.stringWidth(prog)) / 2, barY + (barH + pfm.getAscent()) / 2 - 1); + + // details + List left = new ArrayList<>(); + List leftC = new ArrayList<>(); + List right = new ArrayList<>(); + List rightC = new ArrayList<>(); + + left.add(new String[]{"Price each", String.format(Locale.US, "%,d gp", s.price)}); + leftC.add(Ui.TEXT); + if (r.marketPrice > 0) + { + double diff = (double) (s.price - r.marketPrice) / r.marketPrice; + left.add(new String[]{"GE price now", String.format(Locale.US, "%,d (%s)", r.marketPrice, Ui.pct(diff))}); + leftC.add(Ui.MUTED); + } + left.add(new String[]{s.buy ? "Spent" : "Received", Ui.gp(s.spent) + " gp"}); + leftC.add(Ui.TEXT); + if (s.done > 0) + { + left.add(new String[]{"Avg per item", String.format(Locale.US, "%,d gp", s.spent / s.done)}); + leftC.add(Ui.MUTED); + } + left.add(new String[]{"Offer total", Ui.gp(s.total * s.price) + " gp"}); + leftC.add(Ui.MUTED); + + if (r.limitInfo != null && (r.limitInfo.limit > 0 || r.limitInfo.bought > 0)) + { + String limitStr; + if (r.limitInfo.limit > 0) + { + limitStr = String.format(Locale.US, "%,d / %,d", r.limitInfo.bought, r.limitInfo.limit); + if (r.limitInfo.windowActive && r.limitInfo.resetInMs > 0) + { + limitStr += " (" + duration(r.limitInfo.resetInMs) + ")"; + } + } + else + { + limitStr = String.format(Locale.US, "%,d", r.limitInfo.bought); + } + left.add(new String[]{"4h Buy Limit", limitStr}); + leftC.add(r.limitInfo.remaining == 0 && r.limitInfo.limit > 0 ? Ui.DOWN : Ui.TEXT); + } + + String leftIn = s.buy + ? Ui.gp(s.escrowCoins) + " gp (" + String.format(Locale.US, "%,d", Math.max(0, s.total - s.done)) + " left)" + : String.format(Locale.US, "%,d items", s.escrowItems); + right.add(new String[]{"Left in offer", leftIn}); + rightC.add(Ui.TEXT); + String ready = (s.collectItems > 0 ? String.format(Locale.US, "%,d items", s.collectItems) : "") + + (s.collectItems > 0 && s.collectCoins > 0 ? " + " : "") + + (s.collectCoins > 0 ? Ui.gp(s.collectCoins) + " gp" : ""); + right.add(new String[]{"Ready to collect", ready.isEmpty() ? "nothing" : ready}); + rightC.add(ready.isEmpty() ? Ui.FAINT : Ui.GOLD); + right.add(new String[]{"Slot value", Ui.gp(slotValue(r)) + " gp"}); + rightC.add(Ui.GOLD); + + if (s.placedAt > 0) + { + right.add(new String[]{"Placed", duration(now - s.placedAt) + " ago"}); + } + else + { + right.add(new String[]{"Placed", "> " + duration(now - s.trackedSince) + " ago"}); + } + rightC.add(Ui.TEXT); + right.add(new String[]{"Last trade", s.lastTradeAt > 0 ? duration(now - s.lastTradeAt) + " ago" : "-"}); + rightC.add(Ui.MUTED); + + if (!complete && !cancelled) + { + long since = s.placedAt > 0 ? s.placedAt : s.trackedSince; + long doneSince = s.placedAt > 0 ? s.done : s.done - s.trackedDone; + double hours = (now - since) / 3_600_000.0; + if (hours > 0.05 && doneSince > 0) + { + double rate = doneSince / hours; + long etaMs = (long) ((s.total - s.done) / rate * 3_600_000); + right.add(new String[]{"Speed / ETA", String.format(Locale.US, "%,.0f/h - ~%s", rate, duration(etaMs))}); + } + else + { + right.add(new String[]{"Speed / ETA", "-"}); + } + rightC.add(Ui.MUTED); + } + + g.setFont(FontManager.getRunescapeFont()); + fm = g.getFontMetrics(); + int colW = (CARD_W - pad * 2 - 16) / 2; + paintColumn(g, fm, left, leftC, x + pad, y + 90, colW); + paintColumn(g, fm, right, rightC, x + pad + colW + 16, y + 90, colW); + } + + private void paintColumn(Graphics2D g, FontMetrics fm, List rows, List colors, int x, int y, int w) + { + int lh = 16; + for (int i = 0; i < rows.size(); i++) + { + int base = y + i * lh + fm.getAscent(); + g.setColor(Ui.FAINT); + g.drawString(rows.get(i)[0], x, base); + g.setColor(colors.get(i)); + int labelW = fm.stringWidth(rows.get(i)[0]) + 8; + Ui.drawRight(g, Ui.ellipsize(fm, rows.get(i)[1], w - labelW), x + w, base); + } + } + } +} diff --git a/src/main/java/com/example/essentialsplus/ui/HelpersCard.java b/src/main/java/com/example/essentialsplus/ui/HelpersCard.java new file mode 100644 index 0000000..739ff73 --- /dev/null +++ b/src/main/java/com/example/essentialsplus/ui/HelpersCard.java @@ -0,0 +1,412 @@ +package com.example.essentialsplus.ui; + +import com.example.essentialsplus.EssentialsPlusPlugin; +import com.example.essentialsplus.helpers.TimerTextSize; +import java.awt.Color; +import java.awt.Dimension; +import java.awt.Graphics; +import java.awt.Graphics2D; +import javax.swing.BorderFactory; +import javax.swing.BoxLayout; +import javax.swing.JCheckBox; +import javax.swing.JCheckBoxMenuItem; +import javax.swing.JMenu; +import javax.swing.JMenuItem; +import javax.swing.JPopupMenu; +import javax.swing.JRadioButtonMenuItem; +import javax.swing.SwingUtilities; +import javax.swing.JLabel; +import javax.swing.JPanel; +import net.runelite.client.ui.FontManager; +import net.runelite.client.ui.PluginPanel; + +/** Overview card with on/off switches for the small in-game helpers (attack timers, ...). */ +public class HelpersCard extends JPanel +{ + private final EssentialsPlusPlugin plugin; + private final JCheckBox attackTimer = box("Attack timer", "Ticks until your next attack, above your head. Right-click for options"); + private final JCheckBox enemyAttackStyle = box("Enemy attack style", "Melee, range or mage icon above NPCs attacking you. Right-click for options"); + private final JCheckBox itemPrices = box("Item prices", "GE and high alch price when hovering inventory items. Right-click for options"); + private final JCheckBox enemyAttackTimer = box("Enemy attack timer", "Ticks until attacking NPCs hit next, above their heads. Right-click for options"); + private final JCheckBox treeCooldowns = box("Tree cooldowns", "Respawn-Countdown auf gefällten Bäumen + Rest-Zeit auf langlebigen Bäumen. Rechtsklick für Optionen"); + private final JCheckBox itemCharges = box("Item charges", "Charge-Zahl auf Item-Icons (Blowpipe, Trident, Tome, …) + Loot-Bag-Geld & Coal-Bag-Füllstand. Rechtsklick für Optionen"); + + public HelpersCard(EssentialsPlusPlugin plugin) + { + this.plugin = plugin; + setOpaque(false); + setLayout(new BoxLayout(this, BoxLayout.Y_AXIS)); + setBorder(BorderFactory.createEmptyBorder(6, 10, 8, 10)); + + JLabel title = new JLabel("Helpers"); + title.setFont(FontManager.getRunescapeBoldFont()); + title.setForeground(Ui.ACCENT); + title.setBorder(BorderFactory.createEmptyBorder(0, 0, 4, 0)); + add(title); + + attackTimer.addActionListener(e -> plugin.setHelperOption("attackTimer", attackTimer.isSelected())); + enemyAttackTimer.addActionListener(e -> plugin.setHelperOption("enemyAttackTimer", enemyAttackTimer.isSelected())); + optionsMenu(attackTimer, "attackTimer", "Attack timer", Color.WHITE); + optionsMenu(enemyAttackTimer, "enemyAttackTimer", "Enemy attack timer", new Color(0xFF7043)); + add(attackTimer); + add(enemyAttackTimer); + enemyAttackStyle.addActionListener(e -> plugin.setHelperOption("enemyAttackStyle", enemyAttackStyle.isSelected())); + enemyAttackStyle.addMouseListener(new java.awt.event.MouseAdapter() + { + @Override + public void mousePressed(java.awt.event.MouseEvent e) + { + if (e.isPopupTrigger() || SwingUtilities.isRightMouseButton(e)) + { + styleMenu().show(enemyAttackStyle, e.getX(), e.getY()); + } + } + }); + add(enemyAttackStyle); + itemPrices.addActionListener(e -> plugin.setHelperOption("itemPrices", itemPrices.isSelected())); + itemPrices.addMouseListener(new java.awt.event.MouseAdapter() + { + @Override + public void mousePressed(java.awt.event.MouseEvent e) + { + if (e.isPopupTrigger() || SwingUtilities.isRightMouseButton(e)) + { + priceMenu().show(itemPrices, e.getX(), e.getY()); + } + } + }); + add(itemPrices); + treeCooldowns.addActionListener(e -> plugin.setHelperOption("treeCooldowns", treeCooldowns.isSelected())); + treeCooldowns.addMouseListener(new java.awt.event.MouseAdapter() + { + @Override + public void mousePressed(java.awt.event.MouseEvent e) + { + if (e.isPopupTrigger() || SwingUtilities.isRightMouseButton(e)) + { + treeMenu().show(treeCooldowns, e.getX(), e.getY()); + } + } + }); + add(treeCooldowns); + itemCharges.addActionListener(e -> plugin.setHelperOption("itemCharges", itemCharges.isSelected())); + itemCharges.addMouseListener(new java.awt.event.MouseAdapter() + { + @Override + public void mousePressed(java.awt.event.MouseEvent e) + { + if (e.isPopupTrigger() || SwingUtilities.isRightMouseButton(e)) + { + chargesMenu().show(itemCharges, e.getX(), e.getY()); + } + } + }); + add(itemCharges); + refresh(); + } + + /** Re-reads the switches from the config (also changed in the RuneLite settings). */ + public void refresh() + { + attackTimer.setSelected(plugin.isHelperOption("attackTimer")); + enemyAttackTimer.setSelected(plugin.isHelperOption("enemyAttackTimer")); + enemyAttackStyle.setSelected(plugin.isHelperOption("enemyAttackStyle")); + itemPrices.setSelected(plugin.isHelperOption("itemPrices")); + treeCooldowns.setSelected(plugin.isHelperOption("treeCooldowns")); + itemCharges.setSelected(plugin.isHelperOption("itemCharges")); + } + + private static final Color READY = new Color(0x66FF66); + private static final int[] HEIGHTS = {10, 30, 60, 100}; + private static final String[] HEIGHT_LABELS = {"Low", "Normal", "High", "Very high"}; + private static final int[] STYLE_HEIGHTS = {10, 30, 60, 100, 150}; + private static final String[] STYLE_HEIGHT_LABELS = {"Low", "Normal", "High", "Very high", "Highest"}; + + /** Right-click on a switch: look of that timer (bar, size, colors, height). */ + private void optionsMenu(JCheckBox box, String prefix, String name, Color defColor) + { + box.addMouseListener(new java.awt.event.MouseAdapter() + { + @Override + public void mousePressed(java.awt.event.MouseEvent e) + { + if (e.isPopupTrigger() || SwingUtilities.isRightMouseButton(e)) + { + buildMenu(prefix, name, defColor).show(box, e.getX(), e.getY()); + } + } + }); + } + + private JPopupMenu buildMenu(String prefix, String name, Color defColor) + { + JPopupMenu menu = new JPopupMenu(); + + boolean bar = plugin.getHelperValue(prefix + "Bar", Boolean.class, false); + JCheckBoxMenuItem barItem = new JCheckBoxMenuItem("Show tick bar", bar); + barItem.addActionListener(e -> plugin.setHelperOption(prefix + "Bar", !bar)); + menu.add(barItem); + + JMenu size = new JMenu("Text size"); + TimerTextSize currentSize = plugin.getHelperValue(prefix + "Size", TimerTextSize.class, TimerTextSize.NORMAL); + for (TimerTextSize s : TimerTextSize.values()) + { + JRadioButtonMenuItem item = new JRadioButtonMenuItem(s.toString(), s == currentSize); + item.addActionListener(e -> plugin.setHelperOption(prefix + "Size", s)); + size.add(item); + } + menu.add(size); + + JMenu height = new JMenu("Height"); + int currentHeight = plugin.getHelperValue(prefix + "Offset", Integer.class, 30); + for (int i = 0; i < HEIGHTS.length; i++) + { + int value = HEIGHTS[i]; + JRadioButtonMenuItem item = new JRadioButtonMenuItem(HEIGHT_LABELS[i], value == currentHeight); + item.addActionListener(e -> plugin.setHelperOption(prefix + "Offset", value)); + height.add(item); + } + menu.add(height); + + menu.addSeparator(); + menu.add(colorItem("Color...", prefix + "Color", name + " color", defColor)); + menu.add(colorItem("Color at 0...", prefix + "ReadyColor", name + " color at 0", READY)); + + menu.addSeparator(); + JMenuItem reset = new JMenuItem("Reset look"); + reset.addActionListener(e -> + { + for (String key : new String[]{"Bar", "Size", "Offset", "Color", "ReadyColor"}) + { + plugin.setHelperOption(prefix + key, null); + } + }); + menu.add(reset); + return menu; + } + + private JPopupMenu priceMenu() + { + JPopupMenu menu = new JPopupMenu(); + for (String[] opt : new String[][]{{"itemPricesGe", "Show GE price"}, {"itemPricesHa", "Show HA price"}}) + { + boolean on = plugin.getHelperValue(opt[0], Boolean.class, true); + JCheckBoxMenuItem item = new JCheckBoxMenuItem(opt[1], on); + item.addActionListener(e -> plugin.setHelperOption(opt[0], on ? false : null)); + menu.add(item); + } + return menu; + } + + private JPopupMenu styleMenu() + { + JPopupMenu menu = new JPopupMenu(); + boolean text = plugin.getHelperValue("enemyAttackStyleText", Boolean.class, false); + menu.add(radio("Prayer icon", !text, () -> plugin.setHelperOption("enemyAttackStyleText", null))); + menu.add(radio("Text (Melee / Range / Mage)", text, () -> plugin.setHelperOption("enemyAttackStyleText", true))); + menu.addSeparator(); + JMenu size = new JMenu("Size"); + TimerTextSize current = plugin.getHelperValue("enemyAttackStyleSize", TimerTextSize.class, TimerTextSize.NORMAL); + for (TimerTextSize s : TimerTextSize.values()) + { + size.add(radio(s.toString(), s == current, () -> plugin.setHelperOption("enemyAttackStyleSize", s))); + } + menu.add(size); + JMenu height = new JMenu("Height"); + int currentHeight = plugin.getHelperValue("enemyAttackStyleOffset", Integer.class, 60); + for (int i = 0; i < STYLE_HEIGHTS.length; i++) + { + int value = STYLE_HEIGHTS[i]; + height.add(radio(STYLE_HEIGHT_LABELS[i], value == currentHeight, () -> plugin.setHelperOption("enemyAttackStyleOffset", value))); + } + menu.add(height); + menu.addSeparator(); + JMenuItem reset = new JMenuItem("Reset look"); + reset.addActionListener(e -> + { + for (String key : new String[]{"enemyAttackStyleText", "enemyAttackStyleSize", "enemyAttackStyleOffset"}) + { + plugin.setHelperOption(key, null); + } + }); + menu.add(reset); + return menu; + } + + private static final int[] TREE_HEIGHTS = {20, 50, 80, 120}; + private static final String[] TREE_HEIGHT_LABELS = {"Niedrig", "Normal", "Hoch", "Sehr hoch"}; + + private JPopupMenu treeMenu() + { + JPopupMenu menu = new JPopupMenu(); + for (String[] opt : new String[][]{ + {"treeCooldownRespawn", "Respawn-Countdown anzeigen"}, + {"treeCooldownChop", "Hack-Restzeit anzeigen"}, + {"treeCooldownBar", "Fortschrittsbalken anzeigen"} + }) + { + boolean on = plugin.getHelperValue(opt[0], Boolean.class, !opt[0].equals("treeCooldownBar")); + JCheckBoxMenuItem item = new JCheckBoxMenuItem(opt[1], on); + item.addActionListener(e -> plugin.setHelperOption(opt[0], on ? false : null)); + menu.add(item); + } + menu.addSeparator(); + + JMenu size = new JMenu("Textgröße"); + TimerTextSize currentSize = plugin.getHelperValue("treeCooldownSize", TimerTextSize.class, TimerTextSize.NORMAL); + for (TimerTextSize s : TimerTextSize.values()) + { + size.add(radio(s.toString(), s == currentSize, () -> plugin.setHelperOption("treeCooldownSize", s))); + } + menu.add(size); + + JMenu height = new JMenu("Höhe"); + int currentHeight = plugin.getHelperValue("treeCooldownOffset", Integer.class, 50); + for (int i = 0; i < TREE_HEIGHTS.length; i++) + { + int value = TREE_HEIGHTS[i]; + height.add(radio(TREE_HEIGHT_LABELS[i], value == currentHeight, () -> plugin.setHelperOption("treeCooldownOffset", value))); + } + menu.add(height); + + menu.addSeparator(); + menu.add(colorItem("Farbe...", "treeCooldownColor", "Tree Cooldown Farbe", new Color(0x8FBF3F))); + menu.add(colorItem("Farbe bei Bereit...", "treeCooldownReadyColor", "Tree Cooldown Farbe bei Bereit", READY)); + + menu.addSeparator(); + JMenuItem reset = new JMenuItem("Aussehen zurücksetzen"); + reset.addActionListener(e -> + { + for (String key : new String[]{"treeCooldownRespawn", "treeCooldownChop", "treeCooldownBar", "treeCooldownSize", "treeCooldownOffset", "treeCooldownColor", "treeCooldownReadyColor"}) + { + plugin.setHelperOption(key, null); + } + }); + menu.add(reset); + return menu; + } + + private static final int[] CHARGES_LOW_VALUES = {0, 10, 50, 100, 500, 1000}; + private static final String[] CHARGES_LOW_LABELS = {"Aus (0)", "≤ 10", "≤ 50", "≤ 100", "≤ 500", "≤ 1000"}; + + private JPopupMenu chargesMenu() + { + JPopupMenu menu = new JPopupMenu(); + + for (String[] opt : new String[][]{ + {"itemChargesWeapons", "Waffen & Rüstung anzeigen"}, + {"itemChargesBags", "Looting- & Coal-Bag anzeigen"}, + {"itemChargesBank", "In der Bank anzeigen"} + }) + { + boolean on = plugin.getHelperValue(opt[0], Boolean.class, true); + JCheckBoxMenuItem item = new JCheckBoxMenuItem(opt[1], on); + item.addActionListener(e -> plugin.setHelperOption(opt[0], on ? false : true)); + menu.add(item); + } + + menu.addSeparator(); + + JMenu size = new JMenu("Textgröße"); + TimerTextSize currentSize = plugin.getHelperValue("itemChargesSize", TimerTextSize.class, TimerTextSize.SMALL); + for (TimerTextSize s : TimerTextSize.values()) + { + size.add(radio(s.toString(), s == currentSize, () -> plugin.setHelperOption("itemChargesSize", s))); + } + menu.add(size); + + JMenu lowThreshold = new JMenu("Warnung bei wenig Ladung"); + int currentLowAt = plugin.getHelperValue("itemChargesLowAt", Integer.class, 0); + for (int i = 0; i < CHARGES_LOW_VALUES.length; i++) + { + int value = CHARGES_LOW_VALUES[i]; + lowThreshold.add(radio(CHARGES_LOW_LABELS[i], value == currentLowAt, () -> plugin.setHelperOption("itemChargesLowAt", value))); + } + menu.add(lowThreshold); + + menu.addSeparator(); + menu.add(colorItem("Farbe...", "itemChargesColor", "Item Charges Primärfarbe", Color.WHITE)); + menu.add(colorItem("Sekundärfarbe...", "itemChargesSecondaryColor", "Item Charges Sekundärfarbe", new Color(0xC0C0C0))); + menu.add(colorItem("Warnfarbe...", "itemChargesLowColor", "Item Charges Warnfarbe", new Color(0xFF3333))); + + menu.addSeparator(); + JMenuItem forgetCharges = new JMenuItem("Gespeicherte Ladungen zurücksetzen"); + forgetCharges.addActionListener(e -> + { + if (plugin.getItemCharges() != null) + { + plugin.getItemCharges().resetAllTracked(); + } + }); + menu.add(forgetCharges); + + JMenuItem forgetCoal = new JMenuItem("Coal bag vergessen"); + forgetCoal.addActionListener(e -> + { + if (plugin.getItemCharges() != null) + { + plugin.getItemCharges().resetCoalBag(); + } + }); + menu.add(forgetCoal); + + JMenuItem reset = new JMenuItem("Aussehen zurücksetzen"); + reset.addActionListener(e -> + { + for (String key : new String[]{"itemChargesWeapons", "itemChargesBags", "itemChargesBank", "itemChargesSize", "itemChargesLowAt", "itemChargesColor", "itemChargesSecondaryColor", "itemChargesLowColor"}) + { + plugin.setHelperOption(key, null); + } + }); + menu.add(reset); + return menu; + } + + private static JRadioButtonMenuItem radio(String label, boolean selected, Runnable action) + { + JRadioButtonMenuItem item = new JRadioButtonMenuItem(label, selected); + item.addActionListener(e -> action.run()); + return item; + } + + private JMenuItem colorItem(String label, String key, String title, Color def) + { + JMenuItem item = new JMenuItem(label); + item.addActionListener(e -> + { + Color current = plugin.getHelperValue(key, Color.class, def); + var picker = plugin.getColorPickerManager().create(SwingUtilities.getWindowAncestor(this), current, title, true); + picker.setLocationRelativeTo(this); + picker.setOnColorChange(c -> plugin.setHelperOption(key, c)); + picker.setVisible(true); + }); + return item; + } + + private static JCheckBox box(String text, String tooltip) + { + JCheckBox b = new JCheckBox(text); + b.setFont(FontManager.getRunescapeSmallFont()); + b.setForeground(Ui.TEXT); + b.setOpaque(false); + b.setFocusPainted(false); + b.setToolTipText(tooltip); + return b; + } + + @Override + public Dimension getPreferredSize() + { + return new Dimension(PluginPanel.PANEL_WIDTH - 16, super.getPreferredSize().height); + } + + @Override + protected void paintComponent(Graphics g0) + { + Graphics2D g = Ui.prepare(g0); + g.setColor(Ui.CARD); + g.fillRoundRect(0, 0, getWidth(), getHeight(), 8, 8); + Ui.paintDragOutline(g, this); + g.dispose(); + } +} diff --git a/src/main/java/com/example/essentialsplus/ui/ItemIcons.java b/src/main/java/com/example/essentialsplus/ui/ItemIcons.java new file mode 100644 index 0000000..906beb9 --- /dev/null +++ b/src/main/java/com/example/essentialsplus/ui/ItemIcons.java @@ -0,0 +1,50 @@ +package com.example.essentialsplus.ui; + +import java.awt.Component; +import java.util.HashMap; +import java.util.Map; +import net.runelite.client.game.ItemManager; +import net.runelite.client.util.AsyncBufferedImage; + +/** Item icons and names shared by all views (Swing thread only). */ +final class ItemIcons +{ + private final ItemManager itemManager; + private final Map icons = new HashMap<>(); + private final Map names = new HashMap<>(); + + ItemIcons(ItemManager itemManager) + { + this.itemManager = itemManager; + } + + AsyncBufferedImage icon(int id, Component repaintTarget) + { + AsyncBufferedImage img = icons.get(id); + if (img == null) + { + img = itemManager.getImage(id); + icons.put(id, img); + img.onLoaded(repaintTarget::repaint); + } + return img; + } + + String name(int id) + { + return names.getOrDefault(id, "Item " + id); + } + + boolean hasName(int id) + { + return names.containsKey(id); + } + + void putName(int id, String name) + { + if (name != null) + { + names.put(id, name); + } + } +} diff --git a/src/main/java/com/example/essentialsplus/ui/ItemsWindow.java b/src/main/java/com/example/essentialsplus/ui/ItemsWindow.java new file mode 100644 index 0000000..fec5fef --- /dev/null +++ b/src/main/java/com/example/essentialsplus/ui/ItemsWindow.java @@ -0,0 +1,434 @@ +package com.example.essentialsplus.ui; + +import com.example.essentialsplus.data.ItemRow; +import java.awt.BorderLayout; +import java.awt.Color; +import java.awt.Dimension; +import java.awt.Font; +import java.awt.FontMetrics; +import java.awt.Graphics; +import java.awt.Graphics2D; +import java.awt.Rectangle; +import java.awt.RenderingHints; +import java.awt.event.MouseAdapter; +import java.awt.event.MouseEvent; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; +import java.util.Locale; +import javax.swing.BorderFactory; +import javax.swing.JComponent; +import javax.swing.JFrame; +import javax.swing.JLabel; +import javax.swing.JPanel; +import javax.swing.JScrollPane; +import javax.swing.JTextField; +import javax.swing.Scrollable; +import javax.swing.WindowConstants; +import javax.swing.event.DocumentEvent; +import javax.swing.event.DocumentListener; +import net.runelite.client.ui.FontManager; + +/** Window listing the items of a container (bank, inventory, ...) as a bank-like grid or a table. */ +class ItemsWindow extends JFrame +{ + private final ItemIcons icons; + private final JLabel title = new JLabel(); + private final JLabel subtitle = new JLabel(); + private final JTextField search = new JTextField(); + private final Grid grid = new Grid(); + private List rows = Collections.emptyList(); + private int sort; + private boolean list; + long lastLoad; + String view; + String label; + String selection; + + ItemsWindow(ItemIcons icons, Runnable onClose) + { + this.icons = icons; + setDefaultCloseOperation(WindowConstants.DISPOSE_ON_CLOSE); + addWindowListener(new java.awt.event.WindowAdapter() + { + @Override + public void windowClosed(java.awt.event.WindowEvent e) + { + onClose.run(); + } + }); + + JPanel root = new JPanel(new BorderLayout(0, 10)); + root.setBackground(Ui.BG); + root.setBorder(BorderFactory.createEmptyBorder(12, 12, 12, 12)); + + JPanel head = new JPanel(new BorderLayout(12, 4)); + head.setOpaque(false); + JPanel titles = new JPanel(new BorderLayout(0, 2)); + titles.setOpaque(false); + title.setFont(FontManager.getRunescapeBoldFont().deriveFont(24f)); + title.setForeground(Ui.ACCENT); + subtitle.setFont(FontManager.getRunescapeFont()); + subtitle.setForeground(Ui.MUTED); + titles.add(title, BorderLayout.NORTH); + titles.add(subtitle, BorderLayout.SOUTH); + head.add(titles, BorderLayout.WEST); + + JPanel controls = new JPanel(new BorderLayout(8, 0)); + controls.setOpaque(false); + search.setFont(FontManager.getRunescapeFont()); + search.setBackground(Ui.CARD); + search.setForeground(Ui.TEXT); + search.setCaretColor(Ui.TEXT); + search.setBorder(BorderFactory.createCompoundBorder( + BorderFactory.createLineBorder(new Color(60, 60, 60)), BorderFactory.createEmptyBorder(4, 8, 4, 8))); + search.setPreferredSize(new Dimension(200, 30)); + search.setToolTipText("Search items"); + search.getDocument().addDocumentListener(new DocumentListener() + { + @Override + public void insertUpdate(DocumentEvent e) + { + applyFilter(); + } + + @Override + public void removeUpdate(DocumentEvent e) + { + applyFilter(); + } + + @Override + public void changedUpdate(DocumentEvent e) + { + applyFilter(); + } + }); + Segmented sortBar = new Segmented(new String[]{"Value", "Qty", "Name"}, 0, i -> + { + sort = i; + applyFilter(); + }); + sortBar.setPreferredSize(new Dimension(210, 30)); + Segmented viewBar = new Segmented(new String[]{"Grid", "List"}, 0, i -> + { + list = i == 1; + grid.revalidate(); + grid.repaint(); + }); + viewBar.setPreferredSize(new Dimension(140, 30)); + JPanel bars = new JPanel(new BorderLayout(8, 0)); + bars.setOpaque(false); + bars.add(sortBar, BorderLayout.WEST); + bars.add(viewBar, BorderLayout.EAST); + controls.add(search, BorderLayout.WEST); + controls.add(bars, BorderLayout.EAST); + JPanel controlsWrap = new JPanel(new BorderLayout()); + controlsWrap.setOpaque(false); + controlsWrap.add(controls, BorderLayout.SOUTH); + head.add(controlsWrap, BorderLayout.EAST); + root.add(head, BorderLayout.NORTH); + + JScrollPane scroll = new JScrollPane(grid); + scroll.setBorder(null); + scroll.getViewport().setBackground(Ui.BG); + scroll.getVerticalScrollBar().setUnitIncrement(24); + root.add(scroll, BorderLayout.CENTER); + + setContentPane(root); + setSize(900, 680); + setMinimumSize(new Dimension(520, 360)); + } + + void setTitleText(String titleText) + { + setTitle("Wealth Tracker - " + titleText); + title.setText(titleText); + } + + void setRows(List rows) + { + this.rows = rows; + lastLoad = System.currentTimeMillis(); + applyFilter(); + } + + private void applyFilter() + { + String q = search.getText().trim().toLowerCase(Locale.ROOT); + List shown = new ArrayList<>(); + long total = 0; + long all = 0; + for (ItemRow r : rows) + { + all += r.value; + if (q.isEmpty() || (r.name != null && r.name.toLowerCase(Locale.ROOT).contains(q))) + { + shown.add(r); + total += r.value; + } + } + if (sort == 1) + { + shown.sort(Comparator.comparingLong((ItemRow r) -> -r.qty)); + } + else if (sort == 2) + { + shown.sort(Comparator.comparing((ItemRow r) -> r.name == null ? "" : r.name, String.CASE_INSENSITIVE_ORDER)); + } + subtitle.setText(q.isEmpty() + ? rows.size() + " items - " + Ui.exact(all) + : shown.size() + " of " + rows.size() + " items - " + Ui.exact(total)); + grid.setRows(shown, all); + } + + /** Painted item grid / list. */ + private final class Grid extends JComponent implements Scrollable + { + private static final int TILE_W = 64; + private static final int TILE_H = 66; + private static final int ROW_H = 38; + private List shown = Collections.emptyList(); + private long total; + private int hover = -1; + + Grid() + { + setToolTipText(""); + MouseAdapter mouse = new MouseAdapter() + { + @Override + public void mouseMoved(MouseEvent e) + { + int h = indexAt(e.getX(), e.getY()); + if (h != hover) + { + hover = h; + repaint(); + } + } + + @Override + public void mouseExited(MouseEvent e) + { + hover = -1; + repaint(); + } + }; + addMouseListener(mouse); + addMouseMotionListener(mouse); + } + + void setRows(List shown, long total) + { + this.shown = shown; + this.total = total; + revalidate(); + repaint(); + } + + private int columns() + { + return Math.max(1, (getWidth() - 8) / TILE_W); + } + + private int indexAt(int x, int y) + { + int i; + if (list) + { + i = (y - 4) / ROW_H; + } + else + { + int col = (x - 4) / TILE_W; + if (col >= columns()) + { + return -1; + } + i = ((y - 4) / TILE_H) * columns() + col; + } + return i >= 0 && i < shown.size() ? i : -1; + } + + @Override + public String getToolTipText(MouseEvent e) + { + int i = indexAt(e.getX(), e.getY()); + if (i < 0) + { + return null; + } + ItemRow r = shown.get(i); + return "" + r.name + "
" + String.format(Locale.US, "%,d", r.qty) + " x " + Ui.exact(r.price) + + "
= " + Ui.exact(r.value) + ""; + } + + @Override + public Dimension getPreferredSize() + { + int w = getParent() != null ? getParent().getWidth() : 600; + if (list) + { + return new Dimension(w, shown.size() * ROW_H + 8); + } + int cols = Math.max(1, (w - 8) / TILE_W); + int rowsCount = (shown.size() + cols - 1) / cols; + return new Dimension(w, rowsCount * TILE_H + 8); + } + + @Override + public Dimension getPreferredScrollableViewportSize() + { + return getPreferredSize(); + } + + @Override + public int getScrollableUnitIncrement(Rectangle visibleRect, int orientation, int direction) + { + return 24; + } + + @Override + public int getScrollableBlockIncrement(Rectangle visibleRect, int orientation, int direction) + { + return visibleRect.height - 40; + } + + @Override + public boolean getScrollableTracksViewportWidth() + { + return true; + } + + @Override + public boolean getScrollableTracksViewportHeight() + { + return false; + } + + @Override + protected void paintComponent(Graphics g0) + { + Graphics2D g = Ui.prepare(g0); + g.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR); + Rectangle clip = g.getClipBounds(); + if (shown.isEmpty()) + { + g.setFont(FontManager.getRunescapeFont()); + g.setColor(Ui.MUTED); + String s = rows.isEmpty() ? "No items" : "No matching items"; + FontMetrics fm = g.getFontMetrics(); + g.drawString(s, (getWidth() - fm.stringWidth(s)) / 2, 40); + g.dispose(); + return; + } + if (list) + { + paintList(g, clip); + } + else + { + paintGrid(g, clip); + } + g.dispose(); + } + + private void paintGrid(Graphics2D g, Rectangle clip) + { + int cols = columns(); + Font small = FontManager.getRunescapeSmallFont(); + for (int i = 0; i < shown.size(); i++) + { + int x = 4 + (i % cols) * TILE_W; + int y = 4 + (i / cols) * TILE_H; + if (clip != null && (y + TILE_H < clip.y || y > clip.y + clip.height)) + { + continue; + } + ItemRow r = shown.get(i); + g.setColor(i == hover ? Ui.CARD_HOVER.brighter() : Ui.CARD); + g.fillRoundRect(x + 2, y + 2, TILE_W - 4, TILE_H - 4, 8, 8); + g.drawImage(icons.icon(r.id, this), x + (TILE_W - 36) / 2, y + 10, 36, 32, null); + + g.setFont(small); + FontMetrics fm = g.getFontMetrics(); + if (r.qty > 1 || r.id == 995) + { + String q = stackText(r.qty); + g.setColor(Color.BLACK); + g.drawString(q, x + 6, y + 6 + fm.getAscent()); + g.setColor(stackColor(r.qty)); + g.drawString(q, x + 5, y + 5 + fm.getAscent()); + } + String v = Ui.gp(r.value); + g.setColor(r.value > 0 ? Ui.GOLD : Ui.FAINT); + g.drawString(v, x + (TILE_W - fm.stringWidth(v)) / 2, y + TILE_H - 7); + } + } + + private void paintList(Graphics2D g, Rectangle clip) + { + int w = getWidth(); + for (int i = 0; i < shown.size(); i++) + { + int y = 4 + i * ROW_H; + if (clip != null && (y + ROW_H < clip.y || y > clip.y + clip.height)) + { + continue; + } + ItemRow r = shown.get(i); + g.setColor(i == hover ? Ui.CARD_HOVER.brighter() : (i % 2 == 0 ? Ui.CARD : new Color(34, 34, 34))); + g.fillRoundRect(4, y + 1, w - 8, ROW_H - 2, 6, 6); + g.drawImage(icons.icon(r.id, this), 10, y + 3, 36, 32, null); + + int right = w - 14; + g.setFont(FontManager.getRunescapeBoldFont()); + FontMetrics bfm = g.getFontMetrics(); + int base = y + (ROW_H + bfm.getAscent()) / 2 - 2; + g.setColor(r.value > 0 ? Ui.GOLD : Ui.FAINT); + Ui.drawRight(g, Ui.gp(r.value), right, base); + + g.setFont(FontManager.getRunescapeFont()); + FontMetrics fm = g.getFontMetrics(); + int colShare = right - 90; + int colPrice = colShare - 70; + int colQty = colPrice - 110; + g.setColor(Ui.MUTED); + String share = total > 0 ? String.format(Locale.US, "%.1f%%", r.value * 100.0 / total) : ""; + Ui.drawRight(g, share, colShare, base); + Ui.drawRight(g, Ui.gp(r.price) + " ea", colPrice, base); + g.setColor(Ui.TEXT); + Ui.drawRight(g, String.format(Locale.US, "%,d", r.qty), colQty, base); + g.drawString(Ui.ellipsize(fm, r.name, colQty - 120 - 56), 56, base); + } + } + } + + private static String stackText(long qty) + { + if (qty >= 10_000_000) + { + return (qty / 1_000_000) + "M"; + } + if (qty >= 100_000) + { + return (qty / 1_000) + "K"; + } + return String.valueOf(qty); + } + + private static Color stackColor(long qty) + { + if (qty >= 10_000_000) + { + return new Color(0, 255, 128); + } + if (qty >= 100_000) + { + return Color.WHITE; + } + return new Color(255, 255, 0); + } +} diff --git a/src/main/java/com/example/essentialsplus/ui/MoversView.java b/src/main/java/com/example/essentialsplus/ui/MoversView.java new file mode 100644 index 0000000..5835da3 --- /dev/null +++ b/src/main/java/com/example/essentialsplus/ui/MoversView.java @@ -0,0 +1,230 @@ +package com.example.essentialsplus.ui; + +import java.awt.Color; +import java.awt.Dimension; +import java.awt.FontMetrics; +import java.awt.Graphics; +import java.awt.Graphics2D; +import java.awt.RenderingHints; +import java.awt.event.MouseEvent; +import java.time.Instant; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import javax.swing.JComponent; +import net.runelite.client.ui.FontManager; + +/** + * "What changed": wealth change split into market (price) and activity (quantity) effects, + * plus the items that gained and lost the most value. + */ +class MoversView extends JComponent +{ + private static final int HEADER = 58; + private static final int TITLE = 18; + private static final int ROW = 30; + private static final DateTimeFormatter SINCE = DateTimeFormatter.ofPattern("d MMM HH:mm", Locale.ENGLISH); + + private final ItemIcons icons; + private Analysis.Movers movers = new Analysis.Movers(); + private final List hitRows = new ArrayList<>(); + + MoversView(ItemIcons icons) + { + this.icons = icons; + setToolTipText(""); + } + + void update(Analysis.Movers movers) + { + this.movers = movers; + revalidate(); + repaint(); + } + + @Override + public Dimension getPreferredSize() + { + if (!movers.available) + { + return new Dimension(10, 54); + } + int h = HEADER + 6; + if (!movers.gainers.isEmpty()) + { + h += TITLE + movers.gainers.size() * ROW; + } + if (!movers.losers.isEmpty()) + { + h += TITLE + movers.losers.size() * ROW; + } + return new Dimension(10, h + 4); + } + + @Override + public String getToolTipText(MouseEvent e) + { + if (!movers.available) + { + return null; + } + if (e.getY() < HEADER) + { + if (movers.ignorePrices) + { + return "Activity = items gained or lost, valued at current prices" + + "
Price changes of items you hold only count in the All range" + + "
Activity: " + Ui.exact(movers.qtyEffect) + ""; + } + return "Market = value change of items you already had (price movement)" + + "
Activity = items gained or lost, valued at current prices" + + "
Market: " + Ui.exact(movers.priceEffect) + "
Activity: " + Ui.exact(movers.qtyEffect) + ""; + } + for (Object[] r : hitRows) + { + int y = (int) r[0]; + if (e.getY() >= y && e.getY() < y + ROW) + { + Analysis.Mover m = (Analysis.Mover) r[1]; + double pc = m.priceChange(); + return "" + icons.name(m.id) + "" + + "
Value: " + Ui.exact(m.v0) + " → " + Ui.exact(m.v1) + + "
Quantity: " + String.format(Locale.US, "%,d", m.q0) + " → " + String.format(Locale.US, "%,d", m.q1) + + (Double.isNaN(pc) ? "" : "
Price: " + Ui.pct(pc)) + + (movers.ignorePrices ? "" : "
From prices: " + Ui.exact(m.priceEffect)) + + "
From quantity: " + Ui.exact(m.qtyEffect) + ""; + } + } + return null; + } + + @Override + protected void paintComponent(Graphics g0) + { + Graphics2D g = Ui.prepare(g0); + int w = getWidth(); + g.setColor(Ui.CARD); + g.fillRoundRect(0, 0, w, getHeight(), 8, 8); + g.setFont(FontManager.getRunescapeSmallFont()); + FontMetrics sfm = g.getFontMetrics(); + hitRows.clear(); + + if (!movers.available) + { + g.setColor(Ui.MUTED); + String[] lines = {"Item history is collected while", "you play - check back later"}; + int y = 22; + for (String s : lines) + { + g.drawString(s, (w - sfm.stringWidth(s)) / 2, y); + y += sfm.getHeight(); + } + g.dispose(); + return; + } + + int pad = 8; + // ---- effect split bar + long pe = movers.priceEffect; + long qe = movers.qtyEffect; + int y = 6; + if (movers.ignorePrices) + { + effectLine(g, sfm, pad, w, y + sfm.getAscent(), "Activity (items)", qe); + g.setColor(Ui.FAINT); + g.drawString("Price changes only count in All", pad, y + sfm.getAscent() + 14); + } + else + { + effectLine(g, sfm, pad, w, y + sfm.getAscent(), "Market (prices)", pe); + effectLine(g, sfm, pad, w, y + sfm.getAscent() + 14, "Activity (items)", qe); + } + + int barY = y + 32; + int barW = w - pad * 2; + long abs = Math.abs(pe) + Math.abs(qe); + g.setColor(Ui.alpha(Color.WHITE, 14)); + g.fillRoundRect(pad, barY, barW, 5, 5, 5); + if (abs > 0) + { + int pw = (int) Math.round(barW * (double) Math.abs(pe) / abs); + g.setColor(Ui.alpha(pe >= 0 ? Ui.UP : Ui.DOWN, 150)); + g.fillRoundRect(pad, barY, pw, 5, 5, 5); + g.setColor(pe != 0 && qe != 0 ? Ui.alpha(qe >= 0 ? Ui.UP : Ui.DOWN, 255) : Ui.alpha(qe >= 0 ? Ui.UP : Ui.DOWN, 255)); + g.fillRoundRect(pad + pw, barY, barW - pw, 5, 5, 5); + } + g.setColor(Ui.FAINT); + String since = "since " + Instant.ofEpochMilli(movers.since).atZone(ZoneId.systemDefault()).format(SINCE); + g.drawString(since, pad, barY + 8 + sfm.getAscent()); + + y = HEADER + 6; + y = section(g, sfm, "GAINED", movers.gainers, y, w, pad); + section(g, sfm, "LOST", movers.losers, y, w, pad); + g.dispose(); + } + + private static void effectLine(Graphics2D g, FontMetrics fm, int pad, int w, int base, String label, long value) + { + g.setColor(Ui.MUTED); + g.drawString(label, pad, base); + g.setColor(Ui.deltaColor(value)); + Ui.drawRight(g, Ui.signedGp(value), w - pad, base); + } + + private int section(Graphics2D g, FontMetrics sfm, String title, List list, int y, int w, int pad) + { + if (list.isEmpty()) + { + return y; + } + g.setColor(Ui.alpha(Color.WHITE, 12)); + g.drawLine(pad, y + 1, w - pad, y + 1); + g.setColor(Ui.FAINT); + g.drawString(title, pad, y + 4 + sfm.getAscent()); + y += TITLE; + + g.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR); + for (Analysis.Mover m : list) + { + hitRows.add(new Object[]{y, m}); + g.drawImage(icons.icon(m.id, this), pad - 2, y, 32, 28, null); + + long d = m.delta(); + String val = Ui.signedGp(d); + g.setFont(FontManager.getRunescapeBoldFont()); + FontMetrics bfm = g.getFontMetrics(); + int vw = bfm.stringWidth(val); + g.setColor(Ui.deltaColor(d)); + Ui.drawRight(g, val, w - pad, y + 3 + bfm.getAscent()); + + int tx = pad + 34; + g.setFont(FontManager.getRunescapeFont()); + FontMetrics nfm = g.getFontMetrics(); + g.setColor(Ui.TEXT); + g.drawString(Ui.ellipsize(nfm, icons.name(m.id), w - tx - vw - pad - 4), tx, y + 2 + nfm.getAscent()); + + g.setFont(FontManager.getRunescapeSmallFont()); + StringBuilder sub = new StringBuilder(); + long dq = m.q1 - m.q0; + if (dq != 0) + { + sub.append(dq > 0 ? "+" : "").append(Ui.gp(dq)).append(" qty"); + } + double pc = m.priceChange(); + if (!Double.isNaN(pc) && Math.abs(pc) >= 0.0005) + { + if (sub.length() > 0) + { + sub.append(" "); + } + sub.append("price ").append(Ui.pct(pc)); + } + g.setColor(Ui.MUTED); + g.drawString(Ui.ellipsize(sfm, sub.toString(), w - tx - pad), tx, y + ROW - 4); + y += ROW; + } + return y; + } +} diff --git a/src/main/java/com/example/essentialsplus/ui/Segmented.java b/src/main/java/com/example/essentialsplus/ui/Segmented.java new file mode 100644 index 0000000..2bcd536 --- /dev/null +++ b/src/main/java/com/example/essentialsplus/ui/Segmented.java @@ -0,0 +1,134 @@ +package com.example.essentialsplus.ui; + +import java.awt.Color; +import java.awt.Cursor; +import java.awt.Dimension; +import java.awt.FontMetrics; +import java.awt.Graphics; +import java.awt.Graphics2D; +import java.awt.event.MouseAdapter; +import java.awt.event.MouseEvent; +import java.util.function.IntConsumer; +import javax.swing.JComponent; +import net.runelite.client.ui.FontManager; + +/** Segmented control (time range, chart style). */ +public class Segmented extends JComponent +{ + private final String[] labels; + private final IntConsumer onSelect; + private int selected; + private int hover = -1; + + public Segmented(String[] labels, int selected, IntConsumer onSelect) + { + this.labels = labels; + this.selected = selected; + this.onSelect = onSelect; + setCursor(Cursor.getPredefinedCursor(Cursor.HAND_CURSOR)); + MouseAdapter mouse = new MouseAdapter() + { + @Override + public void mouseMoved(MouseEvent e) + { + int h = indexAt(e.getX()); + if (h != hover) + { + hover = h; + repaint(); + } + } + + @Override + public void mouseExited(MouseEvent e) + { + hover = -1; + repaint(); + } + + @Override + public void mousePressed(MouseEvent e) + { + int i = indexAt(e.getX()); + if (i >= 0) + { + setSelected(i); + onSelect.accept(i); + } + } + }; + addMouseListener(mouse); + addMouseMotionListener(mouse); + } + + static Segmented ranges(TimeRange initial, java.util.function.Consumer onSelect) + { + TimeRange[] values = TimeRange.values(); + String[] labels = new String[values.length]; + for (int i = 0; i < values.length; i++) + { + labels[i] = values[i].getLabel(); + } + return new Segmented(labels, initial.ordinal(), i -> onSelect.accept(values[i])); + } + + public void setSelected(int index) + { + selected = index; + repaint(); + } + + private int indexAt(int x) + { + int i = x * labels.length / Math.max(1, getWidth()); + return i >= 0 && i < labels.length ? i : -1; + } + + @Override + public Dimension getPreferredSize() + { + if (isPreferredSizeSet()) + { + return super.getPreferredSize(); + } + FontMetrics fm = getFontMetrics(FontManager.getRunescapeSmallFont()); + int w = 0; + for (String l : labels) + { + w = Math.max(w, fm.stringWidth(l)); + } + return new Dimension((w + 16) * labels.length, 24); + } + + @Override + protected void paintComponent(Graphics g0) + { + Graphics2D g = Ui.prepare(g0); + int w = getWidth(); + int h = getHeight(); + g.setColor(Ui.CARD); + g.fillRoundRect(0, 0, w, h, 8, 8); + g.setFont(h >= 28 ? FontManager.getRunescapeBoldFont() : FontManager.getRunescapeSmallFont()); + FontMetrics fm = g.getFontMetrics(); + + for (int i = 0; i < labels.length; i++) + { + int x0 = w * i / labels.length; + int x1 = w * (i + 1) / labels.length; + boolean sel = i == selected; + if (sel) + { + g.setColor(Ui.ACCENT); + g.fillRoundRect(x0 + 2, 2, x1 - x0 - 4, h - 4, 6, 6); + } + else if (i == hover) + { + g.setColor(Ui.CARD_HOVER.brighter()); + g.fillRoundRect(x0 + 2, 2, x1 - x0 - 4, h - 4, 6, 6); + } + g.setColor(sel ? Color.WHITE : i == hover ? Ui.TEXT : Ui.MUTED); + g.drawString(labels[i], x0 + (x1 - x0 - fm.stringWidth(labels[i])) / 2, (h + fm.getAscent()) / 2 - 1); + } + g.dispose(); + } +} diff --git a/src/main/java/com/example/essentialsplus/ui/Series.java b/src/main/java/com/example/essentialsplus/ui/Series.java new file mode 100644 index 0000000..11e2a8a --- /dev/null +++ b/src/main/java/com/example/essentialsplus/ui/Series.java @@ -0,0 +1,196 @@ +package com.example.essentialsplus.ui; + +import com.example.essentialsplus.WealthCategory; +import com.example.essentialsplus.data.AccountData; +import com.example.essentialsplus.data.Snapshot; +import java.util.ArrayList; +import java.util.List; +import java.util.TreeSet; + +/** Builds chart-ready timelines out of stored history. */ +final class Series +{ + private Series() + { + } + + static long earliest(List accounts) + { + long min = Long.MAX_VALUE; + for (AccountData a : accounts) + { + List h = a.history; + if (!h.isEmpty()) + { + min = Math.min(min, h.get(0).t); + } + } + return min == Long.MAX_VALUE ? System.currentTimeMillis() : min; + } + + /** + * Points inside [start, end]. The value valid at {@code start} is carried in, and the latest + * known value is extended to {@code end} so the line reaches "now". + */ + /** + * Timeline of one character over [start, end]. With {@code ignorePrices} GE price movements are taken out, + * so only items gained or lost change the value (the last point keeps the real current value). + */ + static List forAccount(AccountData acc, long start, long end, boolean ignorePrices) + { + List out = forAccount(acc, start, end); + if (!ignorePrices) + { + return out; + } + List neutral = new ArrayList<>(out.size()); + for (Snapshot s : out) + { + neutral.add(s.shifted(s.market() - acc.market)); + } + return neutral; + } + + /** Total without GE price movements, comparable between points of one character. */ + static long activityTotal(Snapshot s) + { + return s.total() - s.market(); + } + + static List forAccount(AccountData acc, long start, long end) + { + List history = acc.history; + List out = new ArrayList<>(); + Snapshot before = null; + for (Snapshot s : history) + { + if (s.t < start) + { + before = s; + } + else if (s.t <= end) + { + out.add(s); + } + } + if (before != null && (out.isEmpty() || out.get(0).t > start)) + { + Snapshot first = new Snapshot(start, before.v); + first.m = before.m; + out.add(0, first); + } + + long[] latest = acc.bankKnown && acc.current != null ? acc.current : null; + if (latest == null && !out.isEmpty()) + { + latest = out.get(out.size() - 1).v; + } + if (latest != null && !out.isEmpty() && out.get(out.size() - 1).t < end) + { + out.add(endPoint(acc, end, latest)); + } + else if (latest != null && out.isEmpty() && acc.bankKnown) + { + out.add(endPoint(acc, end, latest)); + } + return AccountData.sanitizeHistory(out); + } + + private static Snapshot endPoint(AccountData acc, long end, long[] latest) + { + Snapshot s = new Snapshot(end, latest); + s.m = acc.market; + return s; + } + + /** Sums several timelines, carrying each one's last value forward. */ + static List combine(List> all) + { + if (all.size() == 1) + { + return all.get(0); + } + TreeSet times = new TreeSet<>(); + for (List s : all) + { + for (Snapshot p : s) + { + times.add(p.t); + } + } + + int[] idx = new int[all.size()]; + List out = new ArrayList<>(times.size()); + for (long t : times) + { + long[] v = new long[WealthCategory.COUNT]; + long lo = 0; + long hi = 0; + long open = 0; + long close = 0; + String agg = null; + for (int i = 0; i < all.size(); i++) + { + List s = all.get(i); + while (idx[i] + 1 < s.size() && s.get(idx[i] + 1).t <= t) + { + idx[i]++; + } + if (s.isEmpty() || s.get(idx[i]).t > t) + { + continue; + } + Snapshot p = s.get(idx[i]); + for (int c = 0; c < v.length; c++) + { + v[c] += p.get(WealthCategory.values()[c]); + } + lo += p.low(); + hi += p.high(); + open += p.open(); + close += p.close(); + if (p.isAggregate()) + { + agg = p.g != null ? p.g : Snapshot.DAY; + } + } + Snapshot sum = new Snapshot(t, v); + if (agg != null) + { + sum.lo = lo; + sum.hi = hi; + sum.o = open; + sum.c = close; + sum.g = agg; + } + out.add(sum); + } + return out; + } + + static long[] sumCurrent(List accounts) + { + long[] v = new long[WealthCategory.COUNT]; + for (AccountData a : accounts) + { + if (a.current != null && a.bankKnown) + { + for (int i = 0; i < Math.min(v.length, a.current.length); i++) + { + v[i] += a.current[i]; + } + } + } + return v; + } + + static long sum(long[] v) + { + long s = 0; + for (long x : v) + { + s += x; + } + return s; + } +} diff --git a/src/main/java/com/example/essentialsplus/ui/SkillsCard.java b/src/main/java/com/example/essentialsplus/ui/SkillsCard.java new file mode 100644 index 0000000..66dad0a --- /dev/null +++ b/src/main/java/com/example/essentialsplus/ui/SkillsCard.java @@ -0,0 +1,338 @@ +package com.example.essentialsplus.ui; + +import java.awt.BasicStroke; +import java.awt.Cursor; +import java.awt.Dimension; +import java.awt.Font; +import java.awt.FontMetrics; +import java.awt.GradientPaint; +import java.awt.Graphics; +import java.awt.Graphics2D; +import java.awt.event.MouseAdapter; +import java.awt.event.MouseEvent; +import java.awt.geom.Path2D; +import java.awt.image.BufferedImage; +import java.util.ArrayList; +import java.util.EnumMap; +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import javax.swing.JComponent; +import javax.swing.SwingUtilities; +import net.runelite.api.Skill; +import net.runelite.client.ui.FontManager; +import net.runelite.client.util.ImageUtil; + +/** Summary card of xp gained in the selected range (overview entry and head of the skills tab). */ +class SkillsCard extends JComponent +{ + private final boolean clickable; + private String title = "Skills"; + private String subtitle = ""; + private long gained; + private boolean hasData; + private int levels; + private String rangeLabel = ""; + private String right = ""; + private long[] spark = new long[0]; + private long[] sparkT = new long[0]; + private boolean hover; + /** 0 = xp gained, 1 = total xp, 2 = total level (overview right-click menu) */ + private int mode; + private boolean showChart = true; + private long totalXp; + private int totalLevel; + private static final int TOP_ROW = 15; + /** Skills with the most xp gained listed under the value (overview right-click menu), 0-3. */ + private int topCount; + private long[] skillGains = new long[0]; + private Function iconSource; + private final Map icons = new EnumMap<>(Skill.class); + + SkillsCard(Runnable onClick) + { + this.clickable = onClick != null; + if (clickable) + { + setCursor(Cursor.getPredefinedCursor(Cursor.HAND_CURSOR)); + addMouseListener(new MouseAdapter() + { + @Override + public void mouseEntered(MouseEvent e) + { + hover = true; + repaint(); + } + + @Override + public void mouseExited(MouseEvent e) + { + hover = false; + repaint(); + } + + @Override + public void mousePressed(MouseEvent e) + { + if (SwingUtilities.isLeftMouseButton(e)) + { + onClick.run(); + } + } + }); + } + } + + void update(String title, String subtitle, boolean hasData, long gained, int levels, String rangeLabel, String right, + long[] sparkT, long[] spark) + { + this.title = title; + this.subtitle = subtitle; + this.hasData = hasData; + this.gained = gained; + this.levels = levels; + this.rangeLabel = rangeLabel; + this.right = right; + this.sparkT = sparkT; + this.spark = spark; + repaint(); + } + + void setOptions(int mode, boolean showChart) + { + this.mode = mode; + this.showChart = showChart; + revalidate(); + repaint(); + } + + void setTopSkills(int count, Function iconSource) + { + this.topCount = count; + this.iconSource = iconSource; + revalidate(); + repaint(); + } + + /** Xp gained per skill, indexed like {@code SkillHistory.SKILLS}. */ + void setSkillGains(long[] gains) + { + int before = topSkills().size(); + this.skillGains = gains; + if (topSkills().size() != before) + { + revalidate(); + } + repaint(); + } + + private List topSkills() + { + List out = new ArrayList<>(); + if (topCount <= 0 || !hasData) + { + return out; + } + for (int i = 0; i < skillGains.length; i++) + { + if (skillGains[i] > 0) + { + out.add(i); + } + } + out.sort((a, b) -> Long.compare(skillGains[b], skillGains[a])); + return out.subList(0, Math.min(topCount, out.size())); + } + + private BufferedImage icon(Skill skill) + { + if (iconSource == null) + { + return null; + } + return icons.computeIfAbsent(skill, s -> + { + BufferedImage img = iconSource.apply(s); + return img == null ? null : ImageUtil.resizeImage(img, 12, 12); + }); + } + + void setTotals(long totalXp, int totalLevel) + { + this.totalXp = totalXp; + this.totalLevel = totalLevel; + repaint(); + } + + @Override + public Dimension getPreferredSize() + { + return new Dimension(net.runelite.client.ui.PluginPanel.PANEL_WIDTH - 16, (showChart ? 120 : 84) + topHeight()); + } + + @Override + public Dimension getMaximumSize() + { + return new Dimension(Integer.MAX_VALUE, (showChart ? 130 : 84) + topHeight()); + } + + @Override + public Dimension getMinimumSize() + { + return new Dimension(10, (showChart ? 110 : 80) + topHeight()); + } + + private int topHeight() + { + int n = topSkills().size(); + return n == 0 ? 0 : n * TOP_ROW + 2; + } + + @Override + protected void paintComponent(Graphics g0) + { + Graphics2D g = Ui.prepare(g0); + int w = getWidth(); + int h = getHeight(); + g.setColor(hover ? Ui.CARD_HOVER : Ui.CARD); + g.fillRoundRect(0, 0, w, h, 8, 8); + + int pad = 10; + int rightX = w - pad; + Font bold = FontManager.getRunescapeBoldFont(); + Font small = FontManager.getRunescapeSmallFont(); + + g.setFont(bold); + FontMetrics fm = g.getFontMetrics(); + int head = 6 + fm.getAscent(); + g.setColor(Ui.ACCENT); + g.drawString(title, pad, head); + int subRight = rightX; + if (clickable) + { + g.setFont(bold.deriveFont(14f)); + g.setColor(hover ? Ui.ACCENT : Ui.FAINT); + Ui.drawRight(g, "›", rightX, head); + subRight -= 14; + } + if (!subtitle.isEmpty()) + { + g.setFont(small); + g.setColor(Ui.MUTED); + FontMetrics sfm = g.getFontMetrics(); + int avail = subRight - (pad + fm.stringWidth(title) + 8); + Ui.drawRight(g, Ui.ellipsize(sfm, subtitle, avail), subRight, head); + } + + g.setFont(bold.deriveFont(26f)); + FontMetrics bfm = g.getFontMetrics(); + int valueBase = 22 + bfm.getAscent(); + String big; + if (!hasData) + { + g.setColor(Ui.FAINT); + big = "-"; + } + else if (mode == 1) + { + g.setColor(Ui.GOLD); + big = Ui.gp(totalXp) + " xp"; + } + else if (mode == 2) + { + g.setColor(Ui.GOLD); + big = "Level " + totalLevel; + } + else + { + g.setColor(gained > 0 ? Ui.UP : Ui.FAINT); + big = "+" + Ui.gp(gained) + " xp"; + } + g.drawString(big, pad, valueBase); + + g.setFont(small); + FontMetrics sfm = g.getFontMetrics(); + int line = valueBase + sfm.getAscent() + 3; + if (!hasData) + { + g.setColor(Ui.FAINT); + g.drawString("Log in to start tracking xp", pad, line); + } + else + { + String lv = mode == 1 ? "+" + Ui.gp(gained) + " xp" : levels == 1 ? "+1 level" : "+" + levels + " levels"; + g.setColor((mode == 1 ? gained : levels) > 0 ? Ui.UP : Ui.MUTED); + g.drawString(lv, pad, line); + int x = pad + sfm.stringWidth(lv); + g.setColor(Ui.FAINT); + g.drawString(" " + rangeLabel, x, line); + if (!right.isEmpty()) + { + g.setColor(Ui.TEXT); + Ui.drawRight(g, right, rightX, line); + } + } + + List top = topSkills(); + if (!top.isEmpty()) + { + line += 2; + for (int idx : top) + { + Skill skill = com.example.essentialsplus.skills.SkillHistory.SKILLS[idx]; + line += TOP_ROW; + BufferedImage img = icon(skill); + int textX = pad; + if (img != null) + { + g.drawImage(img, pad, line - 11, null); + textX += 16; + } + g.setColor(Ui.TEXT); + g.drawString(skill.getName(), textX, line); + g.setColor(Ui.UP); + Ui.drawRight(g, "+" + Ui.gp(skillGains[idx]) + " xp", rightX, line); + } + } + + int sparkH = Math.min(28, Math.max(0, h - line - 10)); + if (sparkH > 6 && showChart) + { + paintSpark(g, pad, h - sparkH - 6, w - pad * 2, sparkH); + } + Ui.paintDragOutline(g, this); + g.dispose(); + } + + private void paintSpark(Graphics2D g, int x, int y, int w, int h) + { + if (spark.length < 2) + { + return; + } + long hi = Math.max(1, spark[spark.length - 1]); + long t0 = sparkT[0]; + long t1 = Math.max(t0 + 1, sparkT[sparkT.length - 1]); + Path2D line = new Path2D.Double(); + double prevY = y + h; + line.moveTo(x, prevY); + for (int i = 1; i < spark.length; i++) + { + double px = x + (double) (sparkT[i] - t0) / (t1 - t0) * w; + double py = y + h - (double) spark[i] / hi * h; + line.lineTo(px, prevY); + line.lineTo(px, py); + prevY = py; + } + line.lineTo(x + w, prevY); + Path2D area = new Path2D.Double(line); + area.lineTo(x + w, y + h); + area.lineTo(x, y + h); + area.closePath(); + g.setPaint(new GradientPaint(0, y, Ui.alpha(Ui.UP, 60), 0, y + h, Ui.alpha(Ui.UP, 0))); + g.fill(area); + g.setStroke(new BasicStroke(1.2f)); + g.setColor(Ui.alpha(Ui.UP, 200)); + g.draw(line); + } +} diff --git a/src/main/java/com/example/essentialsplus/ui/SkillsPanel.java b/src/main/java/com/example/essentialsplus/ui/SkillsPanel.java new file mode 100644 index 0000000..1b155e6 --- /dev/null +++ b/src/main/java/com/example/essentialsplus/ui/SkillsPanel.java @@ -0,0 +1,1007 @@ +package com.example.essentialsplus.ui; + +import com.example.essentialsplus.EssentialsPlusPlugin; +import com.example.essentialsplus.skills.SkillGoals; +import com.example.essentialsplus.skills.SkillHistory; +import com.example.essentialsplus.skills.SkillStore; +import com.example.essentialsplus.skills.XpSample; +import java.awt.BorderLayout; +import java.awt.Color; +import java.awt.Cursor; +import java.awt.Dimension; +import java.awt.FontMetrics; +import java.awt.Graphics; +import java.awt.Graphics2D; +import java.awt.event.MouseAdapter; +import java.awt.event.MouseEvent; +import java.awt.image.BufferedImage; +import java.time.Instant; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.EnumMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import javax.swing.BorderFactory; +import javax.swing.JButton; +import javax.swing.JCheckBox; +import javax.swing.JCheckBoxMenuItem; +import javax.swing.JMenuItem; +import javax.swing.JPopupMenu; +import javax.swing.JComboBox; +import javax.swing.JComponent; +import javax.swing.JLabel; +import javax.swing.JPanel; +import javax.swing.JSpinner; +import javax.swing.SpinnerNumberModel; +import javax.swing.SwingUtilities; +import javax.swing.Timer; +import net.runelite.api.Experience; +import net.runelite.api.Skill; +import net.runelite.client.ui.DynamicGridLayout; +import net.runelite.client.ui.FontManager; +import net.runelite.client.ui.PluginPanel; +import net.runelite.client.util.ImageUtil; + +/** Skills tab: xp gained per skill over the global time range, with charts and level-up history. */ +public class SkillsPanel extends PluginPanel +{ + private static final int LEVEL_UP_LIMIT = 15; + private static final DateTimeFormatter WHEN = DateTimeFormatter.ofPattern("d MMM HH:mm", Locale.ENGLISH); + + private final EssentialsPlusPlugin plugin; + private final SkillStore store; + private final TimeFilter timeFilter; + private final Runnable rangeListener = this::refreshLater; + private final Timer coalesce; + private final Timer ticker; + private final Map icons = new EnumMap<>(Skill.class); + + private final SkillsCard card = new SkillsCard(null); + private final JComboBox characterBox = new JComboBox<>(); + private final JLabel chartTitle = new JLabel(); + private final JLabel showAll = new JLabel("Show all"); + private final XpChart cumulative = new XpChart(XpChart.Mode.CUMULATIVE, 150); + private final XpChart perPeriod = new XpChart(XpChart.Mode.BARS, 100); + private final StatsView stats = new StatsView(2); + private final JPanel skillList = new JPanel(new DynamicGridLayout(0, 1, 0, 2)); + private final JPanel levelList = new JPanel(new DynamicGridLayout(0, 1, 0, 2)); + private final JLabel levelEmpty = new JLabel(); + private final JCheckBox hudBox = new JCheckBox("Show xp HUD in-game"); + private final SkillRow[] rows = new SkillRow[SkillHistory.SKILLS.length]; + private final List rowOrder = new ArrayList<>(); + + private String selectedKey; + /** character whose skills are shown (goals belong to it) */ + private String shownKey; + private int selectedSkill = -1; + private boolean updatingCombo; + + public SkillsPanel(EssentialsPlusPlugin plugin, Runnable onBack) + { + super(true); + this.plugin = plugin; + this.store = plugin.getSkillStore(); + this.timeFilter = plugin.getTimeFilter(); + + setBackground(Ui.BG); + setLayout(new DynamicGridLayout(0, 1, 0, 6)); + + JPanel backRow = new JPanel(new BorderLayout(6, 0)); + backRow.setOpaque(false); + backRow.setBorder(BorderFactory.createEmptyBorder(0, 0, 4, 0)); + JButton backBtn = button("← Overview", onBack); + backBtn.setForeground(Ui.ACCENT); + backRow.add(backBtn, BorderLayout.WEST); + JLabel titleLabel = new JLabel("Skills"); + titleLabel.setFont(FontManager.getRunescapeBoldFont()); + titleLabel.setForeground(Ui.ACCENT); + titleLabel.setHorizontalAlignment(JLabel.RIGHT); + backRow.add(titleLabel, BorderLayout.CENTER); + add(backRow); + + characterBox.setFont(FontManager.getRunescapeSmallFont()); + characterBox.setBackground(Ui.CARD); + characterBox.setFocusable(false); + characterBox.addActionListener(e -> + { + Choice c = (Choice) characterBox.getSelectedItem(); + if (!updatingCombo && c != null) + { + selectedKey = c.key; + refresh(); + } + }); + characterBox.setVisible(false); + add(characterBox); + + card.addMouseListener(new MouseAdapter() + { + @Override + public void mousePressed(MouseEvent e) + { + maybePopup(e); + } + + @Override + public void mouseReleased(MouseEvent e) + { + maybePopup(e); + } + + private void maybePopup(MouseEvent e) + { + if (e.isPopupTrigger() || SwingUtilities.isRightMouseButton(e)) + { + JPopupMenu menu = new JPopupMenu(); + JMenuItem reset = new JMenuItem("Reset Hourly"); + reset.addActionListener(ev -> + { + plugin.resetSkillsHourly(shownKey); + refreshLater(); + }); + menu.add(reset); + menu.show(card, e.getX(), e.getY()); + } + } + }); + add(card); + + hudBox.setFont(FontManager.getRunescapeSmallFont()); + hudBox.setForeground(Ui.TEXT); + hudBox.setOpaque(false); + hudBox.setFocusPainted(false); + hudBox.setToolTipText("In-game overlay with the xp gained per skill in the selected time range"); + hudBox.setSelected(plugin.isSkillsHudEnabled()); + hudBox.addActionListener(e -> plugin.setSkillsHudEnabled(hudBox.isSelected())); + JButton[] hudOptions = new JButton[1]; + hudOptions[0] = button("Options", () -> showHudOptions(hudOptions[0])); + hudOptions[0].setToolTipText("Choose what the in-game xp HUD shows"); + JPanel hudRow = new JPanel(new BorderLayout(6, 0)); + hudRow.setOpaque(false); + hudRow.add(hudBox, BorderLayout.CENTER); + hudRow.add(hudOptions[0], BorderLayout.EAST); + add(hudRow); + + showAll.setFont(FontManager.getRunescapeSmallFont()); + showAll.setForeground(Ui.ACCENT); + showAll.setCursor(Cursor.getPredefinedCursor(Cursor.HAND_CURSOR)); + showAll.addMouseListener(new MouseAdapter() + { + @Override + public void mousePressed(MouseEvent e) + { + selectSkill(-1); + } + }); + JPanel chartHeader = new JPanel(new BorderLayout(6, 0)); + chartHeader.setOpaque(false); + chartHeader.setBorder(BorderFactory.createEmptyBorder(6, 2, 0, 2)); + JLabel xpLabel = new JLabel("XP GAINED"); + xpLabel.setFont(FontManager.getRunescapeSmallFont()); + xpLabel.setForeground(Ui.MUTED); + chartHeader.add(xpLabel, BorderLayout.WEST); + chartTitle.setFont(FontManager.getRunescapeSmallFont()); + chartTitle.setForeground(Ui.TEXT); + chartTitle.setHorizontalAlignment(JLabel.RIGHT); + chartHeader.add(chartTitle, BorderLayout.CENTER); + chartHeader.add(showAll, BorderLayout.EAST); + add(chartHeader); + add(cumulative); + add(stats); + add(WealthPanel.sectionHeader("XP PER PERIOD", null, null, null)); + add(perPeriod); + + add(WealthPanel.sectionHeader("SKILLS (click to chart, cog for a goal)", null, null, null)); + skillList.setOpaque(false); + for (int i = 0; i < rows.length; i++) + { + rows[i] = new SkillRow(i); + } + add(skillList); + + add(WealthPanel.sectionHeader("LEVEL UPS", null, null, null)); + levelList.setOpaque(false); + levelEmpty.setFont(FontManager.getRunescapeSmallFont()); + levelEmpty.setForeground(Ui.FAINT); + levelEmpty.setHorizontalAlignment(JLabel.CENTER); + add(levelList); + + coalesce = new Timer(1000, e -> refresh()); + coalesce.setRepeats(false); + ticker = new Timer(30_000, e -> refresh()); + ticker.start(); + timeFilter.addListener(rangeListener); + refresh(); + } + + public void dispose() + { + ticker.stop(); + coalesce.stop(); + timeFilter.removeListener(rangeListener); + } + + /** Coalesces bursts of xp drops into one repaint per second. */ + public void refreshLater() + { + if (!coalesce.isRunning()) + { + coalesce.start(); + } + } + + private void selectSkill(int skill) + { + selectedSkill = selectedSkill == skill ? -1 : skill; + refresh(); + } + + /** Resolves which character to show: explicit choice, else the logged-in one, else the latest. */ + static SkillHistory resolve(SkillStore store, String preferred, String active) + { + SkillHistory h = store.get(preferred); + if (h == null || h.latest() == null) + { + h = store.get(active); + } + if (h == null || h.latest() == null) + { + List all = store.all(); + h = all.isEmpty() ? null : all.get(0); + } + return h; + } + + /** Fills a summary card (used by the overview too). */ + static void fillCard(SkillsCard card, SkillHistory h, TimeFilter filter, String title) + { + if (h == null) + { + card.update(title, "", false, 0, 0, filter.getLabel(), "", new long[0], new long[0]); + return; + } + long start = h.start(filter); + long rateStart = h.rateStart(filter); + long[] gained = h.gained(filter); + long total = 0; + for (long g : gained) + { + total += g; + } + int levels = 0; + for (SkillHistory.LevelUp lu : h.levelUps(start)) + { + levels += gained[lu.skill] > 0 ? lu.to - lu.from : 0; + } + long rateGained = h.gainedTotalSince(rateStart); + long active = h.activeMillis(rateStart, -1); + String xph = active >= Ui.MINUTE && rateGained > 0 ? Ui.gp(rateGained * Ui.HOUR / active) + " xp/h" : ""; + + List samples = h.range(start); + long[] t = new long[samples.size() + 1]; + long[] v = new long[samples.size() + 1]; + long base = samples.isEmpty() ? 0 : samples.get(0).total(); + for (int i = 0; i < samples.size(); i++) + { + t[i] = Math.max(samples.get(i).t, start); + v[i] = samples.get(i).total() - base; + } + if (samples.isEmpty()) + { + t = new long[0]; + v = new long[0]; + } + else + { + t[samples.size()] = System.currentTimeMillis(); + v[samples.size()] = v[samples.size() - 1]; + } + card.update(title, h.name == null ? "" : h.name, true, total, levels, filter.getLabel(), xph, t, v); + XpSample latest = h.latest(); + int totalLevel = 0; + if (latest != null) + { + for (int s = 0; s < SkillHistory.SKILLS.length; s++) + { + totalLevel += SkillHistory.level(latest.get(s)); + } + } + card.setTotals(latest == null ? 0 : latest.total(), totalLevel); + card.setSkillGains(gained); + } + + private void refresh() + { + long now = System.currentTimeMillis(); + String active = plugin.getSkillKey(); + SkillHistory h = resolve(store, selectedKey, active); + updateCombo(h); + hudBox.setSelected(plugin.isSkillsHudEnabled()); + + shownKey = h == null ? null : h.key; + fillCard(card, h, timeFilter, "Skills"); + String label = timeFilter.getLabel(); + if (h == null) + { + chartTitle.setText(""); + showAll.setVisible(false); + cumulative.setData(null, null, -1, now - Ui.HOUR, now, Ui.GOLD, "Log in to start tracking xp"); + perPeriod.setData(null, null, -1, now - Ui.HOUR, now, Ui.GOLD, "No data yet"); + stats.update(new ArrayList<>()); + skillList.removeAll(); + rowOrder.clear(); + levelList.removeAll(); + revalidate(); + repaint(); + return; + } + + long start = h.start(timeFilter); + long[] starts = h.starts(timeFilter); + long rateStart = h.rateStart(timeFilter); + long[] rateStarts = h.rateStarts(timeFilter); + List samples = h.range(start); + long chartStart = samples.isEmpty() ? now - Ui.HOUR : Math.max(start, samples.get(0).t); + if (timeFilter.getRange() != TimeRange.ALL) + { + chartStart = start; + } + long[] gained = h.gained(timeFilter); + List levelUps = h.levelUps(start); + levelUps.removeIf(lu -> gained[lu.skill] <= 0); + XpSample last = h.latest(); + + // ---- chart + stats for the selection + int sel = selectedSkill; + Color color = sel < 0 ? Ui.GOLD : skillColor(SkillHistory.SKILLS[sel]); + chartTitle.setText(sel < 0 ? "All skills" : SkillHistory.SKILLS[sel].getName()); + chartTitle.setForeground(sel < 0 ? Ui.TEXT : color); + showAll.setVisible(sel >= 0); + String empty = "No xp gained " + timeFilter.inLabel(); + cumulative.setData(samples, levelUps, sel, chartStart, now, color, empty); + perPeriod.setData(samples, levelUps, sel, chartStart, now, color, empty); + + long total = 0; + int trained = 0; + for (long g : gained) + { + total += g; + trained += g > 0 ? 1 : 0; + } + long selGained = sel < 0 ? total : gained[sel]; + long rateSelStart = sel < 0 ? rateStart : rateStarts[sel]; + long selRateGained = sel < 0 ? h.gainedTotalSince(rateStart) : h.gainedSince(rateStarts[sel], sel); + long activeMs = h.activeMillis(rateSelStart, sel); + int levels = 0; + for (SkillHistory.LevelUp lu : levelUps) + { + if (sel < 0 || lu.skill == sel) + { + levels += lu.to - lu.from; + } + } + List cells = new ArrayList<>(); + cells.add(new StatsView.Cell("XP gained", "+" + Ui.gp(selGained), label, selGained > 0 ? Ui.UP : Ui.FAINT, + String.format(Locale.US, "%,d xp", selGained))); + cells.add(new StatsView.Cell("XP / hour", activeMs >= Ui.MINUTE && selRateGained > 0 ? Ui.gp(selRateGained * Ui.HOUR / activeMs) : "-", + "while training", Ui.TEXT, "Xp per hour of active training time")); + cells.add(new StatsView.Cell("Levels", "+" + levels, null, levels > 0 ? Ui.UP : Ui.TEXT, "Levels gained in " + label)); + cells.add(new StatsView.Cell("Active", duration(activeMs), null, Ui.TEXT, "Time spent gaining xp (gaps under 5 minutes)")); + if (sel < 0) + { + int totalLevel = 0; + for (int i = 0; i < SkillHistory.SKILLS.length; i++) + { + totalLevel += Math.min(99, SkillHistory.level(last.get(i))); + } + cells.add(new StatsView.Cell("Skills trained", String.valueOf(trained), null, Ui.TEXT, null)); + cells.add(new StatsView.Cell("Total level", String.valueOf(totalLevel), null, Ui.GOLD, null)); + } + else + { + int xp = last.get(sel); + int lvl = SkillHistory.level(xp); + String toNext = lvl >= Experience.MAX_VIRT_LEVEL ? "-" : Ui.gp(Experience.getXpForLevel(lvl + 1) - xp); + cells.add(new StatsView.Cell("Level", String.valueOf(lvl), Ui.gp(xp) + " xp", color, String.format(Locale.US, "%,d xp", xp))); + cells.add(new StatsView.Cell("To level " + Math.min(lvl + 1, Experience.MAX_VIRT_LEVEL), toNext, null, Ui.TEXT, null)); + SkillGoals.Goal goal = goal(sel); + if (goal != null) + { + int left = goal.xpLeft(xp); + long selXph = activeMs >= Ui.MINUTE && selGained > 0 ? selGained * Ui.HOUR / activeMs : 0; + cells.add(new StatsView.Cell("Goal " + goal.level, percent(goal.progress(xp)), left == 0 ? "reached" : Ui.gp(left) + " xp left", + left == 0 ? Ui.UP : Ui.GOLD, String.format(Locale.US, "%,d / %,d xp", xp, goal.targetXp()))); + cells.add(new StatsView.Cell("Time to goal", left == 0 ? "-" : selXph > 0 ? duration(left * Ui.HOUR / selXph) : "-", + selXph > 0 ? "at " + Ui.gp(selXph) + "/h" : "no xp/h yet", Ui.TEXT, null)); + } + } + stats.update(cells); + + // ---- skill rows (trained skills by xp, then the rest in game order) + List order = new ArrayList<>(); + for (int i = 0; i < gained.length; i++) + { + order.add(i); + } + order.sort((a, b) -> Long.compare(gained[b], gained[a])); + long maxGain = gained.length == 0 ? 0 : gained[order.get(0)]; + for (int i = 0; i < rows.length; i++) + { + int xp = last.get(i); + int from = gained[i] > 0 ? SkillHistory.level(h.at(starts[i]).get(i)) : SkillHistory.level(xp); + long skillRateStart = rateStarts[i]; + long skillRateGained = h.gainedSince(skillRateStart, i); + long skillActive = skillRateGained > 0 ? h.activeMillis(skillRateStart, i) : 0; + long xph = skillActive >= Ui.MINUTE ? skillRateGained * Ui.HOUR / skillActive : 0; + rows[i].update(xp, SkillHistory.level(xp) - from, gained[i], xph, maxGain, i == sel, goal(i)); + } + if (!order.equals(rowOrder)) + { + rowOrder.clear(); + rowOrder.addAll(order); + skillList.removeAll(); + for (int i : order) + { + skillList.add(rows[i]); + } + skillList.revalidate(); + } + + // ---- level ups, newest first + levelList.removeAll(); + if (levelUps.isEmpty()) + { + levelEmpty.setText("No level ups " + timeFilter.inLabel()); + levelList.add(levelEmpty); + } + for (int i = levelUps.size() - 1; i >= Math.max(0, levelUps.size() - LEVEL_UP_LIMIT); i--) + { + levelList.add(new LevelUpRow(levelUps.get(i))); + } + levelList.revalidate(); + + revalidate(); + repaint(); + } + + private SkillGoals.Goal goal(int skill) + { + return plugin.getSkillGoals().get(shownKey, SkillHistory.SKILLS[skill].getName()); + } + + private static String percent(double ratio) + { + double p = ratio * 100; + return (p >= 100 ? "100" : String.format(Locale.US, "%.1f", p)) + "%"; + } + + /** Goal editor for one skill: target level with quick picks, set and clear. */ + private void showGoalEditor(int skill, JComponent anchor, int x, int y) + { + if (shownKey == null) + { + return; + } + Skill s = SkillHistory.SKILLS[skill]; + SkillHistory h = store.get(shownKey); + XpSample last = h == null ? null : h.latest(); + int xp = last == null ? 0 : last.get(skill); + int level = SkillHistory.level(xp); + SkillGoals.Goal current = goal(skill); + int max = Experience.MAX_VIRT_LEVEL; + int initial = current != null ? current.level : level < 99 ? nextMilestone(level) : level + 1; + + JPopupMenu menu = new JPopupMenu(); + JPanel panel = new JPanel(new DynamicGridLayout(0, 1, 0, 6)); + panel.setBackground(Ui.CARD); + panel.setBorder(BorderFactory.createEmptyBorder(8, 8, 8, 8)); + + JLabel title = new JLabel(s.getName() + " goal"); + title.setFont(FontManager.getRunescapeBoldFont()); + title.setForeground(skillColor(s)); + panel.add(title); + JLabel now = new JLabel(String.format(Locale.US, "Now level %d (%,d xp)", level, xp)); + now.setFont(FontManager.getRunescapeSmallFont()); + now.setForeground(Ui.MUTED); + panel.add(now); + + SpinnerNumberModel model = new SpinnerNumberModel(Math.max(2, Math.min(max, initial)), 2, max, 1); + JSpinner spinner = new JSpinner(model); + spinner.setFont(FontManager.getRunescapeFont()); + JLabel preview = new JLabel(); + preview.setFont(FontManager.getRunescapeSmallFont()); + preview.setForeground(Ui.TEXT); + Runnable updatePreview = () -> + { + int target = Experience.getXpForLevel((Integer) model.getValue()); + int left = Math.max(0, target - xp); + preview.setText(left == 0 ? "Already reached" : String.format(Locale.US, "%,d xp needed", left)); + }; + spinner.addChangeListener(e -> updatePreview.run()); + updatePreview.run(); + JPanel levelRow = new JPanel(new BorderLayout(6, 0)); + levelRow.setOpaque(false); + JLabel lvLabel = new JLabel("Target level"); + lvLabel.setFont(FontManager.getRunescapeSmallFont()); + lvLabel.setForeground(Ui.TEXT); + levelRow.add(lvLabel, BorderLayout.WEST); + levelRow.add(spinner, BorderLayout.CENTER); + panel.add(levelRow); + + JPanel quick = new JPanel(new java.awt.GridLayout(1, 4, 3, 0)); + quick.setOpaque(false); + for (int[] pick : new int[][]{{1, 0}, {5, 0}, {0, 70}, {0, 99}}) + { + String label = pick[0] > 0 ? "+" + pick[0] : String.valueOf(pick[1]); + quick.add(button(label, () -> model.setValue(Math.max(2, Math.min(max, pick[0] > 0 ? level + pick[0] : pick[1]))))); + } + panel.add(quick); + panel.add(preview); + + JPanel actions = new JPanel(new java.awt.GridLayout(1, 2, 6, 0)); + actions.setOpaque(false); + JButton set = button("Set goal", () -> + { + plugin.getSkillGoals().set(shownKey, s.getName(), (Integer) model.getValue(), xp); + menu.setVisible(false); + refresh(); + }); + set.setForeground(Ui.ACCENT); + actions.add(set); + JButton clear = button("Clear", () -> + { + plugin.getSkillGoals().clear(shownKey, s.getName()); + menu.setVisible(false); + refresh(); + }); + clear.setEnabled(current != null); + actions.add(clear); + panel.add(actions); + + panel.setPreferredSize(new Dimension(190, panel.getPreferredSize().height)); + menu.add(panel); + menu.show(anchor, Math.max(0, Math.min(x, anchor.getWidth() - 190)), y); + } + + /** Next round level worth aiming for: 10, 20 .. 80, 85, 90, 99. */ + private static int nextMilestone(int level) + { + for (int m : new int[]{10, 20, 30, 40, 50, 60, 70, 80, 85, 90, 99}) + { + if (m > level) + { + return m; + } + } + return level + 1; + } + + private void updateCombo(SkillHistory shown) + { + List all = store.all(); + List choices = new ArrayList<>(); + for (SkillHistory h : all) + { + choices.add(new Choice(h.key, h.name == null ? h.key : h.name)); + } + List current = new ArrayList<>(); + for (int i = 0; i < characterBox.getItemCount(); i++) + { + current.add(characterBox.getItemAt(i)); + } + updatingCombo = true; + if (!choices.equals(current)) + { + characterBox.removeAllItems(); + choices.forEach(characterBox::addItem); + } + for (Choice c : choices) + { + if (shown != null && c.key.equals(shown.key)) + { + characterBox.setSelectedItem(c); + } + } + updatingCombo = false; + boolean visible = choices.size() > 1; + if (characterBox.isVisible() != visible) + { + characterBox.setVisible(visible); + revalidate(); + } + } + + private BufferedImage icon(Skill skill) + { + return icons.computeIfAbsent(skill, s -> + { + BufferedImage img = plugin.getSkillIconManager().getSkillImage(s, true); + return img == null ? null : ImageUtil.resizeImage(img, 16, 16); + }); + } + + private static String duration(long ms) + { + if (ms < Ui.MINUTE) + { + return "-"; + } + long h = ms / Ui.HOUR; + long m = (ms % Ui.HOUR) / Ui.MINUTE; + return h > 0 ? h + "h " + m + "m" : m + "m"; + } + + static Color skillColor(Skill skill) + { + switch (skill.name()) + { + case "ATTACK": return new Color(220, 80, 60); + case "DEFENCE": return new Color(120, 150, 230); + case "STRENGTH": return new Color(60, 190, 120); + case "HITPOINTS": return new Color(230, 90, 90); + case "RANGED": return new Color(150, 200, 60); + case "PRAYER": return new Color(235, 225, 170); + case "MAGIC": return new Color(110, 140, 255); + case "COOKING": return new Color(180, 100, 210); + case "WOODCUTTING": return new Color(110, 180, 80); + case "FLETCHING": return new Color(40, 190, 170); + case "FISHING": return new Color(130, 170, 220); + case "FIREMAKING": return new Color(250, 150, 40); + case "CRAFTING": return new Color(200, 150, 100); + case "SMITHING": return new Color(170, 170, 140); + case "MINING": return new Color(120, 180, 210); + case "HERBLORE": return new Color(60, 200, 70); + case "AGILITY": return new Color(110, 115, 220); + case "THIEVING": return new Color(190, 110, 170); + case "SLAYER": return new Color(200, 70, 70); + case "FARMING": return new Color(130, 200, 90); + case "RUNECRAFT": return new Color(230, 200, 70); + case "HUNTER": return new Color(170, 160, 110); + case "CONSTRUCTION": return new Color(200, 180, 140); + case "SAILING": return new Color(70, 170, 230); + default: return Ui.GOLD; + } + } + + private static JButton button(String text, Runnable action) + { + JButton b = new JButton(text); + b.setFont(FontManager.getRunescapeSmallFont()); + b.setFocusPainted(false); + b.setMargin(new java.awt.Insets(2, 2, 2, 2)); + b.setCursor(Cursor.getPredefinedCursor(Cursor.HAND_CURSOR)); + b.addActionListener(e -> action.run()); + return b; + } + + private static final class Choice + { + final String key; + final String name; + + Choice(String key, String name) + { + this.key = key; + this.name = name; + } + + @Override + public boolean equals(Object o) + { + return o instanceof Choice && ((Choice) o).key.equals(key) && Objects.equals(((Choice) o).name, name); + } + + @Override + public int hashCode() + { + return key.hashCode(); + } + + @Override + public String toString() + { + return name; + } + } + + /** width of the goal cog hit area at the right of a skill row */ + private static final int GEAR_HIT = 20; + + /** One skill: icon, level (+gained), xp gained, xp/h, progress to the next level and the goal, if set. */ + private final class SkillRow extends JComponent + { + private final int skill; + private int xp; + private int levelsGained; + private long gained; + private long xph; + private long maxGain; + private boolean selected; + private SkillGoals.Goal goal; + private boolean hover; + private boolean gearHover; + + SkillRow(int skill) + { + this.skill = skill; + setCursor(Cursor.getPredefinedCursor(Cursor.HAND_CURSOR)); + MouseAdapter mouse = new MouseAdapter() + { + @Override + public void mouseEntered(MouseEvent e) + { + hover = true; + repaint(); + } + + @Override + public void mouseExited(MouseEvent e) + { + hover = false; + gearHover = false; + repaint(); + } + + @Override + public void mouseMoved(MouseEvent e) + { + boolean g = onGear(e.getX(), e.getY()); + if (g != gearHover) + { + gearHover = g; + setToolTipText(g ? "Set a level goal" : tooltip()); + repaint(); + } + } + + @Override + public void mousePressed(MouseEvent e) + { + if (SwingUtilities.isRightMouseButton(e) || onGear(e.getX(), e.getY())) + { + showGoalEditor(skill, SkillRow.this, e.getX(), e.getY()); + } + else + { + selectSkill(skill); + } + } + }; + addMouseListener(mouse); + addMouseMotionListener(mouse); + } + + private boolean onGear(int x, int y) + { + return x >= getWidth() - GEAR_HIT && y <= 20; + } + + private String tooltip() + { + String t = String.format(Locale.US, "%s
%,d xp
+%,d xp gained", SkillHistory.SKILLS[skill].getName(), xp, gained); + if (goal != null) + { + t += String.format(Locale.US, "
Goal %d: %,d xp left", goal.level, goal.xpLeft(xp)); + } + return t + "
Right-click or the cog to set a goal"; + } + + void update(int xp, int levelsGained, long gained, long xph, long maxGain, boolean selected, SkillGoals.Goal goal) + { + boolean resize = (goal == null) != (this.goal == null); + this.goal = goal; + this.xp = xp; + this.levelsGained = levelsGained; + this.gained = gained; + this.xph = xph; + this.maxGain = maxGain; + this.selected = selected; + setToolTipText(tooltip()); + if (resize) + { + revalidate(); + } + repaint(); + } + + @Override + public Dimension getPreferredSize() + { + if (goal == null) + { + return new Dimension(10, 34); + } + int asc = getFontMetrics(FontManager.getRunescapeSmallFont()).getAscent(); + // goal text baseline at 20 + 3 * asc, then gap, 4px bar, bottom padding + return new Dimension(10, 4 + 3 * asc + 16 + 14); + } + + @Override + protected void paintComponent(Graphics g0) + { + Graphics2D g = Ui.prepare(g0); + int w = getWidth(); + int h = getHeight(); + Skill s = SkillHistory.SKILLS[skill]; + Color color = skillColor(s); + boolean trained = gained > 0; + + g.setColor(selected ? Ui.CARD_SELECTED : hover ? Ui.CARD_HOVER : Ui.CARD); + g.fillRoundRect(0, 0, w, h, 6, 6); + if (selected) + { + g.setColor(color); + g.fillRoundRect(0, 0, 3, h, 3, 3); + } + // share of the top skill's gain as a faint background bar + if (trained && maxGain > 0) + { + g.setColor(Ui.alpha(color, 26)); + g.fillRoundRect(0, 0, (int) (w * (double) gained / maxGain), h, 6, 6); + } + + BufferedImage img = icon(s); + if (img != null) + { + g.drawImage(img, 6, (h - 16) / 2 - 2, null); + } + + int level = SkillHistory.level(xp); + g.setFont(FontManager.getRunescapeSmallFont()); + FontMetrics fm = g.getFontMetrics(); + int x = 28; + int base1 = 4 + fm.getAscent(); + int base2 = base1 + fm.getAscent() + 2; + g.setColor(trained ? Ui.TEXT : Ui.MUTED); + g.drawString(s.getName(), x, base1); + String lv = "Lv " + level; + g.setColor(Ui.MUTED); + g.drawString(lv, x, base2); + if (levelsGained > 0) + { + g.setColor(Ui.UP); + g.drawString(" +" + levelsGained, x + fm.stringWidth(lv), base2); + } + + paintGear(g, w - 11, 10, gearHover ? Ui.ACCENT : goal != null ? Ui.GOLD : hover ? Ui.MUTED : Ui.FAINT); + int right = w - GEAR_HIT - 2; + g.setColor(trained ? Ui.UP : Ui.FAINT); + Ui.drawRight(g, trained ? "+" + Ui.gp(gained) + " xp" : "-", right, base1); + if (xph > 0) + { + g.setColor(Ui.MUTED); + Ui.drawRight(g, Ui.gp(xph) + "/h", right, base2); + } + + // progress to next level + int levelBarY = goal == null ? h - 4 : base2 + 6; + if (level < Experience.MAX_VIRT_LEVEL) + { + int lo = Experience.getXpForLevel(level); + int hi = Experience.getXpForLevel(level + 1); + double p = hi > lo ? (double) (xp - lo) / (hi - lo) : 0; + int bx = x; + int bw = w - x - 7; + g.setColor(Ui.alpha(Color.WHITE, 18)); + g.fillRect(bx, levelBarY, bw, 2); + g.setColor(Ui.alpha(color, trained ? 220 : 110)); + g.fillRect(bx, levelBarY, (int) (bw * Math.max(0, Math.min(1, p))), 2); + } + + if (goal != null) + { + int left = goal.xpLeft(xp); + boolean reached = left == 0; + int base3 = levelBarY + 2 + 6 + fm.getAscent(); + String head = "Goal " + goal.level + " "; + g.setColor(Ui.MUTED); + g.drawString(head, x, base3); + g.setColor(reached ? Ui.UP : Ui.GOLD); + g.drawString(percent(goal.progress(xp)), x + fm.stringWidth(head), base3); + String info = reached ? "reached!" : Ui.gp(left) + " xp left" + (xph > 0 ? " ~" + duration(left * Ui.HOUR / xph) : ""); + g.setColor(reached ? Ui.UP : Ui.TEXT); + Ui.drawRight(g, info, w - 7, base3); + int bx = x; + int bw = w - x - 7; + int by = base3 + 5; + g.setColor(Ui.alpha(Color.WHITE, 18)); + g.fillRoundRect(bx, by, bw, 4, 4, 4); + g.setColor(reached ? Ui.UP : Ui.GOLD); + g.fillRoundRect(bx, by, Math.max(4, (int) (bw * goal.progress(xp))), 4, 4, 4); + } + g.dispose(); + } + + /** Small cog: ring with eight teeth. */ + private void paintGear(Graphics2D g, int cx, int cy, Color c) + { + Graphics2D g2 = (Graphics2D) g.create(); + g2.translate(cx, cy); + g2.setColor(c); + for (int i = 0; i < 8; i++) + { + g2.fillRect(-1, -6, 3, 3); + g2.rotate(Math.PI / 4); + } + g2.fillOval(-4, -4, 9, 9); + g2.setColor(selected ? Ui.CARD_SELECTED : hover ? Ui.CARD_HOVER : Ui.CARD); + g2.fillOval(-1, -1, 3, 3); + g2.dispose(); + } + } + + /** "Attack 70 → 71" with the time it happened. */ + private final class LevelUpRow extends JComponent + { + private final SkillHistory.LevelUp levelUp; + + LevelUpRow(SkillHistory.LevelUp levelUp) + { + this.levelUp = levelUp; + setToolTipText(Instant.ofEpochMilli(levelUp.t).atZone(ZoneId.systemDefault()).format(WHEN)); + } + + @Override + public Dimension getPreferredSize() + { + return new Dimension(10, 22); + } + + @Override + protected void paintComponent(Graphics g0) + { + Graphics2D g = Ui.prepare(g0); + int w = getWidth(); + int h = getHeight(); + Skill s = SkillHistory.SKILLS[levelUp.skill]; + g.setColor(Ui.CARD); + g.fillRoundRect(0, 0, w, h, 6, 6); + BufferedImage img = icon(s); + if (img != null) + { + g.drawImage(img, 5, (h - 16) / 2, null); + } + g.setFont(FontManager.getRunescapeSmallFont()); + FontMetrics fm = g.getFontMetrics(); + int base = (h + fm.getAscent()) / 2 - 1; + g.setColor(Ui.TEXT); + String name = s.getName() + " "; + g.drawString(name, 26, base); + g.setColor(skillColor(s)); + String lv = levelUp.to - levelUp.from > 1 ? levelUp.from + " > " + levelUp.to : String.valueOf(levelUp.to); + g.drawString(lv, 26 + fm.stringWidth(name), base); + g.setColor(Ui.MUTED); + String when = Ui.ago(levelUp.t); + if (System.currentTimeMillis() - levelUp.t >= Ui.DAY) + { + when = Instant.ofEpochMilli(levelUp.t).atZone(ZoneId.systemDefault()).format(WHEN); + } + Ui.drawRight(g, when, w - 7, base); + g.dispose(); + } + } + + private static final String[][] HUD_OPTIONS = { + {"skillsHudLevel", "Level"}, + {"skillsHudPercent", "Progress %"}, + {"skillsHudXpLeft", "Xp to level"}, + {"skillsHudTimeToLevel", "Time to level"}, + {"skillsHudXpHour", "Xp per hour"}, + {"skillsHudProgressBar", "Progress bar"}, + }; + + private void showHudOptions(JComponent anchor) + { + JPopupMenu menu = new JPopupMenu(); + for (String[] option : HUD_OPTIONS) + { + JCheckBoxMenuItem item = new JCheckBoxMenuItem(option[1], plugin.isHudOption(option[0])); + item.addActionListener(e -> plugin.setHudOption(option[0], item.isSelected())); + menu.add(item); + } + menu.show(anchor, 0, anchor.getHeight()); + } +} diff --git a/src/main/java/com/example/essentialsplus/ui/StatsView.java b/src/main/java/com/example/essentialsplus/ui/StatsView.java new file mode 100644 index 0000000..e34dda4 --- /dev/null +++ b/src/main/java/com/example/essentialsplus/ui/StatsView.java @@ -0,0 +1,106 @@ +package com.example.essentialsplus.ui; + +import java.awt.Color; +import java.awt.Dimension; +import java.awt.FontMetrics; +import java.awt.Graphics; +import java.awt.Graphics2D; +import java.awt.event.MouseEvent; +import java.util.ArrayList; +import java.util.List; +import javax.swing.JComponent; +import net.runelite.client.ui.FontManager; + +/** Grid of key figures. */ +public class StatsView extends JComponent +{ + private static final int CELL_H = 36; + private static final int GAP = 4; + + public static final class Cell + { + public final String label; + public final String value; + public final String hint; + public final Color color; + public final String tooltip; + + public Cell(String label, String value, String hint, Color color, String tooltip) + { + this.label = label; + this.value = value; + this.hint = hint == null ? "" : hint; + this.color = color; + this.tooltip = tooltip; + } + } + + private final int columns; + private List cells = new ArrayList<>(); + + public StatsView(int columns) + { + this.columns = columns; + setToolTipText(""); + } + + public void update(List cells) + { + boolean resize = cells.size() != this.cells.size(); + this.cells = cells; + if (resize) + { + revalidate(); + } + repaint(); + } + + @Override + public String getToolTipText(MouseEvent e) + { + int cw = (getWidth() - GAP * (columns - 1)) / columns; + int col = Math.min(columns - 1, e.getX() / (cw + GAP)); + int row = e.getY() / (CELL_H + GAP); + int i = row * columns + col; + return i >= 0 && i < cells.size() ? cells.get(i).tooltip : null; + } + + @Override + public Dimension getPreferredSize() + { + int rows = Math.max(1, (cells.size() + columns - 1) / columns); + return new Dimension(10, rows * CELL_H + (rows - 1) * GAP); + } + + @Override + protected void paintComponent(Graphics g0) + { + Graphics2D g = Ui.prepare(g0); + int cw = (getWidth() - GAP * (columns - 1)) / columns; + for (int i = 0; i < cells.size(); i++) + { + Cell cell = cells.get(i); + int x = (i % columns) * (cw + GAP); + int y = (i / columns) * (CELL_H + GAP); + g.setColor(Ui.CARD); + g.fillRoundRect(x, y, cw, CELL_H, 8, 8); + + g.setFont(FontManager.getRunescapeSmallFont()); + FontMetrics sfm = g.getFontMetrics(); + int hintW = cell.hint.isEmpty() ? 0 : sfm.stringWidth(cell.hint) + 6; + g.setColor(Ui.MUTED); + g.drawString(Ui.ellipsize(sfm, cell.label, cw - 14 - hintW), x + 8, y + 4 + sfm.getAscent()); + if (hintW > 0) + { + g.setColor(Ui.alpha(cell.color == Ui.TEXT ? Ui.MUTED : cell.color, 210)); + Ui.drawRight(g, cell.hint, x + cw - 7, y + 4 + sfm.getAscent()); + } + + g.setFont(FontManager.getRunescapeBoldFont()); + FontMetrics fm = g.getFontMetrics(); + g.setColor(cell.color); + g.drawString(Ui.ellipsize(fm, cell.value, cw - 14), x + 8, y + CELL_H - 6); + } + g.dispose(); + } +} diff --git a/src/main/java/com/example/essentialsplus/ui/TimeFilter.java b/src/main/java/com/example/essentialsplus/ui/TimeFilter.java new file mode 100644 index 0000000..e9011ca --- /dev/null +++ b/src/main/java/com/example/essentialsplus/ui/TimeFilter.java @@ -0,0 +1,169 @@ +package com.example.essentialsplus.ui; + +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.function.Consumer; +import java.util.function.IntPredicate; +import java.util.function.IntToLongFunction; + +/** Global time range shared by every Essentials Plus view (wealth, loot, skills, HUD). */ +public final class TimeFilter +{ + /** "Now" drops an activity once nothing was gained for this long */ + public static final long ACTIVE_GAP = 5 * Ui.MINUTE; + /** login time of the current game session (hopping keeps it) */ + private volatile long sessionStart = System.currentTimeMillis(); + + private volatile TimeRange range; + private final Consumer onPersist; + private final List listeners = new CopyOnWriteArrayList<>(); + + public TimeFilter(TimeRange initial, Consumer onPersist) + { + this.range = initial == null ? TimeRange.DAY : initial; + this.onPersist = onPersist; + } + + public static TimeRange parse(String name) + { + if (name != null) + { + for (TimeRange r : TimeRange.values()) + { + if (r.name().equals(name)) + { + return r; + } + } + } + return TimeRange.DAY; + } + + public TimeRange getRange() + { + return range; + } + + public String getLabel() + { + return range.getLabel(); + } + + /** Label for "nothing in ..." texts. */ + public String inLabel() + { + switch (range) + { + case CURRENT: + return "right now"; + case SESSION: + return "this session"; + case ALL: + return "total"; + default: + return "in " + range.getLabel(); + } + } + + public long getSessionStart() + { + return sessionStart; + } + + /** Starts the "Sess" range at a new login. */ + public void setSessionStart(long t) + { + sessionStart = t; + for (Runnable l : listeners) + { + l.run(); + } + } + + public boolean isCurrent() + { + return range == TimeRange.CURRENT; + } + + /** + * Start of the range in epoch millis; 0 for "All". For "Now" this is only a fallback: views work out + * the start of each activity with {@link #streakStart}. + */ + public long start() + { + long now = System.currentTimeMillis(); + switch (range) + { + case ALL: + return 0; + case SESSION: + return sessionStart; + case CURRENT: + return now - ACTIVE_GAP; + default: + return now - range.getMillis(); + } + } + + /** + * Start of the current activity streak in a time-ordered list: the newest gain must be at most + * {@link #ACTIVE_GAP} old and each earlier gain at most that far from the next one. + * + * @param time time of entry {@code i} + * @param gain whether entry {@code i} is progress (e.g. more xp than entry {@code i - 1}) + * @return a start such that the entry before the streak is the baseline, or {@link Long#MAX_VALUE} when idle + */ + public static long streakStart(int size, IntToLongFunction time, IntPredicate gain, long now) + { + int first = -1; + long firstTime = now; + for (int i = size - 1; i >= 0; i--) + { + long t = time.applyAsLong(i); + if (firstTime - t > ACTIVE_GAP) + { + break; + } + if (gain.test(i)) + { + first = i; + firstTime = t; + } + } + if (first < 0) + { + return Long.MAX_VALUE; + } + long start = firstTime - Ui.MINUTE; + return first > 0 ? Math.max(time.applyAsLong(first - 1), start) : start; + } + + public void set(TimeRange r) + { + if (r == null) + { + return; + } + boolean changed = r != range; + range = r; + if (changed && onPersist != null) + { + onPersist.accept(r); + } + // always notify: re-selecting the active range leaves e.g. the wealth session view + for (Runnable l : listeners) + { + l.run(); + } + } + + public void addListener(Runnable r) + { + listeners.add(r); + } + + public void removeListener(Runnable r) + { + listeners.remove(r); + } +} diff --git a/src/main/java/com/example/essentialsplus/ui/TimeRange.java b/src/main/java/com/example/essentialsplus/ui/TimeRange.java new file mode 100644 index 0000000..f3f8991 --- /dev/null +++ b/src/main/java/com/example/essentialsplus/ui/TimeRange.java @@ -0,0 +1,22 @@ +package com.example.essentialsplus.ui; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum TimeRange +{ + /** only what is being gained right now: resets after {@link TimeFilter#ACTIVE_GAP} without progress */ + CURRENT("Now", -1), + /** since the client was started */ + SESSION("Sess", -1), + HOUR("1H", Ui.HOUR), + DAY("1D", Ui.DAY), + TWO_WEEKS("2W", 14 * Ui.DAY), + QUARTER("3M", 91 * Ui.DAY), + ALL("All", -1); + + private final String label; + private final long millis; +} diff --git a/src/main/java/com/example/essentialsplus/ui/TopItemsView.java b/src/main/java/com/example/essentialsplus/ui/TopItemsView.java new file mode 100644 index 0000000..304114d --- /dev/null +++ b/src/main/java/com/example/essentialsplus/ui/TopItemsView.java @@ -0,0 +1,147 @@ +package com.example.essentialsplus.ui; + +import com.example.essentialsplus.data.TopItem; +import java.awt.Dimension; +import java.awt.FontMetrics; +import java.awt.Graphics; +import java.awt.Graphics2D; +import java.awt.RenderingHints; +import java.awt.event.MouseAdapter; +import java.awt.event.MouseEvent; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import javax.swing.JComponent; +import net.runelite.client.ui.FontManager; + +/** Most valuable item stacks. */ +class TopItemsView extends JComponent +{ + private static final int ROW = 34; + + private final ItemIcons icons; + private final int limit; + private List items = new ArrayList<>(); + private long total; + private int hover = -1; + + TopItemsView(ItemIcons icons, int limit) + { + this.icons = icons; + this.limit = limit; + setToolTipText(""); + MouseAdapter mouse = new MouseAdapter() + { + @Override + public void mouseMoved(MouseEvent e) + { + int h = (e.getY() - 4) / ROW; + if (h != hover) + { + hover = h; + repaint(); + } + } + + @Override + public void mouseExited(MouseEvent e) + { + hover = -1; + repaint(); + } + }; + addMouseListener(mouse); + addMouseMotionListener(mouse); + } + + void update(List items, long total) + { + List list = items.size() > limit ? new ArrayList<>(items.subList(0, limit)) : items; + boolean sizeChanged = list.size() != this.items.size(); + this.items = list; + this.total = total; + if (sizeChanged) + { + revalidate(); + } + repaint(); + } + + @Override + public String getToolTipText(MouseEvent e) + { + int i = (e.getY() - 4) / ROW; + if (i < 0 || i >= items.size()) + { + return null; + } + TopItem it = items.get(i); + long each = it.qty > 0 ? it.value / it.qty : 0; + return "" + it.name + "
" + Ui.exact(it.value) + + "
" + String.format(Locale.US, "%,d", it.qty) + " x " + Ui.exact(each) + ""; + } + + @Override + public Dimension getPreferredSize() + { + return new Dimension(10, items.isEmpty() ? 40 : items.size() * ROW + 8); + } + + @Override + protected void paintComponent(Graphics g0) + { + Graphics2D g = Ui.prepare(g0); + int w = getWidth(); + g.setColor(Ui.CARD); + g.fillRoundRect(0, 0, w, getHeight(), 8, 8); + g.setFont(FontManager.getRunescapeSmallFont()); + FontMetrics sfm = g.getFontMetrics(); + + if (items.isEmpty()) + { + g.setColor(Ui.MUTED); + String s = "No items yet"; + g.drawString(s, (w - sfm.stringWidth(s)) / 2, 20 + sfm.getAscent() / 2); + g.dispose(); + return; + } + + g.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR); + int pad = 6; + for (int i = 0; i < items.size(); i++) + { + TopItem it = items.get(i); + int y = 4 + i * ROW; + if (i == hover) + { + g.setColor(Ui.CARD_HOVER.brighter()); + g.fillRoundRect(3, y, w - 6, ROW, 6, 6); + } + g.drawImage(icons.icon(it.id, this), pad, y + 1, 36, 32, null); + + String val = Ui.gp(it.value); + g.setFont(FontManager.getRunescapeBoldFont()); + FontMetrics fm = g.getFontMetrics(); + int vw = fm.stringWidth(val); + g.setColor(Ui.GOLD); + Ui.drawRight(g, val, w - pad - 2, y + 7 + fm.getAscent()); + + int textX = pad + 40; + g.setFont(FontManager.getRunescapeFont()); + FontMetrics nfm = g.getFontMetrics(); + g.setColor(Ui.TEXT); + String name = it.name != null ? it.name : icons.name(it.id); + g.drawString(Ui.ellipsize(nfm, name, w - textX - vw - pad - 6), textX, y + 4 + nfm.getAscent()); + + g.setFont(FontManager.getRunescapeSmallFont()); + g.setColor(Ui.MUTED); + String sub = "x " + Ui.gp(it.qty); + if (total > 0) + { + sub += " " + String.format(Locale.US, "%.1f%%", it.value * 100.0 / total); + } + g.drawString(sub, textX, y + ROW - 6); + } + g.dispose(); + } +} diff --git a/src/main/java/com/example/essentialsplus/ui/Ui.java b/src/main/java/com/example/essentialsplus/ui/Ui.java new file mode 100644 index 0000000..01b4be7 --- /dev/null +++ b/src/main/java/com/example/essentialsplus/ui/Ui.java @@ -0,0 +1,171 @@ +package com.example.essentialsplus.ui; + +import java.awt.Color; +import java.awt.FontMetrics; +import java.awt.Graphics; +import java.awt.Graphics2D; +import java.awt.RenderingHints; +import java.text.NumberFormat; +import java.util.Locale; +import net.runelite.client.ui.ColorScheme; + +/** Shared colours, formatting and painting helpers. */ +public final class Ui +{ + public static final Color BG = ColorScheme.DARK_GRAY_COLOR; + public static final Color CARD = ColorScheme.DARKER_GRAY_COLOR; + public static final Color CARD_HOVER = new Color(38, 38, 38); + public static final Color CARD_SELECTED = new Color(44, 40, 34); + public static final Color TEXT = new Color(232, 232, 232); + public static final Color MUTED = new Color(155, 155, 155); + public static final Color FAINT = new Color(105, 105, 105); + public static final Color GRID = new Color(255, 255, 255, 16); + public static final Color GOLD = new Color(255, 194, 64); + public static final Color UP = new Color(72, 208, 130); + public static final Color DOWN = new Color(236, 96, 96); + public static final Color WARN = new Color(255, 152, 0); + public static final Color ACCENT = ColorScheme.BRAND_ORANGE; + + public static final long MINUTE = 60_000L; + public static final long HOUR = 60 * MINUTE; + public static final long DAY = 24 * HOUR; + + private Ui() + { + } + + public static Graphics2D prepare(Graphics g) + { + Graphics2D g2 = (Graphics2D) g.create(); + g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); + g2.setRenderingHint(RenderingHints.KEY_STROKE_CONTROL, RenderingHints.VALUE_STROKE_PURE); + g2.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_OFF); + return g2; + } + + public static Color alpha(Color c, int a) + { + return new Color(c.getRed(), c.getGreen(), c.getBlue(), Math.max(0, Math.min(255, a))); + } + + public static Color deltaColor(long delta) + { + return delta > 0 ? UP : delta < 0 ? DOWN : MUTED; + } + + /** Short RS-style amount: 999, 12.3K, 117.7M, 1.23B. */ + public static String gp(long v) + { + long a = Math.abs(v); + String sign = v < 0 ? "-" : ""; + if (a < 1_000) + { + return sign + a; + } + if (a < 1_000_000) + { + return sign + trim(a / 1_000.0, a < 100_000 ? 1 : 0) + "K"; + } + if (a < 1_000_000_000) + { + return sign + trim(a / 1_000_000.0, a < 100_000_000 ? (a < 10_000_000 ? 2 : 1) : 1) + "M"; + } + return sign + trim(a / 1_000_000_000.0, a < 10_000_000_000L ? 2 : 1) + "B"; + } + + static String signedGp(long v) + { + return (v > 0 ? "+" : "") + gp(v); + } + + static String exact(long v) + { + return NumberFormat.getIntegerInstance(Locale.US).format(v) + " gp"; + } + + static String pct(double ratio) + { + if (Double.isNaN(ratio) || Double.isInfinite(ratio)) + { + return ""; + } + double p = ratio * 100; + String s = Math.abs(p) >= 100 ? String.format(Locale.US, "%.0f", p) : String.format(Locale.US, "%.1f", p); + return (p > 0 ? "+" : "") + s + "%"; + } + + static String arrow(long delta) + { + return delta > 0 ? "▲ " : delta < 0 ? "▼ " : ""; + } + + public static String ago(long timestamp) + { + if (timestamp <= 0) + { + return "never"; + } + long d = System.currentTimeMillis() - timestamp; + if (d < MINUTE) + { + return "just now"; + } + if (d < HOUR) + { + return (d / MINUTE) + "m ago"; + } + if (d < DAY) + { + return (d / HOUR) + "h ago"; + } + return (d / DAY) + "d ago"; + } + + private static String trim(double v, int decimals) + { + String s = String.format(Locale.US, "%." + decimals + "f", v); + if (s.contains(".")) + { + s = s.replaceAll("0+$", "").replaceAll("\\.$", ""); + } + return s; + } + + public static String ellipsize(FontMetrics fm, String s, int maxWidth) + { + if (s == null) + { + return ""; + } + if (fm.stringWidth(s) <= maxWidth) + { + return s; + } + String dots = ".."; + for (int i = s.length() - 1; i > 0; i--) + { + String t = s.substring(0, i) + dots; + if (fm.stringWidth(t) <= maxWidth) + { + return t; + } + } + return dots; + } + + /** Accent outline on an overview card while it is dragged to a new position. */ + public static void paintDragOutline(Graphics2D g, javax.swing.JComponent card) + { + if (Boolean.TRUE.equals(card.getClientProperty("cardDragging"))) + { + g.setColor(ACCENT); + g.setStroke(new java.awt.BasicStroke(2f)); + g.drawRoundRect(1, 1, card.getWidth() - 3, card.getHeight() - 3, 8, 8); + } + } + + public static void drawRight(Graphics2D g, String s, int rightX, int baseline) + { + g.drawString(s, rightX - g.getFontMetrics().stringWidth(s), baseline); + } +} diff --git a/src/main/java/com/example/essentialsplus/ui/WealthChart.java b/src/main/java/com/example/essentialsplus/ui/WealthChart.java new file mode 100644 index 0000000..880f795 --- /dev/null +++ b/src/main/java/com/example/essentialsplus/ui/WealthChart.java @@ -0,0 +1,963 @@ +package com.example.essentialsplus.ui; + +import com.example.essentialsplus.WealthCategory; +import com.example.essentialsplus.data.Snapshot; +import java.awt.BasicStroke; +import java.awt.Color; +import java.awt.Dimension; +import java.awt.Font; +import java.awt.FontMetrics; +import java.awt.GradientPaint; +import java.awt.Graphics; +import java.awt.Graphics2D; +import java.awt.Stroke; +import java.awt.event.MouseAdapter; +import java.awt.event.MouseEvent; +import java.awt.geom.Ellipse2D; +import java.awt.geom.Path2D; +import java.awt.geom.Rectangle2D; +import java.time.Instant; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Locale; +import java.util.function.DoubleUnaryOperator; +import javax.swing.JComponent; +import net.runelite.client.ui.FontManager; + +/** + * Interactive timeline. + *
    + *
  • LINE: total wealth (min/max band where data is aggregated)
  • + *
  • CANDLES: OHLC candles, bucket size adapts to range and width
  • + *
  • BARS: change per day (per hour for short ranges, per week for long ones)
  • + *
  • STACKED: composition by category
  • + *
+ */ +public class WealthChart extends JComponent +{ + public enum Mode + { + LINE, CANDLES, BARS, STACKED + } + + private static final Stroke LINE_STROKE = new BasicStroke(1.8f, BasicStroke.CAP_ROUND, BasicStroke.JOIN_ROUND); + private static final Stroke THIN = new BasicStroke(1f); + private static final Stroke DASHED = new BasicStroke(1f, BasicStroke.CAP_BUTT, BasicStroke.JOIN_MITER, 10f, new float[]{3f, 3f}, 0f); + private static final DateTimeFormatter TIP_TIME = DateTimeFormatter.ofPattern("EEE d MMM, HH:mm", Locale.ENGLISH); + private static final DateTimeFormatter TIP_HOUR = DateTimeFormatter.ofPattern("EEE d MMM, HH:00", Locale.ENGLISH); + private static final DateTimeFormatter TIP_DAY = DateTimeFormatter.ofPattern("EEE d MMM yyyy", Locale.ENGLISH); + + private Mode mode; + private final int prefHeight; + private List points = Collections.emptyList(); + private List candles = Collections.emptyList(); + private long bucket; + private long start; + private long end; + private String emptyText = "No history yet"; + private int hoverX = -1; + private boolean large; + + public WealthChart(Mode mode, int prefHeight) + { + this.mode = mode; + this.prefHeight = prefHeight; + setOpaque(false); + MouseAdapter mouse = new MouseAdapter() + { + @Override + public void mouseMoved(MouseEvent e) + { + hoverX = e.getX(); + repaint(); + } + + @Override + public void mouseDragged(MouseEvent e) + { + mouseMoved(e); + } + + @Override + public void mouseExited(MouseEvent e) + { + hoverX = -1; + repaint(); + } + }; + addMouseListener(mouse); + addMouseMotionListener(mouse); + } + + public void setLarge(boolean large) + { + this.large = large; + } + + public void setMode(Mode mode) + { + this.mode = mode; + repaint(); + } + + public void setData(List points, long start, long end, String emptyText) + { + this.points = points == null ? Collections.emptyList() : points; + this.start = start; + this.end = Math.max(end, start + Ui.MINUTE); + this.emptyText = emptyText; + this.candles = Collections.emptyList(); + this.bucket = 0; + repaint(); + } + + @Override + public Dimension getPreferredSize() + { + return new Dimension(10, prefHeight); + } + + private void ensureCandles(int plotWidth) + { + long b = mode == Mode.BARS + ? Analysis.changeBucket(end - start) + : Analysis.pickBucket(end - start, Math.max(8, plotWidth / (large ? 9 : 6))); + if (b != bucket || candles.isEmpty()) + { + bucket = b; + candles = Analysis.candles(points, start, end, b); + } + } + + @Override + protected void paintComponent(Graphics g0) + { + Graphics2D g = Ui.prepare(g0); + int w = getWidth(); + int h = getHeight(); + g.setColor(Ui.CARD); + g.fillRoundRect(0, 0, w, h, 8, 8); + + Font font = large ? FontManager.getRunescapeFont() : FontManager.getRunescapeSmallFont(); + g.setFont(font); + FontMetrics fm = g.getFontMetrics(); + + if (points.isEmpty()) + { + paintEmpty(g, fm, w, h); + g.dispose(); + return; + } + + // rough plot width before labels are known (needed to pick the candle size) + int roughPw = w - 50; + if (mode == Mode.CANDLES || mode == Mode.BARS) + { + ensureCandles(roughPw); + } + + // ---- value range + double lo = Double.MAX_VALUE; + double hi = -Double.MAX_VALUE; + switch (mode) + { + case LINE: + for (Snapshot p : points) + { + lo = Math.min(lo, p.low()); + hi = Math.max(hi, p.high()); + } + break; + case CANDLES: + for (Analysis.Candle c : candles) + { + lo = Math.min(lo, c.l); + hi = Math.max(hi, c.h); + } + break; + case BARS: + lo = 0; + hi = 0; + for (Analysis.Candle c : candles) + { + lo = Math.min(lo, c.change()); + hi = Math.max(hi, c.change()); + } + break; + default: + lo = 0; + for (Snapshot p : points) + { + hi = Math.max(hi, p.total()); + } + } + if (lo == Double.MAX_VALUE) + { + paintEmpty(g, fm, w, h); + g.dispose(); + return; + } + + if (mode == Mode.STACKED) + { + hi = Math.max(hi * 1.08, 1000); + } + else if (mode == Mode.BARS) + { + double m = Math.max(Math.max(Math.abs(lo), Math.abs(hi)), 1000); + lo = lo < 0 ? lo - m * 0.1 : -m * 0.05; + hi = hi > 0 ? hi + m * 0.1 : m * 0.05; + } + else + { + double span = hi - lo; + if (span <= 0) + { + span = Math.max(Math.abs(hi) * 0.04, 1000); + } + lo = lo >= 0 ? Math.max(0, lo - span * 0.12) : lo - span * 0.12; + hi = hi + span * 0.15; + } + + double step = niceStep((hi - lo) / (large ? 5 : 3)); + double firstTick = Math.ceil(lo / step) * step; + List ticks = new ArrayList<>(); + for (double v = firstTick; v <= hi + step * 0.001; v += step) + { + ticks.add(Math.abs(v) < step * 1e-6 ? 0 : v); + } + + int labelW = 0; + for (double v : ticks) + { + labelW = Math.max(labelW, fm.stringWidth(axisLabel(v))); + } + + int px = labelW + 10; + int py = 10; + int pw = w - px - 8; + int ph = h - py - fm.getHeight() - 6; + if (pw < 20 || ph < 20) + { + g.dispose(); + return; + } + + final double fLo = lo; + final double fHi = hi; + DoubleUnaryOperator yOf = v -> py + ph - (v - fLo) / (fHi - fLo) * ph; + + for (double v : ticks) + { + int y = (int) Math.round(yOf.applyAsDouble(v)); + g.setColor(mode == Mode.BARS && v == 0 ? Ui.alpha(Ui.TEXT, 60) : Ui.GRID); + g.setStroke(THIN); + g.drawLine(px, y, px + pw, y); + g.setColor(Ui.FAINT); + Ui.drawRight(g, axisLabel(v), px - 5, y + fm.getAscent() / 2 - 1); + } + drawTimeAxis(g, fm, px, py + ph, pw); + + switch (mode) + { + case LINE: + paintLine(g, fm, px, py, pw, ph, yOf); + paintLineHover(g, fm, px, py, pw, ph, yOf); + break; + case STACKED: + paintStacked(g, px, py, pw, ph, yOf); + paintStackedHover(g, fm, px, py, pw, ph); + break; + case CANDLES: + paintCandles(g, px, pw, yOf); + paintCandleHover(g, fm, px, py, pw, ph); + break; + case BARS: + paintBars(g, px, pw, yOf); + paintCandleHover(g, fm, px, py, pw, ph); + break; + } + g.dispose(); + } + + private String axisLabel(double v) + { + long r = Math.round(v); + return mode == Mode.BARS ? Ui.signedGp(r) : Ui.gp(r); + } + + private void paintEmpty(Graphics2D g, FontMetrics fm, int w, int h) + { + g.setColor(Ui.MUTED); + String[] lines = emptyText.split("\n"); + int y = h / 2 - (lines.length - 1) * fm.getHeight() / 2 + fm.getAscent() / 2; + for (String line : lines) + { + g.drawString(line, (w - fm.stringWidth(line)) / 2, y); + y += fm.getHeight(); + } + } + + private double xOf(long t, int px, int pw) + { + return px + (double) (t - start) / (end - start) * pw; + } + + private long tAt(int x, int px, int pw) + { + return start + Math.round((double) (x - px) / pw * (end - start)); + } + + // ------------------------------------------------------------------ line + + private void paintLine(Graphics2D g, FontMetrics fm, int px, int py, int pw, int ph, DoubleUnaryOperator yOf) + { + List pts = downsample(points, pw); + long first = points.get(0).total(); + long last = points.get(points.size() - 1).total(); + Color color = last > first ? Ui.UP : last < first ? Ui.DOWN : Ui.GOLD; + + boolean anyAgg = false; + for (Snapshot p : pts) + { + anyAgg |= p.isAggregate(); + } + if (anyAgg) + { + Path2D band = new Path2D.Double(); + for (int i = 0; i < pts.size(); i++) + { + Snapshot p = pts.get(i); + double x = xOf(p.t, px, pw); + double y = yOf.applyAsDouble(p.high()); + if (i == 0) + { + band.moveTo(x, y); + } + else + { + band.lineTo(x, y); + } + } + for (int i = pts.size() - 1; i >= 0; i--) + { + Snapshot p = pts.get(i); + band.lineTo(xOf(p.t, px, pw), yOf.applyAsDouble(p.low())); + } + band.closePath(); + g.setColor(Ui.alpha(color, 45)); + g.fill(band); + } + + Path2D line = new Path2D.Double(); + double firstX = xOf(pts.get(0).t, px, pw); + double lastX = firstX; + for (int i = 0; i < pts.size(); i++) + { + Snapshot p = pts.get(i); + double x = xOf(p.t, px, pw); + double y = yOf.applyAsDouble(p.total()); + if (i == 0) + { + line.moveTo(x, y); + } + else + { + // raw data: value holds until the next change; aggregates: straight line + if (!p.isAggregate() && !pts.get(i - 1).isAggregate()) + { + line.lineTo(x, yOf.applyAsDouble(pts.get(i - 1).total())); + } + line.lineTo(x, y); + } + lastX = x; + } + if (pts.size() == 1) + { + line.lineTo(px + pw, yOf.applyAsDouble(pts.get(0).total())); + lastX = px + pw; + } + + Path2D area = new Path2D.Double(line); + area.lineTo(lastX, py + ph); + area.lineTo(firstX, py + ph); + area.closePath(); + g.setPaint(new GradientPaint(0, py, Ui.alpha(color, 95), 0, py + ph, Ui.alpha(color, 0))); + g.fill(area); + + int baseY = (int) Math.round(yOf.applyAsDouble(first)); + g.setStroke(DASHED); + g.setColor(Ui.alpha(Ui.MUTED, 90)); + g.drawLine(px, baseY, px + pw, baseY); + + g.setStroke(LINE_STROKE); + g.setColor(color); + g.draw(line); + + int hiIdx = 0; + int loIdx = 0; + for (int i = 1; i < points.size(); i++) + { + if (points.get(i).high() > points.get(hiIdx).high()) + { + hiIdx = i; + } + if (points.get(i).low() < points.get(loIdx).low()) + { + loIdx = i; + } + } + if (points.get(hiIdx).high() != points.get(loIdx).low()) + { + marker(g, fm, px, pw, points.get(hiIdx).t, yOf.applyAsDouble(points.get(hiIdx).high()), points.get(hiIdx).high(), true); + marker(g, fm, px, pw, points.get(loIdx).t, yOf.applyAsDouble(points.get(loIdx).low()), points.get(loIdx).low(), false); + } + + double ly = yOf.applyAsDouble(last); + g.setColor(Ui.alpha(color, 60)); + g.fill(new Ellipse2D.Double(lastX - 5, ly - 5, 10, 10)); + g.setColor(color); + g.fill(new Ellipse2D.Double(lastX - 2.5, ly - 2.5, 5, 5)); + } + + private void marker(Graphics2D g, FontMetrics fm, int px, int pw, long t, double y, long value, boolean top) + { + double x = xOf(t, px, pw); + g.setColor(Ui.TEXT); + g.fill(new Ellipse2D.Double(x - 2, y - 2, 4, 4)); + String s = Ui.gp(value); + int sw = fm.stringWidth(s); + int tx = (int) Math.round(Math.max(px + 1, Math.min(px + pw - sw - 1, x - sw / 2.0))); + int ty = top ? (int) y - 5 : (int) y + fm.getAscent() + 3; + g.setColor(Ui.alpha(Ui.TEXT, 190)); + g.drawString(s, tx, ty); + } + + private void paintLineHover(Graphics2D g, FontMetrics fm, int px, int py, int pw, int ph, DoubleUnaryOperator yOf) + { + if (hoverX < px || hoverX > px + pw) + { + return; + } + int idx = nearestIndex(tAt(hoverX, px, pw)); + if (idx < 0) + { + return; + } + Snapshot p = points.get(idx); + int x = (int) Math.round(xOf(p.t, px, pw)); + crosshair(g, x, py, ph); + + long total = p.total(); + double y = yOf.applyAsDouble(total); + g.setColor(Ui.TEXT); + g.fill(new Ellipse2D.Double(x - 3, y - 3, 6, 6)); + + Tip tip = new Tip(title(p)); + if (p.isAggregate()) + { + tip.row("Open", Ui.gp(p.open()), Ui.TEXT); + tip.row("High", Ui.gp(p.high()), Ui.UP); + tip.row("Low", Ui.gp(p.low()), Ui.DOWN); + tip.row("Close", Ui.gp(p.close()), Ui.TEXT); + tip.row("Average", Ui.gp(total), Ui.GOLD); + } + else + { + tip.row("Total", Ui.gp(total), Ui.GOLD); + } + long first = points.get(0).total(); + if (idx > 0) + { + long d = total - first; + tip.row("vs start", Ui.signedGp(d) + " " + Ui.pct(first != 0 ? (double) d / first : Double.NaN), Ui.deltaColor(d)); + long prev = points.get(idx - 1).total(); + if (prev != total) + { + tip.row("vs previous", Ui.signedGp(total - prev), Ui.deltaColor(total - prev)); + } + } + tip.paint(g, fm, x, px, pw, py); + } + + private static String title(Snapshot p) + { + java.time.ZonedDateTime z = Instant.ofEpochMilli(p.t).atZone(ZoneId.systemDefault()); + if (Snapshot.HOUR.equals(p.g)) + { + return z.format(TIP_HOUR) + " (hour)"; + } + if (p.isAggregate()) + { + return z.format(TIP_DAY) + " (day)"; + } + return z.format(TIP_TIME); + } + + // ------------------------------------------------------------------ candles & bars + + private void paintCandles(Graphics2D g, int px, int pw, DoubleUnaryOperator yOf) + { + for (Analysis.Candle c : candles) + { + double x0 = Math.max(px, xOf(c.t0, px, pw)); + double x1 = Math.min(px + pw, xOf(c.t1, px, pw)); + if (x1 <= x0) + { + continue; + } + double cw = Math.max(1, (x1 - x0) * 0.68); + double cx = (x0 + x1) / 2; + Color color = c.c > c.o ? Ui.UP : c.c < c.o ? Ui.DOWN : Ui.MUTED; + if (c.empty) + { + color = Ui.alpha(Ui.MUTED, 110); + } + g.setColor(color); + g.setStroke(THIN); + g.draw(new java.awt.geom.Line2D.Double(cx, yOf.applyAsDouble(c.h), cx, yOf.applyAsDouble(c.l))); + double yo = yOf.applyAsDouble(c.o); + double yc = yOf.applyAsDouble(c.c); + double top = Math.min(yo, yc); + double bh = Math.max(1.2, Math.abs(yo - yc)); + Rectangle2D body = new Rectangle2D.Double(cx - cw / 2, top, cw, bh); + g.setColor(c.c < c.o ? color : Ui.alpha(color, c.empty ? 110 : 200)); + g.fill(body); + } + } + + private void paintBars(Graphics2D g, int px, int pw, DoubleUnaryOperator yOf) + { + double zero = yOf.applyAsDouble(0); + for (Analysis.Candle c : candles) + { + long ch = c.change(); + if (ch == 0) + { + continue; + } + double x0 = Math.max(px, xOf(c.t0, px, pw)); + double x1 = Math.min(px + pw, xOf(c.t1, px, pw)); + if (x1 <= x0) + { + continue; + } + double bw = Math.max(1, (x1 - x0) * 0.72); + double cx = (x0 + x1) / 2; + double y = yOf.applyAsDouble(ch); + Color color = ch > 0 ? Ui.UP : Ui.DOWN; + g.setPaint(new GradientPaint(0, (float) y, Ui.alpha(color, 230), 0, (float) zero, Ui.alpha(color, 130))); + g.fill(new Rectangle2D.Double(cx - bw / 2, Math.min(y, zero), bw, Math.max(1, Math.abs(zero - y)))); + } + } + + private void paintCandleHover(Graphics2D g, FontMetrics fm, int px, int py, int pw, int ph) + { + if (hoverX < px || hoverX > px + pw || candles.isEmpty()) + { + return; + } + long t = tAt(hoverX, px, pw); + Analysis.Candle hit = null; + for (Analysis.Candle c : candles) + { + if (t >= c.t0 && t < c.t1) + { + hit = c; + break; + } + } + if (hit == null) + { + return; + } + double x0 = Math.max(px, xOf(hit.t0, px, pw)); + double x1 = Math.min(px + pw, xOf(hit.t1, px, pw)); + g.setColor(Ui.alpha(Color.WHITE, 16)); + g.fill(new Rectangle2D.Double(x0, py, Math.max(1, x1 - x0), ph)); + + java.time.ZonedDateTime z = Instant.ofEpochMilli(hit.t0).atZone(ZoneId.systemDefault()); + String title; + if (bucket >= 30 * Ui.DAY) + { + title = z.format(DateTimeFormatter.ofPattern("MMMM yyyy", Locale.ENGLISH)); + } + else if (bucket >= 7 * Ui.DAY) + { + title = "Week of " + z.format(DateTimeFormatter.ofPattern("d MMM yyyy", Locale.ENGLISH)); + } + else if (bucket >= Ui.DAY) + { + title = z.format(TIP_DAY); + } + else + { + DateTimeFormatter hm = DateTimeFormatter.ofPattern("HH:mm"); + title = z.format(TIP_TIME) + " - " + Instant.ofEpochMilli(hit.t1).atZone(ZoneId.systemDefault()).format(hm); + } + + Tip tip = new Tip(title); + long ch = hit.change(); + if (mode == Mode.CANDLES) + { + tip.row("Open", Ui.gp(hit.o), Ui.TEXT); + tip.row("High", Ui.gp(hit.h), Ui.UP); + tip.row("Low", Ui.gp(hit.l), Ui.DOWN); + tip.row("Close", Ui.gp(hit.c), Ui.GOLD); + } + tip.row("Change", Ui.signedGp(ch) + " " + Ui.pct(hit.o != 0 ? (double) ch / hit.o : Double.NaN), Ui.deltaColor(ch)); + if (mode == Mode.BARS) + { + tip.row("Close", Ui.gp(hit.c), Ui.GOLD); + } + if (hit.empty) + { + tip.row("", "no data", Ui.FAINT); + } + tip.paint(g, fm, (int) Math.round((x0 + x1) / 2), px, pw, py); + } + + // ------------------------------------------------------------------ stacked + + private void paintStacked(Graphics2D g, int px, int py, int pw, int ph, DoubleUnaryOperator yOf) + { + List pts = downsample(points, pw); + int n = pts.size(); + WealthCategory[] cats = WealthCategory.values(); + double[][] cum = new double[n][cats.length + 1]; + boolean[] used = new boolean[cats.length]; + for (int i = 0; i < n; i++) + { + for (int c = 0; c < cats.length; c++) + { + long v = pts.get(i).get(cats[c]); + cum[i][c + 1] = cum[i][c] + v; + used[c] |= v != 0; + } + } + + double[] xs = new double[n]; + for (int i = 0; i < n; i++) + { + xs[i] = xOf(pts.get(i).t, px, pw); + } + double endX = n == 1 ? px + pw : xs[n - 1]; + + for (int c = 0; c < cats.length; c++) + { + if (!used[c]) + { + continue; + } + List upper = stepEdge(xs, cum, c + 1, endX, yOf, pts); + List lower = stepEdge(xs, cum, c, endX, yOf, pts); + Path2D poly = new Path2D.Double(); + Path2D top = new Path2D.Double(); + for (int i = 0; i < upper.size(); i++) + { + double[] pt = upper.get(i); + if (i == 0) + { + poly.moveTo(pt[0], pt[1]); + top.moveTo(pt[0], pt[1]); + } + else + { + poly.lineTo(pt[0], pt[1]); + top.lineTo(pt[0], pt[1]); + } + } + for (int i = lower.size() - 1; i >= 0; i--) + { + poly.lineTo(lower.get(i)[0], lower.get(i)[1]); + } + poly.closePath(); + + Color color = cats[c].getColor(); + g.setPaint(new GradientPaint(0, py, Ui.alpha(color, 215), 0, py + ph, Ui.alpha(color, 150))); + g.fill(poly); + g.setStroke(THIN); + g.setColor(color.brighter()); + g.draw(top); + } + } + + private static List stepEdge(double[] xs, double[][] cum, int level, double endX, DoubleUnaryOperator yOf, List src) + { + List pts = new ArrayList<>(xs.length * 2); + for (int i = 0; i < xs.length; i++) + { + double y = yOf.applyAsDouble(cum[i][level]); + if (i > 0 && !src.get(i).isAggregate() && !src.get(i - 1).isAggregate()) + { + pts.add(new double[]{xs[i], yOf.applyAsDouble(cum[i - 1][level])}); + } + pts.add(new double[]{xs[i], y}); + } + if (xs.length == 1) + { + pts.add(new double[]{endX, yOf.applyAsDouble(cum[0][level])}); + } + return pts; + } + + private void paintStackedHover(Graphics2D g, FontMetrics fm, int px, int py, int pw, int ph) + { + if (hoverX < px || hoverX > px + pw) + { + return; + } + int idx = nearestIndex(tAt(hoverX, px, pw)); + if (idx < 0) + { + return; + } + Snapshot p = points.get(idx); + int x = (int) Math.round(xOf(p.t, px, pw)); + crosshair(g, x, py, ph); + + Tip tip = new Tip(title(p)); + tip.swatch = true; + WealthCategory[] cats = WealthCategory.values(); + long total = p.total(); + for (int c = cats.length - 1; c >= 0; c--) + { + long v = p.get(cats[c]); + if (v != 0) + { + String share = total > 0 ? String.format(Locale.US, " %.0f%%", v * 100.0 / total) : ""; + tip.row(cats[c].getLabel(), Ui.gp(v) + share, cats[c].getColor()); + } + } + tip.row("Total", Ui.gp(total), null); + tip.paint(g, fm, x, px, pw, py); + } + + // ------------------------------------------------------------------ shared + + private static void crosshair(Graphics2D g, int x, int py, int ph) + { + g.setStroke(DASHED); + g.setColor(Ui.alpha(Ui.TEXT, 110)); + g.drawLine(x, py, x, py + ph); + } + + /** Tooltip box painted inside the chart. */ + private final class Tip + { + final String title; + final List rows = new ArrayList<>(); + final List colors = new ArrayList<>(); + boolean swatch; + + Tip(String title) + { + this.title = title; + } + + void row(String key, String value, Color color) + { + rows.add(new String[]{key, value}); + colors.add(color); + } + + void paint(Graphics2D g, FontMetrics fm, int x, int px, int pw, int py) + { + int lh = fm.getHeight(); + int keyW = 0; + int valW = 0; + for (String[] r : rows) + { + keyW = Math.max(keyW, fm.stringWidth(r[0])); + valW = Math.max(valW, fm.stringWidth(r[1])); + } + int contentW = Math.max(fm.stringWidth(title), (swatch ? 10 : 0) + keyW + 10 + valW); + int bw = contentW + 12; + int bh = (rows.size() + 1) * lh + 8; + int bx = x + 10; + if (bx + bw > px + pw) + { + bx = x - 10 - bw; + } + bx = Math.max(2, Math.min(getWidth() - bw - 2, bx)); + int by = py; + + g.setColor(new Color(18, 18, 18, 238)); + g.fillRoundRect(bx, by, bw, bh, 8, 8); + g.setStroke(THIN); + g.setColor(new Color(255, 255, 255, 35)); + g.drawRoundRect(bx, by, bw, bh, 8, 8); + + int ty = by + 4 + fm.getAscent(); + g.setColor(Ui.MUTED); + g.drawString(title, bx + 6, ty); + for (int i = 0; i < rows.size(); i++) + { + ty += lh; + String[] r = rows.get(i); + int kx = bx + 6; + Color rc = colors.get(i); + if (swatch) + { + if (rc != null) + { + g.setColor(rc); + g.fillRoundRect(kx, ty - fm.getAscent() + 2, 7, 7, 2, 2); + } + kx += 10; + } + g.setColor(Ui.TEXT); + g.drawString(r[0], kx, ty); + g.setColor(swatch ? (rc == null ? Ui.GOLD : Ui.TEXT) : rc); + Ui.drawRight(g, r[1], bx + bw - 6, ty); + } + } + } + + private int nearestIndex(long t) + { + if (points.isEmpty()) + { + return -1; + } + int lo = 0; + int hi = points.size() - 1; + while (lo < hi) + { + int mid = (lo + hi) >>> 1; + if (points.get(mid).t < t) + { + lo = mid + 1; + } + else + { + hi = mid; + } + } + if (lo > 0 && Math.abs(points.get(lo - 1).t - t) <= Math.abs(points.get(lo).t - t)) + { + return lo - 1; + } + return lo; + } + + private void drawTimeAxis(Graphics2D g, FontMetrics fm, int px, int baseY, int pw) + { + long span = end - start; + DateTimeFormatter f; + if (span <= 2 * Ui.DAY) + { + f = DateTimeFormatter.ofPattern("HH:mm", Locale.ENGLISH); + } + else if (span <= 10 * Ui.DAY) + { + f = DateTimeFormatter.ofPattern("EEE d", Locale.ENGLISH); + } + else if (span <= 200 * Ui.DAY) + { + f = DateTimeFormatter.ofPattern("MMM d", Locale.ENGLISH); + } + else + { + f = DateTimeFormatter.ofPattern("MMM yy", Locale.ENGLISH); + } + + int count = Math.max(2, Math.min(8, pw / (fm.stringWidth("Mmm 00") + 18))); + g.setColor(Ui.FAINT); + int lastRight = Integer.MIN_VALUE; + int y = baseY + fm.getAscent() + 4; + for (int i = 0; i <= count; i++) + { + long t = start + span * i / count; + String s = Instant.ofEpochMilli(t).atZone(ZoneId.systemDefault()).format(f); + int sw = fm.stringWidth(s); + int x = px + pw * i / count - sw / 2; + x = Math.max(px - 4, Math.min(px + pw - sw, x)); + if (x < lastRight + 6) + { + continue; + } + g.drawString(s, x, y); + lastRight = x + sw; + } + } + + /** Keeps at most ~2 points per pixel column (min/max) so large ranges stay fast. */ + private List downsample(List src, int pixels) + { + if (src.size() <= pixels * 2) + { + return src; + } + List out = new ArrayList<>(pixels * 2 + 2); + int col = -1; + Snapshot min = null; + Snapshot max = null; + for (Snapshot s : src) + { + int c = (int) ((s.t - start) * pixels / Math.max(1, end - start)); + if (c != col) + { + addPair(out, min, max); + col = c; + min = s; + max = s; + } + else + { + if (s.total() < min.total()) + { + min = s; + } + if (s.total() > max.total()) + { + max = s; + } + } + } + addPair(out, min, max); + Snapshot last = src.get(src.size() - 1); + if (out.get(out.size() - 1) != last) + { + out.add(last); + } + return out; + } + + private static void addPair(List out, Snapshot a, Snapshot b) + { + if (a == null) + { + return; + } + if (a == b) + { + out.add(a); + } + else if (a.t <= b.t) + { + out.add(a); + out.add(b); + } + else + { + out.add(b); + out.add(a); + } + } + + private static double niceStep(double raw) + { + if (raw <= 0 || Double.isNaN(raw)) + { + return 1; + } + double exp = Math.pow(10, Math.floor(Math.log10(raw))); + double f = raw / exp; + double nice = f < 1.5 ? 1 : f < 3 ? 2 : f < 7 ? 5 : 10; + return nice * exp; + } +} diff --git a/src/main/java/com/example/essentialsplus/ui/WealthPanel.java b/src/main/java/com/example/essentialsplus/ui/WealthPanel.java new file mode 100644 index 0000000..7caa6b5 --- /dev/null +++ b/src/main/java/com/example/essentialsplus/ui/WealthPanel.java @@ -0,0 +1,1070 @@ +package com.example.essentialsplus.ui; + +import com.example.essentialsplus.WealthStore; +import com.example.essentialsplus.EssentialsPlusPlugin; +import com.example.essentialsplus.data.AccountData; +import com.example.essentialsplus.data.ItemLedger; +import com.example.essentialsplus.data.Snapshot; +import com.example.essentialsplus.data.SourceValue; +import com.example.essentialsplus.data.TopItem; +import java.awt.BorderLayout; +import java.awt.Cursor; +import java.awt.GridLayout; +import java.awt.event.MouseAdapter; +import java.awt.event.MouseEvent; +import java.io.File; +import java.time.Instant; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.atomic.AtomicBoolean; +import javax.swing.BorderFactory; +import javax.swing.JButton; +import javax.swing.JLabel; +import javax.swing.JMenuItem; +import javax.swing.JOptionPane; +import javax.swing.JPanel; +import javax.swing.JPopupMenu; +import javax.swing.SwingUtilities; +import javax.swing.Timer; +import net.runelite.client.ui.DynamicGridLayout; +import net.runelite.client.ui.FontManager; +import net.runelite.client.ui.PluginPanel; +import net.runelite.client.util.LinkBrowser; + +public class WealthPanel extends PluginPanel +{ + static final String ALL = "__all_characters__"; + private static final DateTimeFormatter SHORT_DATE = DateTimeFormatter.ofPattern("d MMM", Locale.ENGLISH); + + private final EssentialsPlusPlugin plugin; + private final WealthStore store; + private final ItemIcons icons; + private final AtomicBoolean refreshQueued = new AtomicBoolean(); + private final Set namesRequested = new HashSet<>(); + private final Timer ticker; + + private final AccountCard summaryCard; + private final JPanel accountList = new JPanel(new DynamicGridLayout(0, 1, 0, 4)); + private final Map cards = new LinkedHashMap<>(); + private final TimeFilter timeFilter; + private final Runnable rangeListener = this::onRangeChanged; + private final DetailView detail; + private java.util.function.Consumer onSummaryUpdated; + /** total for the overview card, limited to {@link #overviewKeys} */ + private final AccountCard overviewSummary = new AccountCard(true, () -> { }, null); + /** characters counted on the overview card; empty = all */ + private volatile Set overviewKeys = new HashSet<>(); + + private String selected = ALL; + private WealthChart.Mode mainMode = WealthChart.Mode.LINE; + private WealthPopout popout; + private boolean sessionView; + private final JLabel sessionLabel = new JLabel(); + private final Map itemWindows = new HashMap<>(); + private final Map geWindows = new HashMap<>(); + + private long globalResetHourlyNowAt; + private long globalResetHourlySessionAt; + + public void resetHourly(boolean isSession, long now) + { + if (isSession) + { + globalResetHourlySessionAt = now; + } + else + { + globalResetHourlyNowAt = now; + } + } + + public WealthPanel(EssentialsPlusPlugin plugin) + { + this(plugin, null); + } + + public WealthPanel(EssentialsPlusPlugin plugin, Runnable onBack) + { + super(true); + this.plugin = plugin; + this.store = plugin.getStore(); + this.icons = new ItemIcons(plugin.getItemManager()); + this.timeFilter = plugin.getTimeFilter(); + + setBackground(Ui.BG); + setLayout(new DynamicGridLayout(0, 1, 0, 6)); + + if (onBack != null) + { + JPanel backRow = new JPanel(new BorderLayout(6, 0)); + backRow.setOpaque(false); + backRow.setBorder(BorderFactory.createEmptyBorder(0, 0, 4, 0)); + + JButton backBtn = button("← Overview", onBack); + backBtn.setForeground(Ui.ACCENT); + backRow.add(backBtn, BorderLayout.WEST); + + JLabel titleLabel = new JLabel("Wealth Tracker"); + titleLabel.setFont(FontManager.getRunescapeBoldFont()); + titleLabel.setForeground(Ui.ACCENT); + titleLabel.setHorizontalAlignment(JLabel.RIGHT); + backRow.add(titleLabel, BorderLayout.CENTER); + + add(backRow); + } + + summaryCard = new AccountCard(true, () -> select(ALL), null, () -> { + plugin.resetWealthHourly(ALL); + refreshLater(); + }); + add(summaryCard); + + accountList.setOpaque(false); + add(accountList); + + JPanel sessionRow = new JPanel(new GridLayout(1, 2, 4, 0)); + sessionRow.setOpaque(false); + sessionRow.add(button("Session", this::showSession)); + sessionRow.add(button("New session", this::newSession)); + add(sessionRow); + sessionLabel.setFont(FontManager.getRunescapeSmallFont()); + sessionLabel.setForeground(Ui.MUTED); + sessionLabel.setHorizontalAlignment(JLabel.CENTER); + add(sessionLabel); + + detail = new DetailView(icons, false); + MouseAdapter chartRightClick = new MouseAdapter() + { + @Override + public void mousePressed(MouseEvent e) + { + if (e.isPopupTrigger() || SwingUtilities.isRightMouseButton(e)) + { + showPopup(e); + } + } + + @Override + public void mouseReleased(MouseEvent e) + { + if (e.isPopupTrigger()) + { + showPopup(e); + } + } + + private void showPopup(MouseEvent e) + { + if (!isHourly()) + { + return; + } + JPopupMenu menu = new JPopupMenu(); + JMenuItem reset = new JMenuItem("Reset Hourly"); + reset.addActionListener(a -> + { + plugin.resetWealthHourly(selected); + refreshLater(); + }); + menu.add(reset); + menu.show((java.awt.Component) e.getSource(), e.getX(), e.getY()); + } + }; + detail.main.addMouseListener(chartRightClick); + detail.stats.addMouseListener(chartRightClick); + + add(sectionHeader("NET WORTH", detail.title, "Expand", this::openPopout)); + add(detail.main); + add(detail.stats); + add(sectionHeader("CHANGE PER PERIOD", null, null, null)); + add(detail.bars); + add(sectionHeader("COMPOSITION", null, null, null)); + add(detail.stacked); + JPanel viewButtons = new JPanel(new GridLayout(2, 2, 4, 4)); + viewButtons.setOpaque(false); + viewButtons.add(button("Bank", () -> openItems("bank", "Bank"))); + viewButtons.add(button("Inventory", () -> openItems("inventory", "Inventory"))); + viewButtons.add(button("Equipment", () -> openItems("equipment", "Equipment"))); + viewButtons.add(button("Grand Exchange", () -> openItems("ge", "Grand Exchange"))); + add(viewButtons); + add(detail.breakdown); + detail.breakdown.setOnOpen(this::openItems); + add(sectionHeader("WHAT CHANGED", null, null, null)); + add(detail.movers); + add(sectionHeader("MOST VALUABLE", null, null, null)); + add(detail.topItems); + + add(button("Export CSV", this::export)); + + JLabel hint = new JLabel("
Right-click a character to delete its data
"); + hint.setFont(FontManager.getRunescapeSmallFont()); + hint.setForeground(Ui.FAINT); + hint.setHorizontalAlignment(JLabel.CENTER); + add(hint); + + ticker = new Timer(30_000, e -> refresh()); + ticker.start(); + timeFilter.addListener(rangeListener); + } + + private void onRangeChanged() + { + sessionView = false; + if (popout != null) + { + popout.setRange(timeFilter.getRange().ordinal()); + } + refreshLater(); + } + + public void dispose() + { + ticker.stop(); + timeFilter.removeListener(rangeListener); + new ArrayList<>(itemWindows.values()).forEach(ItemsWindow::dispose); + new ArrayList<>(geWindows.values()).forEach(GeWindow::dispose); + if (popout != null) + { + popout.dispose(); + popout = null; + } + } + + public void setOnSummaryUpdated(java.util.function.Consumer onSummaryUpdated) + { + this.onSummaryUpdated = onSummaryUpdated; + if (onSummaryUpdated != null) + { + onSummaryUpdated.accept(overviewSummary); + } + } + + /** Characters summed on the overview card (keys); empty = all characters. */ + public void setOverviewKeys(Set keys) + { + overviewKeys = new HashSet<>(keys); + refreshLater(); + } + + public AccountCard getSummaryCard() + { + return summaryCard; + } + + public void refreshLater() + { + if (refreshQueued.compareAndSet(false, true)) + { + SwingUtilities.invokeLater(() -> + { + refreshQueued.set(false); + refresh(); + }); + } + } + + void setRange(TimeRange r) + { + timeFilter.set(r); + } + + /** Shows everything since the session start (starts a session if there is none). */ + void showSession() + { + if (plugin.getSessionStart() <= 0) + { + plugin.startSession(); + } + sessionView = true; + if (popout != null) + { + popout.setRange(-1); + } + refresh(); + } + + /** Resets the session to now, e.g. when starting a new activity. */ + void newSession() + { + plugin.startSession(); + long now = System.currentTimeMillis(); + resetHourly(true, now); + for (com.example.essentialsplus.data.AccountData a : store.all()) + { + a.resetHourlySessionAt = now; + } + showSession(); + } + + long sessionStart() + { + return plugin.getSessionStart(); + } + + public boolean isCustomSessionView() + { + return sessionView; + } + + public boolean isSessionView() + { + return sessionView || (timeFilter != null && timeFilter.getRange() == TimeRange.SESSION); + } + + public boolean isHourly() + { + return isSessionView() || (timeFilter != null && timeFilter.isCurrent()); + } + + public void resetHourly(String key) + { + plugin.resetWealthHourly(key); + } + + TimeRange getRange() + { + return timeFilter.getRange(); + } + + void setMainMode(WealthChart.Mode mode) + { + mainMode = mode; + detail.main.setMode(mode); + if (popout != null) + { + popout.setMainMode(mode); + } + } + + WealthChart.Mode getMainMode() + { + return mainMode; + } + + ItemIcons getIcons() + { + return icons; + } + + /** Opens (or focuses) the item window for a view of the side panel's selection. */ + void openItems(String view, String label) + { + openItems(view, label, selected); + } + + /** Opens (or focuses) the item window for a view of a character (or ALL). */ + void openItems(String view, String label, String selection) + { + if ("ge".equals(view)) + { + GeWindow gw = geWindows.get(selection); + if (gw == null) + { + gw = new GeWindow(icons, () -> geWindows.remove(selection)); + gw.selection = selection; + gw.setLocationRelativeTo(this); + geWindows.put(selection, gw); + } + gw.setVisible(true); + gw.toFront(); + loadGe(gw); + return; + } + String id = view + "|" + selection; + ItemsWindow w = itemWindows.get(id); + if (w == null) + { + w = new ItemsWindow(icons, () -> itemWindows.remove(id)); + w.setLocationRelativeTo(this); + w.view = view; + w.label = label; + w.selection = selection; + itemWindows.put(id, w); + } + w.setVisible(true); + w.toFront(); + loadItems(w); + } + + private void loadItems(ItemsWindow w) + { + List accounts = selectedAccounts(w.selection); + String who = ALL.equals(w.selection) ? "All characters" : (accounts.isEmpty() ? "" : accounts.get(0).name); + w.setTitleText(w.label + " - " + who); + w.lastLoad = System.currentTimeMillis(); + plugin.loadItems(accounts, w.view, w::setRows); + } + + private void loadGe(GeWindow w) + { + List accounts = selectedAccounts(w.selection); + w.setTitleText(ALL.equals(w.selection) ? "All characters" : (accounts.isEmpty() ? "" : accounts.get(0).name)); + w.lastLoad = System.currentTimeMillis(); + plugin.loadGe(accounts, w::setRows); + } + + private List selectedAccounts(String selection) + { + List accounts = store.all(); + if (ALL.equals(selection)) + { + accounts.removeIf(a -> !a.bankKnown && a.containers.isEmpty()); + } + else + { + accounts.removeIf(a -> !a.key.equals(selection)); + } + return accounts; + } + + private void select(String key) + { + selected = key; + refresh(); + } + + private void openPopout() + { + if (popout == null) + { + popout = new WealthPopout(this, timeFilter.getRange(), () -> popout = null); + } + popout.setVisible(true); + popout.toFront(); + refresh(); + } + + private void export() + { + List accounts = store.all(); + if (!ALL.equals(selected)) + { + accounts.removeIf(a -> !a.key.equals(selected)); + } + plugin.exportCsv(accounts, () -> + { + File dir = new File(WealthStore.DIR, "export"); + LinkBrowser.open(dir.getAbsolutePath()); + }); + } + + private void refresh() + { + long now = System.currentTimeMillis(); + List accounts = store.all(); + String active = plugin.getActiveKey(); + TimeRange range = timeFilter.getRange(); + + if (!ALL.equals(selected) && accounts.stream().noneMatch(a -> a.key.equals(selected))) + { + selected = ALL; + } + + boolean isSession = isSessionView(); + long pluginSessionStart = plugin.getSessionStart(); + long sessionStart = sessionView + ? (pluginSessionStart > 0 ? pluginSessionStart : timeFilter.getSessionStart()) + : (timeFilter.getSessionStart() > 0 ? timeFilter.getSessionStart() : pluginSessionStart); + long start; + String rangeLabel; + if (sessionView && sessionStart > 0) + { + start = sessionStart; + rangeLabel = "session"; + } + else + { + start = range == TimeRange.ALL ? Series.earliest(accounts) : timeFilter.start(); + rangeLabel = range.getLabel(); + } + sessionLabel.setText(sessionStart <= 0 ? "No session started" + : "Session since " + Instant.ofEpochMilli(sessionStart).atZone(ZoneId.systemDefault()).format(DateTimeFormatter.ofPattern("d MMM HH:mm", Locale.ENGLISH)) + + " (" + Ui.ago(sessionStart).replace(" ago", "") + ")"); + sessionLabel.setForeground(isSession ? Ui.ACCENT : Ui.MUTED); + + // ---- character cards + List keys = new ArrayList<>(); + accounts.forEach(a -> keys.add(a.key)); + if (!keys.equals(new ArrayList<>(cards.keySet()))) + { + accountList.removeAll(); + cards.clear(); + for (AccountData a : accounts) + { + String key = a.key; + AccountCard card = new AccountCard(false, () -> select(key), () -> confirmDelete(key, a.name), () -> { + plugin.resetWealthHourly(key); + refreshLater(); + }); + cards.put(key, card); + accountList.add(card); + } + accountList.revalidate(); + } + + List> allSeries = new ArrayList<>(); + long currentStart = now; + // price movements of held items only count as profit/loss over all time + boolean ignorePrices = isSession || range != TimeRange.ALL; + boolean isHourly = isHourly(); + int needBank = 0; + long totalNow = 0; + Map charGphMap = new HashMap<>(); + for (AccountData a : accounts) + { + for (TopItem t : a.topItems) + { + icons.putName(t.id, t.name); + } + long accountStart = start; + if (timeFilter.isCurrent() && !isSession) + { + List h = a.history; + accountStart = computeActiveStreakStart(h, a.lastUpdated, now); + } + currentStart = Math.min(currentStart, accountStart); + List s = Series.forAccount(a, accountStart, now, ignorePrices); + allSeries.add(s); + long delta = s.size() > 1 ? s.get(s.size() - 1).total() - s.get(0).total() : 0; + boolean live = a.key.equals(active); + + String status; + java.awt.Color statusColor; + if (!a.bankKnown) + { + status = "Open your bank in-game"; + statusColor = Ui.WARN; + needBank++; + } + else if (live) + { + status = "Live"; + statusColor = Ui.UP; + } + else + { + status = Ui.ago(a.lastUpdated); + statusColor = Ui.MUTED; + } + if (a.bankKnown) + { + totalNow += a.currentTotal(); + } + + String charGph = ""; + if (isHourly) + { + long accountRateStart = isSession + ? Math.max(start, Math.max(a.resetHourlySessionAt, globalResetHourlySessionAt)) + : Math.max(accountStart, Math.max(a.resetHourlyNowAt, globalResetHourlyNowAt)); + List rateSeries = accountRateStart > accountStart + ? Series.forAccount(a, accountRateStart, now, ignorePrices) + : s; + Long rate = calculateHourlyRate(rateSeries, accountRateStart, now); + charGph = formatHourlyRate(rate); + } + charGphMap.put(a.key, charGph); + + cards.get(a.key).update(a.name, "", a.bankKnown ? a.currentTotal() : null, delta, Double.NaN, rangeLabel, + status, statusColor, live, s, a.key.equals(selected), charGph); + } + + List combined = allSeries.isEmpty() ? new ArrayList<>() : Series.combine(allSeries); + long allDelta = combined.size() > 1 ? combined.get(combined.size() - 1).total() - combined.get(0).total() : 0; + double allRatio = combined.size() > 1 && combined.get(0).total() != 0 ? (double) allDelta / combined.get(0).total() : Double.NaN; + String sub = accounts.size() == 1 ? "1 character" : accounts.size() + " characters"; + String summaryStatus = accounts.isEmpty() ? "Log in to start" : needBank > 0 ? needBank + " need bank" : ""; + + String totalGph = ""; + if (isHourly) + { + long totRateStart = isSession + ? Math.max(start, globalResetHourlySessionAt) + : Math.max(currentStart, globalResetHourlyNowAt); + List> rateSeriesList = new ArrayList<>(); + for (int i = 0; i < accounts.size(); i++) + { + AccountData a = accounts.get(i); + long aRateStart = isSession + ? Math.max(start, Math.max(a.resetHourlySessionAt, globalResetHourlySessionAt)) + : Math.max(totRateStart, Math.max(a.resetHourlyNowAt, globalResetHourlyNowAt)); + rateSeriesList.add(Series.forAccount(a, aRateStart, now, ignorePrices)); + } + List combRateSeries = rateSeriesList.isEmpty() ? new ArrayList<>() : Series.combine(rateSeriesList); + Long r = calculateHourlyRate(combRateSeries, totRateStart, now); + totalGph = formatHourlyRate(r); + } + + summaryCard.update("Total wealth", sub, accounts.isEmpty() ? null : totalNow, allDelta, allRatio, rangeLabel, + summaryStatus, Ui.WARN, false, combined, ALL.equals(selected), totalGph); + if (onSummaryUpdated != null) + { + Set only = overviewKeys; + List> subSeries = new ArrayList<>(); + long subNow = 0; + int subCount = 0; + int subNeedBank = 0; + for (int i = 0; i < accounts.size(); i++) + { + AccountData a = accounts.get(i); + if (!only.isEmpty() && !only.contains(a.key)) + { + continue; + } + subCount++; + subSeries.add(allSeries.get(i)); + if (a.bankKnown) + { + subNow += a.currentTotal(); + } + else + { + subNeedBank++; + } + } + if (only.isEmpty() || subCount == 0) + { + overviewSummary.copyFrom(summaryCard); + } + else + { + List subCombined = Series.combine(subSeries); + long subDelta = subCombined.size() > 1 ? subCombined.get(subCombined.size() - 1).total() - subCombined.get(0).total() : 0; + double subRatio = subCombined.size() > 1 && subCombined.get(0).total() != 0 ? (double) subDelta / subCombined.get(0).total() : Double.NaN; + String title = subCount == 1 ? "Wealth" : "Total wealth"; + String subtitle = subCount == 1 + ? accounts.stream().filter(a -> only.contains(a.key)).map(a -> a.name).findFirst().orElse("") + : subCount + " of " + accounts.size() + " characters"; + overviewSummary.update(title, subtitle, subNow, subDelta, subRatio, rangeLabel, + subNeedBank > 0 ? subNeedBank + " need bank" : "", Ui.WARN, false, subCombined, false, totalGph); + } + onSummaryUpdated.accept(overviewSummary); + } + + if (timeFilter.isCurrent() && !isSession) + { + start = currentStart; + } + + // ---- details for the selection + String selectedGph = isHourly ? (ALL.equals(selected) ? totalGph : charGphMap.getOrDefault(selected, "")) : null; + Model m = buildModel(selected, accounts, allSeries, combined, start, now, active, selectedGph); + detail.update(m, 5); + if (popout != null) + { + String key = popout.resolveSelection(accounts, active); + String popoutGph = isHourly ? (ALL.equals(key) ? totalGph : charGphMap.getOrDefault(key, "")) : null; + popout.update(buildModel(key, accounts, allSeries, combined, start, now, active, popoutGph), accounts, key); + } + resolveNames(detail.lastMovers); + long nowMs = System.currentTimeMillis(); + for (ItemsWindow w : new ArrayList<>(itemWindows.values())) + { + if (nowMs - w.lastLoad > 3000) + { + loadItems(w); + } + } + for (GeWindow w : new ArrayList<>(geWindows.values())) + { + if (nowMs - w.lastLoad > 3000) + { + loadGe(w); + } + } + revalidate(); + repaint(); + } + + private Model buildModel(String selection, List accounts, List> allSeries, + List combined, long start, long now, String active, String hourlyRate) + { + Model m = new Model(); + m.hourlyRate = hourlyRate; + m.start = start; + m.ignorePrices = isSessionView() || timeFilter.getRange() != TimeRange.ALL; + m.end = now; + m.ledgers = new ArrayList<>(); + m.selection = selection; + if (ALL.equals(selection) || accounts.isEmpty()) + { + m.title = "All characters"; + m.points = combined; + m.current = Series.sumCurrent(accounts); + m.sources = new ArrayList<>(); + Map items = new HashMap<>(); + for (AccountData a : accounts) + { + if (!a.bankKnown) + { + continue; + } + m.sources.addAll(a.sources); + m.ledgers.add(store.ledger(a.key, a.key.equals(active))); + for (TopItem t : a.topItems) + { + items.merge(t.id, new TopItem(t.id, t.name, t.qty, t.value), + (x, y) -> new TopItem(x.id, x.name, x.qty + y.qty, x.value + y.value)); + } + } + m.topItems = new ArrayList<>(items.values()); + m.topItems.sort((x, y) -> Long.compare(y.value, x.value)); + m.emptyText = accounts.isEmpty() ? "Log in and open your bank\nto start tracking" : "No history in this range"; + } + else + { + int idx = 0; + while (idx < accounts.size() - 1 && !accounts.get(idx).key.equals(selection)) + { + idx++; + } + AccountData a = accounts.get(idx); + m.title = a.name; + m.points = allSeries.get(idx); + m.current = a.current; + m.sources = a.sources; + m.topItems = a.topItems; + m.ledgers.add(store.ledger(a.key, a.key.equals(active))); + m.emptyText = a.bankKnown ? "No history in this range" : "Open your bank in-game\nto start tracking"; + } + m.stats = Analysis.stats(m.points); + return m; + } + + /** Fetches names of items that only appear in the item history. */ + void resolveNames(Analysis.Movers movers) + { + if (movers == null) + { + return; + } + Set missing = new HashSet<>(); + for (List list : List.of(movers.gainers, movers.losers)) + { + for (Analysis.Mover mv : list) + { + if (!icons.hasName(mv.id) && namesRequested.add(mv.id)) + { + missing.add(mv.id); + } + } + } + if (!missing.isEmpty()) + { + plugin.resolveItemNames(missing, names -> + { + names.forEach(icons::putName); + detail.movers.repaint(); + if (popout != null) + { + popout.repaint(); + } + }); + } + } + + private void confirmDelete(String key, String name) + { + int r = JOptionPane.showConfirmDialog(this, + "Delete all wealth history of " + name + "?\nThis cannot be undone.", + "Wealth Tracker", JOptionPane.OK_CANCEL_OPTION, JOptionPane.WARNING_MESSAGE); + if (r == JOptionPane.OK_OPTION) + { + if (key.equals(selected)) + { + selected = ALL; + } + plugin.deleteAccount(key); + } + } + + static JPanel sectionHeader(String text, JLabel extra, String action, Runnable onAction) + { + JPanel p = new JPanel(new BorderLayout(6, 0)); + p.setOpaque(false); + p.setBorder(BorderFactory.createEmptyBorder(6, 2, 0, 2)); + JLabel l = new JLabel(text); + l.setFont(FontManager.getRunescapeSmallFont()); + l.setForeground(Ui.MUTED); + p.add(l, BorderLayout.WEST); + if (extra != null) + { + extra.setFont(FontManager.getRunescapeSmallFont()); + extra.setForeground(Ui.TEXT); + extra.setHorizontalAlignment(JLabel.RIGHT); + p.add(extra, BorderLayout.CENTER); + } + if (action != null) + { + JLabel a = new JLabel(action); + a.setFont(FontManager.getRunescapeSmallFont()); + a.setForeground(Ui.ACCENT); + a.setCursor(Cursor.getPredefinedCursor(Cursor.HAND_CURSOR)); + a.addMouseListener(new MouseAdapter() + { + @Override + public void mousePressed(MouseEvent e) + { + onAction.run(); + } + + @Override + public void mouseEntered(MouseEvent e) + { + a.setForeground(Ui.ACCENT.brighter()); + } + + @Override + public void mouseExited(MouseEvent e) + { + a.setForeground(Ui.ACCENT); + } + }); + p.add(a, BorderLayout.EAST); + } + return p; + } + + private static JButton button(String text, Runnable action) + { + JButton b = new JButton(text); + b.setFont(FontManager.getRunescapeSmallFont()); + b.setFocusPainted(false); + b.setMargin(new java.awt.Insets(2, 2, 2, 2)); + b.setCursor(Cursor.getPredefinedCursor(Cursor.HAND_CURSOR)); + b.addActionListener(e -> action.run()); + return b; + } + + /** Everything the detail views need for the current selection. */ + static final class Model + { + String selection; + String title; + List points; + long start; + long end; + long[] current; + List sources; + List topItems; + List ledgers; + boolean ignorePrices; + Analysis.Stats stats; + String emptyText; + String hourlyRate; + } + + public static Long calculateHourlyRate(List series, long rateStart, long now) + { + if (series == null || series.isEmpty() || now <= rateStart) + { + return null; + } + long startVal = series.get(0).total(); + long endVal = series.get(series.size() - 1).total(); + long delta = endVal - startVal; + + long activeMs = calculateActiveMillis(series, rateStart, now); + if (activeMs < Ui.MINUTE) + { + return null; + } + + return Math.round((double) delta * (double) Ui.HOUR / (double) activeMs); + } + + public static long calculateActiveMillis(List series, long rateStart, long now) + { + if (series == null || series.isEmpty()) + { + return 0; + } + if (series.size() == 1) + { + long span = now - rateStart; + return (span > 0 && span <= 10 * Ui.MINUTE) ? span : 0; + } + long active = 0; + for (int i = 1; i < series.size(); i++) + { + Snapshot a = series.get(i - 1); + Snapshot b = series.get(i); + long tA = Math.max(a.t, rateStart); + long tB = Math.max(b.t, rateStart); + long gap = tB - tA; + if (gap > 0 && gap <= 10 * Ui.MINUTE) + { + active += gap; + } + } + long lastT = Math.max(series.get(series.size() - 1).t, rateStart); + long tail = now - lastT; + if (tail > 0 && tail <= 10 * Ui.MINUTE) + { + active += tail; + } + return active; + } + + public static String formatHourlyRate(Long rate) + { + if (rate == null) + { + return ""; + } + return Ui.signedGp(rate) + " gp/h"; + } + + /** Charts + analysis; used in the side panel and in the pop-out window. */ + public static final class DetailView + { + final JLabel title = new JLabel(); + final WealthChart main; + final WealthChart bars; + final WealthChart stacked; + final StatsView stats; + final BreakdownView breakdown = new BreakdownView(); + final MoversView movers; + final TopItemsView topItems; + final boolean large; + Analysis.Movers lastMovers; + + DetailView(ItemIcons icons, boolean large) + { + this.large = large; + main = new WealthChart(WealthChart.Mode.LINE, 170); + bars = new WealthChart(WealthChart.Mode.BARS, 100); + stacked = new WealthChart(WealthChart.Mode.STACKED, 120); + main.setLarge(large); + bars.setLarge(large); + stacked.setLarge(large); + stats = new StatsView(2); + movers = new MoversView(icons); + topItems = new TopItemsView(icons, large ? 25 : 10); + } + + void update(Model m, int moverLimit) + { + title.setText(m.title); + main.setData(m.points, m.start, m.end, m.emptyText); + bars.setData(m.points, m.start, m.end, m.emptyText); + stacked.setData(m.points, m.start, m.end, m.emptyText); + stats.update(cells(m.stats, large, m.hourlyRate)); + + long[] startValues = m.points.isEmpty() ? null : m.points.get(0).v; + breakdown.update(m.current, m.sources == null ? new ArrayList<>() : m.sources, startValues); + + lastMovers = Analysis.movers(m.ledgers, m.start, moverLimit, m.ignorePrices); + movers.update(lastMovers); + + long total = m.current == null ? 0 : Series.sum(m.current); + topItems.update(m.topItems == null ? new ArrayList<>() : m.topItems, total); + } + + private static String date(long t) + { + return Instant.ofEpochMilli(t).atZone(ZoneId.systemDefault()).format(SHORT_DATE); + } + + static List cells(Analysis.Stats s, boolean full) + { + return cells(s, full, null); + } + + public static List cells(Analysis.Stats s, boolean full, String hourlyRate) + { + List cells = new ArrayList<>(); + if (!s.has) + { + for (String l : new String[]{"Change", hourlyRate != null ? "Per hour" : "Per day", "Peak", "Low"}) + { + cells.add(new StatsView.Cell(l, "-", null, Ui.FAINT, null)); + } + return cells; + } + String period = s.bucket >= 7 * Ui.DAY ? "week" : s.bucket >= Ui.DAY ? "day" : s.bucket >= Ui.HOUR ? "hour" : (s.bucket / Ui.MINUTE) + "min"; + + cells.add(new StatsView.Cell("Change", Ui.signedGp(s.change), Ui.pct(s.ratio), Ui.deltaColor(s.change), + "Change over the range: " + Ui.exact(s.change))); + + if (hourlyRate != null) + { + cells.add(new StatsView.Cell("Per hour", hourlyRate.isEmpty() ? "-" : hourlyRate, null, Ui.deltaColor(s.change), + "Rate of change per hour")); + } + else + { + cells.add(new StatsView.Cell("Per day", Ui.signedGp(s.perDay), null, Ui.deltaColor(s.perDay), + "Average change per day: " + Ui.exact(s.perDay))); + } + cells.add(new StatsView.Cell("Peak", Ui.gp(s.peak), date(s.peakT), Ui.TEXT, "Highest value: " + Ui.exact(s.peak))); + cells.add(new StatsView.Cell("Low", Ui.gp(s.low), date(s.lowT), Ui.TEXT, "Lowest value: " + Ui.exact(s.low))); + cells.add(new StatsView.Cell("Best " + period, s.best == null ? "-" : Ui.signedGp(s.best.change()), + s.best == null ? null : date(s.best.t0), s.best == null ? Ui.FAINT : Ui.UP, + s.best == null ? null : "Biggest gain in one " + period + ": " + Ui.exact(s.best.change()))); + cells.add(new StatsView.Cell("Worst " + period, s.worst == null ? "-" : Ui.signedGp(s.worst.change()), + s.worst == null ? null : date(s.worst.t0), s.worst == null ? Ui.FAINT : Ui.DOWN, + s.worst == null ? null : "Biggest loss in one " + period + ": " + Ui.exact(s.worst.change()))); + cells.add(new StatsView.Cell("Max drawdown", s.maxDrawdown <= 0 ? "0%" : String.format(Locale.US, "-%.1f%%", s.maxDrawdown * 100), + null, s.maxDrawdown > 0 ? Ui.DOWN : Ui.TEXT, "Largest drop from a previous peak")); + cells.add(new StatsView.Cell("Up/down", s.up + " / " + s.down, s.periods + (s.bucket < Ui.HOUR ? "x" : period.substring(0, 1)), + s.up >= s.down ? Ui.UP : Ui.DOWN, "Periods with gains / losses")); + if (full) + { + cells.add(new StatsView.Cell("Volatility", Double.isNaN(s.volatility) ? "-" : String.format(Locale.US, "±%.2f%%", s.volatility * 100), + "per " + period, Ui.TEXT, "Standard deviation of the change per " + period)); + double hitRate = s.up + s.down > 0 ? (double) s.up / (s.up + s.down) : Double.NaN; + cells.add(new StatsView.Cell("Win rate", Double.isNaN(hitRate) ? "-" : String.format(Locale.US, "%.0f%%", hitRate * 100), + null, Ui.TEXT, "Share of " + period + "s that ended with a gain")); + } + return cells; + } + } + + private static long computeActiveStreakStart(List h, long lastUpdated, long now) + { + if (h == null || h.isEmpty()) + { + return now - TimeFilter.ACTIVE_GAP; + } + + long latest = Math.max(lastUpdated, h.get(h.size() - 1).t); + if (now - latest > TimeFilter.ACTIVE_GAP) + { + // Character has been idle / logged out for more than 5 minutes + return now - TimeFilter.ACTIVE_GAP; + } + + // Trace continuous active session backwards: consecutive snapshots within 10 minutes of each other + long streakStart = h.get(h.size() - 1).t; + int firstIdx = h.size() - 1; + for (int i = h.size() - 1; i > 0; i--) + { + long gap = h.get(i).t - h.get(i - 1).t; + if (gap <= 10 * Ui.MINUTE) + { + firstIdx = i - 1; + streakStart = h.get(i - 1).t; + } + else + { + break; + } + } + + // Baseline is right before the first active point of this session + if (firstIdx > 0) + { + return Math.max(h.get(firstIdx - 1).t, streakStart - Ui.MINUTE); + } + return streakStart - Ui.MINUTE; + } +} diff --git a/src/main/java/com/example/essentialsplus/ui/WealthPopout.java b/src/main/java/com/example/essentialsplus/ui/WealthPopout.java new file mode 100644 index 0000000..8e234ca --- /dev/null +++ b/src/main/java/com/example/essentialsplus/ui/WealthPopout.java @@ -0,0 +1,339 @@ +package com.example.essentialsplus.ui; + +import com.example.essentialsplus.data.AccountData; +import java.awt.BorderLayout; +import java.awt.Component; +import java.awt.Cursor; +import java.awt.Dimension; +import java.awt.FlowLayout; +import java.awt.GridBagConstraints; +import java.awt.GridBagLayout; +import java.awt.Insets; +import java.awt.event.WindowAdapter; +import java.awt.event.WindowEvent; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import javax.swing.BorderFactory; +import javax.swing.DefaultListCellRenderer; +import javax.swing.JButton; +import javax.swing.JComboBox; +import javax.swing.JComponent; +import javax.swing.JFrame; +import javax.swing.JLabel; +import javax.swing.JList; +import javax.swing.JPanel; +import javax.swing.JScrollPane; +import javax.swing.ScrollPaneConstants; +import javax.swing.WindowConstants; +import net.runelite.client.ui.DynamicGridLayout; +import net.runelite.client.ui.FontManager; + +/** Large resizable analysis window with its own character selection. */ +class WealthPopout extends JFrame +{ + static final String AUTO = "__logged_in__"; + + private final WealthPanel panel; + private final WealthPanel.DetailView view; + private final Segmented rangeBar; + private final Segmented styleBar; + private final JComboBox characterBox = new JComboBox<>(); + private final JLabel sessionLabel = new JLabel(); + private String choice = AUTO; + private String resolved = WealthPanel.ALL; + private List comboKeys = new ArrayList<>(); + private boolean updatingCombo; + + private static final class Choice + { + final String key; + final String label; + + Choice(String key, String label) + { + this.key = key; + this.label = label; + } + + @Override + public String toString() + { + return label; + } + } + + WealthPopout(WealthPanel panel, TimeRange range, Runnable onClose) + { + super("Wealth Tracker"); + this.panel = panel; + setDefaultCloseOperation(WindowConstants.DISPOSE_ON_CLOSE); + addWindowListener(new WindowAdapter() + { + @Override + public void windowClosed(WindowEvent e) + { + onClose.run(); + } + }); + + view = new WealthPanel.DetailView(panel.getIcons(), true); + view.main.setMode(panel.getMainMode()); + view.breakdown.setOnOpen((v, label) -> panel.openItems(v, label, resolved)); + rangeBar = Segmented.ranges(range, panel::setRange); + rangeBar.setSelected(panel.isCustomSessionView() ? -1 : range.ordinal()); + styleBar = new Segmented(new String[]{"Line", "Candles"}, panel.getMainMode() == WealthChart.Mode.CANDLES ? 1 : 0, + i -> panel.setMainMode(i == 1 ? WealthChart.Mode.CANDLES : WealthChart.Mode.LINE)); + + java.awt.event.MouseAdapter popoutRightClick = new java.awt.event.MouseAdapter() + { + @Override + public void mousePressed(java.awt.event.MouseEvent e) + { + if (e.isPopupTrigger()) + { + showPopup(e); + } + } + + @Override + public void mouseReleased(java.awt.event.MouseEvent e) + { + if (e.isPopupTrigger()) + { + showPopup(e); + } + } + + private void showPopup(java.awt.event.MouseEvent e) + { + if (!panel.isHourly()) + { + return; + } + javax.swing.JPopupMenu menu = new javax.swing.JPopupMenu(); + javax.swing.JMenuItem reset = new javax.swing.JMenuItem("Reset Hourly"); + reset.addActionListener(a -> + { + panel.resetHourly(resolved); + panel.refreshLater(); + }); + menu.add(reset); + menu.show((java.awt.Component) e.getSource(), e.getX(), e.getY()); + } + }; + view.main.addMouseListener(popoutRightClick); + view.stats.addMouseListener(popoutRightClick); + + characterBox.setFont(FontManager.getRunescapeBoldFont()); + characterBox.setBackground(Ui.CARD); + characterBox.setForeground(Ui.TEXT); + characterBox.setFocusable(false); + characterBox.setPreferredSize(new Dimension(260, 30)); + characterBox.setRenderer(new DefaultListCellRenderer() + { + @Override + public Component getListCellRendererComponent(JList list, Object value, int index, boolean isSelected, boolean cellHasFocus) + { + JLabel l = (JLabel) super.getListCellRendererComponent(list, value, index, isSelected, cellHasFocus); + l.setFont(FontManager.getRunescapeBoldFont()); + l.setBorder(BorderFactory.createEmptyBorder(3, 8, 3, 8)); + return l; + } + }); + characterBox.addActionListener(e -> + { + if (updatingCombo) + { + return; + } + Choice c = (Choice) characterBox.getSelectedItem(); + if (c != null) + { + choice = c.key; + panel.refreshLater(); + } + }); + + JPanel root = new JPanel(new BorderLayout(12, 10)); + root.setBackground(Ui.BG); + root.setBorder(BorderFactory.createEmptyBorder(12, 12, 12, 12)); + + // ---- top bar + JPanel top = new JPanel(new BorderLayout(12, 0)); + top.setOpaque(false); + JPanel left = new JPanel(new FlowLayout(FlowLayout.LEFT, 12, 0)); + left.setOpaque(false); + view.title.setFont(FontManager.getRunescapeBoldFont().deriveFont(24f)); + view.title.setForeground(Ui.ACCENT); + left.add(view.title); + left.add(characterBox); + top.add(left, BorderLayout.WEST); + + JPanel controls = new JPanel(new FlowLayout(FlowLayout.RIGHT, 10, 0)); + controls.setOpaque(false); + sessionLabel.setFont(FontManager.getRunescapeFont()); + sessionLabel.setForeground(Ui.MUTED); + controls.add(sessionLabel); + controls.add(button("Session", panel::showSession)); + controls.add(button("New session", panel::newSession)); + styleBar.setPreferredSize(new Dimension(150, 30)); + rangeBar.setPreferredSize(new Dimension(380, 30)); + controls.add(styleBar); + controls.add(rangeBar); + top.add(controls, BorderLayout.EAST); + root.add(top, BorderLayout.NORTH); + + // ---- charts + JPanel charts = new JPanel(new GridBagLayout()); + charts.setOpaque(false); + int row = 0; + row = addChart(charts, row, "NET WORTH", view.main, 0.5); + row = addChart(charts, row, "CHANGE PER PERIOD", view.bars, 0.2); + addChart(charts, row, "COMPOSITION", view.stacked, 0.3); + root.add(charts, BorderLayout.CENTER); + + // ---- side column + JPanel side = new JPanel(new DynamicGridLayout(0, 1, 0, 6)); + side.setBackground(Ui.BG); + side.add(label("STATISTICS")); + side.add(view.stats); + side.add(label("BREAKDOWN (click to view items)")); + JPanel viewButtons = new JPanel(new java.awt.GridLayout(1, 4, 4, 0)); + viewButtons.setOpaque(false); + viewButtons.add(button("Bank", () -> panel.openItems("bank", "Bank", resolved))); + viewButtons.add(button("Inventory", () -> panel.openItems("inventory", "Inventory", resolved))); + viewButtons.add(button("Equipment", () -> panel.openItems("equipment", "Equipment", resolved))); + viewButtons.add(button("GE", () -> panel.openItems("ge", "Grand Exchange", resolved))); + side.add(viewButtons); + side.add(view.breakdown); + side.add(label("WHAT CHANGED")); + side.add(view.movers); + side.add(label("MOST VALUABLE")); + side.add(view.topItems); + + JPanel sideWrap = new JPanel(new BorderLayout()); + sideWrap.setBackground(Ui.BG); + sideWrap.add(side, BorderLayout.NORTH); + JScrollPane scroll = new JScrollPane(sideWrap, ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED, ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER); + scroll.setBorder(null); + scroll.getVerticalScrollBar().setUnitIncrement(16); + scroll.setPreferredSize(new Dimension(340, 10)); + root.add(scroll, BorderLayout.EAST); + + setContentPane(root); + setSize(1600, 900); + setMinimumSize(new Dimension(900, 560)); + setLocationRelativeTo(panel); + } + + private static int addChart(JPanel charts, int row, String title, JComponent chart, double weight) + { + GridBagConstraints c = new GridBagConstraints(); + c.gridx = 0; + c.fill = GridBagConstraints.BOTH; + c.weightx = 1; + c.gridy = row; + c.weighty = 0; + c.insets = new Insets(row == 0 ? 0 : 8, 0, 4, 0); + charts.add(label(title), c); + c.gridy = row + 1; + c.weighty = weight; + c.insets = new Insets(0, 0, 0, 0); + chart.setPreferredSize(new Dimension(10, 60)); + charts.add(chart, c); + return row + 2; + } + + /** Character shown in this window: the logged-in one by default, or the one picked in the box. */ + String resolveSelection(List accounts, String activeKey) + { + if (AUTO.equals(choice)) + { + if (activeKey != null && accounts.stream().anyMatch(a -> a.key.equals(activeKey))) + { + resolved = activeKey; + } + else if (accounts.stream().noneMatch(a -> a.key.equals(resolved))) + { + resolved = WealthPanel.ALL; + } + } + else if (WealthPanel.ALL.equals(choice) || accounts.stream().anyMatch(a -> a.key.equals(choice))) + { + resolved = choice; + } + else + { + choice = AUTO; + resolved = WealthPanel.ALL; + } + return resolved; + } + + void setRange(int index) + { + rangeBar.setSelected(index); + } + + void setMainMode(WealthChart.Mode mode) + { + styleBar.setSelected(mode == WealthChart.Mode.CANDLES ? 1 : 0); + view.main.setMode(mode); + } + + void update(WealthPanel.Model m, List accounts, String selectionKey) + { + List keys = new ArrayList<>(); + accounts.forEach(a -> keys.add(a.key + "=" + a.name)); + if (!keys.equals(comboKeys)) + { + comboKeys = keys; + updatingCombo = true; + characterBox.removeAllItems(); + characterBox.addItem(new Choice(AUTO, "Logged-in character")); + characterBox.addItem(new Choice(WealthPanel.ALL, "All characters")); + for (AccountData a : accounts) + { + characterBox.addItem(new Choice(a.key, a.name)); + } + updatingCombo = false; + } + updatingCombo = true; + for (int i = 0; i < characterBox.getItemCount(); i++) + { + if (Objects.equals(characterBox.getItemAt(i).key, choice)) + { + characterBox.setSelectedIndex(i); + break; + } + } + updatingCombo = false; + + long sessionStart = panel.sessionStart(); + sessionLabel.setText(panel.isSessionView() ? "Since session start" : sessionStart > 0 ? "Session: " + Ui.ago(sessionStart).replace(" ago", "") : ""); + sessionLabel.setForeground(panel.isSessionView() ? Ui.ACCENT : Ui.MUTED); + + view.update(m, 10); + panel.resolveNames(view.lastMovers); + } + + private static JButton button(String text, Runnable action) + { + JButton b = new JButton(text); + b.setFont(FontManager.getRunescapeSmallFont()); + b.setFocusPainted(false); + b.setCursor(Cursor.getPredefinedCursor(Cursor.HAND_CURSOR)); + b.setPreferredSize(new Dimension(Math.max(70, text.length() * 8 + 20), 30)); + b.addActionListener(e -> action.run()); + return b; + } + + private static JLabel label(String text) + { + JLabel l = new JLabel(text); + l.setFont(FontManager.getRunescapeSmallFont()); + l.setForeground(Ui.MUTED); + return l; + } +} diff --git a/src/main/java/com/example/essentialsplus/ui/XpChart.java b/src/main/java/com/example/essentialsplus/ui/XpChart.java new file mode 100644 index 0000000..d97430a --- /dev/null +++ b/src/main/java/com/example/essentialsplus/ui/XpChart.java @@ -0,0 +1,452 @@ +package com.example.essentialsplus.ui; + +import com.example.essentialsplus.skills.SkillHistory; +import com.example.essentialsplus.skills.XpSample; +import java.awt.BasicStroke; +import java.awt.Color; +import java.awt.Dimension; +import java.awt.FontMetrics; +import java.awt.GradientPaint; +import java.awt.Graphics; +import java.awt.Graphics2D; +import java.awt.event.MouseAdapter; +import java.awt.event.MouseEvent; +import java.awt.geom.Path2D; +import java.time.Instant; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Locale; +import javax.swing.JComponent; +import net.runelite.client.ui.FontManager; + +/** + * Experience chart: cumulative xp gained over the range (with level-up markers) or xp per period as bars. + */ +class XpChart extends JComponent +{ + enum Mode + { + CUMULATIVE, + BARS + } + + private static final BasicStroke THIN = new BasicStroke(1f); + private static final BasicStroke LINE = new BasicStroke(1.6f, BasicStroke.CAP_ROUND, BasicStroke.JOIN_ROUND); + + private final Mode mode; + private final int prefHeight; + private List samples = Collections.emptyList(); + private List levelUps = Collections.emptyList(); + private int skill = -1; + private long start; + private long end; + private Color color = Ui.GOLD; + private String emptyText = ""; + private int hoverX = -1; + + /** bars: bucket start, bucket end, xp */ + private final List bars = new ArrayList<>(); + private long bucket; + + XpChart(Mode mode, int prefHeight) + { + this.mode = mode; + this.prefHeight = prefHeight; + MouseAdapter mouse = new MouseAdapter() + { + @Override + public void mouseMoved(MouseEvent e) + { + hoverX = e.getX(); + repaint(); + } + + @Override + public void mouseExited(MouseEvent e) + { + hoverX = -1; + repaint(); + } + }; + addMouseListener(mouse); + addMouseMotionListener(mouse); + } + + void setData(List samples, List levelUps, int skill, long start, long end, Color color, String emptyText) + { + this.samples = samples == null ? Collections.emptyList() : samples; + this.levelUps = levelUps == null ? Collections.emptyList() : levelUps; + this.skill = skill; + this.start = start; + this.end = Math.max(end, start + Ui.MINUTE); + this.color = color; + this.emptyText = emptyText; + if (mode == Mode.BARS) + { + buildBars(); + } + repaint(); + } + + @Override + public Dimension getPreferredSize() + { + return new Dimension(10, prefHeight); + } + + static long barBucket(long span) + { + if (span <= 3 * Ui.HOUR) + { + return 5 * Ui.MINUTE; + } + if (span <= 2 * Ui.DAY) + { + return Ui.HOUR; + } + if (span <= 10 * Ui.DAY) + { + return 6 * Ui.HOUR; + } + if (span <= 120 * Ui.DAY) + { + return Ui.DAY; + } + if (span <= 400 * Ui.DAY) + { + return 7 * Ui.DAY; + } + return 30 * Ui.DAY; + } + + private void buildBars() + { + bars.clear(); + bucket = barBucket(end - start); + if (samples.size() < 2) + { + return; + } + long t = Analysis.bucketStart(start, bucket); + int idx = 0; + long prev = samples.get(0).value(skill); + while (t < end) + { + long t1 = Analysis.bucketEnd(t, bucket); + long last = prev; + while (idx < samples.size() && samples.get(idx).t < t1) + { + last = Math.max(last, samples.get(idx).value(skill)); + idx++; + } + bars.add(new long[]{t, t1, Math.max(0, last - prev)}); + prev = last; + t = t1; + } + } + + private long gainedAt(int i) + { + return Math.max(0, samples.get(i).value(skill) - samples.get(0).value(skill)); + } + + @Override + protected void paintComponent(Graphics g0) + { + Graphics2D g = Ui.prepare(g0); + int w = getWidth(); + int h = getHeight(); + g.setColor(Ui.CARD); + g.fillRoundRect(0, 0, w, h, 8, 8); + g.setFont(FontManager.getRunescapeSmallFont()); + FontMetrics fm = g.getFontMetrics(); + + long max = 0; + if (mode == Mode.BARS) + { + for (long[] b : bars) + { + max = Math.max(max, b[2]); + } + } + else if (!samples.isEmpty()) + { + max = gainedAt(samples.size() - 1); + } + + if (samples.size() < 2 || max <= 0) + { + g.setColor(Ui.MUTED); + int y = h / 2 + fm.getAscent() / 2; + g.drawString(emptyText, (w - fm.stringWidth(emptyText)) / 2, y); + g.dispose(); + return; + } + + double hi = max * 1.12; + double step = niceStep(hi / 3); + int labelW = 0; + for (double v = 0; v <= hi; v += step) + { + labelW = Math.max(labelW, fm.stringWidth(Ui.gp(Math.round(v)))); + } + int px = labelW + 10; + int py = 10; + int pw = w - px - 8; + int ph = h - py - fm.getHeight() - 6; + if (pw < 20 || ph < 20) + { + g.dispose(); + return; + } + + for (double v = 0; v <= hi; v += step) + { + int y = (int) Math.round(py + ph - v / hi * ph); + g.setColor(v == 0 ? Ui.alpha(Ui.TEXT, 50) : Ui.GRID); + g.setStroke(THIN); + g.drawLine(px, y, px + pw, y); + g.setColor(Ui.FAINT); + Ui.drawRight(g, Ui.gp(Math.round(v)), px - 5, y + fm.getAscent() / 2 - 1); + } + drawTimeAxis(g, fm, px, py + ph, pw); + + if (mode == Mode.BARS) + { + paintBars(g, fm, px, py, pw, ph, hi); + } + else + { + paintCumulative(g, fm, px, py, pw, ph, hi); + } + g.dispose(); + } + + private double xOf(long t, int px, int pw) + { + return px + (double) (Math.max(start, Math.min(end, t)) - start) / (end - start) * pw; + } + + private void paintCumulative(Graphics2D g, FontMetrics fm, int px, int py, int pw, int ph, double hi) + { + Path2D line = new Path2D.Double(); + double lastY = py + ph; + line.moveTo(px, lastY); + for (int i = 1; i < samples.size(); i++) + { + double x = xOf(samples.get(i).t, px, pw); + double y = py + ph - gainedAt(i) / hi * ph; + line.lineTo(x, lastY); + line.lineTo(x, y); + lastY = y; + } + line.lineTo(px + pw, lastY); + + Path2D area = new Path2D.Double(line); + area.lineTo(px + pw, py + ph); + area.lineTo(px, py + ph); + area.closePath(); + g.setPaint(new GradientPaint(0, py, Ui.alpha(color, 90), 0, py + ph, Ui.alpha(color, 0))); + g.fill(area); + g.setStroke(LINE); + g.setColor(color); + g.draw(line); + + // level-up markers + long base = samples.get(0).value(skill); + for (SkillHistory.LevelUp lu : levelUps) + { + if (skill >= 0 && lu.skill != skill) + { + continue; + } + int i = indexAt(lu.t); + double x = xOf(lu.t, px, pw); + double y = py + ph - Math.max(0, samples.get(i).value(skill) - base) / hi * ph; + g.setColor(Ui.alpha(Ui.UP, 70)); + g.fillOval((int) x - 5, (int) y - 5, 10, 10); + g.setColor(Ui.UP); + g.fillOval((int) x - 3, (int) y - 3, 6, 6); + } + + if (hoverX >= px && hoverX <= px + pw) + { + long t = start + (long) ((double) (hoverX - px) / pw * (end - start)); + int i = indexAt(t); + g.setStroke(THIN); + g.setColor(Ui.alpha(Ui.TEXT, 70)); + g.drawLine(hoverX, py, hoverX, py + ph); + + List rows = new ArrayList<>(); + rows.add(new String[]{"Gained", "+" + Ui.gp(gainedAt(i))}); + List ups = new ArrayList<>(); + for (SkillHistory.LevelUp lu : levelUps) + { + double lx = xOf(lu.t, px, pw); + if ((skill < 0 || lu.skill == skill) && Math.abs(lx - hoverX) <= 4) + { + ups.add(SkillHistory.SKILLS[lu.skill].getName() + " " + lu.to); + } + } + for (String u : ups) + { + rows.add(new String[]{"Level", u}); + } + paintTip(g, fm, hoverX, py, pw, px, formatTime(t), rows); + } + } + + private int indexAt(long t) + { + int idx = 0; + for (int i = 0; i < samples.size(); i++) + { + if (samples.get(i).t <= t) + { + idx = i; + } + else + { + break; + } + } + return idx; + } + + private void paintBars(Graphics2D g, FontMetrics fm, int px, int py, int pw, int ph, double hi) + { + int hovered = -1; + for (int i = 0; i < bars.size(); i++) + { + long[] b = bars.get(i); + double x0 = xOf(b[0], px, pw); + double x1 = xOf(b[1], px, pw); + int bw = (int) Math.max(1, x1 - x0 - (x1 - x0 > 4 ? 2 : 0)); + boolean hover = hoverX >= x0 && hoverX < x1; + if (hover) + { + hovered = i; + g.setColor(Ui.alpha(Ui.TEXT, 18)); + g.fillRect((int) x0, py, (int) Math.max(1, x1 - x0), ph); + } + if (b[2] <= 0) + { + continue; + } + int bh = (int) Math.max(1, Math.round(b[2] / hi * ph)); + g.setColor(hover ? color.brighter() : Ui.alpha(color, 210)); + g.fillRect((int) Math.round(x0) + 1, py + ph - bh, bw, bh); + } + if (hovered >= 0) + { + long[] b = bars.get(hovered); + List rows = new ArrayList<>(); + rows.add(new String[]{"XP", "+" + Ui.gp(b[2])}); + if (bucket < Ui.DAY) + { + rows.add(new String[]{"Per hour", Ui.gp(b[2] * Ui.HOUR / bucket)}); + } + paintTip(g, fm, hoverX, py, pw, px, formatTime(b[0]) + " - " + formatTime(b[1]), rows); + } + } + + private String formatTime(long t) + { + long span = end - start; + String pattern = span <= 2 * Ui.DAY ? "HH:mm" : span <= 120 * Ui.DAY ? "d MMM HH:mm" : "d MMM yy"; + return Instant.ofEpochMilli(t).atZone(ZoneId.systemDefault()).format(DateTimeFormatter.ofPattern(pattern, Locale.ENGLISH)); + } + + private void paintTip(Graphics2D g, FontMetrics fm, int x, int py, int pw, int px, String title, List rows) + { + int lh = fm.getHeight(); + int keyW = 0; + int valW = 0; + for (String[] r : rows) + { + keyW = Math.max(keyW, fm.stringWidth(r[0])); + valW = Math.max(valW, fm.stringWidth(r[1])); + } + int bw = Math.max(fm.stringWidth(title), keyW + 10 + valW) + 12; + int bh = (rows.size() + 1) * lh + 8; + int bx = x + 10; + if (bx + bw > px + pw) + { + bx = x - 10 - bw; + } + bx = Math.max(2, Math.min(getWidth() - bw - 2, bx)); + + g.setColor(new Color(18, 18, 18, 238)); + g.fillRoundRect(bx, py, bw, bh, 8, 8); + g.setStroke(THIN); + g.setColor(new Color(255, 255, 255, 35)); + g.drawRoundRect(bx, py, bw, bh, 8, 8); + + int ty = py + 4 + fm.getAscent(); + g.setColor(Ui.MUTED); + g.drawString(title, bx + 6, ty); + for (String[] r : rows) + { + ty += lh; + g.setColor(Ui.MUTED); + g.drawString(r[0], bx + 6, ty); + g.setColor("Level".equals(r[0]) ? Ui.UP : Ui.TEXT); + Ui.drawRight(g, r[1], bx + bw - 6, ty); + } + } + + private void drawTimeAxis(Graphics2D g, FontMetrics fm, int px, int baseY, int pw) + { + long span = end - start; + DateTimeFormatter f; + if (span <= 2 * Ui.DAY) + { + f = DateTimeFormatter.ofPattern("HH:mm", Locale.ENGLISH); + } + else if (span <= 10 * Ui.DAY) + { + f = DateTimeFormatter.ofPattern("EEE d", Locale.ENGLISH); + } + else if (span <= 200 * Ui.DAY) + { + f = DateTimeFormatter.ofPattern("MMM d", Locale.ENGLISH); + } + else + { + f = DateTimeFormatter.ofPattern("MMM yy", Locale.ENGLISH); + } + int count = Math.max(2, Math.min(8, pw / (fm.stringWidth("Mmm 00") + 18))); + g.setColor(Ui.FAINT); + int lastRight = Integer.MIN_VALUE; + int y = baseY + fm.getAscent() + 4; + for (int i = 0; i <= count; i++) + { + long t = start + span * i / count; + String s = Instant.ofEpochMilli(t).atZone(ZoneId.systemDefault()).format(f); + int sw = fm.stringWidth(s); + int x = px + pw * i / count - sw / 2; + x = Math.max(px - 4, Math.min(px + pw - sw, x)); + if (x < lastRight + 6) + { + continue; + } + g.drawString(s, x, y); + lastRight = x + sw; + } + } + + private static double niceStep(double raw) + { + if (raw <= 0) + { + return 1; + } + double mag = Math.pow(10, Math.floor(Math.log10(raw))); + double n = raw / mag; + double nice = n <= 1 ? 1 : n <= 2 ? 2 : n <= 5 ? 5 : 10; + return Math.max(1, nice * mag); + } +} diff --git a/src/main/java/com/example/essentialsplus/wiki/WikiApi.java b/src/main/java/com/example/essentialsplus/wiki/WikiApi.java new file mode 100644 index 0000000..f4aaf9e --- /dev/null +++ b/src/main/java/com/example/essentialsplus/wiki/WikiApi.java @@ -0,0 +1,380 @@ +package com.example.essentialsplus.wiki; + +import com.example.essentialsplus.loot.droptable.DropLine; +import com.example.essentialsplus.loot.droptable.DropTable; +import com.google.gson.Gson; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import java.awt.image.BufferedImage; +import java.io.IOException; +import java.io.InputStream; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import javax.imageio.ImageIO; +import lombok.extern.slf4j.Slf4j; +import okhttp3.Call; +import okhttp3.Callback; +import okhttp3.HttpUrl; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.Response; +import okhttp3.ResponseBody; + +/** Thin client for the Old School RuneScape Wiki: title search, full-text search, parsed pages and images. */ +@Slf4j +public class WikiApi +{ + public static final String BASE = "https://oldschool.runescape.wiki"; + private static final int MAX_IMAGE_BYTES = 2 * 1024 * 1024; + + private final OkHttpClient http; + private final Gson gson; + + public WikiApi(OkHttpClient http, Gson gson) + { + this.http = http; + this.gson = gson; + } + + /** One search hit; {@code snippet} may be empty, {@code thumbnail} null. */ + public static final class Result + { + public final String title; + public final String snippet; + public final String thumbnail; + + public Result(String title, String snippet, String thumbnail) + { + this.title = title; + this.snippet = snippet; + this.thumbnail = thumbnail; + } + } + + /** A parsed page: resolved title (after redirects) and its raw content html. */ + public static final class Page + { + public final String title; + public final String html; + + Page(String title, String html) + { + this.title = title; + this.html = html; + } + } + + public static String pageUrl(String title) + { + return BASE + "/w/" + encodeTitle(title); + } + + static String encodeTitle(String title) + { + return URLEncoder.encode(title.replace(' ', '_'), StandardCharsets.UTF_8) + .replace("%2F", "/").replace("%3A", ":").replace("+", "%20"); + } + + /** Search-as-you-type suggestions, the same list the wiki's search box shows. */ + public List suggest(String query, int limit) throws IOException + { + HttpUrl url = HttpUrl.parse(BASE + "/rest.php/v1/search/title").newBuilder() + .addQueryParameter("q", query) + .addQueryParameter("limit", String.valueOf(limit)) + .build(); + JsonObject root = getJson(url); + List out = new ArrayList<>(); + JsonArray pages = root.getAsJsonArray("pages"); + if (pages == null) + { + return out; + } + for (JsonElement el : pages) + { + JsonObject p = el.getAsJsonObject(); + String thumb = null; + if (p.has("thumbnail") && p.get("thumbnail").isJsonObject()) + { + thumb = string(p.getAsJsonObject("thumbnail"), "url"); + } + String desc = string(p, "description"); + out.add(new Result(string(p, "title"), desc == null ? "" : desc, thumb)); + } + return out; + } + + /** Full-text search ("pages containing ..."), like the wiki's Special:Search. */ + public List search(String query, int limit) throws IOException + { + HttpUrl url = HttpUrl.parse(BASE + "/api.php").newBuilder() + .addQueryParameter("action", "query") + .addQueryParameter("list", "search") + .addQueryParameter("srsearch", query) + .addQueryParameter("srlimit", String.valueOf(limit)) + .addQueryParameter("srprop", "snippet") + .addQueryParameter("format", "json") + .addQueryParameter("formatversion", "2") + .build(); + JsonObject root = getJson(url); + List out = new ArrayList<>(); + JsonObject q = root.getAsJsonObject("query"); + if (q == null || !q.has("search")) + { + return out; + } + for (JsonElement el : q.getAsJsonArray("search")) + { + JsonObject p = el.getAsJsonObject(); + String snippet = string(p, "snippet"); + out.add(new Result(string(p, "title"), snippet == null ? "" : WikiHtml.plainText(snippet), null)); + } + return out; + } + + /** + * Resolves a game entity to its wiki page via Special:Lookup (handles ids shared by several pages and + * variants). Returns the page url, or null when the wiki only offers a search. + * + * @param type "item", "npc" or "object" + */ + public String lookup(String type, int id, String name) throws IOException + { + HttpUrl url = HttpUrl.parse(BASE + "/w/Special:Lookup").newBuilder() + .addQueryParameter("type", type) + .addQueryParameter("id", String.valueOf(id)) + .addQueryParameter("name", name) + .build(); + try (Response response = http.newCall(new Request.Builder().url(url).build()).execute()) + { + String resolved = response.request().url().toString(); + if (!response.isSuccessful() || resolved.contains("Special:")) + { + return null; + } + return resolved; + } + } + + /** + * Drop table of a monster/source page. Falls back to the redirect target when the name has no rows + * (e.g. different capitalisation). Returns an empty table when the wiki has none. + */ + public DropTable fetchDropTable(String name) throws IOException + { + List lines = dropLines(name); + String page = name; + if (lines.isEmpty()) + { + String resolved = resolveTitle(name); + if (resolved != null && !resolved.equals(name)) + { + page = resolved; + lines = dropLines(resolved); + } + } + return new DropTable(page, lines); + } + + private List dropLines(String page) throws IOException + { + String escaped = page.replace("\\", "\\\\").replace("'", "\\'"); + HttpUrl url = HttpUrl.parse(BASE + "/api.php").newBuilder() + .addQueryParameter("action", "bucket") + .addQueryParameter("format", "json") + .addQueryParameter("query", "bucket('dropsline').select('drop_json').where('page_name','" + escaped + "').limit(5000).run()") + .build(); + JsonObject root = getJson(url); + List out = new ArrayList<>(); + JsonArray rows = root.getAsJsonArray("bucket"); + if (rows == null) + { + return out; + } + for (JsonElement el : rows) + { + String raw = string(el.getAsJsonObject(), "drop_json"); + if (raw == null) + { + continue; + } + try + { + JsonObject d = gson.fromJson(raw, JsonObject.class); + out.add(new DropLine(string(d, "Dropped from"), string(d, "Dropped item"), string(d, "Rarity"), + integer(d, "Rolls", 1), integer(d, "Quantity Low", 1), integer(d, "Quantity High", 1))); + } + catch (RuntimeException e) + { + log.debug("bad drop row on {}: {}", page, raw, e); + } + } + return out; + } + + /** Title after normalisation and redirects, or null if the page does not exist. */ + private String resolveTitle(String title) throws IOException + { + HttpUrl url = HttpUrl.parse(BASE + "/api.php").newBuilder() + .addQueryParameter("action", "query") + .addQueryParameter("titles", title) + .addQueryParameter("redirects", "1") + .addQueryParameter("format", "json") + .addQueryParameter("formatversion", "2") + .build(); + JsonObject q = getJson(url).getAsJsonObject("query"); + if (q == null || !q.has("pages") || q.getAsJsonArray("pages").size() == 0) + { + return null; + } + JsonObject p = q.getAsJsonArray("pages").get(0).getAsJsonObject(); + return p.has("missing") ? null : string(p, "title"); + } + + private static int integer(JsonObject o, String key, int fallback) + { + JsonElement e = o.get(key); + if (e == null || e.isJsonNull()) + { + return fallback; + } + try + { + return (int) Math.round(e.getAsDouble()); + } + catch (RuntimeException ex) + { + return fallback; + } + } + + public Page fetchPage(String title) throws IOException + { + HttpUrl url = HttpUrl.parse(BASE + "/api.php").newBuilder() + .addQueryParameter("action", "parse") + .addQueryParameter("page", title) + .addQueryParameter("prop", "text") + .addQueryParameter("redirects", "1") + .addQueryParameter("disableeditsection", "1") + .addQueryParameter("disabletoc", "1") + .addQueryParameter("format", "json") + .addQueryParameter("formatversion", "2") + .build(); + JsonObject root = getJson(url); + if (root.has("error")) + { + JsonObject err = root.getAsJsonObject("error"); + String code = string(err, "code"); + throw new IOException("missingtitle".equals(code) ? "Page does not exist" : string(err, "info")); + } + JsonObject parse = root.getAsJsonObject("parse"); + if (parse == null) + { + throw new IOException("Unexpected wiki response"); + } + String resolved = string(parse, "title"); + return new Page(resolved == null ? title : resolved, string(parse, "text")); + } + + /** Downloads the given images in parallel; failed or undecodable ones are left out. */ + public Map fetchImages(Collection urls, long timeoutMs) + { + Map out = new ConcurrentHashMap<>(); + CountDownLatch latch = new CountDownLatch(urls.size()); + for (String u : urls) + { + Request req = new Request.Builder().url(u).build(); + http.newCall(req).enqueue(new Callback() + { + @Override + public void onFailure(Call call, IOException e) + { + latch.countDown(); + } + + @Override + public void onResponse(Call call, Response response) + { + try (ResponseBody body = response.body()) + { + if (response.isSuccessful() && body != null && body.contentLength() <= MAX_IMAGE_BYTES) + { + BufferedImage img = read(body.byteStream()); + if (img != null) + { + out.put(u, img); + } + } + } + catch (Exception e) + { + log.debug("wiki image {} failed", u, e); + } + finally + { + latch.countDown(); + } + } + }); + } + try + { + latch.await(timeoutMs, TimeUnit.MILLISECONDS); + } + catch (InterruptedException e) + { + Thread.currentThread().interrupt(); + } + return new java.util.HashMap<>(out); + } + + public BufferedImage fetchImage(String url) throws IOException + { + try (Response response = http.newCall(new Request.Builder().url(url).build()).execute()) + { + ResponseBody body = response.body(); + if (!response.isSuccessful() || body == null) + { + throw new IOException("HTTP " + response.code()); + } + return read(body.byteStream()); + } + } + + private static BufferedImage read(InputStream in) throws IOException + { + return ImageIO.read(in); + } + + private JsonObject getJson(HttpUrl url) throws IOException + { + Request req = new Request.Builder().url(url).header("Accept", "application/json").build(); + try (Response response = http.newCall(req).execute()) + { + ResponseBody body = response.body(); + if (!response.isSuccessful() || body == null) + { + throw new IOException("Wiki returned HTTP " + response.code()); + } + JsonObject obj = gson.fromJson(body.charStream(), JsonObject.class); + if (obj == null) + { + throw new IOException("Empty wiki response"); + } + return obj; + } + } + + private static String string(JsonObject o, String key) + { + JsonElement e = o.get(key); + return e == null || e.isJsonNull() ? null : e.getAsString(); + } +} diff --git a/src/main/java/com/example/essentialsplus/wiki/WikiDocumentView.java b/src/main/java/com/example/essentialsplus/wiki/WikiDocumentView.java new file mode 100644 index 0000000..533a979 --- /dev/null +++ b/src/main/java/com/example/essentialsplus/wiki/WikiDocumentView.java @@ -0,0 +1,444 @@ +package com.example.essentialsplus.wiki; + +import java.awt.Color; +import java.awt.Font; +import java.awt.Graphics2D; +import java.awt.Rectangle; +import java.awt.RenderingHints; +import java.awt.geom.Point2D; +import java.awt.geom.Rectangle2D; +import java.awt.image.BufferedImage; +import java.io.IOException; +import java.io.StringReader; +import java.net.URL; +import java.util.Hashtable; +import java.util.Map; +import javax.swing.JEditorPane; +import javax.swing.SizeRequirements; +import javax.swing.text.AttributeSet; +import javax.swing.text.BadLocationException; +import javax.swing.text.Element; +import javax.swing.text.StyleConstants; +import javax.swing.text.View; +import javax.swing.text.ViewFactory; +import javax.swing.text.html.BlockView; +import javax.swing.text.html.HTML; +import javax.swing.text.html.HTMLDocument; +import javax.swing.text.html.HTMLEditorKit; +import javax.swing.text.html.StyleSheet; +import net.runelite.client.ui.FontManager; + +/** + * Lays out a wiki page with an off-screen {@link JEditorPane} and paints the visible part into an image + * that the in-game overlay draws. All methods except {@link #buildDocument} must run on the Swing thread. + */ +final class WikiDocumentView +{ + static final Color TEXT = new Color(0xE4D8BC); + static final Color LINK = new Color(0xFFB83F); + static final Color HEADING = new Color(0xFF981F); + + private static final HTMLEditorKit KIT = new Kit(); + + private final JEditorPane pane = new JEditorPane(); + private int height; + private int contentWidth; + + WikiDocumentView() + { + pane.setEditable(false); + pane.setOpaque(false); + pane.setDoubleBuffered(false); + pane.setEditorKit(KIT); + } + + /** Builds a detached document; safe to call off the Swing thread. */ + static HTMLDocument buildDocument(String html, String base, Map images) throws IOException + { + StyleSheet styles = new StyleSheet(); + styles.addStyleSheet(KIT.getStyleSheet()); + addRules(styles); + HTMLDocument doc = new HTMLDocument(styles); + doc.setAsynchronousLoadPriority(-1); + doc.putProperty("IgnoreCharsetDirective", Boolean.TRUE); + doc.setBase(new URL(base)); + + Hashtable cache = new Hashtable<>(); + for (Map.Entry e : images.entrySet()) + { + try + { + cache.put(new URL(e.getKey()), e.getValue()); + } + catch (IOException ignored) + { + // malformed url, the sanitizer drops images that are not in the cache + } + } + doc.putProperty("imageCache", cache); + try + { + KIT.read(new StringReader(html), doc, 0); + } + catch (BadLocationException e) + { + throw new IOException(e); + } + return doc; + } + + private static void addRules(StyleSheet s) + { + String font = family(FontManager.getRunescapeFont()); + String small = family(FontManager.getRunescapeSmallFont()); + s.addRule("body { color: " + hex(TEXT) + "; font-family: " + font + "; font-size: 16pt; margin: 2px 4px 8px 2px; }"); + s.addRule("p { margin-top: 3px; margin-bottom: 5px; }"); + s.addRule("a { color: " + hex(LINK) + "; text-decoration: none; }"); + for (int i = 1; i <= 6; i++) + { + String color = i <= 2 ? hex(HEADING) : "#FFCF5C"; + s.addRule("h" + i + " { color: " + color + "; font-family: " + font + "; font-size: 16pt; font-weight: bold;" + + " margin-top: " + (i <= 2 ? 10 : 6) + "px; margin-bottom: 3px; }"); + s.addRule("h" + i + " a { color: " + color + "; }"); + } + s.addRule("h2 { border-bottom-width: 1px; border-bottom-style: solid; border-color: #5B5144; }"); + s.addRule("table { border-color: #5B5144; margin-top: 2px; margin-bottom: 6px; }"); + s.addRule("th { color: " + hex(HEADING) + "; background-color: #2A241D; font-weight: bold; text-align: left; }"); + s.addRule("th a { color: " + hex(HEADING) + "; }"); + s.addRule("td { color: " + hex(TEXT) + "; }"); + s.addRule("caption { color: " + hex(HEADING) + "; font-weight: bold; }"); + s.addRule("ul { margin-left: 16px; margin-top: 2px; margin-bottom: 4px; }"); + s.addRule("ol { margin-left: 20px; margin-top: 2px; margin-bottom: 4px; }"); + s.addRule("li { margin-bottom: 1px; }"); + s.addRule("dt { color: #FFCF5C; }"); + s.addRule("dd { margin-left: 12px; }"); + s.addRule("small { font-family: " + small + "; font-size: 16pt; }"); + s.addRule("sup { font-family: " + small + "; font-size: 16pt; }"); + s.addRule("sub { font-family: " + small + "; font-size: 16pt; }"); + s.addRule("pre { font-family: " + font + "; font-size: 16pt; color: #FFFFFF; }"); + s.addRule("code { font-family: " + font + "; font-size: 16pt; color: #FFFFFF; }"); + s.addRule("b { color: #FFFFFF; }"); + s.addRule("strong { color: #FFFFFF; }"); + s.addRule("blockquote { margin-left: 12px; }"); + } + + void setDocument(HTMLDocument doc, int width) + { + pane.setDocument(doc); + layout(width); + } + + /** + * Text wraps at {@code width}; tables that cannot shrink that far stick out to the right, which makes + * {@link #contentWidth()} wider than the viewport (scrolled horizontally). + */ + void layout(int width) + { + View root = pane.getUI().getRootView(pane); + BodyView body = findBody(root); + if (body != null) + { + body.wrapWidth = width; + } + root.setSize(width, Integer.MAX_VALUE / 4f); + contentWidth = body == null ? width : Math.max(width, body.naturalMinimum); + root.setSize(contentWidth, Integer.MAX_VALUE / 4f); + height = (int) Math.ceil(root.getPreferredSpan(View.Y_AXIS)); + pane.setSize(contentWidth, height); + } + + private static BodyView findBody(View v) + { + if (v instanceof BodyView) + { + return (BodyView) v; + } + for (int i = 0; v != null && i < v.getViewCount(); i++) + { + BodyView b = findBody(v.getView(i)); + if (b != null) + { + return b; + } + } + return null; + } + + int height() + { + return height; + } + + int contentWidth() + { + return contentWidth; + } + + /** Paints the page area at ({@code scrollX}, {@code scrollY}) with the given size into a transparent image. */ + BufferedImage paint(int w, int h, int scrollX, int scrollY) + { + if (w <= 0 || h <= 0) + { + return null; + } + BufferedImage img = new BufferedImage(w, h, BufferedImage.TYPE_INT_ARGB); + Graphics2D g = img.createGraphics(); + try + { + g.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_OFF); + g.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_NEAREST_NEIGHBOR); + g.setClip(0, 0, w, h); + g.translate(-scrollX, -scrollY); + pane.paint(g); + } + finally + { + g.dispose(); + } + return img; + } + + /** The href under a point in page coordinates, or null. */ + String linkAt(int x, int y) + { + int pos = pane.viewToModel2D(new Point2D.Float(x, y)); + if (pos < 0 || !(pane.getDocument() instanceof HTMLDocument)) + { + return null; + } + try + { + Rectangle2D r = pane.modelToView2D(pos); + Rectangle2D next = pos + 1 < pane.getDocument().getLength() ? pane.modelToView2D(pos + 1) : null; + if (r == null) + { + return null; + } + double left = r.getX(); + double right = next != null && Math.abs(next.getY() - r.getY()) < 1 ? next.getX() : r.getX() + 8; + if (right < left) + { + right = left + 8; + } + // viewToModel snaps to the nearest character; only count real hits + if (x < left - 2 || x > right + 2 || y < r.getY() - 2 || y > r.getY() + Math.max(r.getHeight(), 16) + 2) + { + return linkNearImage(pos, x, y); + } + String href = href(((HTMLDocument) pane.getDocument()).getCharacterElement(pos)); + return href != null ? href : linkNearImage(pos, x, y); + } + catch (BadLocationException e) + { + return null; + } + } + + /** Images are single characters whose view can be much larger than a text glyph. */ + private String linkNearImage(int pos, int x, int y) throws BadLocationException + { + HTMLDocument doc = (HTMLDocument) pane.getDocument(); + for (int p = Math.max(0, pos - 1); p <= Math.min(doc.getLength() - 1, pos + 1); p++) + { + Element el = doc.getCharacterElement(p); + if (el.getAttributes().getAttribute(StyleConstants.NameAttribute) != HTML.Tag.IMG) + { + continue; + } + Rectangle2D r = pane.modelToView2D(p); + View v = viewFor(el); + if (r != null && v != null) + { + Rectangle box = new Rectangle((int) r.getX(), (int) r.getY(), (int) v.getPreferredSpan(View.X_AXIS), + (int) v.getPreferredSpan(View.Y_AXIS)); + if (box.contains(x, y)) + { + return href(el); + } + } + } + return null; + } + + private View viewFor(Element el) + { + View v = pane.getUI().getRootView(pane); + while (v != null && v.getElement() != el) + { + int i = v.getViewIndex(el.getStartOffset(), javax.swing.text.Position.Bias.Forward); + if (i < 0) + { + return null; + } + v = v.getView(i); + } + return v; + } + + private static String href(Element el) + { + Object a = el.getAttributes().getAttribute(HTML.Tag.A); + if (a instanceof AttributeSet) + { + Object href = ((AttributeSet) a).getAttribute(HTML.Attribute.HREF); + return href == null ? null : href.toString(); + } + return null; + } + + /** The h2/h3 headings of the page with their anchors and positions, in page order. */ + java.util.List sections() + { + java.util.List out = new java.util.ArrayList<>(); + if (!(pane.getDocument() instanceof HTMLDocument)) + { + return out; + } + HTMLDocument doc = (HTMLDocument) pane.getDocument(); + java.util.Set seen = new java.util.HashSet<>(); + for (HTMLDocument.Iterator it = doc.getIterator(HTML.Tag.A); it.isValid(); it.next()) + { + Object n = it.getAttributes().getAttribute(HTML.Attribute.NAME); + if (n == null || !seen.add(n.toString())) + { + continue; + } + Element para = doc.getParagraphElement(it.getStartOffset()); + Object tag = para.getAttributes().getAttribute(StyleConstants.NameAttribute); + int level = tag == HTML.Tag.H2 ? 2 : tag == HTML.Tag.H3 ? 3 : 0; + if (level == 0) + { + continue; + } + try + { + String text = doc.getText(para.getStartOffset(), para.getEndOffset() - para.getStartOffset()).trim(); + Rectangle2D r = pane.modelToView2D(it.getStartOffset()); + if (!text.isEmpty() && r != null) + { + out.add(new WikiWindow.Section(text, n.toString(), level, (int) r.getY())); + } + } + catch (BadLocationException ignored) + { + // heading vanished + } + } + out.sort(java.util.Comparator.comparingInt(sec -> sec.y)); + return out; + } + + /** Y position of a named anchor ({@code }), or -1. */ + int anchorY(String name) + { + if (!(pane.getDocument() instanceof HTMLDocument)) + { + return -1; + } + HTMLDocument doc = (HTMLDocument) pane.getDocument(); + for (HTMLDocument.Iterator it = doc.getIterator(HTML.Tag.A); it.isValid(); it.next()) + { + Object n = it.getAttributes().getAttribute(HTML.Attribute.NAME); + if (n != null && n.toString().equals(name)) + { + try + { + Rectangle2D r = pane.modelToView2D(it.getStartOffset()); + return r == null ? -1 : (int) r.getY(); + } + catch (BadLocationException e) + { + return -1; + } + } + } + return -1; + } + + private static final class Kit extends HTMLEditorKit + { + private static final java.util.Set LENIENT = java.util.Set.of(HTML.Tag.DIV, HTML.Tag.CENTER, + HTML.Tag.BLOCKQUOTE, HTML.Tag.DL, HTML.Tag.DD, HTML.Tag.DT); + + private final ViewFactory factory = new HTMLFactory() + { + @Override + public View create(Element el) + { + Object tag = el.getAttributes().getAttribute(StyleConstants.NameAttribute); + if (tag == HTML.Tag.BODY) + { + return new BodyView(el); + } + if (tag instanceof HTML.Tag && LENIENT.contains(tag)) + { + return new LenientBlockView(el); + } + return super.create(el); + } + }; + + @Override + public ViewFactory getViewFactory() + { + return factory; + } + } + + /** + * Block that reports no minimum width, so a wide table inside it cannot widen the surrounding text; + * the real minimum is kept in {@link #naturalMinimum} to size horizontal scrolling. + */ + private static class LenientBlockView extends BlockView + { + int naturalMinimum; + + LenientBlockView(Element el) + { + super(el, View.Y_AXIS); + } + + @Override + protected SizeRequirements calculateMinorAxisRequirements(int axis, SizeRequirements r) + { + SizeRequirements req = super.calculateMinorAxisRequirements(axis, r); + int natural = 0; + for (int i = 0; i < getViewCount(); i++) + { + View child = getView(i); + int min = child instanceof LenientBlockView ? ((LenientBlockView) child).naturalMinimum : (int) child.getMinimumSpan(axis); + natural = Math.max(natural, min); + } + naturalMinimum = natural + (int) (axis == View.X_AXIS ? getLeftInset() + getRightInset() : getTopInset() + getBottomInset()); + req.minimum = 0; + return req; + } + } + + /** Body that wraps its text at {@link #wrapWidth} even when a wide table gets a larger allocation. */ + private static final class BodyView extends LenientBlockView + { + int wrapWidth = Integer.MAX_VALUE; + + BodyView(Element el) + { + super(el); + } + + @Override + protected void layoutMinorAxis(int targetSpan, int axis, int[] offsets, int[] spans) + { + super.layoutMinorAxis(Math.min(targetSpan, wrapWidth), axis, offsets, spans); + } + } + + private static String family(Font f) + { + return "\"" + f.getFamily() + "\""; + } + + private static String hex(Color c) + { + return String.format("#%02X%02X%02X", c.getRed(), c.getGreen(), c.getBlue()); + } +} diff --git a/src/main/java/com/example/essentialsplus/wiki/WikiHtml.java b/src/main/java/com/example/essentialsplus/wiki/WikiHtml.java new file mode 100644 index 0000000..064cc48 --- /dev/null +++ b/src/main/java/com/example/essentialsplus/wiki/WikiHtml.java @@ -0,0 +1,344 @@ +package com.example.essentialsplus.wiki; + +import java.awt.image.BufferedImage; +import java.util.ArrayDeque; +import java.util.Deque; +import java.util.LinkedHashSet; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.HashMap; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Rewrites wiki page html into the small HTML 3.2 subset Swing can render: drops scripts, styles, + * navboxes and JS-only widgets, makes links and images absolute and strips unsupported attributes. + */ +final class WikiHtml +{ + private static final Pattern COMMENT = Pattern.compile("", Pattern.DOTALL); + private static final Pattern TAG = Pattern.compile("<(/?)([a-zA-Z][a-zA-Z0-9]*)((?:\"[^\"]*\"|'[^']*'|[^'\">])*)>"); + private static final Pattern ATTR = Pattern.compile("([a-zA-Z_:][-a-zA-Z0-9_:.]*)\\s*=\\s*(?:\"([^\"]*)\"|'([^']*)'|([^\\s>]+))"); + private static final Pattern ANY_TAG = Pattern.compile("<[^>]*>"); + private static final Pattern EMPTY_ROW = Pattern.compile("\\s*"); + + private static final Set VOID = Set.of("img", "br", "hr", "input", "meta", "link", "col", "wbr", "source", "area", "track", "embed"); + /** Dropped together with everything inside. */ + private static final Set DROP_TAGS = Set.of("script", "style", "noscript", "audio", "video", "svg", "math", + "button", "select", "textarea", "form", "iframe", "object", "template", "input", "link", "meta", "colgroup", "col"); + private static final Set DROP_CLASSES = Set.of("navbox", "noprint", "mw-editsection", "reference", "references", + "mw-references-wrap", "reflist", "toc", "rs-external-header-links", "navigation-not-searchable", "hidden", "printfooter", + "catlinks", "infobox-buttons", "mw-empty-elt", "mw-cite-backlink", "mbox-image", "noexcerpt-hidden", "GEChartBox", + "GEdatachart", "rsw-chart", "embed-audio", "audio-player", "sm2-container", "tabbernav", "mw-collapsible-toggle", + "skinchooser", "external-header-links", "infobox-padding", "mw-headline-number", "sortkey"); + /** Kept as they are (with filtered attributes). */ + private static final Set KEEP = Set.of("a", "b", "strong", "i", "em", "u", "s", "strike", "p", "br", "hr", + "h1", "h2", "h3", "h4", "h5", "h6", "ul", "ol", "li", "dl", "dt", "dd", "table", "tr", "td", "th", "caption", "img", + "div", "span", "pre", "code", "sup", "sub", "center", "blockquote", "small", "big", "tt", "kbd", "var", "cite"); + /** Unknown to Swing, rendered as another tag. */ + private static final Map RENAME = Map.of("figure", "div", "figcaption", "div", "section", "div", + "article", "div", "aside", "div", "header", "div", "footer", "div", "abbr", "span", "q", "i", "del", "s"); + + private WikiHtml() + { + } + + static final class Result + { + final String html; + final Set images; + + Result(String html, Set images) + { + this.html = html; + this.images = images; + } + } + + /** + * @param maxImageWidth wider images are scaled down to this width + * @param loaded downloaded images by absolute url; only these stay in the output. {@code null} keeps all + * (first pass, to collect the urls) + */ + static Result sanitize(String raw, int maxImageWidth, Map loaded) + { + String html = COMMENT.matcher(raw == null ? "" : raw).replaceAll(""); + StringBuilder out = new StringBuilder(html.length()); + Set images = new LinkedHashSet<>(); + Deque closers = new ArrayDeque<>(); + int skip = 0; + Matcher m = TAG.matcher(html); + int last = 0; + while (m.find()) + { + if (skip == 0) + { + out.append(html, last, m.start()); + } + last = m.end(); + + boolean closing = !m.group(1).isEmpty(); + String name = m.group(2).toLowerCase(Locale.ENGLISH); + String attrText = m.group(3); + boolean selfClosing = attrText.endsWith("/") || VOID.contains(name); + + if (skip > 0) + { + if (!selfClosing) + { + skip += closing ? -1 : 1; + } + continue; + } + + if (closing) + { + if (VOID.contains(name)) + { + continue; + } + String closer = closers.poll(); + if (closer != null) + { + out.append(closer); + } + continue; + } + + Map attrs = attributes(attrText); + if (shouldDrop(name, attrs)) + { + if (!selfClosing) + { + skip = 1; + } + continue; + } + + String tag = KEEP.contains(name) ? name : RENAME.get(name); + if (tag == null) + { + // unknown wrapper (tbody, thead, bdi, ...): drop the tag, keep its content + if (!selfClosing) + { + closers.push(""); + } + continue; + } + + if (tag.equals("img")) + { + appendImage(out, attrs, maxImageWidth, loaded, images); + continue; + } + if (selfClosing) + { + out.append('<').append(tag).append('>'); + continue; + } + + StringBuilder open = new StringBuilder("<").append(tag); + String closer = ""; + switch (tag) + { + case "a": + String href = link(attrs.get("href"), attrs.get("class")); + if (href != null) + { + open.append(" href=\"").append(escape(href)).append('"'); + String title = attrs.get("title"); + if (title != null) + { + open.append(" title=\"").append(escape(title)).append('"'); + } + } + break; + case "table": + String cls = attrs.getOrDefault("class", ""); + boolean bordered = cls.contains("wikitable") || cls.contains("infobox") || cls.contains("lighttable"); + open.append(bordered ? " border=\"1\" cellspacing=\"0\" cellpadding=\"3\"" : " cellspacing=\"0\" cellpadding=\"2\""); + if (cls.contains("infobox")) + { + open.append(" class=\"infobox\""); + } + break; + case "td": + case "th": + copy(attrs, open, "colspan", "rowspan", "align", "valign"); + break; + case "div": + case "p": + String style = attrs.getOrDefault("style", "").toLowerCase(Locale.ENGLISH); + if (style.contains("text-align:center") || style.contains("text-align: center")) + { + open.append(" align=\"center\""); + } + break; + default: + break; + } + open.append('>'); + + // headings keep their id as an anchor so "#section" links can scroll to them + String id = attrs.get("id"); + if (id != null && (tag.charAt(0) == 'h' && tag.length() == 2 || tag.equals("span"))) + { + open.append(""); + closer = "" + closer; + } + out.append(open); + closers.push(closer); + } + if (skip == 0) + { + out.append(html.substring(last)); + } + while (!closers.isEmpty()) + { + out.append(closers.pop()); + } + String body = EMPTY_ROW.matcher(out).replaceAll(""); + return new Result("" + body + "", images); + } + + private static boolean shouldDrop(String name, Map attrs) + { + if (DROP_TAGS.contains(name)) + { + return true; + } + String style = attrs.get("style"); + if (style != null && style.replace(" ", "").toLowerCase(Locale.ENGLISH).contains("display:none")) + { + return true; + } + String cls = attrs.get("class"); + if (cls != null) + { + for (String c : cls.split("\\s+")) + { + if (DROP_CLASSES.contains(c)) + { + return true; + } + } + } + return false; + } + + private static void appendImage(StringBuilder out, Map attrs, int maxWidth, Map loaded, Set images) + { + String src = absolute(attrs.get("src")); + if (src == null || src.toLowerCase(Locale.ENGLISH).contains(".svg")) + { + return; + } + images.add(src); + BufferedImage img = loaded == null ? null : loaded.get(src); + if (loaded != null && img == null) + { + return; + } + int w = parseInt(attrs.get("width")); + int h = parseInt(attrs.get("height")); + if ((w <= 0 || h <= 0) && img != null) + { + w = img.getWidth(); + h = img.getHeight(); + } + if (w > maxWidth && maxWidth > 0 && h > 0) + { + h = Math.max(1, h * maxWidth / w); + w = maxWidth; + } + out.append(" 0 && h > 0) + { + out.append(" width=\"").append(w).append("\" height=\"").append(h).append('"'); + } + out.append(" border=\"0\">"); + } + + /** Absolute link target, or null for links that lead nowhere useful (red links, edit links). */ + private static String link(String href, String cls) + { + if (href == null || (cls != null && (" " + cls + " ").contains(" new ")) || href.contains("action=edit")) + { + return null; + } + if (href.startsWith("#")) + { + return href; + } + return absolute(href); + } + + static String absolute(String url) + { + if (url == null || url.isEmpty()) + { + return null; + } + url = url.replace("&", "&"); + if (url.startsWith("//")) + { + return "https:" + url; + } + if (url.startsWith("/")) + { + return WikiApi.BASE + url; + } + return url; + } + + private static void copy(Map attrs, StringBuilder open, String... names) + { + for (String n : names) + { + String v = attrs.get(n); + if (v != null && !v.isEmpty()) + { + open.append(' ').append(n).append("=\"").append(escape(v)).append('"'); + } + } + } + + private static Map attributes(String text) + { + Map map = new HashMap<>(); + Matcher m = ATTR.matcher(text); + while (m.find()) + { + String v = m.group(2) != null ? m.group(2) : m.group(3) != null ? m.group(3) : m.group(4); + map.putIfAbsent(m.group(1).toLowerCase(Locale.ENGLISH), v); + } + return map; + } + + private static int parseInt(String s) + { + try + { + return s == null ? 0 : Integer.parseInt(s.trim()); + } + catch (NumberFormatException e) + { + return 0; + } + } + + private static String escape(String s) + { + return s.replace("&", "&").replace("\"", """).replace("<", "<").replace(">", ">"); + } + + /** Tags removed and the common entities decoded, for search snippets. */ + static String plainText(String html) + { + return ANY_TAG.matcher(html).replaceAll("") + .replace(""", "\"").replace("'", "'").replace("'", "'") + .replace("<", "<").replace(">", ">").replace(" ", " ").replace("&", "&") + .replaceAll("\\s+", " ").trim(); + } +} diff --git a/src/main/java/com/example/essentialsplus/wiki/WikiOverlay.java b/src/main/java/com/example/essentialsplus/wiki/WikiOverlay.java new file mode 100644 index 0000000..488dc8c --- /dev/null +++ b/src/main/java/com/example/essentialsplus/wiki/WikiOverlay.java @@ -0,0 +1,582 @@ +package com.example.essentialsplus.wiki; + +import java.awt.Color; +import java.awt.Dimension; +import java.awt.Font; +import java.awt.FontMetrics; +import java.awt.Graphics2D; +import java.awt.Point; +import java.awt.Rectangle; +import java.awt.RenderingHints; +import java.awt.Shape; +import java.awt.image.BufferedImage; +import net.runelite.api.gameval.SpriteID; +import net.runelite.client.game.SpriteManager; +import net.runelite.client.ui.FontManager; +import net.runelite.client.ui.overlay.Overlay; +import net.runelite.client.ui.overlay.OverlayLayer; +import net.runelite.client.ui.overlay.OverlayPosition; +import net.runelite.client.util.ImageUtil; + +/** Draws the wiki window in the style of the game's own interfaces (steel border, dark backing, stone buttons). */ +public class WikiOverlay extends Overlay +{ + private static final Color BACKING = new Color(0x3E3529); + private static final Color BORDER_DARK = new Color(0x1A1612); + private static final Color BORDER_LIGHT = new Color(0x6B5F4D); + private static final Color ORANGE = new Color(0xFF981F); + private static final Color SHADOW = Color.BLACK; + private static final Color BUTTON = new Color(0x4A4034); + private static final Color BUTTON_HOVER = new Color(0x5C5042); + private static final Color BUTTON_DOWN = new Color(0x362F26); + private static final Color TRACK = new Color(0x2B251E); + private static final Color TOOLTIP_BG = new Color(0xFFFFA0); + + private static final int[] CORNER_SPRITES = { + SpriteID.Steelborder.TOP_LEFT, SpriteID.Steelborder.TOP_RIGHT, SpriteID.Steelborder.BOTTOM_LEFT, SpriteID.Steelborder.BOTTOM_RIGHT + }; + + private final SpriteManager spriteManager; + private final WikiWindows windows; + private final BufferedImage[] border = new BufferedImage[8]; + private BufferedImage backing; + private BufferedImage close; + private BufferedImage closeHover; + private BufferedImage arrowUp; + private BufferedImage arrowDown; + private BufferedImage thumbTop; + private BufferedImage thumbMiddle; + private BufferedImage thumbBottom; + private BufferedImage track; + + public WikiOverlay(SpriteManager spriteManager, WikiWindows windows) + { + this.spriteManager = spriteManager; + this.windows = windows; + setPosition(OverlayPosition.DYNAMIC); + setLayer(OverlayLayer.ALWAYS_ON_TOP); + setPriority(Overlay.PRIORITY_HIGHEST); + setMovable(false); + setResettable(false); + } + + @Override + public Dimension render(Graphics2D g) + { + java.util.List open = windows.windows(); + if (open.isEmpty()) + { + return null; + } + loadSprites(); + for (WikiWindow window : open) + { + if (window.isOpen()) + { + drawWindow(g, window); + } + } + return null; + } + + private void drawWindow(Graphics2D g, WikiWindow window) + { + window.ensureOnScreen(); + window.lastDrawn = System.currentTimeMillis(); + + Font small = FontManager.getRunescapeSmallFont(); + g.setFont(small); + window.externalWidth = g.getFontMetrics().stringWidth("Open in browser") + 14; + if (border[0] != null && border[4] != null && border[5] != null) + { + window.border = Math.max(4, Math.min(24, Math.max(border[4].getWidth(), border[5].getHeight()))); + window.corner = Math.min(40, border[0].getWidth()); + } + + g.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_OFF); + g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_OFF); + + WikiWindow.Layout l = window.layout(); + Rectangle b = l.bounds; + WikiWindow.Part hover = window.hover; + WikiWindow.Part pressed = window.pressed; + + // backing + Shape oldClip = g.getClip(); + g.setColor(BACKING); + g.fillRect(b.x, b.y, b.width, b.height); + if (backing != null) + { + g.clipRect(b.x, b.y, b.width, b.height); + tile(g, backing, b.x, b.y, b.width, b.height); + g.setClip(oldClip); + } + + // title bar + g.setColor(BORDER_DARK); + g.drawLine(l.title.x, l.title.y + l.title.height, l.title.x + l.title.width - 1, l.title.y + l.title.height); + g.setColor(BORDER_LIGHT); + g.drawLine(l.title.x, l.title.y + l.title.height + 1, l.title.x + l.title.width - 1, l.title.y + l.title.height + 1); + + Font bold = FontManager.getRunescapeBoldFont(); + g.setFont(bold); + FontMetrics fm = g.getFontMetrics(); + int titleLeft = (window.canGoBack ? l.back.x + l.back.width : l.title.x) + 8; + int titleRight = l.external.x - 8; + String title = ellipsize(fm, window.title, titleRight - titleLeft); + int tx = Math.max(titleLeft, l.title.x + (l.title.width - fm.stringWidth(title)) / 2); + if (tx + fm.stringWidth(title) > titleRight) + { + tx = titleLeft; + } + text(g, title, tx, l.title.y + (l.title.height + fm.getAscent()) / 2 - 1, ORANGE); + + g.setFont(small); + if (window.canGoBack) + { + button(g, l.back, "Back", hover == WikiWindow.Part.BACK, pressed == WikiWindow.Part.BACK); + } + button(g, l.external, "Open in browser", hover == WikiWindow.Part.EXTERNAL, pressed == WikiWindow.Part.EXTERNAL); + BufferedImage closeImg = hover == WikiWindow.Part.CLOSE && closeHover != null ? closeHover : close; + if (closeImg != null) + { + g.drawImage(closeImg, l.close.x + (l.close.width - closeImg.getWidth()) / 2, l.close.y + (l.close.height - closeImg.getHeight()) / 2, null); + } + else + { + button(g, l.close, "X", hover == WikiWindow.Part.CLOSE, pressed == WikiWindow.Part.CLOSE); + } + + // content + g.setColor(new Color(0, 0, 0, 40)); + g.fillRect(l.content.x, l.content.y, l.content.width, l.content.height); + BufferedImage frame = window.frame; + String status = window.status; + if (frame != null && status == null) + { + g.clipRect(l.content.x, l.content.y, l.content.width, l.content.height); + g.drawImage(frame, l.content.x, l.content.y, null); + g.setClip(oldClip); + } + if (status != null) + { + g.setFont(FontManager.getRunescapeFont()); + FontMetrics sfm = g.getFontMetrics(); + String s = ellipsize(sfm, status, l.content.width - 20); + text(g, s, l.content.x + (l.content.width - sfm.stringWidth(s)) / 2, l.content.y + l.content.height / 2, ORANGE); + } + + drawToc(g, window, l); + + // scrollbars + drawVerticalBar(g, window, l); + if (l.hbar != null) + { + drawHorizontalBar(g, window, l); + } + + drawBorder(g, b); + drawGrip(g, l.grip, hover == WikiWindow.Part.GRIP); + + String link = window.hoverLink; + if (link != null && hover == WikiWindow.Part.CONTENT) + { + drawTooltip(g, describe(link), window.mouse, b); + } + } + + /** Contents bar: section chips below the title, the one being read highlighted. */ + private static void drawToc(Graphics2D g, WikiWindow window, WikiWindow.Layout l) + { + WikiWindow.Toc toc = window.toc; + if (toc.height <= 0) + { + return; + } + java.util.List sections = window.sections; + int current = WikiWindow.currentSection(sections, window.scrollY, true); + int currentMain = WikiWindow.currentSection(sections, window.scrollY, false); + int hoverChip = window.hoverChip; + Shape clip = g.getClip(); + g.clipRect(l.toc.x, l.toc.y, l.toc.width, l.toc.height); + g.setFont(FontManager.getRunescapeSmallFont()); + FontMetrics fm = g.getFontMetrics(); + for (int i = 0; i < toc.chips.size(); i++) + { + WikiWindow.Chip chip = toc.chips.get(i); + Rectangle r = new Rectangle(l.toc.x + chip.rect.x, l.toc.y + chip.rect.y, chip.rect.width, chip.rect.height); + boolean hover = i == hoverChip; + int ty = r.y + (r.height + fm.getAscent()) / 2 - 1; + if (chip.section == WikiWindow.CHIP_LABEL) + { + String name = "Contents"; + text(g, name, r.x + 2, ty, ORANGE); + String toggle = chip.label.substring(name.length()); + text(g, toggle, r.x + 2 + fm.stringWidth(name), ty, hover ? Color.WHITE : new Color(0xC8B48C)); + continue; + } + boolean active = chip.section >= 0 && (chip.section == current || chip.section == currentMain); + boolean sub = chip.section >= 0 && chip.section < sections.size() && sections.get(chip.section).level > 2; + g.setColor(active ? new Color(0x6A4F22) : hover ? BUTTON_HOVER : sub ? new Color(0x3A3229) : BUTTON); + g.fillRect(r.x, r.y, r.width, r.height); + bevel(g, r.x, r.y, r.width, r.height, active); + Color color = hover ? Color.WHITE : chip.section < 0 ? new Color(0xC8B48C) : sub ? new Color(0xE4D8BC) : ORANGE; + String label = ellipsize(fm, chip.label, r.width - 8); + text(g, label, r.x + (r.width - fm.stringWidth(label)) / 2, ty, active ? Color.WHITE : color); + } + g.setClip(clip); + } + + private void loadSprites() + { + if (spriteManager == null) + { + return; + } + for (int i = 0; i < CORNER_SPRITES.length; i++) + { + if (border[i] == null) + { + border[i] = spriteManager.getSprite(CORNER_SPRITES[i], 0); + } + } + // the game draws the bottom edge as the flipped top edge and the left edge as the flipped right edge + if (border[5] == null) + { + BufferedImage top = spriteManager.getSprite(SpriteID.Steelborder2.EDGE_TOP, 0); + if (top != null) + { + border[5] = top; + border[7] = ImageUtil.flipImage(top, false, true); + } + } + if (border[6] == null) + { + BufferedImage right = spriteManager.getSprite(SpriteID.Steelborder2.EDGE_RIGHT, 0); + if (right != null) + { + border[6] = right; + border[4] = ImageUtil.flipImage(right, true, false); + } + } + if (backing == null) + { + backing = spriteManager.getSprite(SpriteID.TRADEBACKING_DARK, 0); + } + if (close == null) + { + close = spriteManager.getSprite(SpriteID.CloseButtons.BUTTON, 0); + } + if (closeHover == null) + { + closeHover = spriteManager.getSprite(SpriteID.CloseButtons.HOVERED, 0); + } + if (arrowUp == null) + { + arrowUp = spriteManager.getSprite(SpriteID.ScrollbarV2.ARROW_UP, 0); + } + if (arrowDown == null) + { + arrowDown = spriteManager.getSprite(SpriteID.ScrollbarV2.ARROW_DOWN, 0); + } + if (thumbTop == null) + { + thumbTop = spriteManager.getSprite(SpriteID.ScrollbarDraggerV2.TOP, 0); + } + if (thumbMiddle == null) + { + thumbMiddle = spriteManager.getSprite(SpriteID.ScrollbarDraggerV2.MIDDLE, 0); + } + if (thumbBottom == null) + { + thumbBottom = spriteManager.getSprite(SpriteID.ScrollbarDraggerV2.BOTTOM, 0); + } + if (track == null) + { + track = spriteManager.getSprite(SpriteID.ScrollbarDraggerV2.TRACK, 0); + } + } + + private void drawBorder(Graphics2D g, Rectangle b) + { + BufferedImage tl = border[0]; + BufferedImage tr = border[1]; + BufferedImage bl = border[2]; + BufferedImage br = border[3]; + BufferedImage left = border[4]; + BufferedImage top = border[5]; + BufferedImage right = border[6]; + BufferedImage bottom = border[7]; + if (tl == null || tr == null || bl == null || br == null || left == null || top == null || right == null || bottom == null) + { + g.setColor(BORDER_DARK); + g.drawRect(b.x, b.y, b.width - 1, b.height - 1); + g.setColor(BORDER_LIGHT); + g.drawRect(b.x + 1, b.y + 1, b.width - 3, b.height - 3); + g.setColor(BORDER_DARK); + g.drawRect(b.x + 2, b.y + 2, b.width - 5, b.height - 5); + return; + } + Shape clip = g.getClip(); + g.clipRect(b.x, b.y, b.width, b.height); + + clipped(g, b.x + tl.getWidth(), b.y, b.width - tl.getWidth() - tr.getWidth(), top.getHeight(), + () -> tile(g, top, b.x + tl.getWidth(), b.y, b.width - tl.getWidth() - tr.getWidth(), top.getHeight())); + clipped(g, b.x + bl.getWidth(), b.y + b.height - bottom.getHeight(), b.width - bl.getWidth() - br.getWidth(), bottom.getHeight(), + () -> tile(g, bottom, b.x + bl.getWidth(), b.y + b.height - bottom.getHeight(), b.width - bl.getWidth() - br.getWidth(), bottom.getHeight())); + clipped(g, b.x, b.y + tl.getHeight(), left.getWidth(), b.height - tl.getHeight() - bl.getHeight(), + () -> tile(g, left, b.x, b.y + tl.getHeight(), left.getWidth(), b.height - tl.getHeight() - bl.getHeight())); + clipped(g, b.x + b.width - right.getWidth(), b.y + tr.getHeight(), right.getWidth(), b.height - tr.getHeight() - br.getHeight(), + () -> tile(g, right, b.x + b.width - right.getWidth(), b.y + tr.getHeight(), right.getWidth(), b.height - tr.getHeight() - br.getHeight())); + + g.drawImage(tl, b.x, b.y, null); + g.drawImage(tr, b.x + b.width - tr.getWidth(), b.y, null); + g.drawImage(bl, b.x, b.y + b.height - bl.getHeight(), null); + g.drawImage(br, b.x + b.width - br.getWidth(), b.y + b.height - br.getHeight(), null); + g.setClip(clip); + } + + private static void clipped(Graphics2D g, int x, int y, int w, int h, Runnable paint) + { + if (w <= 0 || h <= 0) + { + return; + } + Shape before = g.getClip(); + g.clipRect(x, y, w, h); + paint.run(); + g.setClip(before); + } + + private void drawVerticalBar(Graphics2D g, WikiWindow window, WikiWindow.Layout l) + { + Rectangle bar = l.vbar; + int trackStart = bar.y + WikiWindow.SCROLL_W; + int trackLen = bar.height - WikiWindow.SCROLL_W * 2; + paintTrack(g, bar.x, trackStart, bar.width, trackLen, true); + arrow(g, arrowUp, bar.x, bar.y, 0); + arrow(g, arrowDown, bar.x, bar.y + bar.height - WikiWindow.SCROLL_W, 2); + if (window.contentHeight > l.content.height) + { + WikiWindow.Thumb t = WikiWindow.thumb(trackStart, trackLen, l.content.height, window.contentHeight, window.scrollY); + paintThumb(g, bar.x, t.pos, bar.width, t.size, true, window.hover == WikiWindow.Part.THUMB); + } + } + + private void drawHorizontalBar(Graphics2D g, WikiWindow window, WikiWindow.Layout l) + { + Rectangle bar = l.hbar; + int trackStart = bar.x + WikiWindow.SCROLL_W; + int trackLen = bar.width - WikiWindow.SCROLL_W * 2; + paintTrack(g, trackStart, bar.y, trackLen, bar.height, false); + arrow(g, arrowUp, bar.x, bar.y, 3); + arrow(g, arrowDown, bar.x + bar.width - WikiWindow.SCROLL_W, bar.y, 1); + WikiWindow.Thumb t = WikiWindow.thumb(trackStart, trackLen, l.content.width, window.contentWidth, window.scrollX); + paintThumb(g, t.pos, bar.y, t.size, bar.height, false, window.hover == WikiWindow.Part.HTHUMB); + } + + private void paintTrack(Graphics2D g, int x, int y, int w, int h, boolean vertical) + { + if (w <= 0 || h <= 0) + { + return; + } + g.setColor(TRACK); + g.fillRect(x, y, w, h); + if (track != null && vertical) + { + Shape clip = g.getClip(); + g.clipRect(x, y, w, h); + tile(g, track, x, y, w, h); + g.setClip(clip); + } + } + + /** + * @param quarterTurns rotation of the "up" style arrow: 0 up, 1 right, 2 down, 3 left + */ + private static void arrow(Graphics2D g, BufferedImage sprite, int x, int y, int quarterTurns) + { + int s = WikiWindow.SCROLL_W; + if (sprite != null && (quarterTurns == 0 || quarterTurns == 2)) + { + g.drawImage(sprite, x, y, s, s, null); + return; + } + g.setColor(BUTTON); + g.fillRect(x, y, s, s); + bevel(g, x, y, s, s, false); + g.setColor(ORANGE); + int cx = x + s / 2; + int cy = y + s / 2; + int[] xs; + int[] ys; + switch (quarterTurns) + { + case 1: + xs = new int[]{cx - 2, cx + 3, cx - 2}; + ys = new int[]{cy - 4, cy, cy + 4}; + break; + case 3: + xs = new int[]{cx + 2, cx - 3, cx + 2}; + ys = new int[]{cy - 4, cy, cy + 4}; + break; + case 2: + xs = new int[]{cx - 4, cx, cx + 4}; + ys = new int[]{cy - 2, cy + 3, cy - 2}; + break; + default: + xs = new int[]{cx - 4, cx, cx + 4}; + ys = new int[]{cy + 2, cy - 3, cy + 2}; + break; + } + g.fillPolygon(xs, ys, 3); + } + + private void paintThumb(Graphics2D g, int x, int y, int w, int h, boolean vertical, boolean hover) + { + if (vertical && thumbTop != null && thumbMiddle != null && thumbBottom != null && h > thumbTop.getHeight() + thumbBottom.getHeight()) + { + Shape clip = g.getClip(); + int midY = y + thumbTop.getHeight(); + int midH = h - thumbTop.getHeight() - thumbBottom.getHeight(); + g.clipRect(x, midY, w, midH); + tile(g, thumbMiddle, x, midY, w, midH); + g.setClip(clip); + g.drawImage(thumbTop, x, y, w, thumbTop.getHeight(), null); + g.drawImage(thumbBottom, x, y + h - thumbBottom.getHeight(), w, thumbBottom.getHeight(), null); + if (hover) + { + g.setColor(new Color(255, 255, 255, 30)); + g.fillRect(x, y, w, h); + } + return; + } + g.setColor(hover ? BUTTON_HOVER : BUTTON); + g.fillRect(x, y, w, h); + bevel(g, x, y, w, h, false); + } + + private static void drawGrip(Graphics2D g, Rectangle r, boolean hover) + { + g.setColor(hover ? ORANGE : BORDER_LIGHT); + for (int i = 0; i < 3; i++) + { + int off = 4 + i * 3; + g.drawLine(r.x + r.width - 3, r.y + r.height - off, r.x + r.width - off, r.y + r.height - 3); + } + } + + private static void button(Graphics2D g, Rectangle r, String label, boolean hover, boolean down) + { + g.setColor(down ? BUTTON_DOWN : hover ? BUTTON_HOVER : BUTTON); + g.fillRect(r.x, r.y, r.width, r.height); + bevel(g, r.x, r.y, r.width, r.height, down); + FontMetrics fm = g.getFontMetrics(); + int tx = r.x + (r.width - fm.stringWidth(label)) / 2 + (down ? 1 : 0); + int ty = r.y + (r.height + fm.getAscent()) / 2 - 1 + (down ? 1 : 0); + text(g, label, tx, ty, hover ? Color.WHITE : ORANGE); + } + + private static void bevel(Graphics2D g, int x, int y, int w, int h, boolean inset) + { + g.setColor(BORDER_DARK); + g.drawRect(x, y, w - 1, h - 1); + g.setColor(inset ? new Color(0x2A241C) : BORDER_LIGHT); + g.drawLine(x + 1, y + 1, x + w - 2, y + 1); + g.drawLine(x + 1, y + 1, x + 1, y + h - 2); + g.setColor(inset ? BORDER_LIGHT : new Color(0x2A241C)); + g.drawLine(x + 1, y + h - 2, x + w - 2, y + h - 2); + g.drawLine(x + w - 2, y + 1, x + w - 2, y + h - 2); + } + + private void drawTooltip(Graphics2D g, String text, Point mouse, Rectangle window) + { + g.setFont(FontManager.getRunescapeSmallFont()); + FontMetrics fm = g.getFontMetrics(); + int w = fm.stringWidth(text) + 8; + int h = fm.getHeight() + 2; + int x = Math.min(mouse.x + 12, window.x + window.width - w - 4); + int y = mouse.y + 20; + if (y + h > window.y + window.height - 4) + { + y = mouse.y - h - 6; + } + x = Math.max(window.x + 4, x); + g.setColor(TOOLTIP_BG); + g.fillRect(x, y, w, h); + g.setColor(Color.BLACK); + g.drawRect(x, y, w - 1, h - 1); + g.drawString(text, x + 4, y + fm.getAscent() + 1); + } + + /** Readable link target for the hover tooltip. */ + private static String describe(String href) + { + String internal = WikiApi.BASE + "/w/"; + if (href.startsWith(internal)) + { + String t = href.substring(internal.length()); + try + { + t = java.net.URLDecoder.decode(t.replace("+", "%2B"), java.nio.charset.StandardCharsets.UTF_8); + } + catch (IllegalArgumentException ignored) + { + // keep encoded + } + return t.replace('_', ' ').replace("#", " > "); + } + if (href.startsWith("#")) + { + return "Section: " + href.substring(1).replace('_', ' '); + } + return href + " (browser)"; + } + + private static void tile(Graphics2D g, BufferedImage img, int x, int y, int w, int h) + { + int iw = img.getWidth(); + int ih = img.getHeight(); + if (iw <= 0 || ih <= 0) + { + return; + } + for (int ty = y; ty < y + h; ty += ih) + { + for (int tx = x; tx < x + w; tx += iw) + { + g.drawImage(img, tx, ty, null); + } + } + } + + private static void text(Graphics2D g, String s, int x, int y, Color color) + { + g.setColor(SHADOW); + g.drawString(s, x + 1, y + 1); + g.setColor(color); + g.drawString(s, x, y); + } + + private static String ellipsize(FontMetrics fm, String s, int max) + { + if (s == null) + { + return ""; + } + if (fm.stringWidth(s) <= max) + { + return s; + } + for (int i = s.length() - 1; i > 0; i--) + { + String t = s.substring(0, i) + "..."; + if (fm.stringWidth(t) <= max) + { + return t; + } + } + return ""; + } +} diff --git a/src/main/java/com/example/essentialsplus/wiki/WikiWindow.java b/src/main/java/com/example/essentialsplus/wiki/WikiWindow.java new file mode 100644 index 0000000..4ca79e0 --- /dev/null +++ b/src/main/java/com/example/essentialsplus/wiki/WikiWindow.java @@ -0,0 +1,1120 @@ +package com.example.essentialsplus.wiki; + +import java.awt.FontMetrics; +import java.awt.Point; +import java.awt.Rectangle; +import java.awt.event.MouseEvent; +import java.awt.event.MouseWheelEvent; +import java.awt.image.BufferedImage; +import java.net.URI; +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Deque; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ExecutorService; +import java.util.function.IntSupplier; +import javax.swing.SwingUtilities; +import javax.swing.text.html.HTMLDocument; +import lombok.extern.slf4j.Slf4j; +import net.runelite.client.ui.FontManager; +import net.runelite.client.util.LinkBrowser; + +/** + * One in-game wiki window: page loading, history, contents bar, scrolling and input handling. Mouse events + * (dispatched by {@link WikiWindows}) and page layout run on the Swing thread, the overlay reads the volatile + * fields from the client thread. + */ +@Slf4j +public class WikiWindow +{ + static final int TITLE_H = 30; + static final int SCROLL_W = 16; + static final int PAD = 6; + static final int MIN_W = 300; + static final int MIN_H = 220; + static final int GRIP = 14; + static final int CHIP_H = 16; + static final int CHIP_GAP = 4; + private static final int WHEEL_STEP = 48; + private static final int ARROW_STEP = 24; + private static final int HISTORY = 50; + + static final int CHIP_LABEL = -1; + static final int CHIP_MORE = -2; + static final int CHIP_LESS = -3; + + /** Callbacks into the window manager. */ + interface Host + { + void openInNewWindow(String title, String anchor, WikiWindow from); + + void closed(WikiWindow window); + + void saveBounds(Rectangle bounds); + + void pageOpened(String title); + } + + enum Part + { + NONE, TITLE, CLOSE, EXTERNAL, BACK, TOC, UP, DOWN, TRACK, THUMB, LEFT, RIGHT, HTRACK, HTHUMB, GRIP, CONTENT, FRAME + } + + /** Positions of all window parts for one set of bounds. */ + static final class Layout + { + final Rectangle bounds; + final Rectangle title; + final Rectangle close; + final Rectangle external; + final Rectangle back; + final Rectangle toc; + final Rectangle content; + final Rectangle vbar; + final Rectangle hbar; + final Rectangle grip; + + Layout(Rectangle b, int border, int corner, int externalWidth, boolean horizontal, int tocHeight) + { + bounds = b; + int side = Math.max(border + 4, corner + 2); + title = new Rectangle(b.x + border, b.y + border, b.width - border * 2, TITLE_H); + close = new Rectangle(b.x + b.width - side - 26, b.y + border + 3, 26, 23); + external = new Rectangle(close.x - 6 - externalWidth, b.y + border + 6, externalWidth, 18); + back = new Rectangle(b.x + side, b.y + border + 6, 40, 18); + int left = b.x + border + PAD; + int right = b.x + b.width - border - PAD; + int top = title.y + title.height + 2; + toc = new Rectangle(left, top + 2, right - left, tocHeight); + if (tocHeight > 0) + { + top = toc.y + toc.height + 4; + } + int bottom = b.y + b.height - border - PAD; + int hbarH = horizontal ? SCROLL_W : 0; + vbar = new Rectangle(right - SCROLL_W, top, SCROLL_W, bottom - top - hbarH); + content = new Rectangle(left, top, vbar.x - 4 - left, bottom - top - hbarH); + hbar = horizontal ? new Rectangle(left, bottom - SCROLL_W, content.width, SCROLL_W) : null; + grip = new Rectangle(b.x + b.width - GRIP, b.y + b.height - GRIP, GRIP, GRIP); + } + } + + /** Scrollbar thumb along one axis. */ + static final class Thumb + { + final int pos; + final int size; + + Thumb(int pos, int size) + { + this.pos = pos; + this.size = size; + } + } + + /** A heading of the page (h2 or h3) with its position in page coordinates. */ + static final class Section + { + final String title; + final String anchor; + final int level; + final int y; + + Section(String title, String anchor, int level, int y) + { + this.title = title; + this.anchor = anchor; + this.level = level; + this.y = y; + } + } + + /** One entry of the contents bar; {@code section} is an index into the sections or a CHIP_* constant. */ + static final class Chip + { + final Rectangle rect; + final int section; + final String label; + + Chip(Rectangle rect, int section, String label) + { + this.rect = rect; + this.section = section; + this.label = label; + } + } + + static final class Toc + { + static final Toc EMPTY = new Toc(Collections.emptyList(), 0); + + final List chips; + final int height; + + Toc(List chips, int height) + { + this.chips = chips; + this.height = height; + } + } + + private static final class Entry + { + final String title; + final int scrollY; + + Entry(String title, int scrollY) + { + this.title = title; + this.scrollY = scrollY; + } + } + + private static FontMetrics chipMetrics; + + private final WikiApi api; + private final ExecutorService executor; + private final IntSupplier canvasWidth; + private final IntSupplier canvasHeight; + private final Host host; + private final Deque history = new ArrayDeque<>(); + + private WikiDocumentView view; + private int loadSeq; + private boolean tocExpanded; + + // read by the overlay (client thread) + volatile boolean open; + volatile Rectangle bounds; + volatile int border = 6; + volatile int corner = 6; + volatile int externalWidth = 96; + volatile String title = ""; + volatile String status; + volatile BufferedImage frame; + volatile int scrollX; + volatile int scrollY; + volatile int contentWidth; + volatile int contentHeight; + volatile List
sections = Collections.emptyList(); + volatile Toc toc = Toc.EMPTY; + volatile boolean tocHidden; + volatile int hoverChip = -1; + volatile Part hover = Part.NONE; + volatile Part pressed = Part.NONE; + volatile String hoverLink; + volatile Point mouse = new Point(); + volatile boolean canGoBack; + /** Last time the overlay drew the window; input is only captured while it is actually visible. */ + volatile long lastDrawn; + + private String currentTitle; + private Part drag = Part.NONE; + private int dragDx; + private int dragDy; + private int dragStartScroll; + private int dragStartMouse; + + WikiWindow(WikiApi api, ExecutorService executor, IntSupplier canvasWidth, IntSupplier canvasHeight, Rectangle initialBounds, Host host) + { + this.api = api; + this.executor = executor; + this.canvasWidth = canvasWidth; + this.canvasHeight = canvasHeight; + this.host = host; + this.bounds = initialBounds != null ? initialBounds : new Rectangle(60, 40, 520, 420); + } + + boolean isOpen() + { + return open; + } + + /** Opens a page in this window (Swing thread). */ + void open(String pageTitle, String anchor) + { + navigate(pageTitle, anchor, true, 0); + } + + /** Closes the window (Swing thread). */ + void close() + { + open = false; + loadSeq++; + frame = null; + view = null; + drag = Part.NONE; + hover = Part.NONE; + host.closed(this); + } + + Layout layout() + { + return layout(bounds); + } + + Layout layout(Rectangle b) + { + return new Layout(b, border, corner, externalWidth, contentWidth > 0 && viewportWidth(b) < contentWidth, toc.height); + } + + private int viewportWidth(Rectangle b) + { + return new Layout(b, border, corner, externalWidth, false, 0).content.width; + } + + // ---------------------------------------------------------------- pages + + private void navigate(String pageTitle, String anchor, boolean push, int restoreScroll) + { + if (pageTitle == null || pageTitle.trim().isEmpty()) + { + return; + } + if (!open) + { + open = true; + clampBounds(); + } + if (push && currentTitle != null && !currentTitle.equalsIgnoreCase(pageTitle)) + { + history.push(new Entry(currentTitle, scrollY)); + while (history.size() > HISTORY) + { + history.removeLast(); + } + } + canGoBack = !history.isEmpty(); + int seq = ++loadSeq; + currentTitle = pageTitle; + title = pageTitle; + status = "Loading..."; + frame = null; + hoverLink = null; + sections = Collections.emptyList(); + toc = Toc.EMPTY; + int width = viewportWidth(); + + executor.execute(() -> + { + try + { + WikiApi.Page page = api.fetchPage(pageTitle); + String raw = page.html; + if (page.title.startsWith("File:")) + { + // the parser output of a file page does not contain the file itself + String src = WikiApi.BASE + "/images/" + WikiApi.encodeTitle(page.title.substring(5)); + raw = "

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

OSRS Live Map & Telemetry

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

🎒 Inventar (0/28)

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

🛡️ Angelegte Ausrüstung

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

🏦 Spieler Bank (0 Items)

\n" + + " \n" + + "
\n" + + "
\n" + + "
\n" + + " \n" + + " \n" + + "
\n" + + "
\n" + + "
\n" + + "
\n" + + "
\n" + + "
\n" + + "
\n" + + "\n" + + " \n" + + "
\n" + + "\n" + + " \n" + + " \n" + + " \n" + + "\n" + + ""; + + byte[] bytes = html.getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().set("Content-Type", "text/html; charset=UTF-8"); + exchange.sendResponseHeaders(200, bytes.length); + try (OutputStream os = exchange.getResponseBody()) + { + os.write(bytes); + } + } + } +} diff --git a/src/main/java/com/example/livedashboard/overlay/GpsPathOverlay.java b/src/main/java/com/example/livedashboard/overlay/GpsPathOverlay.java new file mode 100644 index 0000000..38c94a8 --- /dev/null +++ b/src/main/java/com/example/livedashboard/overlay/GpsPathOverlay.java @@ -0,0 +1,183 @@ +package com.example.livedashboard.overlay; + +import com.example.livedashboard.LiveDashboardPlugin; +import java.awt.BasicStroke; +import java.awt.Color; +import java.awt.Dimension; +import java.awt.Graphics2D; +import java.awt.Polygon; +import java.awt.Stroke; +import java.util.ArrayList; +import java.util.List; +import javax.inject.Inject; +import javax.inject.Singleton; +import net.runelite.api.Client; +import net.runelite.api.GameState; +import net.runelite.api.Perspective; +import net.runelite.api.Point; +import net.runelite.api.coords.LocalPoint; +import net.runelite.api.coords.WorldPoint; +import net.runelite.client.ui.overlay.Overlay; +import net.runelite.client.ui.overlay.OverlayLayer; +import net.runelite.client.ui.overlay.OverlayPosition; +import net.runelite.client.ui.overlay.OverlayUtil; + +@Singleton +public class GpsPathOverlay extends Overlay +{ + private final Client client; + private final LiveDashboardPlugin plugin; + + // Target tile styles + private static final Color GPS_TARGET_FILL = new Color(245, 158, 11, 75); + private static final Color GPS_TARGET_BORDER = new Color(245, 158, 11, 230); + + // Waypoint steps along the path + private static final Color PATH_TILE_FILL = new Color(245, 158, 11, 25); + private static final Color PATH_TILE_BORDER = new Color(245, 158, 11, 90); + + // Connecting breadcrumb path line + private static final Color PATH_LINE_COLOR = new Color(245, 158, 11, 210); + private static final Stroke PATH_LINE_STROKE = new BasicStroke( + 3.0f, + BasicStroke.CAP_ROUND, + BasicStroke.JOIN_ROUND, + 10.0f, + new float[]{6.0f, 6.0f}, + 0.0f + ); + private static final Stroke TILE_STROKE = new BasicStroke(1.5f); + private static final Stroke TARGET_STROKE = new BasicStroke(2.0f); + + @Inject + public GpsPathOverlay(Client client, LiveDashboardPlugin plugin) + { + this.client = client; + this.plugin = plugin; + setPosition(OverlayPosition.DYNAMIC); + setLayer(OverlayLayer.ABOVE_SCENE); + } + + @Override + public Dimension render(Graphics2D graphics) + { + if (client.getGameState() != GameState.LOGGED_IN || client.getLocalPlayer() == null) + { + return null; + } + + WorldPoint target = plugin.getGpsTarget(); + if (target == null) + { + return null; + } + + if (target.getPlane() != client.getPlane()) + { + return null; + } + + WorldPoint playerWp = client.getLocalPlayer().getWorldLocation(); + if (playerWp == null) + { + return null; + } + + // Clear if player arrived on the tile + if (playerWp.equals(target)) + { + plugin.setGpsTarget(null); + return null; + } + + List path = plugin.getGpsPath(); + List linePoints = new ArrayList<>(); + + // Start path line at player center + LocalPoint playerLp = client.getLocalPlayer().getLocalLocation(); + if (playerLp != null) + { + Point pStart = Perspective.localToCanvas(client, playerLp, client.getPlane()); + if (pStart != null) + { + linePoints.add(pStart); + } + } + + // If a computed path around obstacles exists, render waypoint tiles and connecting path + if (path != null && !path.isEmpty()) + { + int pathSize = path.size(); + for (int i = 0; i < pathSize; i++) + { + WorldPoint wp = path.get(i); + if (wp.getPlane() != client.getPlane()) + { + continue; + } + + LocalPoint lp = LocalPoint.fromWorld(client, wp); + if (lp == null) + { + continue; + } + + // Draw path line segment + Point canvasPt = Perspective.localToCanvas(client, lp, client.getPlane()); + if (canvasPt != null) + { + linePoints.add(canvasPt); + } + + // Subtly highlight waypoint steps (e.g. every 2-3 tiles so screen doesn't get cluttered) + boolean isTarget = (i == pathSize - 1) && wp.equals(target); + if (!isTarget) + { + Polygon tilePoly = Perspective.getCanvasTilePoly(client, lp); + if (tilePoly != null) + { + graphics.setColor(PATH_TILE_FILL); + graphics.fill(tilePoly); + OverlayUtil.renderPolygon(graphics, tilePoly, PATH_TILE_BORDER, TILE_STROKE); + } + } + } + } + + // Draw smooth dashed path line through all obstacle-free waypoints + if (linePoints.size() >= 2) + { + graphics.setColor(PATH_LINE_COLOR); + graphics.setStroke(PATH_LINE_STROKE); + for (int i = 0; i < linePoints.size() - 1; i++) + { + Point p1 = linePoints.get(i); + Point p2 = linePoints.get(i + 1); + graphics.drawLine(p1.getX(), p1.getY(), p2.getX(), p2.getY()); + } + } + + // Render the final destination tile + LocalPoint targetLp = LocalPoint.fromWorld(client, target); + if (targetLp != null) + { + Polygon poly = Perspective.getCanvasTilePoly(client, targetLp); + if (poly != null) + { + graphics.setColor(GPS_TARGET_FILL); + graphics.fill(poly); + OverlayUtil.renderPolygon(graphics, poly, GPS_TARGET_BORDER, TARGET_STROKE); + } + + int dist = playerWp.distanceTo(target); + String text = "📍 GPS Ziel (" + dist + " Tiles)"; + Point textLoc = Perspective.getCanvasTextLocation(client, graphics, targetLp, text, 20); + if (textLoc != null) + { + OverlayUtil.renderTextLocation(graphics, textLoc, text, GPS_TARGET_BORDER); + } + } + + return null; + } +} diff --git a/src/main/java/com/example/livedashboard/pathfinding/Pathfinder.java b/src/main/java/com/example/livedashboard/pathfinding/Pathfinder.java new file mode 100644 index 0000000..9ea59b2 --- /dev/null +++ b/src/main/java/com/example/livedashboard/pathfinding/Pathfinder.java @@ -0,0 +1,304 @@ +package com.example.livedashboard.pathfinding; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; +import java.util.PriorityQueue; +import net.runelite.api.Client; +import net.runelite.api.CollisionData; +import net.runelite.api.CollisionDataFlag; +import net.runelite.api.Constants; +import net.runelite.api.Skill; +import net.runelite.api.coords.LocalPoint; +import net.runelite.api.coords.WorldPoint; + +public class Pathfinder +{ + private static class Node + { + final int x; + final int y; + final Node parent; + final int g; + final int f; + + Node(int x, int y, Node parent, int g, int h) + { + this.x = x; + this.y = y; + this.parent = parent; + this.g = g; + this.f = g + h; + } + } + + public static List findPath(Client client, WorldPoint startWp, WorldPoint targetWp) + { + if (client == null || startWp == null || targetWp == null) + { + return Collections.emptyList(); + } + + if (startWp.getPlane() != targetWp.getPlane()) + { + return Collections.emptyList(); + } + + CollisionData[] colData = client.getCollisionMaps(); + if (colData == null || client.getPlane() >= colData.length || colData[client.getPlane()] == null) + { + return Collections.singletonList(targetWp); + } + + int[][] flags = colData[client.getPlane()].getFlags(); + if (flags == null) + { + return Collections.singletonList(targetWp); + } + + LocalPoint startLp = LocalPoint.fromWorld(client, startWp); + if (startLp == null) + { + return Collections.singletonList(targetWp); + } + + int startSceneX = startLp.getSceneX(); + int startSceneY = startLp.getSceneY(); + + LocalPoint targetLp = LocalPoint.fromWorld(client, targetWp); + int targetSceneX = targetLp != null ? targetLp.getSceneX() : -1; + int targetSceneY = targetLp != null ? targetLp.getSceneY() : -1; + + boolean outsideScene = false; + if (targetSceneX < 0 || targetSceneX >= Constants.SCENE_SIZE || targetSceneY < 0 || targetSceneY >= Constants.SCENE_SIZE) + { + outsideScene = true; + int dx = targetWp.getX() - startWp.getX(); + int dy = targetWp.getY() - startWp.getY(); + double dist = Math.max(Math.abs(dx), Math.abs(dy)); + if (dist > 0) + { + int maxStep = Math.min((int) dist, 48); + int clampedX = startSceneX + (int) Math.round((dx / dist) * maxStep); + int clampedY = startSceneY + (int) Math.round((dy / dist) * maxStep); + targetSceneX = Math.max(2, Math.min(Constants.SCENE_SIZE - 3, clampedX)); + targetSceneY = Math.max(2, Math.min(Constants.SCENE_SIZE - 3, clampedY)); + } + else + { + targetSceneX = startSceneX; + targetSceneY = startSceneY; + } + } + + // A* search in loaded scene (up to 104x104) + PriorityQueue openSet = new PriorityQueue<>(Comparator.comparingInt(n -> n.f)); + boolean[][] visited = new boolean[Constants.SCENE_SIZE][Constants.SCENE_SIZE]; + + Node startNode = new Node(startSceneX, startSceneY, null, 0, manhattan(startSceneX, startSceneY, targetSceneX, targetSceneY) * 10); + openSet.add(startNode); + + Node bestNode = startNode; + int bestDistance = startNode.f; + + int[] dx = {0, 0, -1, 1, -1, 1, -1, 1}; + int[] dy = {1, -1, 0, 0, 1, 1, -1, -1}; + + int iterations = 0; + int maxIterations = 5000; + + while (!openSet.isEmpty() && iterations++ < maxIterations) + { + Node curr = openSet.poll(); + + if (visited[curr.x][curr.y]) + { + continue; + } + visited[curr.x][curr.y] = true; + + int h = manhattan(curr.x, curr.y, targetSceneX, targetSceneY); + if (h < bestDistance) + { + bestDistance = h; + bestNode = curr; + } + + if (curr.x == targetSceneX && curr.y == targetSceneY) + { + bestNode = curr; + break; + } + + for (int i = 0; i < 8; i++) + { + int nx = curr.x + dx[i]; + int ny = curr.y + dy[i]; + + if (nx < 1 || nx >= Constants.SCENE_SIZE - 1 || ny < 1 || ny >= Constants.SCENE_SIZE - 1) + { + continue; + } + + if (visited[nx][ny]) + { + continue; + } + + if (!canWalk(flags, curr.x, curr.y, dx[i], dy[i])) + { + continue; + } + + int stepCost = (i < 4) ? 10 : 14; + int nextG = curr.g + stepCost; + int nextH = manhattan(nx, ny, targetSceneX, targetSceneY) * 10; + openSet.add(new Node(nx, ny, curr, nextG, nextH)); + } + } + + List path = new ArrayList<>(); + Node node = bestNode; + int baseX = client.getBaseX(); + int baseY = client.getBaseY(); + int plane = client.getPlane(); + + while (node != null) + { + if (node.parent != null) + { + path.add(new WorldPoint(baseX + node.x, baseY + node.y, plane)); + } + node = node.parent; + } + + Collections.reverse(path); + + // If destination is beyond the loaded scene, interpolate coarse waypoints every 10 tiles towards target + if (outsideScene && !path.isEmpty()) + { + WorldPoint lastPoint = path.get(path.size() - 1); + int diffX = targetWp.getX() - lastPoint.getX(); + int diffY = targetWp.getY() - lastPoint.getY(); + double dist = Math.sqrt(diffX * diffX + diffY * diffY); + int numSegments = Math.max(1, (int) Math.round(dist / 8.0)); + + for (int i = 1; i <= numSegments; i++) + { + double fraction = (double) i / numSegments; + int wx = lastPoint.getX() + (int) Math.round(diffX * fraction); + int wy = lastPoint.getY() + (int) Math.round(diffY * fraction); + path.add(new WorldPoint(wx, wy, plane)); + } + } + + return path; + } + + // Finds nearby Agility shortcuts along the route + public static List findNearbyShortcuts(Client client, List path) + { + List result = new ArrayList<>(); + if (client == null || path == null || path.isEmpty()) + { + return result; + } + + int playerAgility = client.getRealSkillLevel(Skill.AGILITY); + + for (ShortcutData sc : ShortcutData.SHORTCUTS) + { + for (WorldPoint p : path) + { + if (p.distanceTo(sc.getPoint1()) <= 15 || p.distanceTo(sc.getPoint2()) <= 15) + { + if (!result.contains(sc)) + { + result.add(sc); + } + break; + } + } + } + + return result; + } + + private static boolean canWalk(int[][] flags, int x, int y, int dx, int dy) + { + int targetFlag = flags[x + dx][y + dy]; + + if ((targetFlag & (CollisionDataFlag.BLOCK_MOVEMENT_FULL + | CollisionDataFlag.BLOCK_MOVEMENT_FLOOR + | CollisionDataFlag.BLOCK_MOVEMENT_OBJECT)) != 0) + { + return false; + } + + if (dx == 0 && dy == 1) // North + { + return (flags[x][y] & CollisionDataFlag.BLOCK_MOVEMENT_NORTH) == 0 + && (targetFlag & CollisionDataFlag.BLOCK_MOVEMENT_SOUTH) == 0; + } + if (dx == 0 && dy == -1) // South + { + return (flags[x][y] & CollisionDataFlag.BLOCK_MOVEMENT_SOUTH) == 0 + && (targetFlag & CollisionDataFlag.BLOCK_MOVEMENT_NORTH) == 0; + } + if (dx == -1 && dy == 0) // West + { + return (flags[x][y] & CollisionDataFlag.BLOCK_MOVEMENT_WEST) == 0 + && (targetFlag & CollisionDataFlag.BLOCK_MOVEMENT_EAST) == 0; + } + if (dx == 1 && dy == 0) // East + { + return (flags[x][y] & CollisionDataFlag.BLOCK_MOVEMENT_EAST) == 0 + && (targetFlag & CollisionDataFlag.BLOCK_MOVEMENT_WEST) == 0; + } + + if (dx == -1 && dy == 1) // North-West + { + return canWalk(flags, x, y, -1, 0) + && canWalk(flags, x, y, 0, 1) + && canWalk(flags, x - 1, y, 0, 1) + && canWalk(flags, x, y + 1, -1, 0) + && (flags[x][y] & CollisionDataFlag.BLOCK_MOVEMENT_NORTH_WEST) == 0 + && (targetFlag & CollisionDataFlag.BLOCK_MOVEMENT_SOUTH_EAST) == 0; + } + if (dx == 1 && dy == 1) // North-East + { + return canWalk(flags, x, y, 1, 0) + && canWalk(flags, x, y, 0, 1) + && canWalk(flags, x + 1, y, 0, 1) + && canWalk(flags, x, y + 1, 1, 0) + && (flags[x][y] & CollisionDataFlag.BLOCK_MOVEMENT_NORTH_EAST) == 0 + && (targetFlag & CollisionDataFlag.BLOCK_MOVEMENT_SOUTH_WEST) == 0; + } + if (dx == -1 && dy == -1) // South-West + { + return canWalk(flags, x, y, -1, 0) + && canWalk(flags, x, y, 0, -1) + && canWalk(flags, x - 1, y, 0, -1) + && canWalk(flags, x, y - 1, -1, 0) + && (flags[x][y] & CollisionDataFlag.BLOCK_MOVEMENT_SOUTH_WEST) == 0 + && (targetFlag & CollisionDataFlag.BLOCK_MOVEMENT_NORTH_EAST) == 0; + } + if (dx == 1 && dy == -1) // South-East + { + return canWalk(flags, x, y, 1, 0) + && canWalk(flags, x, y, 0, -1) + && canWalk(flags, x + 1, y, 0, -1) + && canWalk(flags, x, y - 1, 1, 0) + && (flags[x][y] & CollisionDataFlag.BLOCK_MOVEMENT_SOUTH_EAST) == 0 + && (targetFlag & CollisionDataFlag.BLOCK_MOVEMENT_NORTH_WEST) == 0; + } + + return false; + } + + private static int manhattan(int x1, int y1, int x2, int y2) + { + return Math.abs(x1 - x2) + Math.abs(y1 - y2); + } +} diff --git a/src/main/java/com/example/livedashboard/pathfinding/ShortcutData.java b/src/main/java/com/example/livedashboard/pathfinding/ShortcutData.java new file mode 100644 index 0000000..d395645 --- /dev/null +++ b/src/main/java/com/example/livedashboard/pathfinding/ShortcutData.java @@ -0,0 +1,42 @@ +package com.example.livedashboard.pathfinding; + +import java.util.Arrays; +import java.util.List; +import lombok.Getter; +import net.runelite.api.coords.WorldPoint; + +@Getter +public class ShortcutData +{ + private final String name; + private final int requiredLevel; + private final WorldPoint point1; + private final WorldPoint point2; + private final String description; + + public ShortcutData(String name, int requiredLevel, WorldPoint point1, WorldPoint point2, String description) + { + this.name = name; + this.requiredLevel = requiredLevel; + this.point1 = point1; + this.point2 = point2; + this.description = description; + } + + // Curated list of popular OSRS shortcuts + public static final List SHORTCUTS = Arrays.asList( + new ShortcutData("Varrock South Fence", 13, new WorldPoint(3240, 3335, 0), new WorldPoint(3240, 3331, 0), "Zaun springen"), + new ShortcutData("Falador West Crumbling Wall", 5, new WorldPoint(2936, 3355, 0), new WorldPoint(2934, 3355, 0), "Mauer erklettern"), + new ShortcutData("Grand Exchange Underwall Tunnel", 21, new WorldPoint(3139, 3516, 0), new WorldPoint(3143, 3514, 0), "Tunnel zum GE"), + new ShortcutData("Edgeville Dungeon Monkey Bars", 15, new WorldPoint(3120, 9964, 0), new WorldPoint(3120, 9970, 0), "Monkey bars"), + new ShortcutData("Draynor Manor Stepping Stones", 31, new WorldPoint(3150, 3362, 0), new WorldPoint(3154, 3363, 0), "Stepping stones Fluss"), + new ShortcutData("Al Kharid Palace Window", 70, new WorldPoint(3299, 3158, 0), new WorldPoint(3302, 3158, 0), "Fenster"), + new ShortcutData("Yanille Underwall Tunnel", 16, new WorldPoint(2575, 3112, 0), new WorldPoint(2573, 3107, 0), "Mauer"), + new ShortcutData("Karamja Stepping Stones", 12, new WorldPoint(2925, 3155, 0), new WorldPoint(2924, 3149, 0), "Steine Fluss"), + new ShortcutData("Taverley Dungeon Strange Floor", 80, new WorldPoint(2883, 9799, 0), new WorldPoint(2884, 9801, 0), "Spike floor"), + new ShortcutData("Lumbridge Swamp Stepping Stones", 1, new WorldPoint(3200, 3169, 0), new WorldPoint(3204, 3169, 0), "Fluss Steine"), + new ShortcutData("Catherby Cliffside", 32, new WorldPoint(2841, 3424, 0), new WorldPoint(2843, 3425, 0), "Klippe"), + new ShortcutData("Ardougne Log Balance", 33, new WorldPoint(2601, 3336, 0), new WorldPoint(2598, 3336, 0), "Baumstamm"), + new ShortcutData("Seers Village Fence", 13, new WorldPoint(2735, 3489, 0), new WorldPoint(2735, 3491, 0), "Zaun") + ); +} diff --git a/src/main/java/com/example/livedashboard/ui/LiveDashboardPanel.java b/src/main/java/com/example/livedashboard/ui/LiveDashboardPanel.java new file mode 100644 index 0000000..d06f9ca --- /dev/null +++ b/src/main/java/com/example/livedashboard/ui/LiveDashboardPanel.java @@ -0,0 +1,223 @@ +package com.example.livedashboard.ui; + +import com.example.livedashboard.LiveDashboardPlugin; +import java.awt.BorderLayout; +import java.awt.Color; +import java.awt.Cursor; +import java.awt.Desktop; +import java.awt.Dimension; +import java.net.URI; +import java.util.Map; +import javax.swing.BorderFactory; +import javax.swing.Box; +import javax.swing.BoxLayout; +import javax.swing.JButton; +import javax.swing.JLabel; +import javax.swing.JPanel; +import javax.swing.SwingConstants; +import javax.swing.SwingUtilities; +import javax.swing.border.EmptyBorder; +import net.runelite.client.ui.ColorScheme; +import net.runelite.client.ui.FontManager; +import net.runelite.client.ui.PluginPanel; + +public class LiveDashboardPanel extends PluginPanel +{ + private final LiveDashboardPlugin plugin; + + private final JLabel statusDot = new JLabel("● "); + private final JLabel statusLabel = new JLabel("Server offline"); + private final JLabel portLabel = new JLabel("Port: -"); + private final JButton openBrowserBtn = new JButton("🌐 Im Browser öffnen"); + private final JButton toggleServerBtn = new JButton("Server stoppen"); + + private final JLabel coordsLabel = new JLabel("X: -, Y: -, Plane: -"); + private final JLabel regionLabel = new JLabel("Region: -"); + private final JLabel playerLabel = new JLabel("Spieler: -"); + + public LiveDashboardPanel(LiveDashboardPlugin plugin) + { + super(false); + this.plugin = plugin; + + setLayout(new BorderLayout()); + setBackground(ColorScheme.DARK_GRAY_COLOR); + setBorder(new EmptyBorder(10, 10, 10, 10)); + + JPanel container = new JPanel(); + container.setLayout(new BoxLayout(container, BoxLayout.Y_AXIS)); + container.setBackground(ColorScheme.DARK_GRAY_COLOR); + + // Header Card + JPanel headerPanel = new JPanel(new BorderLayout()); + headerPanel.setBackground(ColorScheme.DARKER_GRAY_COLOR); + headerPanel.setBorder(BorderFactory.createCompoundBorder( + BorderFactory.createMatteBorder(0, 0, 1, 0, ColorScheme.BORDER_COLOR), + new EmptyBorder(10, 10, 10, 10) + )); + + JLabel titleLabel = new JLabel("Live Web Dashboard"); + titleLabel.setFont(FontManager.getRunescapeBoldFont()); + titleLabel.setForeground(ColorScheme.BRAND_ORANGE); + headerPanel.add(titleLabel, BorderLayout.WEST); + + container.add(headerPanel); + container.add(Box.createRigidArea(new Dimension(0, 10))); + + // Status Card + JPanel statusPanel = new JPanel(); + statusPanel.setLayout(new BoxLayout(statusPanel, BoxLayout.Y_AXIS)); + statusPanel.setBackground(ColorScheme.DARKER_GRAY_COLOR); + statusPanel.setBorder(new EmptyBorder(10, 10, 10, 10)); + + JPanel statusLine = new JPanel(new BorderLayout()); + statusLine.setBackground(ColorScheme.DARKER_GRAY_COLOR); + statusDot.setFont(FontManager.getDefaultBoldFont().deriveFont(16f)); + statusDot.setForeground(new Color(220, 60, 50)); + statusLabel.setFont(FontManager.getRunescapeFont()); + statusLabel.setForeground(ColorScheme.TEXT_COLOR); + statusLine.add(statusDot, BorderLayout.WEST); + statusLine.add(statusLabel, BorderLayout.CENTER); + + portLabel.setFont(FontManager.getDefaultFont().deriveFont(10.5f)); + portLabel.setForeground(ColorScheme.LIGHT_GRAY_COLOR); + + statusPanel.add(statusLine); + statusPanel.add(Box.createRigidArea(new Dimension(0, 4))); + statusPanel.add(portLabel); + statusPanel.add(Box.createRigidArea(new Dimension(0, 10))); + + // Buttons + openBrowserBtn.setAlignmentX(CENTER_ALIGNMENT); + openBrowserBtn.setBackground(ColorScheme.BRAND_ORANGE); + openBrowserBtn.setForeground(Color.WHITE); + openBrowserBtn.setFocusPainted(false); + openBrowserBtn.setCursor(Cursor.getPredefinedCursor(Cursor.HAND_CURSOR)); + openBrowserBtn.setMaximumSize(new Dimension(Integer.MAX_VALUE, 30)); + openBrowserBtn.addActionListener(e -> openInBrowser()); + + toggleServerBtn.setAlignmentX(CENTER_ALIGNMENT); + toggleServerBtn.setBackground(ColorScheme.DARK_GRAY_COLOR); + toggleServerBtn.setForeground(ColorScheme.TEXT_COLOR); + toggleServerBtn.setFocusPainted(false); + toggleServerBtn.setCursor(Cursor.getPredefinedCursor(Cursor.HAND_CURSOR)); + toggleServerBtn.setMaximumSize(new Dimension(Integer.MAX_VALUE, 26)); + toggleServerBtn.addActionListener(e -> plugin.toggleServer()); + + statusPanel.add(openBrowserBtn); + statusPanel.add(Box.createRigidArea(new Dimension(0, 6))); + statusPanel.add(toggleServerBtn); + + container.add(statusPanel); + container.add(Box.createRigidArea(new Dimension(0, 10))); + + // Quick In-Game Telemetry Card + JPanel telemetryPanel = new JPanel(); + telemetryPanel.setLayout(new BoxLayout(telemetryPanel, BoxLayout.Y_AXIS)); + telemetryPanel.setBackground(ColorScheme.DARKER_GRAY_COLOR); + telemetryPanel.setBorder(new EmptyBorder(10, 10, 10, 10)); + + JLabel telemetryTitle = new JLabel("Live Ingame Telemetrie"); + telemetryTitle.setFont(FontManager.getRunescapeBoldFont()); + telemetryTitle.setForeground(ColorScheme.LIGHT_GRAY_COLOR); + + coordsLabel.setFont(FontManager.getDefaultFont().deriveFont(11f)); + coordsLabel.setForeground(ColorScheme.TEXT_COLOR); + + regionLabel.setFont(FontManager.getDefaultFont().deriveFont(10.5f)); + regionLabel.setForeground(ColorScheme.LIGHT_GRAY_COLOR); + + playerLabel.setFont(FontManager.getDefaultFont().deriveFont(10.5f)); + playerLabel.setForeground(ColorScheme.LIGHT_GRAY_COLOR); + + telemetryPanel.add(telemetryTitle); + telemetryPanel.add(Box.createRigidArea(new Dimension(0, 6))); + telemetryPanel.add(playerLabel); + telemetryPanel.add(Box.createRigidArea(new Dimension(0, 4))); + telemetryPanel.add(coordsLabel); + telemetryPanel.add(Box.createRigidArea(new Dimension(0, 4))); + telemetryPanel.add(regionLabel); + + container.add(telemetryPanel); + + add(container, BorderLayout.NORTH); + } + + public void updateServerState(boolean running, int port) + { + SwingUtilities.invokeLater(() -> + { + if (running) + { + statusDot.setForeground(new Color(46, 204, 113)); + statusLabel.setText("Verbunden & Aktiv"); + statusLabel.setForeground(new Color(46, 204, 113)); + portLabel.setText("Port: " + port + " (http://localhost:" + port + ")"); + openBrowserBtn.setEnabled(true); + toggleServerBtn.setText("Server stoppen"); + } + else + { + statusDot.setForeground(new Color(220, 60, 50)); + statusLabel.setText("Server gestoppt"); + statusLabel.setForeground(new Color(220, 60, 50)); + portLabel.setText("Port: -"); + openBrowserBtn.setEnabled(false); + toggleServerBtn.setText("Server starten"); + } + }); + } + + public void updateTelemetry(Map data) + { + SwingUtilities.invokeLater(() -> + { + if (data == null || data.isEmpty()) + { + playerLabel.setText("Spieler: -"); + coordsLabel.setText("X: -, Y: -, Plane: -"); + regionLabel.setText("Region: -"); + return; + } + + Object player = data.get("player"); + playerLabel.setText("Spieler: " + (player != null ? player : "Nicht eingeloggt")); + + Object wx = data.get("worldX"); + Object wy = data.get("worldY"); + Object wp = data.get("plane"); + coordsLabel.setText("World: " + wx + ", " + wy + " (P" + wp + ")"); + + Object region = data.get("regionId"); + Object rx = data.get("regionX"); + Object ry = data.get("regionY"); + regionLabel.setText("Region ID: " + region + " (" + rx + ", " + ry + ")"); + }); + } + + private void openInBrowser() + { + String url = plugin.getServerUrl(); + if (url == null || url.isEmpty()) + { + return; + } + + try + { + if (Desktop.isDesktopSupported() && Desktop.getDesktop().isSupported(Desktop.Action.BROWSE)) + { + Desktop.getDesktop().browse(new URI(url)); + } + else + { + // Fallback for Linux xdg-open + new ProcessBuilder("xdg-open", url).start(); + } + } + catch (Exception ex) + { + // Ignore or log + } + } +} diff --git a/src/main/java/com/example/model/Folder.java b/src/main/java/com/example/model/Folder.java new file mode 100644 index 0000000..47f9b4c --- /dev/null +++ b/src/main/java/com/example/model/Folder.java @@ -0,0 +1,34 @@ +package com.example.model; + +import java.util.UUID; +import lombok.Data; + +@Data +public class Folder +{ + private String id; + private String name; + private boolean enabled = true; + private boolean collapsed = false; + private String iconItem = "Prayer potion(4)"; + private int iconItemId = -1; + + public Folder() + { + this.id = UUID.randomUUID().toString(); + this.name = "Neuer Ordner"; + } + + public Folder(String name) + { + this.id = UUID.randomUUID().toString(); + this.name = name; + } + + public Folder(String name, String iconItem) + { + this.id = UUID.randomUUID().toString(); + this.name = name; + this.iconItem = iconItem; + } +} diff --git a/src/main/java/com/example/model/Profile.java b/src/main/java/com/example/model/Profile.java new file mode 100644 index 0000000..891155d --- /dev/null +++ b/src/main/java/com/example/model/Profile.java @@ -0,0 +1,301 @@ +package com.example.model; + +import java.util.UUID; +import lombok.Data; +import net.runelite.api.Client; +import net.runelite.api.GameState; +import net.runelite.api.InventoryID; +import net.runelite.api.Item; +import net.runelite.api.ItemComposition; +import net.runelite.api.ItemContainer; +import net.runelite.api.SoundEffectID; +import net.runelite.api.coords.WorldPoint; +import net.runelite.api.widgets.ComponentID; +import net.runelite.api.widgets.Widget; +import net.runelite.client.game.ItemManager; +import net.runelite.client.plugins.banktags.BankTagsService; + +@Data +public class Profile +{ + private String id; + private String name; + private boolean enabled = true; + private String folderId = ""; // Zugehöriger Ordner (oder leer für Hauptansicht) + + // Item Icon + private String iconItem = ""; + private int iconItemId = -1; + + // WENN: Bedingungen (IF...) + private boolean requireBankOpen = false; + private boolean requireBankClosed = false; + private String bankTag = ""; + private String regionId = ""; + private String coordinates = ""; + private String requiredInventory = ""; + private String requiredEquipment = ""; + + // DANN: Item Highlights (Default: Nur Rahmen / Border) + private boolean enableItemHighlight = true; + private String highlightItems = ""; + private boolean highlightInBank = true; + private boolean highlightInInventory = true; + private String highlightColor = "#FF9800"; + private int borderThickness = 2; // 1 - 4 px + private int fillOpacity = 0; // 0 = Nur Rahmen + private boolean showTextLabel = false; + private String customTextLabel = ""; + + // DANN: 3D Welt- & Objekt-Highlights (GameObjects, Bank Booth, Chest, Banker, Agility Obstacles...) + private boolean enableObjectHighlight = false; + private String highlightObjects = ""; + private String objectHighlightColor = "#00E5FF"; + private boolean showObjectText = true; + private String objectCustomText = "Bank"; + + // DANN: 3D Boden-Kacheln & Pfad-Marker (Ground Tiles & Paths) + private boolean enableTileHighlight = false; + private String highlightTiles = ""; // Format: "X, Y, Plane, Label; X2, Y2, Plane, Label2" + private String tileHighlightColor = "#2ECC71"; + private int tileFillOpacity = 40; // 0 - 255 + private boolean showTileLabel = true; + private String customTileLabel = "Hier stehen"; + private boolean renderPathLine = false; + + // DANN: Benachrichtigungen & Feedback + private boolean notifyOnActivation = true; + private String activationMessage = "Profil '{name}' wurde automatisch aktiviert!"; + private boolean screenFlash = false; + private String flashColor = "#FF9800"; + private boolean playSound = false; + private int soundId = SoundEffectID.GE_ADD_OFFER_DINGALING; + + public Profile() + { + this.id = UUID.randomUUID().toString(); + this.name = "Neues Profil"; + } + + public Profile(String name) + { + this.id = UUID.randomUUID().toString(); + this.name = name; + } + + public Profile(String name, String iconItem) + { + this.id = UUID.randomUUID().toString(); + this.name = name; + this.iconItem = iconItem; + } + + public boolean matches(Client client, ItemManager itemManager, BankTagsService bankTagsService) + { + if (!enabled || client == null || client.getGameState() != GameState.LOGGED_IN) + { + return false; + } + + try + { + // Safe Bank Open / Closed check using isSelfHidden() + Widget bankWidget = client.getWidget(ComponentID.BANK_CONTAINER); + boolean isBankOpen = (bankWidget != null && !bankWidget.isSelfHidden()); + + if (requireBankOpen && !isBankOpen) + { + return false; + } + + if (requireBankClosed && isBankOpen) + { + return false; + } + + // Safe Bank Tag check + if (bankTag != null && !bankTag.trim().isEmpty()) + { + if (!isBankOpen) + { + return false; + } + + String currentTag = ""; + if (bankTagsService != null) + { + try + { + currentTag = bankTagsService.getActiveTag(); + } + catch (Throwable ignored) + { + } + } + + if (currentTag == null || currentTag.isEmpty()) + { + Widget titleWidget = client.getWidget(ComponentID.BANK_TITLE_BAR); + if (titleWidget != null && !titleWidget.isSelfHidden() && titleWidget.getText() != null) + { + currentTag = titleWidget.getText(); + } + } + + if (currentTag == null || currentTag.isEmpty() || !currentTag.toLowerCase().contains(bankTag.trim().toLowerCase())) + { + return false; + } + } + + // Safe Region check + if (regionId != null && !regionId.trim().isEmpty()) + { + if (client.getLocalPlayer() == null || client.getLocalPlayer().getWorldLocation() == null) + { + return false; + } + + int currentRegion = client.getLocalPlayer().getWorldLocation().getRegionID(); + boolean regionMatch = false; + for (String r : regionId.split(",")) + { + try + { + if (Integer.parseInt(r.trim()) == currentRegion) + { + regionMatch = true; + break; + } + } + catch (NumberFormatException ignored) + { + } + } + if (!regionMatch) + { + return false; + } + } + + // Safe Coordinates check + if (coordinates != null && !coordinates.trim().isEmpty()) + { + if (client.getLocalPlayer() == null || client.getLocalPlayer().getWorldLocation() == null) + { + return false; + } + + WorldPoint playerLoc = client.getLocalPlayer().getWorldLocation(); + String[] parts = coordinates.split(","); + if (parts.length >= 2) + { + try + { + int targetX = Integer.parseInt(parts[0].trim()); + int targetY = Integer.parseInt(parts[1].trim()); + int targetPlane = parts.length >= 3 ? Integer.parseInt(parts[2].trim()) : playerLoc.getPlane(); + int radius = parts.length >= 4 ? Integer.parseInt(parts[3].trim()) : 10; + + if (playerLoc.getPlane() != targetPlane + || Math.abs(playerLoc.getX() - targetX) > radius + || Math.abs(playerLoc.getY() - targetY) > radius) + { + return false; + } + } + catch (NumberFormatException ignored) + { + } + } + } + + // Safe Inventory check + if (requiredInventory != null && !requiredInventory.trim().isEmpty()) + { + ItemContainer inv = client.getItemContainer(InventoryID.INVENTORY); + if (inv == null || !containsAllItems(inv, requiredInventory, itemManager)) + { + return false; + } + } + + // Safe Equipment check + if (requiredEquipment != null && !requiredEquipment.trim().isEmpty()) + { + ItemContainer equip = client.getItemContainer(InventoryID.EQUIPMENT); + if (equip == null || !containsAllItems(equip, requiredEquipment, itemManager)) + { + return false; + } + } + + return true; + } + catch (Throwable t) + { + return false; + } + } + + private boolean containsAllItems(ItemContainer container, String filterList, ItemManager itemManager) + { + if (container == null) + { + return false; + } + + Item[] items = container.getItems(); + if (items == null || items.length == 0) + { + return false; + } + + String[] needed = filterList.split(","); + for (String need : needed) + { + String query = need.trim().toLowerCase(); + if (query.isEmpty()) + { + continue; + } + + boolean found = false; + for (Item item : items) + { + if (item == null || item.getId() <= 0) + { + continue; + } + + if (String.valueOf(item.getId()).equals(query)) + { + found = true; + break; + } + + if (itemManager != null) + { + try + { + ItemComposition def = itemManager.getItemComposition(item.getId()); + if (def != null && def.getName() != null && def.getName().toLowerCase().contains(query)) + { + found = true; + break; + } + } + catch (Throwable ignored) + { + } + } + } + + if (!found) + { + return false; + } + } + return true; + } +} diff --git a/src/main/java/com/example/overlay/BankHighlighterOverlay.java b/src/main/java/com/example/overlay/BankHighlighterOverlay.java new file mode 100644 index 0000000..f5c5a99 --- /dev/null +++ b/src/main/java/com/example/overlay/BankHighlighterOverlay.java @@ -0,0 +1,185 @@ +package com.example.overlay; + +import com.example.ProfileManager; +import com.example.model.Profile; +import java.awt.BasicStroke; +import java.awt.Color; +import java.awt.FontMetrics; +import java.awt.Graphics2D; +import java.awt.Rectangle; +import javax.inject.Inject; +import javax.inject.Singleton; +import net.runelite.api.ItemComposition; +import net.runelite.api.widgets.ComponentID; +import net.runelite.api.widgets.Widget; +import net.runelite.api.widgets.WidgetItem; +import net.runelite.client.game.ItemManager; +import net.runelite.client.ui.FontManager; +import net.runelite.client.ui.overlay.WidgetItemOverlay; +import net.runelite.client.util.ColorUtil; + +@Singleton +public class BankHighlighterOverlay extends WidgetItemOverlay +{ + private final ProfileManager profileManager; + private final ItemManager itemManager; + + @Inject + public BankHighlighterOverlay(ProfileManager profileManager, ItemManager itemManager) + { + this.profileManager = profileManager; + this.itemManager = itemManager; + + showOnBank(); + showOnInventory(); + showOnEquipment(); + } + + @Override + public void renderItemOverlay(Graphics2D graphics, int itemId, WidgetItem widgetItem) + { + Profile active = profileManager.getActiveProfile(); + if (active == null || !active.isEnabled() || !active.isEnableItemHighlight()) + { + return; + } + + String highlightItems = active.getHighlightItems(); + if (highlightItems == null || highlightItems.trim().isEmpty()) + { + return; + } + + // Container check + Widget widget = widgetItem.getWidget(); + if (widget != null) + { + int parentId = widget.getParentId(); + int widgetId = widget.getId(); + + boolean isBank = (widgetId == ComponentID.BANK_ITEM_CONTAINER || parentId == ComponentID.BANK_ITEM_CONTAINER); + boolean isInv = (widgetId == ComponentID.INVENTORY_CONTAINER || parentId == ComponentID.INVENTORY_CONTAINER + || widgetId == ComponentID.BANK_INVENTORY_ITEM_CONTAINER || parentId == ComponentID.BANK_INVENTORY_ITEM_CONTAINER); + + if (isBank && !active.isHighlightInBank()) + { + return; + } + if (isInv && !active.isHighlightInInventory()) + { + return; + } + } + + // Item Match Check + ItemComposition itemDef = itemManager.getItemComposition(itemId); + if (itemDef == null) + { + return; + } + + String itemName = itemDef.getName() != null ? itemDef.getName().toLowerCase() : ""; + String itemIdStr = String.valueOf(itemId); + + boolean matches = false; + String matchedFilter = ""; + for (String itemQuery : highlightItems.split(",")) + { + String q = itemQuery.trim().toLowerCase(); + if (q.isEmpty()) + { + continue; + } + + if (q.equals(itemIdStr) || (!itemName.isEmpty() && itemName.contains(q))) + { + matches = true; + matchedFilter = itemQuery.trim(); + break; + } + } + + if (!matches) + { + return; + } + + // Parse Color + Color color; + try + { + color = ColorUtil.fromString(active.getHighlightColor()); + if (color == null) + { + color = new Color(255, 152, 0); + } + } + catch (Exception e) + { + color = new Color(255, 152, 0); + } + + Rectangle bounds = widgetItem.getCanvasBounds(); + if (bounds == null) + { + return; + } + + // 1. Fill background ONLY if fillOpacity > 0 (default is 0 = nur Rahmen) + int opacity = Math.min(255, Math.max(0, active.getFillOpacity())); + if (opacity > 0) + { + Color fillColor = ColorUtil.colorWithAlpha(color, opacity); + graphics.setColor(fillColor); + graphics.fillRect(bounds.x, bounds.y, bounds.width, bounds.height); + } + + // 2. Draw crisp outline border with inset + int strokeWidth = Math.max(1, Math.min(4, active.getBorderThickness())); + graphics.setColor(color); + graphics.setStroke(new BasicStroke(strokeWidth)); + int inset = strokeWidth / 2; + graphics.drawRect( + bounds.x + inset, + bounds.y + inset, + bounds.width - strokeWidth, + bounds.height - strokeWidth + ); + + // 3. Draw text label (mit Schrift) + if (active.isShowTextLabel()) + { + String label = active.getCustomTextLabel(); + if (label == null || label.trim().isEmpty()) + { + label = matchedFilter; + } + if (label.isEmpty() && itemDef.getName() != null) + { + label = itemDef.getName(); + } + + if (!label.isEmpty()) + { + graphics.setFont(FontManager.getRunescapeSmallFont()); + FontMetrics fm = graphics.getFontMetrics(); + + if (label.length() > 12) + { + label = label.substring(0, 10) + ".."; + } + + int textX = bounds.x + (bounds.width - fm.stringWidth(label)) / 2; + int textY = bounds.y + fm.getHeight() - 1; + + // Shadow + graphics.setColor(Color.BLACK); + graphics.drawString(label, textX + 1, textY + 1); + + // Foreground + graphics.setColor(Color.WHITE); + graphics.drawString(label, textX, textY); + } + } + } +} diff --git a/src/main/java/com/example/overlay/ScreenFlashOverlay.java b/src/main/java/com/example/overlay/ScreenFlashOverlay.java new file mode 100644 index 0000000..522a645 --- /dev/null +++ b/src/main/java/com/example/overlay/ScreenFlashOverlay.java @@ -0,0 +1,67 @@ +package com.example.overlay; + +import java.awt.Color; +import java.awt.Dimension; +import java.awt.Graphics2D; +import javax.inject.Inject; +import javax.inject.Singleton; +import net.runelite.api.Client; +import net.runelite.client.ui.overlay.Overlay; +import net.runelite.client.ui.overlay.OverlayLayer; +import net.runelite.client.ui.overlay.OverlayPosition; +import net.runelite.client.util.ColorUtil; + +@Singleton +public class ScreenFlashOverlay extends Overlay +{ + private final Client client; + private Color flashColor = new Color(255, 152, 0); + private int remainingTicks = 0; + + @Inject + public ScreenFlashOverlay(Client client) + { + this.client = client; + setPosition(OverlayPosition.DYNAMIC); + setLayer(OverlayLayer.ALWAYS_ON_TOP); + } + + public void flash(Color color, int ticks) + { + this.flashColor = color != null ? color : new Color(255, 152, 0); + this.remainingTicks = Math.max(1, ticks); + } + + public void decrementTick() + { + if (remainingTicks > 0) + { + remainingTicks--; + } + } + + @Override + public Dimension render(Graphics2D graphics) + { + if (remainingTicks <= 0 || client == null) + { + return null; + } + + int width = client.getCanvasWidth(); + int height = client.getCanvasHeight(); + + int alpha = Math.min(120, remainingTicks * 40); + Color renderColor = ColorUtil.colorWithAlpha(flashColor, alpha); + + // Flash Border + graphics.setColor(renderColor); + int borderSize = 25; + graphics.fillRect(0, 0, width, borderSize); // Top + graphics.fillRect(0, height - borderSize, width, borderSize); // Bottom + graphics.fillRect(0, borderSize, borderSize, height - (2 * borderSize)); // Left + graphics.fillRect(width - borderSize, borderSize, borderSize, height - (2 * borderSize)); // Right + + return null; + } +} diff --git a/src/main/java/com/example/overlay/TileHighlighterOverlay.java b/src/main/java/com/example/overlay/TileHighlighterOverlay.java new file mode 100644 index 0000000..9c5d227 --- /dev/null +++ b/src/main/java/com/example/overlay/TileHighlighterOverlay.java @@ -0,0 +1,186 @@ +package com.example.overlay; + +import com.example.ProfileManager; +import com.example.model.Profile; +import java.awt.BasicStroke; +import java.awt.Color; +import java.awt.Dimension; +import java.awt.Graphics2D; +import java.awt.Polygon; +import java.awt.Stroke; +import java.util.ArrayList; +import java.util.List; +import javax.inject.Inject; +import javax.inject.Singleton; +import net.runelite.api.Client; +import net.runelite.api.GameState; +import net.runelite.api.Perspective; +import net.runelite.api.Point; +import net.runelite.api.coords.LocalPoint; +import net.runelite.api.coords.WorldPoint; +import net.runelite.client.ui.overlay.Overlay; +import net.runelite.client.ui.overlay.OverlayLayer; +import net.runelite.client.ui.overlay.OverlayPosition; +import net.runelite.client.ui.overlay.OverlayUtil; +import net.runelite.client.util.ColorUtil; + +@Singleton +public class TileHighlighterOverlay extends Overlay +{ + private final Client client; + private final ProfileManager profileManager; + + @Inject + public TileHighlighterOverlay(Client client, ProfileManager profileManager) + { + this.client = client; + this.profileManager = profileManager; + setPosition(OverlayPosition.DYNAMIC); + setLayer(OverlayLayer.ABOVE_SCENE); + } + + @Override + public Dimension render(Graphics2D graphics) + { + Profile active = profileManager.getActiveProfile(); + if (active == null || !active.isEnabled() || !active.isEnableTileHighlight()) + { + return null; + } + + String tileStr = active.getHighlightTiles(); + if (tileStr == null || tileStr.trim().isEmpty()) + { + return null; + } + + if (client.getGameState() != GameState.LOGGED_IN || client.getLocalPlayer() == null) + { + return null; + } + + Color baseColor; + try + { + baseColor = ColorUtil.fromString(active.getTileHighlightColor()); + if (baseColor == null) + { + baseColor = new Color(46, 204, 113); + } + } + catch (Exception e) + { + baseColor = new Color(46, 204, 113); + } + + int fillOp = Math.max(0, Math.min(255, active.getTileFillOpacity())); + Color fillColor = new Color(baseColor.getRed(), baseColor.getGreen(), baseColor.getBlue(), fillOp); + Color outlineColor = new Color(baseColor.getRed(), baseColor.getGreen(), baseColor.getBlue(), 255); + Stroke stroke = new BasicStroke(Math.max(1, Math.min(4, active.getBorderThickness()))); + + WorldPoint playerLoc = client.getLocalPlayer().getWorldLocation(); + LocalPoint playerLocal = client.getLocalPlayer().getLocalLocation(); + + List canvasPointsForPath = new ArrayList<>(); + if (active.isRenderPathLine() && playerLocal != null) + { + Point pPt = Perspective.localToCanvas(client, playerLocal, client.getPlane()); + if (pPt != null) + { + canvasPointsForPath.add(pPt); + } + } + + String[] tileEntries = tileStr.split(";"); + for (String entry : tileEntries) + { + if (entry == null || entry.trim().isEmpty()) + { + continue; + } + + String[] parts = entry.trim().split(","); + if (parts.length < 2) + { + continue; + } + + try + { + int wx = Integer.parseInt(parts[0].trim()); + int wy = Integer.parseInt(parts[1].trim()); + int plane = parts.length >= 3 ? Integer.parseInt(parts[2].trim()) : playerLoc.getPlane(); + String label = parts.length >= 4 ? parts[3].trim() : active.getCustomTileLabel(); + + if (plane != client.getPlane()) + { + continue; + } + + WorldPoint wp = new WorldPoint(wx, wy, plane); + LocalPoint lp = LocalPoint.fromWorld(client, wp); + if (lp == null) + { + continue; + } + + Polygon poly = Perspective.getCanvasTilePoly(client, lp); + if (poly != null) + { + if (fillOp > 0) + { + graphics.setColor(fillColor); + graphics.fill(poly); + } + OverlayUtil.renderPolygon(graphics, poly, outlineColor, stroke); + } + + if (active.isShowTileLabel()) + { + String textToDraw = (label != null && !label.isEmpty()) ? label : active.getCustomTileLabel(); + if (textToDraw == null || textToDraw.isEmpty()) + { + textToDraw = "⬇ Ziel"; + } + else if (!textToDraw.startsWith("⬇") && !textToDraw.startsWith("📍")) + { + textToDraw = "⬇ " + textToDraw; + } + + Point textLoc = Perspective.getCanvasTextLocation(client, graphics, lp, textToDraw, 20); + if (textLoc != null) + { + OverlayUtil.renderTextLocation(graphics, textLoc, textToDraw, outlineColor); + } + } + + if (active.isRenderPathLine()) + { + Point tPt = Perspective.localToCanvas(client, lp, client.getPlane()); + if (tPt != null) + { + canvasPointsForPath.add(tPt); + } + } + } + catch (Exception ignored) + { + } + } + + // Draw Path line connecting waypoints + if (active.isRenderPathLine() && canvasPointsForPath.size() >= 2) + { + graphics.setColor(new Color(outlineColor.getRed(), outlineColor.getGreen(), outlineColor.getBlue(), 180)); + graphics.setStroke(new BasicStroke(2.0f, BasicStroke.CAP_ROUND, BasicStroke.JOIN_ROUND, 10.0f, new float[]{6.0f, 6.0f}, 0.0f)); + for (int i = 0; i < canvasPointsForPath.size() - 1; i++) + { + Point p1 = canvasPointsForPath.get(i); + Point p2 = canvasPointsForPath.get(i + 1); + graphics.drawLine(p1.getX(), p1.getY(), p2.getX(), p2.getY()); + } + } + + return null; + } +} diff --git a/src/main/java/com/example/overlay/WorldObjectHighlighterOverlay.java b/src/main/java/com/example/overlay/WorldObjectHighlighterOverlay.java new file mode 100644 index 0000000..3cc48a2 --- /dev/null +++ b/src/main/java/com/example/overlay/WorldObjectHighlighterOverlay.java @@ -0,0 +1,260 @@ +package com.example.overlay; + +import com.example.ProfileManager; +import com.example.model.Profile; +import java.awt.BasicStroke; +import java.awt.Color; +import java.awt.Dimension; +import java.awt.Graphics2D; +import java.awt.Shape; +import java.awt.Stroke; +import java.util.List; +import javax.inject.Inject; +import javax.inject.Singleton; +import net.runelite.api.Client; +import net.runelite.api.DecorativeObject; +import net.runelite.api.GameObject; +import net.runelite.api.GameState; +import net.runelite.api.GroundObject; +import net.runelite.api.NPC; +import net.runelite.api.ObjectComposition; +import net.runelite.api.Perspective; +import net.runelite.api.Point; +import net.runelite.api.Scene; +import net.runelite.api.Tile; +import net.runelite.api.TileObject; +import net.runelite.api.WallObject; +import net.runelite.client.ui.overlay.Overlay; +import net.runelite.client.ui.overlay.OverlayLayer; +import net.runelite.client.ui.overlay.OverlayPosition; +import net.runelite.client.ui.overlay.OverlayUtil; +import net.runelite.client.util.ColorUtil; + +@Singleton +public class WorldObjectHighlighterOverlay extends Overlay +{ + private final Client client; + private final ProfileManager profileManager; + + @Inject + public WorldObjectHighlighterOverlay(Client client, ProfileManager profileManager) + { + this.client = client; + this.profileManager = profileManager; + setPosition(OverlayPosition.DYNAMIC); + setLayer(OverlayLayer.ABOVE_SCENE); + } + + @Override + public Dimension render(Graphics2D graphics) + { + Profile active = profileManager.getActiveProfile(); + if (active == null || !active.isEnabled() || !active.isEnableObjectHighlight()) + { + return null; + } + + String targetObjects = active.getHighlightObjects(); + if (targetObjects == null || targetObjects.trim().isEmpty()) + { + return null; + } + + if (client.getGameState() != GameState.LOGGED_IN) + { + return null; + } + + Scene scene = client.getScene(); + if (scene == null) + { + return null; + } + + Color color; + try + { + color = ColorUtil.fromString(active.getObjectHighlightColor()); + if (color == null) + { + color = new Color(0, 229, 255); + } + } + catch (Exception e) + { + color = new Color(0, 229, 255); + } + + Stroke stroke = new BasicStroke(Math.max(1, Math.min(4, active.getBorderThickness()))); + String[] queries = targetObjects.split(","); + + // 1. Scan Scene Tiles for matching GameObjects, WallObjects, etc. + Tile[][][] tiles = scene.getTiles(); + int plane = client.getPlane(); + if (tiles != null && plane >= 0 && plane < tiles.length) + { + Tile[][] planeTiles = tiles[plane]; + for (int x = 0; x < planeTiles.length; x++) + { + for (int y = 0; y < planeTiles[x].length; y++) + { + Tile tile = planeTiles[x][y]; + if (tile == null) + { + continue; + } + + // GameObjects + GameObject[] gameObjects = tile.getGameObjects(); + if (gameObjects != null) + { + for (GameObject go : gameObjects) + { + if (go != null) + { + checkAndRenderObject(graphics, go, queries, color, stroke, active); + } + } + } + + // WallObject + WallObject wo = tile.getWallObject(); + if (wo != null) + { + checkAndRenderObject(graphics, wo, queries, color, stroke, active); + } + + // DecorativeObject + DecorativeObject deco = tile.getDecorativeObject(); + if (deco != null) + { + checkAndRenderObject(graphics, deco, queries, color, stroke, active); + } + + // GroundObject + GroundObject gro = tile.getGroundObject(); + if (gro != null) + { + checkAndRenderObject(graphics, gro, queries, color, stroke, active); + } + } + } + } + + // 2. Scan NPCs (Bankers, Clerks, etc.) + List npcs = client.getNpcs(); + if (npcs != null) + { + for (NPC npc : npcs) + { + if (npc == null || npc.getName() == null) + { + continue; + } + + String npcName = npc.getName().toLowerCase(); + String matchedQuery = null; + for (String q : queries) + { + String queryTrimmed = q.trim().toLowerCase(); + if (!queryTrimmed.isEmpty() && npcName.contains(queryTrimmed)) + { + matchedQuery = q.trim(); + break; + } + } + + if (matchedQuery != null) + { + Shape hull = npc.getConvexHull(); + if (hull != null) + { + OverlayUtil.renderPolygon(graphics, hull, color, stroke); + } + + if (active.isShowObjectText()) + { + String label = active.getObjectCustomText(); + if (label == null || label.trim().isEmpty()) + { + label = matchedQuery; + } + + Point textLoc = npc.getCanvasTextLocation(graphics, label, npc.getLogicalHeight() + 40); + if (textLoc != null) + { + OverlayUtil.renderTextLocation(graphics, textLoc, label, color); + } + } + } + } + } + + return null; + } + + private void checkAndRenderObject(Graphics2D graphics, TileObject object, String[] queries, Color color, Stroke stroke, Profile active) + { + if (object == null) + { + return; + } + + ObjectComposition comp = client.getObjectDefinition(object.getId()); + if (comp == null) + { + return; + } + + if (comp.getImpostorIds() != null) + { + comp = comp.getImpostor(); + if (comp == null) + { + return; + } + } + + String name = comp.getName(); + if (name == null || name.equalsIgnoreCase("null")) + { + return; + } + + String nameLower = name.toLowerCase(); + String matchedQuery = null; + for (String q : queries) + { + String queryTrimmed = q.trim().toLowerCase(); + if (!queryTrimmed.isEmpty() && nameLower.contains(queryTrimmed)) + { + matchedQuery = q.trim(); + break; + } + } + + if (matchedQuery != null) + { + Shape clickbox = object.getClickbox(); + if (clickbox != null) + { + OverlayUtil.renderPolygon(graphics, clickbox, color, stroke); + } + + if (active.isShowObjectText()) + { + String label = active.getObjectCustomText(); + if (label == null || label.trim().isEmpty()) + { + label = matchedQuery; + } + + Point textLoc = Perspective.getCanvasTextLocation(client, graphics, object.getLocalLocation(), label, 100); + if (textLoc != null) + { + OverlayUtil.renderTextLocation(graphics, textLoc, label, color); + } + } + } + } +} diff --git a/src/main/java/com/example/ui/ExamplePluginPanel.java b/src/main/java/com/example/ui/ExamplePluginPanel.java new file mode 100644 index 0000000..6a4efdc --- /dev/null +++ b/src/main/java/com/example/ui/ExamplePluginPanel.java @@ -0,0 +1,73 @@ +package com.example.ui; + +import com.example.ExamplePlugin; +import com.example.ProfileManager; +import com.example.model.Profile; +import java.awt.BorderLayout; +import java.awt.CardLayout; +import javax.swing.JPanel; +import net.runelite.api.Client; +import net.runelite.client.ui.ColorScheme; +import net.runelite.client.ui.PluginPanel; + +public class ExamplePluginPanel extends PluginPanel implements ProfileManager.ProfileChangeListener +{ + private static final String VIEW_LIST = "LIST"; + private static final String VIEW_EDIT = "EDIT"; + + private final CardLayout cardLayout = new CardLayout(); + private final JPanel container = new JPanel(cardLayout); + + private final ProfileListPanel listPanel; + private final ProfileEditPanel editPanel; + private final ProfileManager profileManager; + + public ExamplePluginPanel(ExamplePlugin plugin, ProfileManager profileManager) + { + super(false); + this.profileManager = profileManager; + + setLayout(new BorderLayout()); + setBackground(ColorScheme.DARK_GRAY_COLOR); + + listPanel = new ProfileListPanel(plugin, profileManager, this); + editPanel = new ProfileEditPanel(plugin, profileManager, this); + + container.add(listPanel, VIEW_LIST); + container.add(editPanel, VIEW_EDIT); + + add(container, BorderLayout.CENTER); + + profileManager.addListener(this); + } + + public void showList() + { + cardLayout.show(container, VIEW_LIST); + listPanel.refreshList(); + } + + public void showEditor(Profile profile) + { + editPanel.editProfile(profile); + cardLayout.show(container, VIEW_EDIT); + } + + public void updateLiveInspector(Client client) + { + listPanel.updateLiveInspector(client); + } + + @Override + public void onActiveProfileChanged(Profile oldProfile, Profile newProfile) + { + listPanel.updateActiveBanner(newProfile); + listPanel.refreshList(); + } + + @Override + public void onProfileListUpdated() + { + listPanel.refreshList(); + } +} diff --git a/src/main/java/com/example/ui/ProfileEditPanel.java b/src/main/java/com/example/ui/ProfileEditPanel.java new file mode 100644 index 0000000..683a505 --- /dev/null +++ b/src/main/java/com/example/ui/ProfileEditPanel.java @@ -0,0 +1,677 @@ +package com.example.ui; + +import com.example.ExamplePlugin; +import com.example.ProfileManager; +import com.example.model.Folder; +import com.example.model.Profile; +import java.awt.BorderLayout; +import java.awt.Color; +import java.awt.Component; +import java.awt.Dimension; +import java.awt.FlowLayout; +import java.awt.GridLayout; +import java.util.ArrayList; +import java.util.List; +import javax.swing.Box; +import javax.swing.BoxLayout; +import javax.swing.JButton; +import javax.swing.JCheckBox; +import javax.swing.JComboBox; +import javax.swing.JLabel; +import javax.swing.JPanel; +import javax.swing.JScrollPane; +import javax.swing.JTextField; +import javax.swing.ScrollPaneConstants; +import javax.swing.SwingUtilities; +import javax.swing.border.CompoundBorder; +import javax.swing.border.EmptyBorder; +import javax.swing.border.LineBorder; +import net.runelite.api.Client; +import net.runelite.api.InventoryID; +import net.runelite.api.Item; +import net.runelite.api.ItemComposition; +import net.runelite.api.ItemContainer; +import net.runelite.api.coords.WorldPoint; +import net.runelite.api.widgets.ComponentID; +import net.runelite.api.widgets.Widget; +import net.runelite.client.game.ItemManager; +import net.runelite.client.ui.ColorScheme; + +public class ProfileEditPanel extends JPanel +{ + private final ExamplePlugin plugin; + private final ProfileManager profileManager; + private final ExamplePluginPanel parentPanel; + private Profile currentProfile; + + // 1. Allgemein & Ordner & Icon + private final JTextField nameInput = UIUtils.createTextField(""); + private final JCheckBox enabledCb = UIUtils.createCheckBox("Profil aktiv (automatisch prüfen)", true); + private final JComboBox folderCombo = new JComboBox<>(); + private final JTextField iconItemInput = UIUtils.createTextField(""); + private final JLabel iconPreviewLabel = new JLabel(); + + // 2. WENN: Bedingungen + private final JCheckBox requireBankOpenCb = UIUtils.createCheckBox("Bank muss GEÖFFNET sein", false); + private final JCheckBox requireBankClosedCb = UIUtils.createCheckBox("Bank muss GESCHLOSSEN sein", false); + private final JTextField bankTagInput = UIUtils.createTextField(""); + private final JTextField regionIdInput = UIUtils.createTextField(""); + private final JTextField coordinatesInput = UIUtils.createTextField(""); + private final JTextField requiredInventoryInput = UIUtils.createTextField(""); + private final JTextField requiredEquipmentInput = UIUtils.createTextField(""); + + // 3. DANN: Item Highlights + private final JCheckBox enableItemHighlightCb = UIUtils.createCheckBox("Items hervorheben (Highlight)", true); + private final JCheckBox highlightInBankCb = UIUtils.createCheckBox("In Bank", true); + private final JCheckBox highlightInInvCb = UIUtils.createCheckBox("Im Inventar", true); + private final JTextField highlightItemsInput = UIUtils.createTextField(""); + private final JTextField highlightColorInput = UIUtils.createTextField("#FF9800"); + private final JComboBox borderThicknessCombo = new JComboBox<>(new String[]{"1 px", "2 px", "3 px", "4 px"}); + private final JComboBox opacityCombo = new JComboBox<>(new String[]{"0 % (Nur Rahmen)", "20 %", "40 %", "60 %", "80 %", "100 %"}); + private final JCheckBox showTextLabelCb = UIUtils.createCheckBox("Mit Schrift / Text auf Item", false); + private final JTextField customTextLabelInput = UIUtils.createTextField(""); + + // 4. DANN: 3D Welt- & Objekt-Highlights + private final JCheckBox enableObjectHighlightCb = UIUtils.createCheckBox("Objekte in der 3D-Welt hervorheben", false); + private final JTextField highlightObjectsInput = UIUtils.createTextField("Bank booth, Bank chest, Banker"); + private final JTextField objectHighlightColorInput = UIUtils.createTextField("#00E5FF"); + private final JCheckBox showObjectTextCb = UIUtils.createCheckBox("Text über Objekt anzeigen", true); + private final JTextField objectCustomTextInput = UIUtils.createTextField("Bank öffnen"); + + // 5. DANN: 3D Boden-Tiles & Pfade + private final JCheckBox enableTileHighlightCb = UIUtils.createCheckBox("Boden-Kacheln (Ground Tiles) markieren", false); + private final JTextField highlightTilesInput = UIUtils.createTextField(""); + private final JTextField tileHighlightColorInput = UIUtils.createTextField("#2ECC71"); + private final JCheckBox showTileLabelCb = UIUtils.createCheckBox("3D Pfeil & Text über Kachel anzeigen", true); + private final JTextField customTileLabelInput = UIUtils.createTextField("Hier stehen"); + private final JCheckBox renderPathLineCb = UIUtils.createCheckBox("Pfad-Linie zur Kachel zeichnen", true); + + // 6. DANN: Benachrichtigungen & Feedback + private final JCheckBox notifyOnActivationCb = UIUtils.createCheckBox("Chat-Nachricht senden", true); + private final JTextField activationMessageInput = UIUtils.createTextField(""); + private final JCheckBox screenFlashCb = UIUtils.createCheckBox("Bildschirm-Flash Alert", false); + private final JTextField flashColorInput = UIUtils.createTextField("#FF9800"); + private final JCheckBox playSoundCb = UIUtils.createCheckBox("Sound-Effekt abspielen (Ding)", false); + + public ProfileEditPanel(ExamplePlugin plugin, ProfileManager profileManager, ExamplePluginPanel parentPanel) + { + this.plugin = plugin; + this.profileManager = profileManager; + this.parentPanel = parentPanel; + + setLayout(new BorderLayout()); + setBackground(ColorScheme.DARK_GRAY_COLOR); + + styleComboBox(folderCombo); + styleComboBox(borderThicknessCombo); + styleComboBox(opacityCombo); + } + + public void editProfile(Profile profile) + { + this.currentProfile = profile; + removeAll(); + + // Populate Folder Combo + folderCombo.removeAllItems(); + folderCombo.addItem("(Kein Ordner)"); + List folders = profileManager.getFolders(); + int selectedFolderIdx = 0; + for (int i = 0; i < folders.size(); i++) + { + Folder f = folders.get(i); + folderCombo.addItem(f.getName()); + if (f.getId().equals(profile.getFolderId())) + { + selectedFolderIdx = i + 1; + } + } + folderCombo.setSelectedIndex(selectedFolderIdx); + + // 1. Allgemein & Icon + nameInput.setText(profile.getName()); + enabledCb.setSelected(profile.isEnabled()); + iconItemInput.setText(profile.getIconItem()); + updateIconPreview(profile.getIconItem()); + + // 2. WENN + requireBankOpenCb.setSelected(profile.isRequireBankOpen()); + requireBankClosedCb.setSelected(profile.isRequireBankClosed()); + bankTagInput.setText(UIUtils.cleanBankTagName(profile.getBankTag())); + regionIdInput.setText(profile.getRegionId()); + coordinatesInput.setText(profile.getCoordinates()); + requiredInventoryInput.setText(profile.getRequiredInventory()); + requiredEquipmentInput.setText(profile.getRequiredEquipment()); + + // 3. DANN: Item Highlights + enableItemHighlightCb.setSelected(profile.isEnableItemHighlight()); + highlightInBankCb.setSelected(profile.isHighlightInBank()); + highlightInInvCb.setSelected(profile.isHighlightInInventory()); + highlightItemsInput.setText(profile.getHighlightItems()); + highlightColorInput.setText(profile.getHighlightColor()); + + int thickIdx = Math.max(0, Math.min(3, profile.getBorderThickness() - 1)); + borderThicknessCombo.setSelectedIndex(thickIdx); + + int op = profile.getFillOpacity(); + if (op == 0) opacityCombo.setSelectedIndex(0); + else if (op <= 60) opacityCombo.setSelectedIndex(1); + else if (op <= 110) opacityCombo.setSelectedIndex(2); + else if (op <= 160) opacityCombo.setSelectedIndex(3); + else if (op <= 210) opacityCombo.setSelectedIndex(4); + else opacityCombo.setSelectedIndex(5); + + showTextLabelCb.setSelected(profile.isShowTextLabel()); + customTextLabelInput.setText(profile.getCustomTextLabel()); + + // 4. DANN: Object Highlights + enableObjectHighlightCb.setSelected(profile.isEnableObjectHighlight()); + highlightObjectsInput.setText(profile.getHighlightObjects()); + objectHighlightColorInput.setText(profile.getObjectHighlightColor()); + showObjectTextCb.setSelected(profile.isShowObjectText()); + objectCustomTextInput.setText(profile.getObjectCustomText()); + + // 5. DANN: Tile & Path Highlights + enableTileHighlightCb.setSelected(profile.isEnableTileHighlight()); + highlightTilesInput.setText(profile.getHighlightTiles()); + tileHighlightColorInput.setText(profile.getTileHighlightColor()); + showTileLabelCb.setSelected(profile.isShowTileLabel()); + customTileLabelInput.setText(profile.getCustomTileLabel()); + renderPathLineCb.setSelected(profile.isRenderPathLine()); + + // 6. DANN: Feedback + notifyOnActivationCb.setSelected(profile.isNotifyOnActivation()); + activationMessageInput.setText(profile.getActivationMessage()); + screenFlashCb.setSelected(profile.isScreenFlash()); + flashColorInput.setText(profile.getFlashColor()); + playSoundCb.setSelected(profile.isPlaySound()); + + // Top-aligned content container + JPanel topWrapper = new JPanel(new BorderLayout()); + topWrapper.setBackground(ColorScheme.DARK_GRAY_COLOR); + + JPanel contentPanel = new JPanel(); + contentPanel.setLayout(new BoxLayout(contentPanel, BoxLayout.Y_AXIS)); + contentPanel.setBackground(ColorScheme.DARK_GRAY_COLOR); + contentPanel.setBorder(new EmptyBorder(3, 3, 3, 3)); + + // --- Nav Header --- + JPanel navHeader = new JPanel(new BorderLayout()); + navHeader.setBackground(UIUtils.CARD_BG); + navHeader.setBorder(new CompoundBorder( + new LineBorder(UIUtils.BORDER_SUBTLE, 1), + new EmptyBorder(3, 5, 3, 5) + )); + navHeader.setAlignmentX(Component.LEFT_ALIGNMENT); + + JButton backBtn = UIUtils.createButton("← Zurück", UIUtils.INPUT_BG, Color.WHITE); + backBtn.setFont(UIUtils.getSmallFont()); + backBtn.addActionListener(e -> parentPanel.showList()); + navHeader.add(backBtn, BorderLayout.WEST); + + JLabel titleLabel = new JLabel("Profil bearbeiten"); + titleLabel.setFont(UIUtils.getTitleFont()); + titleLabel.setForeground(UIUtils.ACCENT_COLOR); + navHeader.add(titleLabel, BorderLayout.EAST); + + contentPanel.add(navHeader); + contentPanel.add(Box.createRigidArea(new Dimension(0, 4))); + + // --- 1. Allgemein & Ordner & Icon --- + JPanel generalCard = UIUtils.createSectionCard("1. Allgemein & Icon"); + generalCard.add(UIUtils.createFieldLabel("Profilname:")); + generalCard.add(Box.createRigidArea(new Dimension(0, 2))); + generalCard.add(nameInput); + generalCard.add(Box.createRigidArea(new Dimension(0, 4))); + + generalCard.add(UIUtils.createFieldLabel("Ordner zuweisen:")); + generalCard.add(Box.createRigidArea(new Dimension(0, 2))); + generalCard.add(folderCombo); + generalCard.add(Box.createRigidArea(new Dimension(0, 4))); + + generalCard.add(UIUtils.createFieldLabel("Item-Icon (Name oder ID):")); + generalCard.add(Box.createRigidArea(new Dimension(0, 2))); + + JPanel iconRow = new JPanel(new BorderLayout(4, 0)); + iconRow.setBackground(UIUtils.CARD_BG); + iconRow.setAlignmentX(Component.LEFT_ALIGNMENT); + iconRow.add(iconItemInput, BorderLayout.CENTER); + iconRow.add(iconPreviewLabel, BorderLayout.EAST); + generalCard.add(iconRow); + generalCard.add(Box.createRigidArea(new Dimension(0, 4))); + + generalCard.add(enabledCb); + contentPanel.add(generalCard); + contentPanel.add(Box.createRigidArea(new Dimension(0, 4))); + + // --- 2. WENN: Bedingungen --- + JPanel condCard = UIUtils.createSectionCard("2. WENN: Bedingungen"); + + JPanel bankStatusPanel = new JPanel(new GridLayout(1, 2, 4, 0)); + bankStatusPanel.setBackground(UIUtils.CARD_BG); + bankStatusPanel.setAlignmentX(Component.LEFT_ALIGNMENT); + bankStatusPanel.add(requireBankOpenCb); + bankStatusPanel.add(requireBankClosedCb); + condCard.add(bankStatusPanel); + condCard.add(Box.createRigidArea(new Dimension(0, 4))); + + // Bank Tag + condCard.add(UIUtils.createFieldLabel("Bank-Tag / Tab-Titel:")); + condCard.add(Box.createRigidArea(new Dimension(0, 2))); + condCard.add(bankTagInput); + condCard.add(Box.createRigidArea(new Dimension(0, 2))); + condCard.add(UIUtils.createGrabButton("⤓ Aktuellen Bank-Titel übernehmen", () -> grabBankTitle())); + condCard.add(Box.createRigidArea(new Dimension(0, 4))); + + // Region ID + condCard.add(UIUtils.createFieldLabel("Region ID(s):")); + condCard.add(Box.createRigidArea(new Dimension(0, 2))); + condCard.add(regionIdInput); + condCard.add(Box.createRigidArea(new Dimension(0, 2))); + condCard.add(UIUtils.createGrabButton("⤓ Aktuelle Region übernehmen", () -> grabRegion())); + condCard.add(Box.createRigidArea(new Dimension(0, 4))); + + // Coordinates + condCard.add(UIUtils.createFieldLabel("Koordinaten (X, Y, Plane, Radius):")); + condCard.add(Box.createRigidArea(new Dimension(0, 2))); + condCard.add(coordinatesInput); + condCard.add(Box.createRigidArea(new Dimension(0, 2))); + condCard.add(UIUtils.createGrabButton("⤓ Aktuelle Position übernehmen", () -> grabCoords())); + condCard.add(Box.createRigidArea(new Dimension(0, 4))); + + // Required Inventory + condCard.add(UIUtils.createFieldLabel("Benötigte Inventar-Items:")); + condCard.add(Box.createRigidArea(new Dimension(0, 2))); + condCard.add(requiredInventoryInput); + condCard.add(Box.createRigidArea(new Dimension(0, 2))); + condCard.add(UIUtils.createGrabButton("⤓ Aktuelles Inventar übernehmen", () -> grabInventory())); + condCard.add(Box.createRigidArea(new Dimension(0, 4))); + + // Required Equipment + condCard.add(UIUtils.createFieldLabel("Benötigte Ausrüstung:")); + condCard.add(Box.createRigidArea(new Dimension(0, 2))); + condCard.add(requiredEquipmentInput); + condCard.add(Box.createRigidArea(new Dimension(0, 2))); + condCard.add(UIUtils.createGrabButton("⤓ Aktuelle Ausrüstung übernehmen", () -> grabEquipment())); + + contentPanel.add(condCard); + contentPanel.add(Box.createRigidArea(new Dimension(0, 4))); + + // --- 3. DANN: Item Highlights --- + JPanel highlightCard = UIUtils.createSectionCard("3. DANN: Item-Highlights"); + + highlightCard.add(enableItemHighlightCb); + highlightCard.add(Box.createRigidArea(new Dimension(0, 3))); + + JPanel wherePanel = new JPanel(new GridLayout(1, 2, 4, 0)); + wherePanel.setBackground(UIUtils.CARD_BG); + wherePanel.setAlignmentX(Component.LEFT_ALIGNMENT); + wherePanel.add(highlightInBankCb); + wherePanel.add(highlightInInvCb); + highlightCard.add(wherePanel); + highlightCard.add(Box.createRigidArea(new Dimension(0, 4))); + + highlightCard.add(UIUtils.createFieldLabel("Items (Namen/IDs, kommagetrennt):")); + highlightCard.add(Box.createRigidArea(new Dimension(0, 2))); + highlightCard.add(highlightItemsInput); + highlightCard.add(Box.createRigidArea(new Dimension(0, 4))); + + highlightCard.add(UIUtils.createFieldLabel("Highlight-Farbe (Hex z. B. #FF9800):")); + highlightCard.add(Box.createRigidArea(new Dimension(0, 2))); + highlightCard.add(highlightColorInput); + highlightCard.add(Box.createRigidArea(new Dimension(0, 4))); + + JPanel styleGrid = new JPanel(new GridLayout(2, 2, 3, 2)); + styleGrid.setBackground(UIUtils.CARD_BG); + styleGrid.setAlignmentX(Component.LEFT_ALIGNMENT); + styleGrid.add(UIUtils.createFieldLabel("Rahmendicke:")); + styleGrid.add(UIUtils.createFieldLabel("Füllung:")); + styleGrid.add(borderThicknessCombo); + styleGrid.add(opacityCombo); + highlightCard.add(styleGrid); + highlightCard.add(Box.createRigidArea(new Dimension(0, 4))); + + highlightCard.add(showTextLabelCb); + highlightCard.add(Box.createRigidArea(new Dimension(0, 2))); + highlightCard.add(UIUtils.createFieldLabel("Eigener Text (optional):")); + highlightCard.add(Box.createRigidArea(new Dimension(0, 2))); + highlightCard.add(customTextLabelInput); + + contentPanel.add(highlightCard); + contentPanel.add(Box.createRigidArea(new Dimension(0, 4))); + + // --- 4. DANN: 3D Welt- & Objekt-Highlights --- + JPanel objCard = UIUtils.createSectionCard("4. DANN: Welt- & Objekt-Highlights"); + + objCard.add(enableObjectHighlightCb); + objCard.add(Box.createRigidArea(new Dimension(0, 3))); + + objCard.add(UIUtils.createFieldLabel("Objekte/NPCs (z. B. Bank booth, Banker):")); + objCard.add(Box.createRigidArea(new Dimension(0, 2))); + objCard.add(highlightObjectsInput); + objCard.add(Box.createRigidArea(new Dimension(0, 4))); + + objCard.add(UIUtils.createFieldLabel("Objekt-Farbe (Hex z. B. #00E5FF):")); + objCard.add(Box.createRigidArea(new Dimension(0, 2))); + objCard.add(objectHighlightColorInput); + objCard.add(Box.createRigidArea(new Dimension(0, 4))); + + objCard.add(showObjectTextCb); + objCard.add(Box.createRigidArea(new Dimension(0, 2))); + objCard.add(UIUtils.createFieldLabel("Text über Objekt (z. B. 'Bank öffnen'):")); + objCard.add(Box.createRigidArea(new Dimension(0, 2))); + objCard.add(objectCustomTextInput); + + contentPanel.add(objCard); + contentPanel.add(Box.createRigidArea(new Dimension(0, 4))); + + // --- 5. DANN: 3D Boden-Kacheln & Pfade --- + JPanel tileCard = UIUtils.createSectionCard("5. DANN: Boden-Kacheln & Pfade"); + + tileCard.add(enableTileHighlightCb); + tileCard.add(Box.createRigidArea(new Dimension(0, 3))); + + tileCard.add(UIUtils.createFieldLabel("Kacheln (X, Y, Plane, Label; ...):")); + tileCard.add(Box.createRigidArea(new Dimension(0, 2))); + tileCard.add(highlightTilesInput); + tileCard.add(Box.createRigidArea(new Dimension(0, 2))); + tileCard.add(UIUtils.createGrabButton("⤓ Aktuelle Spieler-Kachel hinzufügen", () -> grabCurrentTile())); + tileCard.add(Box.createRigidArea(new Dimension(0, 4))); + + tileCard.add(UIUtils.createFieldLabel("Kachel-Farbe (Hex z. B. #2ECC71):")); + tileCard.add(Box.createRigidArea(new Dimension(0, 2))); + tileCard.add(tileHighlightColorInput); + tileCard.add(Box.createRigidArea(new Dimension(0, 4))); + + tileCard.add(showTileLabelCb); + tileCard.add(Box.createRigidArea(new Dimension(0, 2))); + tileCard.add(UIUtils.createFieldLabel("Standard-Text über Kachel:")); + tileCard.add(Box.createRigidArea(new Dimension(0, 2))); + tileCard.add(customTileLabelInput); + tileCard.add(Box.createRigidArea(new Dimension(0, 4))); + + tileCard.add(renderPathLineCb); + + contentPanel.add(tileCard); + contentPanel.add(Box.createRigidArea(new Dimension(0, 4))); + + // --- 6. DANN: Effekte & Benachrichtigungen --- + JPanel alertCard = UIUtils.createSectionCard("6. DANN: Alerts & Feedback"); + + alertCard.add(notifyOnActivationCb); + alertCard.add(Box.createRigidArea(new Dimension(0, 2))); + alertCard.add(activationMessageInput); + alertCard.add(Box.createRigidArea(new Dimension(0, 4))); + + alertCard.add(screenFlashCb); + alertCard.add(Box.createRigidArea(new Dimension(0, 2))); + alertCard.add(UIUtils.createFieldLabel("Flash-Farbe (Hex z. B. #FF9800):")); + alertCard.add(Box.createRigidArea(new Dimension(0, 2))); + alertCard.add(flashColorInput); + alertCard.add(Box.createRigidArea(new Dimension(0, 4))); + + alertCard.add(playSoundCb); + + contentPanel.add(alertCard); + contentPanel.add(Box.createRigidArea(new Dimension(0, 6))); + + // --- Bottom Sticky Buttons (Save & Cancel) --- + JPanel bottomPanel = new JPanel(new GridLayout(1, 2, 4, 0)); + bottomPanel.setBackground(ColorScheme.DARK_GRAY_COLOR); + bottomPanel.setAlignmentX(Component.LEFT_ALIGNMENT); + bottomPanel.setMaximumSize(new Dimension(Integer.MAX_VALUE, 26)); + + JButton cancelBtn = UIUtils.createButton("Abbrechen", UIUtils.CARD_BG, Color.WHITE); + cancelBtn.addActionListener(e -> parentPanel.showList()); + bottomPanel.add(cancelBtn); + + JButton saveBtn = UIUtils.createButton("💾 Speichern", UIUtils.ACCENT_COLOR, Color.WHITE); + saveBtn.addActionListener(e -> saveCurrentProfile()); + bottomPanel.add(saveBtn); + + contentPanel.add(bottomPanel); + contentPanel.add(Box.createRigidArea(new Dimension(0, 4))); + + topWrapper.add(contentPanel, BorderLayout.NORTH); + + JScrollPane scrollPane = new JScrollPane(topWrapper); + scrollPane.setHorizontalScrollBarPolicy(ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER); + scrollPane.setVerticalScrollBarPolicy(ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED); + scrollPane.setBorder(null); + scrollPane.setBackground(ColorScheme.DARK_GRAY_COLOR); + scrollPane.getVerticalScrollBar().setUnitIncrement(16); + + add(scrollPane, BorderLayout.CENTER); + revalidate(); + repaint(); + } + + private void updateIconPreview(String iconQuery) + { + iconPreviewLabel.setIcon(null); + iconPreviewLabel.setText(""); + if (iconQuery == null || iconQuery.trim().isEmpty() || plugin.getItemManager() == null) + { + return; + } + + int id = UIUtils.resolveItemId(iconQuery, plugin.getItemManager()); + if (id > 0) + { + plugin.getItemManager().getImage(id).addTo(iconPreviewLabel); + } + } + + private void styleComboBox(JComboBox combo) + { + combo.setFont(UIUtils.getSmallFont()); + combo.setBackground(UIUtils.INPUT_BG); + combo.setForeground(Color.WHITE); + combo.setPreferredSize(new Dimension(80, 24)); + combo.setMaximumSize(new Dimension(Integer.MAX_VALUE, 24)); + combo.setAlignmentX(Component.LEFT_ALIGNMENT); + } + + private void grabCurrentTile() + { + plugin.getClientThread().invokeLater(() -> + { + Client client = plugin.getClient(); + if (client != null && client.getLocalPlayer() != null) + { + WorldPoint wp = client.getLocalPlayer().getWorldLocation(); + String current = highlightTilesInput.getText().trim(); + String newEntry = String.format("%d, %d, %d, Ziel", wp.getX(), wp.getY(), wp.getPlane()); + final String combined = current.isEmpty() ? newEntry : current + "; " + newEntry; + SwingUtilities.invokeLater(() -> highlightTilesInput.setText(combined)); + } + }); + } + + private void grabBankTitle() + { + plugin.getClientThread().invokeLater(() -> + { + Client client = plugin.getClient(); + if (client == null) return; + + String rawTag = null; + if (plugin.getBankTagsService() != null) + { + try { rawTag = plugin.getBankTagsService().getActiveTag(); } + catch (Throwable ignored) {} + } + + if (rawTag == null || rawTag.trim().isEmpty()) + { + Widget titleWidget = client.getWidget(ComponentID.BANK_TITLE_BAR); + if (titleWidget != null && titleWidget.getText() != null) + { + rawTag = titleWidget.getText(); + } + } + + final String cleaned = UIUtils.cleanBankTagName(rawTag); + SwingUtilities.invokeLater(() -> + { + if (!cleaned.isEmpty()) bankTagInput.setText(cleaned); + }); + }); + } + + private void grabRegion() + { + plugin.getClientThread().invokeLater(() -> + { + Client client = plugin.getClient(); + if (client != null && client.getLocalPlayer() != null) + { + int reg = client.getLocalPlayer().getWorldLocation().getRegionID(); + SwingUtilities.invokeLater(() -> regionIdInput.setText(String.valueOf(reg))); + } + }); + } + + private void grabCoords() + { + plugin.getClientThread().invokeLater(() -> + { + Client client = plugin.getClient(); + if (client != null && client.getLocalPlayer() != null) + { + WorldPoint wp = client.getLocalPlayer().getWorldLocation(); + String coords = String.format("%d, %d, %d, 10", wp.getX(), wp.getY(), wp.getPlane()); + SwingUtilities.invokeLater(() -> coordinatesInput.setText(coords)); + } + }); + } + + private void grabInventory() + { + plugin.getClientThread().invokeLater(() -> + { + String itemsStr = getContainerItemsString(InventoryID.INVENTORY); + SwingUtilities.invokeLater(() -> + { + if (!itemsStr.isEmpty()) requiredInventoryInput.setText(itemsStr); + }); + }); + } + + private void grabEquipment() + { + plugin.getClientThread().invokeLater(() -> + { + String equipStr = getContainerItemsString(InventoryID.EQUIPMENT); + SwingUtilities.invokeLater(() -> + { + if (!equipStr.isEmpty()) requiredEquipmentInput.setText(equipStr); + }); + }); + } + + private String getContainerItemsString(InventoryID containerId) + { + Client client = plugin.getClient(); + ItemManager itemManager = plugin.getItemManager(); + if (client == null || itemManager == null) return ""; + + ItemContainer container = client.getItemContainer(containerId); + if (container == null || container.getItems() == null) return ""; + + List names = new ArrayList<>(); + for (Item item : container.getItems()) + { + if (item != null && item.getId() > 0) + { + try + { + ItemComposition def = itemManager.getItemComposition(item.getId()); + if (def != null && def.getName() != null && !def.getName().trim().isEmpty() && !def.getName().equalsIgnoreCase("null")) + { + if (!names.contains(def.getName())) names.add(def.getName()); + } + } + catch (Throwable ignored) {} + } + } + return String.join(", ", names); + } + + private void saveCurrentProfile() + { + if (currentProfile == null) return; + + String name = nameInput.getText().trim(); + if (!name.isEmpty()) currentProfile.setName(name); + + currentProfile.setEnabled(enabledCb.isSelected()); + + // Folder Assignment + int folderIdx = folderCombo.getSelectedIndex(); + if (folderIdx <= 0) + { + currentProfile.setFolderId(""); + } + else + { + List folders = profileManager.getFolders(); + if (folderIdx - 1 < folders.size()) + { + currentProfile.setFolderId(folders.get(folderIdx - 1).getId()); + } + } + + // Custom Item Icon + currentProfile.setIconItem(iconItemInput.getText().trim()); + + // WENN + currentProfile.setRequireBankOpen(requireBankOpenCb.isSelected()); + currentProfile.setRequireBankClosed(requireBankClosedCb.isSelected()); + currentProfile.setBankTag(UIUtils.cleanBankTagName(bankTagInput.getText().trim())); + currentProfile.setRegionId(regionIdInput.getText().trim()); + currentProfile.setCoordinates(coordinatesInput.getText().trim()); + currentProfile.setRequiredInventory(requiredInventoryInput.getText().trim()); + currentProfile.setRequiredEquipment(requiredEquipmentInput.getText().trim()); + + // DANN: Item Highlights + currentProfile.setEnableItemHighlight(enableItemHighlightCb.isSelected()); + currentProfile.setHighlightInBank(highlightInBankCb.isSelected()); + currentProfile.setHighlightInInventory(highlightInInvCb.isSelected()); + currentProfile.setHighlightItems(highlightItemsInput.getText().trim()); + currentProfile.setHighlightColor(highlightColorInput.getText().trim()); + currentProfile.setBorderThickness(borderThicknessCombo.getSelectedIndex() + 1); + + int[] opValues = new int[]{0, 50, 100, 150, 200, 255}; + int opIdx = opacityCombo.getSelectedIndex(); + currentProfile.setFillOpacity(opValues[Math.max(0, Math.min(5, opIdx))]); + + currentProfile.setShowTextLabel(showTextLabelCb.isSelected()); + currentProfile.setCustomTextLabel(customTextLabelInput.getText().trim()); + + // DANN: 3D Object Highlights + currentProfile.setEnableObjectHighlight(enableObjectHighlightCb.isSelected()); + currentProfile.setHighlightObjects(highlightObjectsInput.getText().trim()); + currentProfile.setObjectHighlightColor(objectHighlightColorInput.getText().trim()); + currentProfile.setShowObjectText(showObjectTextCb.isSelected()); + currentProfile.setObjectCustomText(objectCustomTextInput.getText().trim()); + + // DANN: 3D Tile & Path Highlights + currentProfile.setEnableTileHighlight(enableTileHighlightCb.isSelected()); + currentProfile.setHighlightTiles(highlightTilesInput.getText().trim()); + currentProfile.setTileHighlightColor(tileHighlightColorInput.getText().trim()); + currentProfile.setShowTileLabel(showTileLabelCb.isSelected()); + currentProfile.setCustomTileLabel(customTileLabelInput.getText().trim()); + currentProfile.setRenderPathLine(renderPathLineCb.isSelected()); + + // DANN: Alerts + currentProfile.setNotifyOnActivation(notifyOnActivationCb.isSelected()); + currentProfile.setActivationMessage(activationMessageInput.getText().trim()); + currentProfile.setScreenFlash(screenFlashCb.isSelected()); + currentProfile.setFlashColor(flashColorInput.getText().trim()); + currentProfile.setPlaySound(playSoundCb.isSelected()); + + profileManager.updateProfile(currentProfile); + parentPanel.showList(); + } +} diff --git a/src/main/java/com/example/ui/ProfileListPanel.java b/src/main/java/com/example/ui/ProfileListPanel.java new file mode 100644 index 0000000..a51a3a3 --- /dev/null +++ b/src/main/java/com/example/ui/ProfileListPanel.java @@ -0,0 +1,729 @@ +package com.example.ui; + +import com.example.ExamplePlugin; +import com.example.ProfileManager; +import com.example.model.Folder; +import com.example.model.Profile; +import java.awt.BorderLayout; +import java.awt.Color; +import java.awt.Component; +import java.awt.Cursor; +import java.awt.Dimension; +import java.awt.FlowLayout; +import java.awt.GridLayout; +import java.awt.Insets; +import java.awt.event.MouseAdapter; +import java.awt.event.MouseEvent; +import java.util.ArrayList; +import java.util.List; +import javax.swing.BorderFactory; +import javax.swing.Box; +import javax.swing.BoxLayout; +import javax.swing.JButton; +import javax.swing.JCheckBox; +import javax.swing.JLabel; +import javax.swing.JOptionPane; +import javax.swing.JPanel; +import javax.swing.JScrollPane; +import javax.swing.ScrollPaneConstants; +import javax.swing.SwingUtilities; +import javax.swing.border.CompoundBorder; +import javax.swing.border.EmptyBorder; +import javax.swing.border.LineBorder; +import net.runelite.api.Client; +import net.runelite.api.GameState; +import net.runelite.api.InventoryID; +import net.runelite.api.ItemContainer; +import net.runelite.api.coords.WorldPoint; +import net.runelite.api.widgets.ComponentID; +import net.runelite.api.widgets.Widget; +import net.runelite.client.ui.ColorScheme; +import net.runelite.client.util.Text; + +public class ProfileListPanel extends JPanel +{ + private final ExamplePlugin plugin; + private final ProfileManager profileManager; + private final ExamplePluginPanel parentPanel; + + private final JPanel activeBannerPanel = new JPanel(new BorderLayout()); + private final JLabel activeStatusLabel = new JLabel(); + private final JLabel activeDetailsLabel = new JLabel(); + + private final JPanel profileCardsContainer = new JPanel(); + private final JPanel liveInfoPanel = new JPanel(); + private final JLabel liveBankLabel = new JLabel(); + private final JLabel liveRegionLabel = new JLabel(); + private final JLabel liveCoordsLabel = new JLabel(); + private final JLabel liveItemsLabel = new JLabel(); + + public ProfileListPanel(ExamplePlugin plugin, ProfileManager profileManager, ExamplePluginPanel parentPanel) + { + this.plugin = plugin; + this.profileManager = profileManager; + this.parentPanel = parentPanel; + + setLayout(new BorderLayout()); + setBackground(ColorScheme.DARK_GRAY_COLOR); + + // Top-aligned content wrapper (pure vertical stack) + JPanel topWrapper = new JPanel(new BorderLayout()); + topWrapper.setBackground(ColorScheme.DARK_GRAY_COLOR); + + JPanel contentPanel = new JPanel(); + contentPanel.setLayout(new BoxLayout(contentPanel, BoxLayout.Y_AXIS)); + contentPanel.setBackground(ColorScheme.DARK_GRAY_COLOR); + contentPanel.setBorder(new EmptyBorder(3, 3, 3, 3)); + + // 1. Compact Header + JPanel headerPanel = new JPanel(new BorderLayout()); + headerPanel.setBackground(UIUtils.CARD_BG); + headerPanel.setBorder(new CompoundBorder( + new LineBorder(UIUtils.BORDER_SUBTLE, 1), + new EmptyBorder(3, 4, 3, 4) + )); + headerPanel.setAlignmentX(Component.LEFT_ALIGNMENT); + + JLabel title = new JLabel("Auto-Profile & Templates"); + title.setFont(UIUtils.getTitleFont()); + title.setForeground(UIUtils.ACCENT_COLOR); + headerPanel.add(title, BorderLayout.WEST); + + contentPanel.add(headerPanel); + contentPanel.add(Box.createRigidArea(new Dimension(0, 4))); + + // 2. Active Profile Banner Card + setupActiveBanner(); + contentPanel.add(activeBannerPanel); + contentPanel.add(Box.createRigidArea(new Dimension(0, 4))); + + // 3. Vertical Buttons Stack (Full Width, Zero Horizontal Overflow) + JPanel topButtonsPanel = new JPanel(); + topButtonsPanel.setLayout(new BoxLayout(topButtonsPanel, BoxLayout.Y_AXIS)); + topButtonsPanel.setBackground(ColorScheme.DARK_GRAY_COLOR); + topButtonsPanel.setAlignmentX(Component.LEFT_ALIGNMENT); + + JButton createBtn = UIUtils.createButton("+ Neues Profil erstellen", UIUtils.ACCENT_COLOR, Color.WHITE); + createBtn.setMaximumSize(new Dimension(Integer.MAX_VALUE, 26)); + createBtn.addActionListener(e -> + { + String name = JOptionPane.showInputDialog(this, "Name für das neue Profil:", "Neues Profil", JOptionPane.PLAIN_MESSAGE); + if (name != null && !name.trim().isEmpty()) + { + Profile p = profileManager.createProfile(name.trim()); + parentPanel.showEditor(p); + } + }); + topButtonsPanel.add(createBtn); + topButtonsPanel.add(Box.createRigidArea(new Dimension(0, 3))); + + JButton createFolderBtn = UIUtils.createButton("+ Neuer Ordner", new Color(35, 45, 55), Color.WHITE); + createFolderBtn.setMaximumSize(new Dimension(Integer.MAX_VALUE, 24)); + createFolderBtn.addActionListener(e -> + { + String name = JOptionPane.showInputDialog(this, "Name für den neuen Ordner:", "Neuer Ordner", JOptionPane.PLAIN_MESSAGE); + if (name != null && !name.trim().isEmpty()) + { + String iconItem = JOptionPane.showInputDialog(this, "Item-Name für Ordner-Icon (z. B. 'Prayer potion(4)'):", "Ordner-Icon", JOptionPane.PLAIN_MESSAGE); + profileManager.createFolder(name.trim(), iconItem != null ? iconItem.trim() : ""); + } + }); + topButtonsPanel.add(createFolderBtn); + topButtonsPanel.add(Box.createRigidArea(new Dimension(0, 3))); + + JButton templateLibraryBtn = UIUtils.createButton("⚡ Vorlagen & Workflows laden...", new Color(25, 40, 55), new Color(130, 200, 255)); + templateLibraryBtn.setMaximumSize(new Dimension(Integer.MAX_VALUE, 24)); + templateLibraryBtn.addActionListener(e -> showTemplateSelector()); + topButtonsPanel.add(templateLibraryBtn); + + contentPanel.add(topButtonsPanel); + contentPanel.add(Box.createRigidArea(new Dimension(0, 4))); + + // 4. Profiles & Folders Stream Container + profileCardsContainer.setLayout(new BoxLayout(profileCardsContainer, BoxLayout.Y_AXIS)); + profileCardsContainer.setBackground(ColorScheme.DARK_GRAY_COLOR); + profileCardsContainer.setAlignmentX(Component.LEFT_ALIGNMENT); + contentPanel.add(profileCardsContainer); + + // 5. Live Game Inspector Box + setupLiveInspector(); + contentPanel.add(Box.createRigidArea(new Dimension(0, 4))); + contentPanel.add(liveInfoPanel); + + topWrapper.add(contentPanel, BorderLayout.NORTH); + + JScrollPane scrollPane = new JScrollPane(topWrapper); + scrollPane.setHorizontalScrollBarPolicy(ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER); + scrollPane.setVerticalScrollBarPolicy(ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED); + scrollPane.setBorder(null); + scrollPane.setBackground(ColorScheme.DARK_GRAY_COLOR); + scrollPane.getVerticalScrollBar().setUnitIncrement(16); + + add(scrollPane, BorderLayout.CENTER); + + refreshList(); + } + + private void showTemplateSelector() + { + String[] options = { + "🌿 Herblore: Prayer Potions (8-Schritte Loop)", + "🍳 Cooking: Karambwan am Herd/Feuer", + "🏃 Agility: Seers' Rooftop Course (3D Hindernisse & Kacheln)" + }; + + String selection = (String) JOptionPane.showInputDialog( + this, + "Wähle ein fertiges Workflow-Template zum Laden:", + "Workflow-Vorlagen", + JOptionPane.PLAIN_MESSAGE, + null, + options, + options[0] + ); + + if (selection != null) + { + if (selection.startsWith("🌿 Herblore")) + { + profileManager.loadHerblorePrayerPotionWorkflow(); + } + else if (selection.startsWith("🍳 Cooking")) + { + profileManager.loadCookingKarambwanWorkflow(); + } + else if (selection.startsWith("🏃 Agility")) + { + profileManager.loadAgilitySeersWorkflow(); + } + } + } + + private void setupActiveBanner() + { + activeBannerPanel.setBackground(UIUtils.CARD_BG); + activeBannerPanel.setAlignmentX(Component.LEFT_ALIGNMENT); + + activeStatusLabel.setFont(UIUtils.getBoldFont()); + activeDetailsLabel.setFont(UIUtils.getSmallFont()); + activeDetailsLabel.setForeground(ColorScheme.LIGHT_GRAY_COLOR); + + JPanel bannerContent = new JPanel(); + bannerContent.setLayout(new BoxLayout(bannerContent, BoxLayout.Y_AXIS)); + bannerContent.setBackground(UIUtils.CARD_BG); + bannerContent.setBorder(new EmptyBorder(3, 4, 3, 4)); + bannerContent.add(activeStatusLabel); + bannerContent.add(Box.createRigidArea(new Dimension(0, 1))); + bannerContent.add(activeDetailsLabel); + + activeBannerPanel.add(bannerContent, BorderLayout.CENTER); + updateActiveBanner(profileManager.getActiveProfile()); + } + + private void setupLiveInspector() + { + liveInfoPanel.setLayout(new BoxLayout(liveInfoPanel, BoxLayout.Y_AXIS)); + liveInfoPanel.setBackground(UIUtils.CARD_BG); + liveInfoPanel.setBorder(new CompoundBorder( + new LineBorder(UIUtils.BORDER_SUBTLE, 1), + new EmptyBorder(3, 4, 3, 4) + )); + liveInfoPanel.setAlignmentX(Component.LEFT_ALIGNMENT); + + JLabel inspectorTitle = new JLabel("Live-Spielerdaten"); + inspectorTitle.setFont(UIUtils.getBoldFont()); + inspectorTitle.setForeground(UIUtils.ACCENT_COLOR); + liveInfoPanel.add(inspectorTitle); + liveInfoPanel.add(Box.createRigidArea(new Dimension(0, 1))); + + JPanel grid = new JPanel(new GridLayout(4, 1, 0, 1)); + grid.setBackground(UIUtils.CARD_BG); + grid.setAlignmentX(Component.LEFT_ALIGNMENT); + + liveBankLabel.setFont(UIUtils.getSmallFont()); + liveRegionLabel.setFont(UIUtils.getSmallFont()); + liveCoordsLabel.setFont(UIUtils.getSmallFont()); + liveItemsLabel.setFont(UIUtils.getSmallFont()); + + grid.add(liveBankLabel); + grid.add(liveRegionLabel); + grid.add(liveCoordsLabel); + grid.add(liveItemsLabel); + + liveInfoPanel.add(grid); + } + + public void updateActiveBanner(Profile active) + { + SwingUtilities.invokeLater(() -> + { + if (active != null) + { + activeBannerPanel.setBorder(new CompoundBorder( + new LineBorder(UIUtils.SUCCESS_GREEN, 1), + new EmptyBorder(0, 0, 0, 0) + )); + activeStatusLabel.setText("
● " + active.getName() + "
"); + + StringBuilder details = new StringBuilder(); + if (active.isRequireBankOpen()) + { + details.append("Bank "); + } + String cleanTag = UIUtils.cleanBankTagName(active.getBankTag()); + if (!cleanTag.isEmpty()) + { + details.append("• Tag: '").append(cleanTag).append("' "); + } + if (!active.getRegionId().isEmpty()) + { + details.append("• Reg: ").append(active.getRegionId()).append(" "); + } + if (active.isEnableObjectHighlight() && !active.getHighlightObjects().isEmpty()) + { + details.append("• 3D OBJ "); + } + if (active.isEnableTileHighlight() && !active.getHighlightTiles().isEmpty()) + { + details.append("• TILE "); + } + activeDetailsLabel.setText("
" + (details.length() > 0 ? details.toString() : "Bedingungen aktiv") + "
"); + } + else + { + activeBannerPanel.setBorder(new CompoundBorder( + new LineBorder(UIUtils.BORDER_SUBTLE, 1), + new EmptyBorder(0, 0, 0, 0) + )); + activeStatusLabel.setText("
○ Kein Profil aktiv
"); + activeDetailsLabel.setText("
Bedingungen nicht erfüllt
"); + } + activeBannerPanel.revalidate(); + activeBannerPanel.repaint(); + }); + } + + public void updateLiveInspector(Client client) + { + if (client == null || client.getGameState() != GameState.LOGGED_IN || client.getLocalPlayer() == null) + { + SwingUtilities.invokeLater(() -> + { + liveBankLabel.setText("Bank: Nicht eingeloggt"); + liveRegionLabel.setText(""); + liveCoordsLabel.setText(""); + liveItemsLabel.setText(""); + }); + return; + } + + try + { + WorldPoint wp = client.getLocalPlayer().getWorldLocation(); + int region = wp != null ? wp.getRegionID() : 0; + int x = wp != null ? wp.getX() : 0; + int y = wp != null ? wp.getY() : 0; + int plane = wp != null ? wp.getPlane() : 0; + + Widget bankWidget = client.getWidget(ComponentID.BANK_CONTAINER); + boolean bankOpen = (bankWidget != null && !bankWidget.isSelfHidden()); + + String bankTitle = "Keiner"; + if (bankOpen) + { + String rawTag = ""; + if (plugin.getBankTagsService() != null) + { + try + { + rawTag = plugin.getBankTagsService().getActiveTag(); + } + catch (Throwable ignored) + { + } + } + + if (rawTag == null || rawTag.isEmpty()) + { + Widget titleWidget = client.getWidget(ComponentID.BANK_TITLE_BAR); + if (titleWidget != null && !titleWidget.isSelfHidden() && titleWidget.getText() != null) + { + rawTag = titleWidget.getText(); + } + } + + String clean = UIUtils.cleanBankTagName(rawTag); + if (!clean.isEmpty()) + { + bankTitle = clean; + } + else if (rawTag != null && !rawTag.isEmpty()) + { + bankTitle = Text.removeTags(rawTag).trim(); + } + } + + ItemContainer inv = client.getItemContainer(InventoryID.INVENTORY); + int invCount = (inv != null && inv.getItems() != null) ? inv.count() : 0; + + ItemContainer equip = client.getItemContainer(InventoryID.EQUIPMENT); + int equipCount = (equip != null && equip.getItems() != null) ? equip.count() : 0; + + final boolean finalBankOpen = bankOpen; + final String finalBankTitle = bankTitle; + + SwingUtilities.invokeLater(() -> + { + liveBankLabel.setText(String.format( + "Bank: %s (%s)", + finalBankOpen ? "Offen" : "Zu", + finalBankTitle + )); + liveRegionLabel.setText(String.format("Region: %d", region)); + liveCoordsLabel.setText(String.format("Koord: %d, %d, %d", x, y, plane)); + liveItemsLabel.setText(String.format("Items: Inv: %d | Equip: %d", invCount, equipCount)); + liveInfoPanel.revalidate(); + }); + } + catch (Throwable ignored) + { + } + } + + public void refreshList() + { + SwingUtilities.invokeLater(() -> + { + profileCardsContainer.removeAll(); + List allProfiles = profileManager.getProfiles(); + List folders = profileManager.getFolders(); + + Profile active = profileManager.getActiveProfile(); + updateActiveBanner(active); + + if (allProfiles.isEmpty() && folders.isEmpty()) + { + JPanel emptyPanel = new JPanel(); + emptyPanel.setBackground(UIUtils.CARD_BG); + emptyPanel.setBorder(new EmptyBorder(6, 6, 6, 6)); + JLabel empty = new JLabel("Keine Profile oder Ordner vorhanden."); + empty.setFont(UIUtils.getLabelFont()); + empty.setForeground(ColorScheme.LIGHT_GRAY_COLOR); + emptyPanel.add(empty); + profileCardsContainer.add(emptyPanel); + } + else + { + // 1. Render Folders as Stream Headers (NO heavy nesting to preserve 100% width) + for (Folder folder : folders) + { + List folderProfiles = new ArrayList<>(); + for (Profile p : allProfiles) + { + if (folder.getId().equals(p.getFolderId())) + { + folderProfiles.add(p); + } + } + + // Folder Header + profileCardsContainer.add(createFolderHeader(folder, folderProfiles.size())); + profileCardsContainer.add(Box.createRigidArea(new Dimension(0, 2))); + + // Unnested child profiles (full width available!) + if (!folder.isCollapsed()) + { + for (Profile profile : folderProfiles) + { + profileCardsContainer.add(createProfileCard(profile, true)); + profileCardsContainer.add(Box.createRigidArea(new Dimension(0, 2))); + } + } + profileCardsContainer.add(Box.createRigidArea(new Dimension(0, 3))); + } + + // 2. Render Uncategorized Profiles + List uncategorized = new ArrayList<>(); + for (Profile p : allProfiles) + { + if (p.getFolderId() == null || p.getFolderId().isEmpty() || profileManager.getFolderById(p.getFolderId()) == null) + { + uncategorized.add(p); + } + } + + if (!uncategorized.isEmpty()) + { + if (!folders.isEmpty()) + { + JLabel uncatLabel = new JLabel("Einzelne Profile:"); + uncatLabel.setFont(UIUtils.getBoldFont()); + uncatLabel.setForeground(ColorScheme.LIGHT_GRAY_COLOR); + uncatLabel.setBorder(new EmptyBorder(3, 2, 2, 2)); + profileCardsContainer.add(uncatLabel); + profileCardsContainer.add(Box.createRigidArea(new Dimension(0, 2))); + } + + for (Profile profile : uncategorized) + { + profileCardsContainer.add(createProfileCard(profile, false)); + profileCardsContainer.add(Box.createRigidArea(new Dimension(0, 2))); + } + } + } + + profileCardsContainer.revalidate(); + profileCardsContainer.repaint(); + }); + } + + private JPanel createFolderHeader(Folder folder, int count) + { + JPanel header = new JPanel(new BorderLayout(3, 0)); + header.setBackground(UIUtils.FOLDER_BG); + header.setBorder(new CompoundBorder( + new LineBorder(folder.isEnabled() ? new Color(60, 75, 90) : UIUtils.BORDER_SUBTLE, 1), + new EmptyBorder(3, 4, 3, 4) + )); + header.setAlignmentX(Component.LEFT_ALIGNMENT); + header.setMaximumSize(new Dimension(Integer.MAX_VALUE, 28)); + + // Left: Arrow + Icon + JPanel left = new JPanel(new FlowLayout(FlowLayout.LEFT, 2, 0)); + left.setBackground(UIUtils.FOLDER_BG); + left.setCursor(Cursor.getPredefinedCursor(Cursor.HAND_CURSOR)); + + JLabel arrow = new JLabel(folder.isCollapsed() ? "▶" : "▼"); + arrow.setFont(UIUtils.getSmallFont()); + arrow.setForeground(ColorScheme.LIGHT_GRAY_COLOR); + left.add(arrow); + + if (folder.getIconItem() != null && !folder.getIconItem().isEmpty()) + { + JLabel icon = UIUtils.createItemIconLabel(folder.getIconItem(), plugin.getItemManager()); + left.add(icon); + } + header.add(left, BorderLayout.WEST); + + // Center: Name (click to collapse/expand) + JLabel name = new JLabel(String.format("
%s (%d)
", folder.getName(), count)); + name.setFont(UIUtils.getBoldFont()); + name.setForeground(folder.isEnabled() ? Color.WHITE : Color.GRAY); + name.setCursor(Cursor.getPredefinedCursor(Cursor.HAND_CURSOR)); + name.addMouseListener(new MouseAdapter() + { + @Override + public void mouseClicked(MouseEvent e) + { + folder.setCollapsed(!folder.isCollapsed()); + profileManager.saveProfiles(); + } + }); + header.add(name, BorderLayout.CENTER); + + // Right: Checkbox + Delete + JPanel right = new JPanel(new FlowLayout(FlowLayout.RIGHT, 2, 0)); + right.setBackground(UIUtils.FOLDER_BG); + + JCheckBox enableCb = new JCheckBox("", folder.isEnabled()); + enableCb.setToolTipText(folder.isEnabled() ? "Ordner deaktivieren" : "Ordner aktivieren"); + enableCb.setBackground(UIUtils.FOLDER_BG); + enableCb.setMargin(new Insets(0, 0, 0, 0)); + enableCb.addActionListener(e -> + { + folder.setEnabled(enableCb.isSelected()); + profileManager.saveProfiles(); + }); + right.add(enableCb); + + JButton delBtn = UIUtils.createButton("✕", UIUtils.INPUT_BG, Color.GRAY); + delBtn.setPreferredSize(new Dimension(18, 18)); + delBtn.setToolTipText("Ordner löschen"); + delBtn.addActionListener(e -> + { + int confirm = JOptionPane.showConfirmDialog(this, "Ordner '" + folder.getName() + "' löschen? (Profile bleiben erhalten)", "Ordner löschen", JOptionPane.YES_NO_OPTION); + if (confirm == JOptionPane.YES_OPTION) + { + profileManager.deleteFolder(folder); + } + }); + right.add(delBtn); + + header.add(right, BorderLayout.EAST); + return header; + } + + private JPanel createProfileCard(Profile profile, boolean inFolder) + { + JPanel card = new JPanel(); + card.setLayout(new BoxLayout(card, BoxLayout.Y_AXIS)); + card.setBackground(UIUtils.CARD_BG); + card.setAlignmentX(Component.LEFT_ALIGNMENT); + + boolean isActive = profileManager.getActiveProfile() != null && + profileManager.getActiveProfile().getId().equals(profile.getId()); + + if (isActive) + { + card.setBorder(new CompoundBorder( + new LineBorder(UIUtils.SUCCESS_GREEN, 1), + new EmptyBorder(2, 3, 2, 3) + )); + } + else if (inFolder) + { + // Left accent line for folder items (NO nested indentation, 100% full width preserved!) + card.setBorder(new CompoundBorder( + BorderFactory.createMatteBorder(0, 3, 0, 0, new Color(45, 65, 85)), + new CompoundBorder( + new LineBorder(UIUtils.BORDER_SUBTLE, 1), + new EmptyBorder(2, 3, 2, 3) + ) + )); + } + else + { + card.setBorder(new CompoundBorder( + new LineBorder(UIUtils.BORDER_SUBTLE, 1), + new EmptyBorder(2, 3, 2, 3) + )); + } + + // Row 1: Profile Icon (WEST) + Name (CENTER) + Enabled Checkbox (EAST) + JPanel topRow = new JPanel(new BorderLayout(3, 0)); + topRow.setBackground(UIUtils.CARD_BG); + + String iconQuery = profile.getIconItem(); + if (iconQuery == null || iconQuery.isEmpty()) + { + if (profile.getHighlightItems() != null && !profile.getHighlightItems().isEmpty()) + { + iconQuery = profile.getHighlightItems().split(",")[0].trim(); + } + } + + if (iconQuery != null && !iconQuery.isEmpty()) + { + JLabel iconLabel = UIUtils.createItemIconLabel(iconQuery, plugin.getItemManager()); + topRow.add(iconLabel, BorderLayout.WEST); + } + + JLabel nameLabel = new JLabel(String.format("
%s
", profile.getName())); + nameLabel.setFont(UIUtils.getBoldFont()); + nameLabel.setForeground(isActive ? UIUtils.SUCCESS_GREEN : Color.WHITE); + topRow.add(nameLabel, BorderLayout.CENTER); + + JCheckBox enableCb = new JCheckBox("", profile.isEnabled()); + enableCb.setToolTipText(profile.isEnabled() ? "Profil aktiv" : "Profil inaktiv"); + enableCb.setBackground(UIUtils.CARD_BG); + enableCb.setMargin(new Insets(0, 0, 0, 0)); + enableCb.setBorder(null); + enableCb.addActionListener(e -> + { + profile.setEnabled(enableCb.isSelected()); + profileManager.saveProfiles(); + }); + topRow.add(enableCb, BorderLayout.EAST); + + card.add(topRow); + card.add(Box.createRigidArea(new Dimension(0, 2))); + + // Row 2: Badges in wrapping FlowLayout + JPanel badgesPanel = new JPanel(new FlowLayout(FlowLayout.LEFT, 2, 1)); + badgesPanel.setBackground(UIUtils.CARD_BG); + badgesPanel.setAlignmentX(Component.LEFT_ALIGNMENT); + + if (profile.isRequireBankOpen()) + { + badgesPanel.add(UIUtils.createBadge("BANK", new Color(40, 50, 60), new Color(130, 180, 240))); + } + if (profile.isRequireBankClosed()) + { + badgesPanel.add(UIUtils.createBadge("BANK ZU", new Color(55, 35, 35), new Color(240, 120, 120))); + } + + String cleanTag = UIUtils.cleanBankTagName(profile.getBankTag()); + if (!cleanTag.isEmpty()) + { + badgesPanel.add(UIUtils.createBadge(cleanTag, new Color(50, 45, 25), UIUtils.ACCENT_COLOR)); + } + if (!profile.getRegionId().isEmpty()) + { + badgesPanel.add(UIUtils.createBadge("REG: " + profile.getRegionId(), new Color(40, 40, 40), Color.LIGHT_GRAY)); + } + if (!profile.getRequiredInventory().isEmpty()) + { + badgesPanel.add(UIUtils.createBadge("INV", new Color(45, 35, 50), new Color(190, 140, 220))); + } + if (profile.isEnableObjectHighlight() && !profile.getHighlightObjects().isEmpty()) + { + badgesPanel.add(UIUtils.createBadge("3D OBJ", new Color(20, 45, 55), new Color(0, 229, 255))); + } + if (profile.isEnableTileHighlight() && !profile.getHighlightTiles().isEmpty()) + { + badgesPanel.add(UIUtils.createBadge("TILE", new Color(25, 50, 35), new Color(46, 204, 113))); + } + + if (badgesPanel.getComponentCount() > 0) + { + card.add(badgesPanel); + card.add(Box.createRigidArea(new Dimension(0, 2))); + } + + // Row 3: Action Toolbar ( ▲ ▼ [Bearbeiten] [Löschen] ) + JPanel actionRow = new JPanel(new BorderLayout(2, 0)); + actionRow.setBackground(UIUtils.CARD_BG); + actionRow.setAlignmentX(Component.LEFT_ALIGNMENT); + actionRow.setPreferredSize(new Dimension(100, 20)); + actionRow.setMaximumSize(new Dimension(Integer.MAX_VALUE, 20)); + + JPanel moveButtons = new JPanel(new GridLayout(1, 2, 1, 0)); + moveButtons.setBackground(UIUtils.CARD_BG); + + JButton upBtn = UIUtils.createButton("▲", UIUtils.INPUT_BG, Color.LIGHT_GRAY); + upBtn.setPreferredSize(new Dimension(18, 18)); + upBtn.setToolTipText("Priorität nach oben"); + upBtn.addActionListener(e -> profileManager.moveProfileUp(profile)); + moveButtons.add(upBtn); + + JButton downBtn = UIUtils.createButton("▼", UIUtils.INPUT_BG, Color.LIGHT_GRAY); + downBtn.setPreferredSize(new Dimension(18, 18)); + downBtn.setToolTipText("Priorität nach unten"); + downBtn.addActionListener(e -> profileManager.moveProfileDown(profile)); + moveButtons.add(downBtn); + + actionRow.add(moveButtons, BorderLayout.WEST); + + JPanel rightActions = new JPanel(new BorderLayout(2, 0)); + rightActions.setBackground(UIUtils.CARD_BG); + + JButton editBtn = UIUtils.createButton("Bearbeiten", UIUtils.INPUT_BG, Color.WHITE); + editBtn.setFont(UIUtils.getSmallFont()); + editBtn.addActionListener(e -> parentPanel.showEditor(profile)); + rightActions.add(editBtn, BorderLayout.CENTER); + + JButton deleteBtn = UIUtils.createButton("Löschen", UIUtils.DANGER_RED, Color.WHITE); + deleteBtn.setFont(UIUtils.getSmallFont()); + deleteBtn.setPreferredSize(new Dimension(48, 18)); + deleteBtn.setToolTipText("Profil löschen"); + deleteBtn.addActionListener(e -> + { + int confirm = JOptionPane.showConfirmDialog( + this, + "Möchtest du das Profil '" + profile.getName() + "' wirklich löschen?", + "Profil löschen", + JOptionPane.YES_NO_OPTION + ); + if (confirm == JOptionPane.YES_OPTION) + { + profileManager.deleteProfile(profile); + } + }); + rightActions.add(deleteBtn, BorderLayout.EAST); + + actionRow.add(rightActions, BorderLayout.CENTER); + + card.add(actionRow); + return card; + } +} diff --git a/src/main/java/com/example/ui/UIUtils.java b/src/main/java/com/example/ui/UIUtils.java new file mode 100644 index 0000000..00994e8 --- /dev/null +++ b/src/main/java/com/example/ui/UIUtils.java @@ -0,0 +1,335 @@ +package com.example.ui; + +import java.awt.BorderLayout; +import java.awt.Color; +import java.awt.Component; +import java.awt.Cursor; +import java.awt.Dimension; +import java.awt.Font; +import java.awt.Image; +import java.awt.Insets; +import java.awt.event.MouseAdapter; +import java.awt.event.MouseEvent; +import java.util.List; +import javax.swing.BorderFactory; +import javax.swing.Box; +import javax.swing.BoxLayout; +import javax.swing.ImageIcon; +import javax.swing.JButton; +import javax.swing.JCheckBox; +import javax.swing.JLabel; +import javax.swing.JPanel; +import javax.swing.JTextField; +import javax.swing.border.CompoundBorder; +import javax.swing.border.EmptyBorder; +import javax.swing.border.LineBorder; +import net.runelite.client.game.ItemManager; +import net.runelite.client.ui.ColorScheme; +import net.runelite.client.ui.FontManager; +import net.runelite.client.util.AsyncBufferedImage; +import net.runelite.client.util.Text; +import net.runelite.http.api.item.ItemPrice; + +public class UIUtils +{ + public static final Color ACCENT_COLOR = new Color(225, 145, 20); // Warm RuneLite Orange + public static final Color ACCENT_HOVER = new Color(245, 165, 40); + public static final Color SUCCESS_GREEN = new Color(46, 204, 113); + public static final Color DANGER_RED = new Color(220, 60, 50); + public static final Color DANGER_RED_HOVER = new Color(195, 45, 35); + public static final Color CARD_BG = new Color(30, 30, 30); + public static final Color FOLDER_BG = new Color(24, 28, 34); + public static final Color INPUT_BG = new Color(20, 20, 20); + public static final Color BORDER_SUBTLE = new Color(45, 45, 45); + + public static Font getTitleFont() + { + return FontManager.getDefaultBoldFont().deriveFont(12f); + } + + public static Font getLabelFont() + { + return FontManager.getDefaultFont().deriveFont(10.5f); + } + + public static Font getBoldFont() + { + return FontManager.getDefaultBoldFont().deriveFont(10.5f); + } + + public static Font getSmallFont() + { + return FontManager.getDefaultFont().deriveFont(9.5f); + } + + public static Font getBadgeFont() + { + return FontManager.getDefaultBoldFont().deriveFont(8.5f); + } + + public static int resolveItemId(String itemQuery, ItemManager itemManager) + { + if (itemQuery == null || itemQuery.trim().isEmpty() || itemManager == null) + { + return -1; + } + + String q = itemQuery.trim(); + try + { + return Integer.parseInt(q); + } + catch (NumberFormatException ignored) + { + } + + try + { + List results = itemManager.search(q); + if (results != null && !results.isEmpty()) + { + for (ItemPrice price : results) + { + if (price.getName().equalsIgnoreCase(q)) + { + return price.getId(); + } + } + return results.get(0).getId(); + } + } + catch (Throwable ignored) + { + } + + return -1; + } + + public static JLabel createItemIconLabel(String iconQuery, ItemManager itemManager) + { + JLabel label = new JLabel(); + label.setPreferredSize(new Dimension(20, 20)); + label.setMinimumSize(new Dimension(20, 20)); + label.setMaximumSize(new Dimension(20, 20)); + + if (itemManager == null || iconQuery == null || iconQuery.trim().isEmpty()) + { + return label; + } + + int itemId = resolveItemId(iconQuery, itemManager); + if (itemId > 0) + { + AsyncBufferedImage img = itemManager.getImage(itemId); + img.addTo(label); + } + + return label; + } + + public static String cleanBankTagName(String raw) + { + if (raw == null) + { + return ""; + } + + String cleaned = Text.removeTags(raw).trim(); + String lower = cleaned.toLowerCase(); + if (lower.startsWith("tag tab ")) + { + cleaned = cleaned.substring(8).trim(); + } + else if (lower.startsWith("bank tag: ")) + { + cleaned = cleaned.substring(10).trim(); + } + else if (lower.startsWith("tag: ")) + { + cleaned = cleaned.substring(5).trim(); + } + else if (lower.startsWith("tab: ")) + { + cleaned = cleaned.substring(5).trim(); + } + else if (cleaned.equalsIgnoreCase("The Bank of Gielinor")) + { + return ""; + } + + cleaned = cleaned.replaceAll("\\s*\\([^)]*\\)$", "").trim(); + return cleaned; + } + + public static JPanel createCard() + { + JPanel panel = new JPanel(); + panel.setLayout(new BoxLayout(panel, BoxLayout.Y_AXIS)); + panel.setBackground(CARD_BG); + panel.setBorder(new CompoundBorder( + new LineBorder(BORDER_SUBTLE, 1), + new EmptyBorder(4, 4, 4, 4) + )); + panel.setAlignmentX(Component.LEFT_ALIGNMENT); + return panel; + } + + public static JPanel createSectionCard(String title) + { + JPanel panel = createCard(); + + JLabel label = new JLabel(title); + label.setFont(getTitleFont()); + label.setForeground(ACCENT_COLOR); + label.setAlignmentX(Component.LEFT_ALIGNMENT); + + panel.add(label); + panel.add(Box.createRigidArea(new Dimension(0, 4))); + return panel; + } + + public static JLabel createFieldLabel(String text) + { + JLabel label = new JLabel(text); + label.setFont(getLabelFont()); + label.setForeground(ColorScheme.LIGHT_GRAY_COLOR); + label.setAlignmentX(Component.LEFT_ALIGNMENT); + return label; + } + + public static JTextField createTextField(String text) + { + JTextField tf = new JTextField(text != null ? text : ""); + tf.setFont(getLabelFont()); + tf.setBackground(INPUT_BG); + tf.setForeground(Color.WHITE); + tf.setCaretColor(Color.WHITE); + tf.setBorder(new CompoundBorder( + new LineBorder(BORDER_SUBTLE, 1), + new EmptyBorder(2, 4, 2, 4) + )); + tf.setPreferredSize(new Dimension(100, 22)); + tf.setMaximumSize(new Dimension(Integer.MAX_VALUE, 22)); + tf.setAlignmentX(Component.LEFT_ALIGNMENT); + return tf; + } + + public static JCheckBox createCheckBox(String text, boolean selected) + { + JCheckBox cb = new JCheckBox(text, selected); + cb.setFont(getLabelFont()); + cb.setBackground(CARD_BG); + cb.setForeground(Color.WHITE); + cb.setFocusPainted(false); + cb.setMargin(new Insets(0, 0, 0, 0)); + cb.setBorder(null); + cb.setAlignmentX(Component.LEFT_ALIGNMENT); + cb.setCursor(Cursor.getPredefinedCursor(Cursor.HAND_CURSOR)); + return cb; + } + + public static JButton createButton(String text, Color bg, Color fg) + { + JButton btn = new JButton(text); + btn.setFont(getSmallFont()); + btn.setBackground(bg); + btn.setForeground(fg); + btn.setFocusPainted(false); + btn.setMargin(new Insets(1, 2, 1, 2)); + btn.setBorder(new CompoundBorder( + new LineBorder(BORDER_SUBTLE, 1), + new EmptyBorder(2, 3, 2, 3) + )); + btn.setCursor(Cursor.getPredefinedCursor(Cursor.HAND_CURSOR)); + btn.setAlignmentX(Component.LEFT_ALIGNMENT); + + btn.addMouseListener(new MouseAdapter() + { + @Override + public void mouseEntered(MouseEvent e) + { + if (bg.equals(ACCENT_COLOR)) + { + btn.setBackground(ACCENT_HOVER); + } + else if (bg.equals(DANGER_RED)) + { + btn.setBackground(DANGER_RED_HOVER); + } + else + { + btn.setBackground(ColorScheme.DARKER_GRAY_HOVER_COLOR); + } + } + + @Override + public void mouseExited(MouseEvent e) + { + btn.setBackground(bg); + } + }); + + return btn; + } + + public static JButton createGrabButton(String text, Runnable action) + { + JButton btn = new JButton(text); + btn.setFont(getSmallFont()); + btn.setBackground(new Color(25, 30, 35)); + btn.setForeground(ACCENT_COLOR); + btn.setFocusPainted(false); + btn.setMargin(new Insets(2, 3, 2, 3)); + btn.setBorder(new CompoundBorder( + new LineBorder(new Color(50, 60, 70), 1), + new EmptyBorder(2, 3, 2, 3) + )); + btn.setCursor(Cursor.getPredefinedCursor(Cursor.HAND_CURSOR)); + btn.setAlignmentX(Component.LEFT_ALIGNMENT); + btn.setMaximumSize(new Dimension(Integer.MAX_VALUE, 22)); + + btn.addMouseListener(new MouseAdapter() + { + @Override + public void mouseEntered(MouseEvent e) + { + btn.setBackground(new Color(35, 45, 55)); + } + + @Override + public void mouseExited(MouseEvent e) + { + btn.setBackground(new Color(25, 30, 35)); + } + }); + + btn.addActionListener(e -> action.run()); + return btn; + } + + public static JPanel createBadge(String text, Color bg, Color fg) + { + JPanel badge = new JPanel(new BorderLayout()); + badge.setBackground(bg); + badge.setBorder(new EmptyBorder(1, 2, 1, 2)); + + String displayText = cleanBankTagName(text); + if (displayText.isEmpty()) + { + displayText = text; + } + + if (displayText.length() > 8) + { + badge.setToolTipText(displayText); + displayText = displayText.substring(0, 7) + ".."; + } + + JLabel label = new JLabel(displayText, JLabel.CENTER); + label.setFont(getBadgeFont()); + label.setForeground(fg); + badge.add(label, BorderLayout.CENTER); + + return badge; + } +} diff --git a/src/main/resources/com/example/essentialsplus/icon.png b/src/main/resources/com/example/essentialsplus/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..173155416142ef6d1bd857d2c7c9cf63bb904ad2 GIT binary patch literal 269 zcmeAS@N?(olHy`uVBq!ia0vp^3LwnE1|*BCs=ffJE1oWnAr*6y6C@H2bohRF=rZyD zmJL33|9RVLeZFjxnSQh9hKPl)P_-X7`IW>&94l62*vns1JYbTsxM9+QeV0G;%hhVB`kZGk