From 1353e791656ba230180c2e99f2a646a0d30cafba Mon Sep 17 00:00:00 2001 From: RednedEpic Date: Sun, 18 Aug 2024 20:20:08 -0500 Subject: [PATCH] Initial commit --- .github/FUNDING.yml | 14 + .github/workflows/build.yml | 33 + .gitignore | 45 ++ LICENSE | 674 +++++++++++++++++ README.md | 27 + build.gradle.kts | 138 ++++ gradle/libs.versions.toml | 12 + gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 60756 bytes gradle/wrapper/gradle-wrapper.properties | 6 + gradlew | 234 ++++++ gradlew.bat | 89 +++ settings.gradle.kts | 15 + .../battleplugins/tracker/BattleTracker.java | 466 ++++++++++++ .../tracker/BattleTrackerConfig.java | 98 +++ .../tracker/BattleTrackerListener.java | 94 +++ .../org/battleplugins/tracker/SqlTracker.java | 339 +++++++++ .../tracker/TrackedDataType.java | 19 + .../org/battleplugins/tracker/Tracker.java | 346 +++++++++ .../tracker/TrackerExecutor.java | 466 ++++++++++++ .../battleplugins/tracker/TrackerLoader.java | 86 +++ .../BattleTrackerPostInitializeEvent.java | 38 + .../BattleTrackerPreInitializeEvent.java | 38 + .../tracker/event/TallyRecordEvent.java | 74 ++ .../tracker/event/TrackerDeathEvent.java | 107 +++ .../event/feature/DeathMessageEvent.java | 64 ++ .../tracker/feature/Feature.java | 12 + .../tracker/feature/Killstreaks.java | 87 +++ .../tracker/feature/Rampage.java | 96 +++ .../tracker/feature/TrackerFeature.java | 14 + .../feature/battlearena/ArenaTracker.java | 35 + .../battlearena/BattleArenaFeature.java | 38 + .../battlearena/BattleArenaHandler.java | 32 + .../battlearena/BattleArenaListener.java | 285 ++++++++ .../battlearena/TrackerSubExecutor.java | 96 +++ .../tracker/feature/combatlog/CombatLog.java | 352 +++++++++ .../damageindicators/DamageIndicators.java | 102 +++ .../feature/message/DeathMessages.java | 29 + .../message/DeathMessagesListener.java | 157 ++++ .../feature/message/EntityMessages.java | 51 ++ .../feature/message/MessageAudience.java | 69 ++ .../feature/message/PlayerMessages.java | 49 ++ .../feature/message/WorldMessages.java | 50 ++ .../tracker/feature/recap/BattleRecap.java | 145 ++++ .../tracker/feature/recap/EntitySnapshot.java | 13 + .../tracker/feature/recap/Recap.java | 287 ++++++++ .../tracker/feature/recap/RecapEntry.java | 102 +++ .../tracker/feature/recap/RecapRoundup.java | 232 ++++++ .../tracker/listener/PvEListener.java | 207 ++++++ .../tracker/listener/PvPListener.java | 150 ++++ .../tracker/message/Messages.java | 157 ++++ .../battleplugins/tracker/sql/DbCache.java | 280 ++++++++ .../battleplugins/tracker/sql/DbCacheMap.java | 131 ++++ .../tracker/sql/DbCacheMultimap.java | 187 +++++ .../battleplugins/tracker/sql/DbCacheSet.java | 108 +++ .../battleplugins/tracker/sql/DbValue.java | 32 + .../tracker/sql/SqlSerializer.java | 678 ++++++++++++++++++ .../tracker/sql/TrackerSqlSerializer.java | 468 ++++++++++++ .../battleplugins/tracker/stat/Record.java | 144 ++++ .../battleplugins/tracker/stat/StatType.java | 77 ++ .../tracker/stat/TallyContext.java | 6 + .../tracker/stat/TallyEntry.java | 21 + .../tracker/stat/VersusTally.java | 44 ++ .../stat/calculator/EloCalculator.java | 145 ++++ .../stat/calculator/RatingCalculator.java | 60 ++ .../tracker/util/CommandInjector.java | 34 + .../tracker/util/ItemCollection.java | 74 ++ .../tracker/util/MessageType.java | 9 + .../tracker/util/MessageUtil.java | 16 + .../tracker/util/TrackerInventoryHolder.java | 70 ++ .../org/battleplugins/tracker/util/Util.java | 170 +++++ src/main/resources/config.yml | 39 + src/main/resources/features/combat-log.yml | 66 ++ .../resources/features/damage-indicators.yml | 17 + src/main/resources/messages.yml | 52 ++ src/main/resources/plugin.yml | 14 + src/main/resources/trackers/pve.yml | 383 ++++++++++ src/main/resources/trackers/pvp.yml | 112 +++ 77 files changed, 9806 insertions(+) create mode 100644 .github/FUNDING.yml create mode 100644 .github/workflows/build.yml create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 build.gradle.kts create mode 100644 gradle/libs.versions.toml 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 settings.gradle.kts create mode 100644 src/main/java/org/battleplugins/tracker/BattleTracker.java create mode 100644 src/main/java/org/battleplugins/tracker/BattleTrackerConfig.java create mode 100644 src/main/java/org/battleplugins/tracker/BattleTrackerListener.java create mode 100644 src/main/java/org/battleplugins/tracker/SqlTracker.java create mode 100644 src/main/java/org/battleplugins/tracker/TrackedDataType.java create mode 100644 src/main/java/org/battleplugins/tracker/Tracker.java create mode 100644 src/main/java/org/battleplugins/tracker/TrackerExecutor.java create mode 100644 src/main/java/org/battleplugins/tracker/TrackerLoader.java create mode 100644 src/main/java/org/battleplugins/tracker/event/BattleTrackerPostInitializeEvent.java create mode 100644 src/main/java/org/battleplugins/tracker/event/BattleTrackerPreInitializeEvent.java create mode 100644 src/main/java/org/battleplugins/tracker/event/TallyRecordEvent.java create mode 100644 src/main/java/org/battleplugins/tracker/event/TrackerDeathEvent.java create mode 100644 src/main/java/org/battleplugins/tracker/event/feature/DeathMessageEvent.java create mode 100644 src/main/java/org/battleplugins/tracker/feature/Feature.java create mode 100644 src/main/java/org/battleplugins/tracker/feature/Killstreaks.java create mode 100644 src/main/java/org/battleplugins/tracker/feature/Rampage.java create mode 100644 src/main/java/org/battleplugins/tracker/feature/TrackerFeature.java create mode 100644 src/main/java/org/battleplugins/tracker/feature/battlearena/ArenaTracker.java create mode 100644 src/main/java/org/battleplugins/tracker/feature/battlearena/BattleArenaFeature.java create mode 100644 src/main/java/org/battleplugins/tracker/feature/battlearena/BattleArenaHandler.java create mode 100644 src/main/java/org/battleplugins/tracker/feature/battlearena/BattleArenaListener.java create mode 100644 src/main/java/org/battleplugins/tracker/feature/battlearena/TrackerSubExecutor.java create mode 100644 src/main/java/org/battleplugins/tracker/feature/combatlog/CombatLog.java create mode 100644 src/main/java/org/battleplugins/tracker/feature/damageindicators/DamageIndicators.java create mode 100644 src/main/java/org/battleplugins/tracker/feature/message/DeathMessages.java create mode 100644 src/main/java/org/battleplugins/tracker/feature/message/DeathMessagesListener.java create mode 100644 src/main/java/org/battleplugins/tracker/feature/message/EntityMessages.java create mode 100644 src/main/java/org/battleplugins/tracker/feature/message/MessageAudience.java create mode 100644 src/main/java/org/battleplugins/tracker/feature/message/PlayerMessages.java create mode 100644 src/main/java/org/battleplugins/tracker/feature/message/WorldMessages.java create mode 100644 src/main/java/org/battleplugins/tracker/feature/recap/BattleRecap.java create mode 100644 src/main/java/org/battleplugins/tracker/feature/recap/EntitySnapshot.java create mode 100644 src/main/java/org/battleplugins/tracker/feature/recap/Recap.java create mode 100644 src/main/java/org/battleplugins/tracker/feature/recap/RecapEntry.java create mode 100644 src/main/java/org/battleplugins/tracker/feature/recap/RecapRoundup.java create mode 100644 src/main/java/org/battleplugins/tracker/listener/PvEListener.java create mode 100644 src/main/java/org/battleplugins/tracker/listener/PvPListener.java create mode 100644 src/main/java/org/battleplugins/tracker/message/Messages.java create mode 100644 src/main/java/org/battleplugins/tracker/sql/DbCache.java create mode 100644 src/main/java/org/battleplugins/tracker/sql/DbCacheMap.java create mode 100644 src/main/java/org/battleplugins/tracker/sql/DbCacheMultimap.java create mode 100644 src/main/java/org/battleplugins/tracker/sql/DbCacheSet.java create mode 100644 src/main/java/org/battleplugins/tracker/sql/DbValue.java create mode 100644 src/main/java/org/battleplugins/tracker/sql/SqlSerializer.java create mode 100644 src/main/java/org/battleplugins/tracker/sql/TrackerSqlSerializer.java create mode 100644 src/main/java/org/battleplugins/tracker/stat/Record.java create mode 100644 src/main/java/org/battleplugins/tracker/stat/StatType.java create mode 100644 src/main/java/org/battleplugins/tracker/stat/TallyContext.java create mode 100644 src/main/java/org/battleplugins/tracker/stat/TallyEntry.java create mode 100644 src/main/java/org/battleplugins/tracker/stat/VersusTally.java create mode 100644 src/main/java/org/battleplugins/tracker/stat/calculator/EloCalculator.java create mode 100644 src/main/java/org/battleplugins/tracker/stat/calculator/RatingCalculator.java create mode 100644 src/main/java/org/battleplugins/tracker/util/CommandInjector.java create mode 100644 src/main/java/org/battleplugins/tracker/util/ItemCollection.java create mode 100644 src/main/java/org/battleplugins/tracker/util/MessageType.java create mode 100644 src/main/java/org/battleplugins/tracker/util/MessageUtil.java create mode 100644 src/main/java/org/battleplugins/tracker/util/TrackerInventoryHolder.java create mode 100644 src/main/java/org/battleplugins/tracker/util/Util.java create mode 100644 src/main/resources/config.yml create mode 100644 src/main/resources/features/combat-log.yml create mode 100644 src/main/resources/features/damage-indicators.yml create mode 100644 src/main/resources/messages.yml create mode 100644 src/main/resources/plugin.yml create mode 100644 src/main/resources/trackers/pve.yml create mode 100644 src/main/resources/trackers/pvp.yml diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..ae7baf5 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,14 @@ +# These are supported funding model platforms + +github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] +patreon: battleplugins +open_collective: # Replace with a single Open Collective username +ko_fi: # Replace with a single Ko-fi username +tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel +community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry +liberapay: # Replace with a single Liberapay username +issuehunt: # Replace with a single IssueHunt username +lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry +polar: # Replace with a single Polar username +buy_me_a_coffee: # Replace with a single Buy Me a Coffee username +custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..537bf06 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,33 @@ +name: Build + +on: + push: + branches: [ master ] + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Set up JDK 17 + uses: actions/setup-java@v2 + with: + java-version: '17' + distribution: 'adopt' + cache: gradle + - name: Build with Gradle + run: ./gradlew shadowJar + - name: Publish to Maven Repository + if: ${{ success() && github.repository == 'BattlePlugins/BattleTracker' && github.ref_name == 'master' }} + run: ./gradlew publish + env: + BUILD_NUMBER: ${{ github.run_number }} + ORG_GRADLE_PROJECT_battlepluginsUsername: ${{ vars.DEPLOY_USER }} + ORG_GRADLE_PROJECT_battlepluginsPassword: ${{ secrets.DEPLOY_SECRET }} + - name: Publish to Modrinth + if: ${{ success() && github.repository == 'BattlePlugins/BattleTracker' && github.ref_name == 'master' }} + env: + CHANGELOG: ${{ github.event.head_commit.message }} + BUILD_NUMBER: ${{ github.run_number }} + MODRINTH_TOKEN: ${{ secrets.MODRINTH_TOKEN }} + run: ./gradlew modrinth diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fd1a8eb --- /dev/null +++ b/.gitignore @@ -0,0 +1,45 @@ +.gradle +build/ +!gradle/wrapper/gradle-wrapper.jar +!**/src/main/**/build/ +!**/src/test/**/build/ + +### IntelliJ IDEA ### +.idea/modules.xml +.idea/jarRepositories.xml +.idea/compiler.xml +.idea/libraries/ +*.iws +*.iml +*.ipr +out/ +!**/src/main/**/out/ +!**/src/test/**/out/ + +### Eclipse ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache +bin/ +!**/src/main/**/bin/ +!**/src/test/**/bin/ + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ + +### VS Code ### +.vscode/ + +### Mac OS ### +.DS_Store + +### Run Server ### +run/ \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f288702 --- /dev/null +++ b/LICENSE @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. diff --git a/README.md b/README.md new file mode 100644 index 0000000..e923fd8 --- /dev/null +++ b/README.md @@ -0,0 +1,27 @@ +# BattleTracker + +A standalone plugin that tracks PVP & PVE statistics along with a suite of combat features to enhance server gameplay. + +# Features +## PVP and PVE Records +One of the core features of BattleTracker is its robust tracking system for PVP and PVE statistics. BattleTracker tracks a variety of statistics including kills, deaths, killstreaks, and more. These statistics are saved in MySQL or SQLite, allowing them to be shared across Minecraft servers, or accessed by web applications. + +## Leaderboards +Another feature BattleTracker includes is a leaderboard system that allows players to view the top players on the server based on various statistics. + +## Death Messages +BattleTracker provides customizable death messages that can be displayed to players when they are killed in PVP or PVE combat. These are easily configurable for each tracker type, and can be customized to fit the theme of your server. + +## Damage Recap +BattleTracker also includes a damage recap feature that displays a summary of the damage dealt and received by a player during combat. This can be summarized in multiple ways, such as which item dealt the most damage, a breakdown of players that dealt damage, or all the types of damage a player received. + +## Combat Logging +BattleTracker includes a combat logging feature that places players "in combat" when they attack another player. If they log out, they will be killed and their attacker will receive credit for the kill. + +## Damage Indicators +Inside BattleTracker, there is also support for damage indicators, which show to a player how much damage they inflicted on another player or entity. + +# Links +- Website: https://www.battleplugins.org +- Download: https://modrinth.com/plugin/battletracker +- Discord: [BattlePlugins Discord](https://discord.com/invite/J3Hjjb8) diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..a93bbdc --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,138 @@ +plugins { + id("maven-publish") + id("java") + id("java-library") + id("xyz.jpenilla.run-paper") version "2.3.0" + id("com.modrinth.minotaur") version "2.+" + id("com.github.johnrengelman.shadow") version "8.1.1" +} + +group = "org.battleplugins.tracker" +version = "4.0.0-SNAPSHOT" + +val supportedVersions = listOf("1.19.4", "1.20", "1.20.1", "1.20.2", "1.20.3", "1.20.4", "1.20.5", "1.20.6", "1.21") + +java.toolchain.languageVersion.set(JavaLanguageVersion.of(17)) + +repositories { + mavenCentral() + + maven("https://repo.papermc.io/repository/maven-public/") + maven("https://repo.battleplugins.org/releases/") + maven("https://repo.battleplugins.org/snapshots/") +} + +dependencies { + implementation(libs.bstats.bukkit) + implementation(libs.commons.dbcp) + implementation(libs.commons.pool) + + compileOnlyApi(libs.paper.api) + compileOnlyApi(libs.battlearena) +} + +java { + withJavadocJar() + withSourcesJar() +} + +tasks { + runServer { + minecraftVersion("1.20.6") + + // Set Java 21 (1.20.6 requires Java 21) + javaLauncher = project.javaToolchains.launcherFor { + languageVersion = JavaLanguageVersion.of(21) + } + } + + jar { + archiveClassifier.set("unshaded") + } + + shadowJar { + from("src/main/java/resources") { + include("*") + } + + relocate("org.bstats", "org.battleplugins.tracker.util.shaded.bstats") + relocate("org.apache", "org.battleplugins.tracker.util.shaded.apache") + + archiveFileName.set("BattleTracker.jar") + archiveClassifier.set("") + } + + javadoc { + (options as CoreJavadocOptions).addBooleanOption("Xdoclint:none", true) + } + + processResources { + filesMatching("plugin.yml") { + expand("version" to rootProject.version) + } + } +} + +publishing { + val isSnapshot = "SNAPSHOT" in version.toString() + + repositories { + maven { + name = "battleplugins" + url = uri("https://repo.battleplugins.org/${if (isSnapshot) "snapshots" else "releases"}") + credentials(PasswordCredentials::class) + authentication { + create("basic") + } + } + + publications { + create("mavenJava") { + artifactId = "arena" + + from(components["java"]) + pom { + packaging = "jar" + url.set("https://github.com/BattlePlugins/BattleTracker") + + scm { + connection.set("scm:git:git://github.com/BattlePlugins/BattleTracker.git") + developerConnection.set("scm:git:ssh://github.com/BattlePlugins/BattleTracker.git") + url.set("https://github.com/BattlePlugins/BattleTracker"); + } + + licenses { + license { + name.set("GNU General Public License v3.0") + url.set("https://www.gnu.org/licenses/gpl-3.0.html") + } + } + + developers { + developer { + name.set("BattlePlugins Team") + organization.set("BattlePlugins") + organizationUrl.set("https://github.com/BattlePlugins") + } + } + } + } + } + } +} + +modrinth { + val snapshot = "SNAPSHOT" in rootProject.version.toString() + + token.set(System.getenv("MODRINTH_TOKEN") ?: "") + projectId.set("battletracker") + versionNumber.set(rootProject.version as String + if (snapshot) "-" + System.getenv("BUILD_NUMBER") else "") + versionType.set(if (snapshot) "beta" else "release") + changelog.set(System.getenv("CHANGELOG") ?: "") + uploadFile.set(tasks.shadowJar) + gameVersions.set(supportedVersions) + + dependencies { + optional.project("battlearena") + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml new file mode 100644 index 0000000..bb72b07 --- /dev/null +++ b/gradle/libs.versions.toml @@ -0,0 +1,12 @@ +[versions] +bstats-bukkit = "3.0.2" +paper-api = "1.20.4-R0.1-SNAPSHOT" +commons = "2.12.0" +battlearena = "4.0.0-SNAPSHOT" + +[libraries] +battlearena = { group = "org.battleplugins", name = "arena", version.ref = "battlearena" } +bstats-bukkit = { group = "org.bstats", name = "bstats-bukkit", version.ref = "bstats-bukkit" } +paper-api = { group = "io.papermc.paper", name = "paper-api", version.ref = "paper-api" } +commons-dbcp = { group = "org.apache.commons", name = "commons-dbcp2", version.ref = "commons" } +commons-pool = { group = "org.apache.commons", name = "commons-pool2", version.ref = "commons" } diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..249e5832f090a2944b7473328c07c9755baa3196 GIT binary patch literal 60756 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 '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit + +APP_NAME="Gradle" +APP_BASE_NAME=${0##*/} + +# 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"' + +# 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 + which java >/dev/null 2>&1 || 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 + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + 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 + +# Collect all arguments for the java command; +# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of +# shell script including quotes and variable substitutions, so put them in +# double quotes to make sure that they get re-expanded; and +# * put everything else in single quotes, so that it's not re-expanded. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# 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..107acd3 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,89 @@ +@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 + +@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=. +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%" == "0" goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +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%"=="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! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..23f9def --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,15 @@ +rootProject.name = "BattleTracker" + +enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS") + +dependencyResolutionManagement { + repositories { + mavenCentral() + + // Spigot + maven("https://hub.spigotmc.org/nexus/content/groups/public/") + + // Paper, Velocity + maven("https://repo.papermc.io/repository/maven-public") + } +} diff --git a/src/main/java/org/battleplugins/tracker/BattleTracker.java b/src/main/java/org/battleplugins/tracker/BattleTracker.java new file mode 100644 index 0000000..6c3fc29 --- /dev/null +++ b/src/main/java/org/battleplugins/tracker/BattleTracker.java @@ -0,0 +1,466 @@ +package org.battleplugins.tracker; + +import org.battleplugins.tracker.event.BattleTrackerPreInitializeEvent; +import org.battleplugins.tracker.feature.Feature; +import org.battleplugins.tracker.feature.battlearena.BattleArenaFeature; +import org.battleplugins.tracker.feature.combatlog.CombatLog; +import org.battleplugins.tracker.feature.damageindicators.DamageIndicators; +import org.battleplugins.tracker.message.Messages; +import org.battleplugins.tracker.sql.SqlSerializer; +import org.battleplugins.tracker.sql.TrackerSqlSerializer; +import org.battleplugins.tracker.stat.calculator.EloCalculator; +import org.battleplugins.tracker.stat.calculator.RatingCalculator; +import org.battleplugins.tracker.util.CommandInjector; +import org.bstats.bukkit.Metrics; +import org.bukkit.Bukkit; +import org.bukkit.configuration.Configuration; +import org.bukkit.configuration.ConfigurationSection; +import org.bukkit.configuration.file.YamlConfiguration; +import org.bukkit.event.HandlerList; +import org.bukkit.event.Listener; +import org.bukkit.plugin.java.JavaPlugin; +import org.jetbrains.annotations.Nullable; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Function; +import java.util.function.Supplier; +import java.util.stream.Stream; + +/** + * The main class for BattleTracker. + */ +public class BattleTracker extends JavaPlugin { + private static final int PLUGIN_ID = 4598; + + private static BattleTracker instance; + + final Map> trackerSuppliers = new HashMap<>(); + + private final Map trackerLoaders = new HashMap<>(); + + private final Map trackers = new ConcurrentHashMap<>(); + private final Map ratingCalculators = new HashMap<>(); + private final Map> trackerListeners = new HashMap<>(); + + private BattleArenaFeature battleArenaFeature; + private CombatLog combatLog; + private DamageIndicators damageIndicators; + + private BattleTrackerConfig config; + + private Path trackersPath; + private Path featuresPath; + + private boolean debugMode; + + @Override + public void onLoad() { + instance = this; + + this.loadConfig(false); + + Path dataFolder = this.getDataFolder().toPath(); + this.trackersPath = dataFolder.resolve("trackers"); + this.featuresPath = dataFolder.resolve("features"); + + new BattleTrackerPreInitializeEvent(this).callEvent(); + } + + @Override + public void onEnable() { + Bukkit.getPluginManager().registerEvents(new BattleTrackerListener(this), this); + + // Register default calculators + this.registerCalculator(new EloCalculator(this.config.getRating().elo())); + + this.enable(); + + // Loads all tracker loaders + this.loadTrackerLoaders(this.trackersPath); + + if (this.config.getAdvanced().saveInterval() != -1) { + Bukkit.getScheduler().runTaskTimerAsynchronously(this, () -> { + this.debug("Auto save: Saving all trackers."); + for (Tracker tracker : this.trackers.values()) { + long startTIme = System.currentTimeMillis(); + tracker.saveAll().whenComplete((aVoid, e) -> { + if (e != null) { + this.error("Error saving tracker {}!", tracker.getName(), e); + } else { + this.debug("Auto save: Saved tracker {} in {}ms.", tracker.getName(), System.currentTimeMillis() - startTIme); + } + + tracker.flush(false); + + this.debug("Auto save: Flushed tracker {}.", tracker.getName()); + }); + } + + this.debug("Auto save: Finished saving all trackers."); + }, this.config.getAdvanced().saveInterval() * 20L, this.config.getAdvanced().saveInterval() * 20L); + } + + new Metrics(this, PLUGIN_ID); + } + + private void enable() { + if (Files.notExists(this.trackersPath)) { + try { + Files.createDirectories(this.trackersPath); + } catch (IOException e) { + throw new RuntimeException("Error creating trackers directory!", e); + } + + this.saveResource("trackers/pve.yml", false); + this.saveResource("trackers/pvp.yml", false); + } + + if (Files.notExists(this.featuresPath)) { + try { + Files.createDirectories(this.featuresPath); + } catch (IOException e) { + throw new RuntimeException("Error creating features directory!", e); + } + + this.saveResource("features/combat-log.yml", false); + this.saveResource("features/damage-indicators.yml", false); + } + + Path dataFolder = this.getDataFolder().toPath(); + if (Files.notExists(dataFolder.resolve("messages.yml"))) { + this.saveResource("messages.yml", false); + } + + this.battleArenaFeature = new BattleArenaFeature(); + this.battleArenaFeature.onEnable(this); + } + + @Override + public void onDisable() { + this.disable(true).whenComplete((aVoid, e) -> { + if (e != null) { + this.error("Error disabling BattleTracker!", e); + } + }); + } + + private CompletableFuture disable(boolean block) { + if (this.battleArenaFeature != null) { + this.unloadFeature(this.battleArenaFeature); + } + + if (this.combatLog != null) { + this.unloadFeature(this.combatLog); + } + + if (this.damageIndicators != null) { + this.unloadFeature(this.damageIndicators); + } + + // Save all trackers + List> saveFutures = new ArrayList<>(); + for (Tracker tracker : this.trackers.values()) { + List listeners = this.trackerListeners.remove(tracker); + if (listeners != null && !listeners.isEmpty()) { + listeners.forEach(HandlerList::unregisterAll); + } + + saveFutures.add(tracker.saveAll().thenRun(tracker::destroy)); + } + + CompletableFuture future = CompletableFuture.allOf(saveFutures.toArray(CompletableFuture[]::new)) + .thenRun(this.trackers::clear); + + if (block) { + future.join(); + } + + return future; + } + + void postInitialize() { + Messages.load(this.getDataFolder().toPath().resolve("messages.yml")); + + this.loadTrackers(); + + this.combatLog = this.loadFeature(this.featuresPath.resolve("combat-log.yml"), CombatLog::load); + this.damageIndicators = this.loadFeature(this.featuresPath.resolve("damage-indicators.yml"), DamageIndicators::load); + } + + /** + * Reloads the plugin. + */ + public void reload() { + this.disable(false).whenCompleteAsync((aVoid, e) -> { + if (e != null) { + this.error("Error disabling plugin!", e); + } + + // Reload the config + this.loadConfig(true); + + this.enable(); + this.postInitialize(); + }, Bukkit.getScheduler().getMainThreadExecutor(this)); + } + + T loadFeature(Path path, Function featureLoader) { + Configuration configuration = YamlConfiguration.loadConfiguration(path.toFile()); + T feature = featureLoader.apply(configuration); + if (feature.enabled()) { + feature.onEnable(this); + } + + return feature; + } + + void unloadFeature(Feature feature) { + if (feature.enabled()) { + feature.onDisable(this); + } + } + + /** + * Returns an in-memory representation of the configuration. + * + * @return the BattleTracker configuration + */ + public BattleTrackerConfig getMainConfig() { + return config; + } + + /** + * Returns the rating calculator with the given name. + * + * @param name the name of the rating calculator + * @return the rating calculator with the given name + */ + @Nullable + public RatingCalculator getCalculator(String name) { + return this.ratingCalculators.get(name); + } + + /** + * Registers a {@link RatingCalculator}. + * + * @param calculator the rating calculator to register + */ + public void registerCalculator(RatingCalculator calculator) { + this.ratingCalculators.put(calculator.getName(), calculator); + } + + void registerTracker(Tracker tracker) { + this.trackers.put(tracker.getName(), tracker); + } + + /** + * Registers a {@link Listener} to a {@link Tracker}. + * + * @param tracker the tracker to register the listener to + * @param listener the listener to register + */ + public void registerListener(Tracker tracker, Listener listener) { + this.trackerListeners.computeIfAbsent(tracker, key -> new ArrayList<>()).add(listener); + + Bukkit.getPluginManager().registerEvents(listener, this); + } + + /** + * Registers a {@link Tracker} supplier. + * + * @param name the name of the tracker supplier to register + * @param trackerSupplier the tracker supplier to register + */ + public void registerTracker(String name, Supplier trackerSupplier) { + this.trackerSuppliers.put(name, trackerSupplier); + } + + /** + * Returns the {@link Tracker} with the given name. + * + * @param name the name of the tracker + * @return the tracker with the given name + */ + public Optional tracker(String name) { + return Optional.ofNullable(this.getTracker(name)); + } + + /** + * Returns the {@link Tracker} with the given name, + * or null if the tracker does not exist. + * + * @param name the name of the tracker + * @return the tracker with the given name + */ + @Nullable + public Tracker getTracker(String name) { + return this.trackers.get(name); + } + + /** + * Returns all the {@link Tracker}s associated with the plugin. + * + * @return all the trackers associated with the plugin + */ + public List getTrackers() { + return List.copyOf(this.trackers.values()); + } + + /** + * Returns the {@link CombatLog} feature. + * + * @return the CombatLog feature + */ + public CombatLog getCombatLog() { + return this.combatLog; + } + + /** + * Returns whether the plugin is in debug mode. + * + * @return whether the plugin is in debug mode + */ + public boolean isDebugMode() { + return this.debugMode; + } + + /** + * Sets whether the plugin is in debug mode. + * + * @param debugMode whether the plugin is in debug mode + */ + public void setDebugMode(boolean debugMode) { + this.debugMode = debugMode; + } + + public void info(String message) { + this.getSLF4JLogger().info(message); + } + + public void info(String message, Object... args) { + this.getSLF4JLogger().info(message, args); + } + + public void error(String message) { + this.getSLF4JLogger().error(message); + } + + public void error(String message, Object... args) { + this.getSLF4JLogger().error(message, args); + } + + public void warn(String message) { + this.getSLF4JLogger().warn(message); + } + + public void warn(String message, Object... args) { + this.getSLF4JLogger().warn(message, args); + } + + public void debug(String message, Object... args) { + if (this.isDebugMode()) { + this.getSLF4JLogger().info("[DEBUG] " + message, args); + } + } + + private void loadTrackerLoaders(Path path) { + if (Files.notExists(path)) { + return; + } + + // Create tracker loaders + try (Stream trackerPaths = Files.walk(path)) { + trackerPaths.forEach(trackerPath -> { + try { + if (Files.isDirectory(trackerPath)) { + return; + } + + Configuration configuration = YamlConfiguration.loadConfiguration(Files.newBufferedReader(trackerPath)); + String name = configuration.getString("name"); + if (name == null) { + this.warn("Tracker {} does not have a name!", trackerPath.getFileName()); + return; + } + + TrackerLoader loader = new TrackerLoader(this, configuration, trackerPath); + this.trackerLoaders.put(name, loader); + + CommandInjector.inject(name, name.toLowerCase(Locale.ROOT)); + } catch (IOException e) { + throw new RuntimeException("Error reading tracker config", e); + } + }); + } catch (IOException e) { + throw new RuntimeException("Error walking trackers path!", e); + } + } + + private void loadTrackers() { + // Register our trackers once ALL the plugins have loaded. This ensures that + // all custom plugins adding their own trackers have been loaded. + for (TrackerLoader value : this.trackerLoaders.values()) { + try { + value.load(); + } catch (Exception e) { + this.error("An error occurred when loading tracker {}: {}", value.trackerPath().getFileName(), e.getMessage(), e); + } + } + + // Load custom trackers + for (Map.Entry> entry : this.trackerSuppliers.entrySet()) { + Tracker tracker = entry.getValue().get(); + this.registerTracker(tracker); + } + } + + private void loadConfig(boolean reload) { + this.saveDefaultConfig(); + + File configFile = new File(this.getDataFolder(), "config.yml"); + Configuration config = YamlConfiguration.loadConfiguration(configFile); + try { + this.config = BattleTrackerConfig.load(config); + } catch (Exception e) { + this.error("Failed to load BattleTracker configuration!", e); + if (!reload) { + this.getServer().getPluginManager().disablePlugin(this); + } + } + + BattleTrackerConfig.DatabaseOptions databaseOptions = this.config.getDatabaseOptions(); + + TrackerSqlSerializer.TYPE = databaseOptions.type(); + TrackerSqlSerializer.TABLE_PREFIX = databaseOptions.prefix(); + TrackerSqlSerializer.DATABASE = databaseOptions.db(); + TrackerSqlSerializer.PORT = databaseOptions.port(); + TrackerSqlSerializer.USERNAME = databaseOptions.user(); + TrackerSqlSerializer.PASSWORD = databaseOptions.password(); + + if (databaseOptions.type() == SqlSerializer.SqlType.SQLITE) { + TrackerSqlSerializer.URL = this.getDataFolder().toString(); + } else { + TrackerSqlSerializer.URL = databaseOptions.url(); + } + } + + /** + * Returns the instance of the plugin. + * + * @return the instance of the plugin + */ + public static BattleTracker getInstance() { + return instance; + } +} diff --git a/src/main/java/org/battleplugins/tracker/BattleTrackerConfig.java b/src/main/java/org/battleplugins/tracker/BattleTrackerConfig.java new file mode 100644 index 0000000..6309c04 --- /dev/null +++ b/src/main/java/org/battleplugins/tracker/BattleTrackerConfig.java @@ -0,0 +1,98 @@ +package org.battleplugins.tracker; + +import org.battleplugins.tracker.sql.SqlSerializer; +import org.bukkit.configuration.Configuration; +import org.bukkit.configuration.ConfigurationSection; + +import java.util.Locale; + +public class BattleTrackerConfig { + private final DatabaseOptions databaseOptions; + private final Rating rating; + private final Advanced advanced; + + BattleTrackerConfig(DatabaseOptions databaseOptions, Rating rating, Advanced advanced) { + this.databaseOptions = databaseOptions; + this.rating = rating; + this.advanced = advanced; + } + + public DatabaseOptions getDatabaseOptions() { + return this.databaseOptions; + } + + public Rating getRating() { + return this.rating; + } + + public Advanced getAdvanced() { + return this.advanced; + } + + static BattleTrackerConfig load(Configuration configuration) { + ConfigurationSection dbSection = configuration.getConfigurationSection("database"); + if (dbSection == null) { + throw new IllegalArgumentException("Database section not found in configuration!"); + } + + ConfigurationSection ratingSection = configuration.getConfigurationSection("rating"); + if (ratingSection == null) { + throw new IllegalArgumentException("Rating section not found in configuration!"); + } + + ConfigurationSection advancedSection = configuration.getConfigurationSection("advanced"); + if (advancedSection == null) { + throw new IllegalArgumentException("Advanced section not found in configuration!"); + } + + return new BattleTrackerConfig(DatabaseOptions.load(dbSection), Rating.load(ratingSection), Advanced.load(advancedSection)); + } + + public record DatabaseOptions( + SqlSerializer.SqlType type, + String prefix, + String db, + String url, + String port, + String user, + String password + ) { + + public static DatabaseOptions load(ConfigurationSection section) { + SqlSerializer.SqlType type = SqlSerializer.SqlType.valueOf(section.getString("type").toUpperCase(Locale.ROOT)); + String prefix = section.getString("prefix"); + String db = section.getString("db"); + String url = section.getString("url"); + String port = section.getString("port"); + String user = section.getString("user"); + String password = section.getString("password"); + return new DatabaseOptions(type, prefix, db, url, port, user, password); + } + } + + public record Rating(Elo elo) { + + public static Rating load(ConfigurationSection section) { + ConfigurationSection eloSection = section.getConfigurationSection("elo"); + Elo elo = new Elo( + (float) eloSection.getDouble("default"), + (float) eloSection.getDouble("spread") + ); + + return new Rating(elo); + } + } + + public record Elo(float defaultElo, float spread) { + } + + public record Advanced(boolean flushOnLeave, int saveInterval, int staleEntryTime) { + + public static Advanced load(ConfigurationSection section) { + boolean flushOnLeave = section.getBoolean("flush-on-leave"); + int saveInterval = section.getInt("save-interval"); + int staleEntryTime = section.getInt("stale-entry-time"); + return new Advanced(flushOnLeave, saveInterval, staleEntryTime); + } + } +} diff --git a/src/main/java/org/battleplugins/tracker/BattleTrackerListener.java b/src/main/java/org/battleplugins/tracker/BattleTrackerListener.java new file mode 100644 index 0000000..aca25cd --- /dev/null +++ b/src/main/java/org/battleplugins/tracker/BattleTrackerListener.java @@ -0,0 +1,94 @@ +package org.battleplugins.tracker; + +import org.battleplugins.tracker.event.BattleTrackerPostInitializeEvent; +import org.battleplugins.tracker.util.TrackerInventoryHolder; +import org.bukkit.Bukkit; +import org.bukkit.OfflinePlayer; +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; +import org.bukkit.event.inventory.InventoryClickEvent; +import org.bukkit.event.player.PlayerJoinEvent; +import org.bukkit.event.player.PlayerQuitEvent; +import org.bukkit.event.server.ServerLoadEvent; +import org.bukkit.inventory.Inventory; +import org.bukkit.inventory.ItemStack; + +import java.util.concurrent.CompletableFuture; + +class BattleTrackerListener implements Listener { + private final BattleTracker plugin; + + public BattleTrackerListener(BattleTracker plugin) { + this.plugin = plugin; + } + + @EventHandler(priority = EventPriority.HIGHEST) + public void onServerLoad(ServerLoadEvent event) { + // There is logic called later, however by this point all plugins + // using the BattleTracker API should have been loaded. As modules will + // listen for this event to register their behavior, we need to ensure + // they are fully initialized so any references to said modules in + // tracker config files will be valid. + new BattleTrackerPostInitializeEvent(this.plugin).callEvent(); + + this.plugin.postInitialize(); + } + + @EventHandler + public void onJoin(PlayerJoinEvent event) { + Player player = event.getPlayer(); + for (Tracker tracker : this.plugin.getTrackers()) { + tracker.getOrCreateRecord((OfflinePlayer) player).whenComplete((record, e) -> { + if (tracker instanceof SqlTracker sqlTracker) { + sqlTracker.getRecords().lock(player.getUniqueId()); + } + }); + } + } + + @EventHandler + public void onQuit(PlayerQuitEvent event) { + Player player = event.getPlayer(); + for (Tracker tracker : this.plugin.getTrackers()) { + CompletableFuture saveFuture = tracker.save(player); + if (this.plugin.isDebugMode()) { + long startTimestamp = System.currentTimeMillis(); + + saveFuture.thenRun(() -> this.plugin.info("Saved data for {} in {}ms", player.getName(), System.currentTimeMillis() - startTimestamp)); + } + + if (Bukkit.isStopping()) { + saveFuture.join(); + } + + if (tracker instanceof SqlTracker sqlTracker) { + sqlTracker.getRecords().unlock(player.getUniqueId()); + } + } + } + + @EventHandler + public void onInventoryClick(InventoryClickEvent event) { + Inventory topInventory = event.getView().getTopInventory(); + if (!(topInventory.getHolder() instanceof TrackerInventoryHolder keyedHolder)) { + return; + } + + if (keyedHolder.getKey().equals(TrackerInventoryHolder.RECAP_KEY)) { + event.setCancelled(true); + } + + if (!topInventory.equals(event.getClickedInventory())) { + return; + } + + ItemStack itemInSlot = event.getCurrentItem(); + if (itemInSlot != null && itemInSlot.hasItemMeta() && itemInSlot.getItemMeta().getPersistentDataContainer().has(TrackerInventoryHolder.RECAP_KEY)) { + event.getWhoClicked().closeInventory(); + + keyedHolder.handleClick(TrackerInventoryHolder.RECAP_KEY); + } + } +} diff --git a/src/main/java/org/battleplugins/tracker/SqlTracker.java b/src/main/java/org/battleplugins/tracker/SqlTracker.java new file mode 100644 index 0000000..edd31c0 --- /dev/null +++ b/src/main/java/org/battleplugins/tracker/SqlTracker.java @@ -0,0 +1,339 @@ +package org.battleplugins.tracker; + +import com.google.common.collect.ClassToInstanceMap; +import com.google.common.collect.MutableClassToInstanceMap; +import org.battleplugins.tracker.event.TallyRecordEvent; +import org.battleplugins.tracker.feature.TrackerFeature; +import org.battleplugins.tracker.sql.DbCache; +import org.battleplugins.tracker.sql.TrackerSqlSerializer; +import org.battleplugins.tracker.stat.Record; +import org.battleplugins.tracker.stat.StatType; +import org.battleplugins.tracker.stat.TallyContext; +import org.battleplugins.tracker.stat.TallyEntry; +import org.battleplugins.tracker.stat.VersusTally; +import org.battleplugins.tracker.stat.calculator.RatingCalculator; +import org.bukkit.OfflinePlayer; +import org.bukkit.entity.Player; +import org.jetbrains.annotations.Nullable; + +import java.sql.SQLException; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.function.Consumer; + +public class SqlTracker implements Tracker { + private final BattleTracker plugin; + private final String name; + private final RatingCalculator calculator; + private final Set trackedData; + private final List disabledWorlds; + private final TrackerSqlSerializer sqlSerializer; + + private final DbCache.MapCache records = DbCache.createMap(); + private final DbCache.SetCache tallies = DbCache.createSet(); + private final DbCache.MultimapCache tallyEntries = DbCache.createMultimap(); + + private final ClassToInstanceMap features = MutableClassToInstanceMap.create(); + + private long lastTopLoad = 0; + + public SqlTracker(BattleTracker plugin, String name, RatingCalculator calculator, Set trackedData, List disabledWorlds) { + this(plugin, name, calculator, trackedData, disabledWorlds, null); + } + + public SqlTracker(BattleTracker plugin, String name, RatingCalculator calculator, Set trackedData, List disabledWorlds, TrackerSqlSerializer sqlSerializer) { + this.plugin = plugin; + this.name = name; + this.calculator = calculator; + this.trackedData = trackedData; + this.disabledWorlds = disabledWorlds; + + if (sqlSerializer == null) { + sqlSerializer = this.createSerializer(); + } + + this.sqlSerializer = sqlSerializer; + } + + protected TrackerSqlSerializer createSerializer() { + return new TrackerSqlSerializer(this); + } + + @Override + public String getName() { + return this.name; + } + + public DbCache.MapCache getRecords() { + return this.records; + } + + @Override + public CompletableFuture<@Nullable Record> getRecord(OfflinePlayer player) { + return this.records.getOrLoad(player.getUniqueId(), this.sqlSerializer.loadRecord(player.getUniqueId())).exceptionally(e -> { + this.plugin.error("Failed to load record for {}", player.getUniqueId(), e); + return null; + }); + } + + @Override + public CompletableFuture> getTopRecords(int limit, StatType orderBy) { + // Recompute this every minute + if (System.currentTimeMillis() - this.lastTopLoad > 60000) { + this.lastTopLoad = System.currentTimeMillis(); + return this.records.loadBulk(this.sqlSerializer.getTopRecords(limit, orderBy), Record::getId) + .thenApply(records -> records.stream().toList()); + } + + // Load from cache + return CompletableFuture.completedFuture(this.records.keySet() + .stream() + .map(this.records::getCached) + .sorted((r1, r2) -> Float.compare(r2.getStat(orderBy), r1.getStat(orderBy))) + .limit(limit) + .toList() + ); + } + + @Override + public Record getRecord(Player player) { + // Online players will always have a record + Record record = this.records.getCached(player.getUniqueId()); + if (record == null) { + // Uh oh, there's a bug in BattleTracker! + throw new RuntimeException("Failed to load record for " + player.getUniqueId() + ". This is a bug in the plugin and should be reported!"); + } + + return record; + } + + @Override + public void addRecord(UUID uuid, Record record) { + this.records.put(uuid, record); + } + + public DbCache.SetCache getTallies() { + return this.tallies; + } + + @Override + public CompletableFuture getVersusTally(OfflinePlayer player1, OfflinePlayer player2) { + return this.tallies.getOrLoad(tally -> tally.isTallyFor(player1.getUniqueId(), player2.getUniqueId()), this.sqlSerializer.loadVersusTally(player1.getUniqueId(), player2.getUniqueId()) + .exceptionally(e -> { + this.plugin.error("Failed to load tally entries for {} and {}", player1.getUniqueId(), player2.getUniqueId(), e); + return null; + })); + } + + @Override + public VersusTally createNewVersusTally(OfflinePlayer player1, OfflinePlayer player2) { + VersusTally versusTally = new VersusTally(this, player1, player2, new HashMap<>()); + this.tallies.add(versusTally); + return versusTally; + } + + @Override + public CompletableFuture getOrCreateVersusTally(OfflinePlayer player1, OfflinePlayer player2) { + return this.getVersusTally(player1, player2).thenApply(tally -> { + if (tally == null) { + return this.createNewVersusTally(player1, player2); + } + + return tally; + }); + } + + @Override + public void modifyTally(VersusTally tally, Consumer context) { + context.accept((statType, value) -> { + tally.statistics().put(statType, value); + SqlTracker.this.tallies.modify(tally); + }); + } + + public DbCache.MultimapCache getTallyEntries() { + return this.tallyEntries; + } + + @Override + public void recordTallyEntry(TallyEntry entry) { + this.tallyEntries.put(entry.id1(), entry); + this.tallyEntries.put(entry.id2(), entry); + + Record record1 = this.records.getCached(entry.id1()); + Record record2 = this.records.getCached(entry.id2()); + if (record1 == null || record2 == null) { + this.plugin.warn("Failed to call tally entry event for {} and {} as one of the records was not found!", entry.id1(), entry.id2()); + return; + } + + new TallyRecordEvent(this, record1, record2, entry.tie()).callEvent(); + } + + @Override + public CompletableFuture> getTallyEntries(UUID uuid, boolean includeLosses) { + return this.tallyEntries.getOrLoad(uuid, this.sqlSerializer.loadTallyEntries(uuid)).thenApply(entries -> { + if (includeLosses) { + return entries; + } + + return entries.stream().filter(entry -> entry.id1().equals(uuid)).toList(); + }).exceptionally(e -> { + this.plugin.error("Failed to load tally entries for {}", uuid, e); + return List.of(); + }); + } + + @Override + public void setValue(StatType statType, float value, OfflinePlayer player) { + this.getOrCreateRecord(player).whenComplete((record, e) -> { + if (record == null) { + return; + } + + record.setValue(statType, value); + }); + } + + @Override + public void updateRating(Player killer, Player loser, boolean tie) { + Record killerRecord = this.getOrCreateRecord(killer); + Record killedRecord = this.getOrCreateRecord(loser); + this.calculator.updateRating(killerRecord, killedRecord, tie); + + float killerRating = killerRecord.getRating(); + float killerMaxRating = killerRecord.getStat(StatType.MAX_RATING); + + this.setValue(StatType.RATING, killerRecord.getRating(), killer); + this.setValue(StatType.RATING, killedRecord.getRating(), loser); + + if (killerRating > killerMaxRating) { + this.setValue(StatType.MAX_RATING, killerRating, killer); + } + + if (tie) { + this.incrementValue(StatType.TIES, killer); + this.incrementValue(StatType.TIES, loser); + } + + this.setValue(StatType.KD_RATIO, killerRecord.getStat(StatType.KILLS) / Math.max(1, killerRecord.getStat(StatType.DEATHS)), killer); + this.setValue(StatType.KD_RATIO, killedRecord.getStat(StatType.KILLS) / Math.max(1, killedRecord.getStat(StatType.DEATHS)), loser); + + float killerKdr = killerRecord.getStat(StatType.KD_RATIO); + float killerMaxKdr = killerRecord.getStat(StatType.MAX_KD_RATIO); + + if (killerKdr > killerMaxKdr) { + this.setValue(StatType.MAX_KD_RATIO, killerKdr, killer); + } + + this.setValue(StatType.STREAK, 0, loser); + this.incrementValue(StatType.STREAK, killer); + + float killerStreak = killerRecord.getStat(StatType.STREAK); + float killerMaxStreak = killerRecord.getStat(StatType.MAX_STREAK); + + if (killerStreak > killerMaxStreak) { + this.setValue(StatType.MAX_STREAK, killerStreak, killer); + } + } + + @Override + public Record createNewRecord(OfflinePlayer player) { + Map columns = new HashMap<>(); + for (String column : this.sqlSerializer.getOverallColumns()) { + columns.put(StatType.get(column), 0f); + } + + Record record = new Record(this, player.getUniqueId(), player.getName(), columns); + return this.createNewRecord(player, record); + } + + @Override + public Record createNewRecord(OfflinePlayer player, Record record) { + record.setRating(this.calculator.getDefaultRating()); + + this.records.put(player.getUniqueId(), record); + return record; + } + + @Override + public void removeRecord(OfflinePlayer player) { + this.records.remove(player.getUniqueId()); + this.sqlSerializer.removeRecord(player.getUniqueId()); + } + + @Override + public Optional feature(Class feature) { + return Optional.ofNullable(this.getFeature(feature)); + } + + @Override + public boolean hasFeature(Class feature) { + return this.features.containsKey(feature); + } + + @Override + public @Nullable T getFeature(Class feature) { + return this.features.getInstance(feature); + } + + @Override + public void registerFeature(TrackerFeature feature) { + this.features.put(feature.getClass(), feature); + + if (feature.enabled()) { + feature.onEnable(this.plugin, this); + } + } + + @Override + public Set getTrackedData() { + return Set.copyOf(this.trackedData); + } + + @Override + public List getDisabledWorlds() { + return List.copyOf(this.disabledWorlds); + } + + @Override + public RatingCalculator getRatingCalculator() { + return this.calculator; + } + + @Override + public CompletableFuture save(OfflinePlayer player) { + return this.sqlSerializer.save(player.getUniqueId()); + } + + @Override + public CompletableFuture saveAll() { + return this.sqlSerializer.saveAll(); + } + + @Override + public void flush(boolean aggressive) { + for (UUID uuid : this.records.keySet()) { + this.records.flush(uuid, aggressive); + } + + this.tallies.flush(aggressive); + for (UUID uuid : this.tallyEntries.keySet()) { + this.tallyEntries.flush(uuid, aggressive); + } + } + + @Override + public void destroy() { + try { + this.sqlSerializer.closeConnection(this.sqlSerializer.getConnection()); + } catch (SQLException e) { + throw new RuntimeException("Failed to close connection!", e); + } + } +} diff --git a/src/main/java/org/battleplugins/tracker/TrackedDataType.java b/src/main/java/org/battleplugins/tracker/TrackedDataType.java new file mode 100644 index 0000000..931d4d6 --- /dev/null +++ b/src/main/java/org/battleplugins/tracker/TrackedDataType.java @@ -0,0 +1,19 @@ +package org.battleplugins.tracker; + +/** + * Represents the type of data being tracked. + */ +public enum TrackedDataType { + /** + * Represents player versus player data. + */ + PVP, + /** + * Represents player versus enemy data. + */ + PVE, + /** + * Represents player versus world data. + */ + WORLD +} diff --git a/src/main/java/org/battleplugins/tracker/Tracker.java b/src/main/java/org/battleplugins/tracker/Tracker.java new file mode 100644 index 0000000..b75346c --- /dev/null +++ b/src/main/java/org/battleplugins/tracker/Tracker.java @@ -0,0 +1,346 @@ +package org.battleplugins.tracker; + +import org.battleplugins.tracker.feature.TrackerFeature; +import org.battleplugins.tracker.stat.Record; +import org.battleplugins.tracker.stat.StatType; +import org.battleplugins.tracker.stat.TallyContext; +import org.battleplugins.tracker.stat.TallyEntry; +import org.battleplugins.tracker.stat.VersusTally; +import org.battleplugins.tracker.stat.calculator.RatingCalculator; +import org.bukkit.OfflinePlayer; +import org.bukkit.entity.Player; +import org.jetbrains.annotations.Nullable; + +import java.util.List; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.function.Consumer; + +/** + * Main interface used for tracking stats. + */ +public interface Tracker { + + /** + * Returns the name of the tracker. + * + * @return the name of the tracker + */ + String getName(); + + /** + * Returns the record for the given OfflinePlayer. + * + * @param player the OfflinePlayer to get the record from + * @return the record for the given OfflinePlayer + */ + CompletableFuture<@Nullable Record> getRecord(OfflinePlayer player); + + /** + * Returns the top records for the specified stat type, ordered by + * the specified stat type. + * + * @param limit the limit of records to get + * @param orderBy the stat type to order by + * @return the top records for the specified stat type + */ + CompletableFuture> getTopRecords(int limit, StatType orderBy); + + /** + * Returns the record for the given Player. + * + * @param player the Player to get the record from + * @return the record for the given Player + */ + Record getRecord(Player player); + + /** + * Adds a record to the tracker. + * + * @param uuid the uuid to add the record for + * @param record the record to add + */ + void addRecord(UUID uuid, Record record); + + /** + * Returns the versus tally for the given OfflinePlayers. + * + * @param player1 the first OfflinePlayer to get the versus tally from + * @param player2 the second OfflinePlayer to get the versus tally from + * @return the versus tally for the given OfflinePlayers + */ + CompletableFuture getVersusTally(OfflinePlayer player1, OfflinePlayer player2); + + /** + * Creates a new versus tally for the given OfflinePlayers. + * + * @param player1 the first OfflinePlayer in the versus tally + * @param player2 the second OfflinePlayer in the versus tally + * @return the new versus tally for the given OfflinePlayers + */ + VersusTally createNewVersusTally(OfflinePlayer player1, OfflinePlayer player2); + + /** + * Returns or creates a versus tally for the given OfflinePlayers. + * + * @param player1 the first OfflinePlayer to get the versus tally from + * @param player2 the second OfflinePlayer to get the versus tally from + * @return the versus tally for the given OfflinePlayers or create a new one + */ + CompletableFuture getOrCreateVersusTally(OfflinePlayer player1, OfflinePlayer player2); + + /** + * Modifies the tally with the given context. + * + * @param tally the tally to modify + * @param context the context to modify the tally with + */ + void modifyTally(VersusTally tally, Consumer context); + + /** + * Records a tally entry. + * + * @param entry the tally entry to record + */ + void recordTallyEntry(TallyEntry entry); + + /** + * Returns a list of tally entries for the given player. + * + * @param uuid the UUID of the player to get the tally entries for + * @param includeLosses if losses should be included in the returned list + * @return a list of tally entries for the given player + */ + CompletableFuture> getTallyEntries(UUID uuid, boolean includeLosses); + + /** + * Increments a value with the given stat type. + * + * @param statType the stat type to increment the value for + * @param player the player to increment the value for + */ + default void incrementValue(StatType statType, Player player) { + Record record = this.getRecord(player); + if (record == null) { + return; + } + + record.setValue(statType, record.getStat(statType) + 1); + } + + /** + * Decrements a value with the given stat type. + * + * @param statType the stat type to decrement the value for + * @param player the player to decrement the value for + */ + default void decrementValue(StatType statType, Player player) { + Record record = this.getRecord(player); + if (record == null) { + return; + } + + record.setValue(statType, record.getStat(statType) - 1); + } + + /** + * Sets a value with the given stat type. + * + * @param statType the stat type to set the value for + * @param value the value to set + * @param player the player to set the value for + */ + void setValue(StatType statType, float value, OfflinePlayer player); + + /** + * Sets the rating for the specified players. + * + * @param killer the player to increment the rating for + * @param loser the player to decrement the rating for + * @param tie if the end result was a tie + */ + void updateRating(Player killer, Player loser, boolean tie); + + /** + * Enables tracking for the specified player. + * + * @param player the player to enable tracking for + */ + default void enableTracking(Player player) { + Record record = this.getRecord(player); + if (record == null) { + return; + } + + record.setTracking(true); + } + + /** + * Disables tracking for the specified player. + * + * @param player the player to disable tracking for + */ + default void disableTracking(Player player) { + Record record = this.getRecord(player); + if (record == null) { + return; + } + + record.setTracking(false); + } + + /** + * Adds a record for the specified player to the tracker from the + * default SQL columns. + * + * @param player the player to create the record for + * @return the new record created + */ + Record createNewRecord(OfflinePlayer player); + + /** + * Adds a record for the specified player to the tracker. + * + * @param player the player to create the record for + * @param record the record to add + * @return the new record created + */ + Record createNewRecord(OfflinePlayer player, Record record); + + /** + * Returns the record for the given player or creates + * a new one of one was unable to be found. + * + * @param player the player to get/create the record for + * @return the record for the given player or create a new one + */ + default CompletableFuture getOrCreateRecord(OfflinePlayer player) { + return this.getRecord(player).thenApply(record -> { + if (record == null) { + return this.createNewRecord(player); + } + + return record; + }); + } + + /** + * Returns the record for the given player or creates + * a new one of one was unable to be found. + * + * @param player the player to get/create the record for + * @return the record for the given player or create a new one + */ + default Record getOrCreateRecord(Player player) { + Record record = this.getRecord(player); + if (record == null) { + return this.createNewRecord(player); + } + + return record; + } + + /** + * Removes the record for the specified player. + * + * @param player the player to remove the record for + */ + void removeRecord(OfflinePlayer player); + + /** + * Returns the {@link TrackerFeature} from the given class. + * + * @param feature the class of the feature + * @return the feature from the given class + */ + Optional feature(Class feature); + + /** + * Returns if the tracker has the specified feature. + * + * @param feature the class of the feature + * @return if the tracker has the specified feature + */ + boolean hasFeature(Class feature); + + /** + * Returns the {@link TrackerFeature} from the given class. + * + * @param feature the class of the feature + * @return the feature from the given class + */ + @Nullable + T getFeature(Class feature); + + /** + * Registers a feature to the tracker. + * + * @param feature the feature to register + */ + void registerFeature(T feature); + + /** + * Returns if the tracker tracks the specified data type. + * + * @param type the data type to check + * @return if the tracker tracks the specified data type + */ + default boolean tracksData(TrackedDataType type) { + return this.getTrackedData().contains(type); + } + + /** + * Returns the {@link TrackedDataType} tracked by this + * tracker. + * + * @return the data tracked by this tracker + */ + Set getTrackedData(); + + /** + * Returns a list of disabled worlds for this tracker. + * + * @return a list of disabled worlds for this tracker + */ + List getDisabledWorlds(); + + /** + * Returns the rating calculator for this tracker. + * + * @return the rating calculator for this tracker + */ + RatingCalculator getRatingCalculator(); + + /** + * Saves the records to the database for the specified player. + * + * @param player the player to save records for + */ + CompletableFuture save(OfflinePlayer player); + + /** + * Saves the records for all the players in the cache. + */ + CompletableFuture saveAll(); + + /** + * Flushes cached data in the tracker. + *

+ * This method provides the option to aggressively + * flush the cache, which will remove all cached objects + * that do not have a lock set on them. + *

+ * NOTE: Ensure you have run the {@link #saveAll()} method + * as this method will not clear unsaved entries. + * + * @param aggressive if the flush should be aggressive. + */ + void flush(boolean aggressive); + + /** + * Destroys the tracker. + */ + void destroy(); +} diff --git a/src/main/java/org/battleplugins/tracker/TrackerExecutor.java b/src/main/java/org/battleplugins/tracker/TrackerExecutor.java new file mode 100644 index 0000000..0b3d5a2 --- /dev/null +++ b/src/main/java/org/battleplugins/tracker/TrackerExecutor.java @@ -0,0 +1,466 @@ +package org.battleplugins.tracker; + +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; +import org.battleplugins.tracker.feature.recap.BattleRecap; +import org.battleplugins.tracker.feature.recap.Recap; +import org.battleplugins.tracker.feature.recap.RecapRoundup; +import org.battleplugins.tracker.message.Messages; +import org.battleplugins.tracker.stat.Record; +import org.battleplugins.tracker.stat.StatType; +import org.battleplugins.tracker.stat.TallyEntry; +import org.battleplugins.tracker.stat.VersusTally; +import org.battleplugins.tracker.util.Util; +import org.bukkit.Bukkit; +import org.bukkit.OfflinePlayer; +import org.bukkit.command.Command; +import org.bukkit.command.CommandExecutor; +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.text.DecimalFormat; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.function.BiConsumer; + +public class TrackerExecutor implements CommandExecutor { + private final Tracker tracker; + private final Map commands; + + public TrackerExecutor(Tracker tracker) { + this.tracker = tracker; + + this.commands = new HashMap<>( + Map.of( + "top", new SimpleExecutor("View the top players of this tracker.", Arguments.ofOptional("max"), this::top), + "rank", new SimpleExecutor("View the rank of a player.", Arguments.ofOptional("player"), this::rank), + "versus", new SimpleExecutor("Compare the stats of players in relation to each other.", Arguments.of("player").optional("target"), this::versus) + ) + ); + + if (this.tracker.hasFeature(Recap.class)) { + this.commands.put("recap", new SimpleExecutor("View the recap of a player.", Arguments.ofOptional("player"), this::recap)); + } + } + + @Override + public boolean onCommand(@NotNull CommandSender sender, @NotNull Command command, @NotNull String label, @NotNull String[] args) { + if (args.length == 0) { + this.sendHelp(sender, label); + return true; + } + + SimpleExecutor simpleCommand = this.commands.get(args[0]); + if (simpleCommand != null) { + if (!hasPermission(sender, args[0])) { + Messages.send(sender, "command-no-permission"); + return true; + } + + simpleCommand.consumer().accept(sender, args.length == 1 ? "" : String.join(" ", args).replaceFirst(args[0], "").trim()); + return true; + } + + this.sendHelp(sender, label); + return true; + } + + public void sendHelp(CommandSender sender, String label) { + if (!hasPermission(sender, "help")) { + Messages.send(sender, "command-no-permission"); + return; + } + + Messages.send(sender, "header", this.tracker.getName()); + Map executors = new HashMap<>(this.commands); + + // Sort alphabetical + executors.keySet().stream() + .sorted() + .forEach(command -> { + Executor executor = executors.get(command); + String args = executor.describeArgs(); + sender.sendMessage(Component.text("/" + label + " " + command + (args.isEmpty() ? "" : " " + args), NamedTextColor.YELLOW) + .append(Component.text(" " + executor.description(), NamedTextColor.GOLD))); + }); + } + + public void sendHelp(CommandSender sender, String label, String cmd, @Nullable Executor executor) { + if (executor == null) { + this.sendHelp(sender, label); + return; + } + + Messages.send(sender, "command-usage", "/" + label + " " + cmd + " " + executor.describeArgs()); + } + + private void top(CommandSender sender, String argument) { + int amount = Math.max(1, argument == null || argument.isBlank() ? 5 : Math.min(100, Integer.parseInt(argument))); + Util.getSortedRecords(this.tracker, amount, StatType.RATING).whenComplete((records, e) -> { + if (records.isEmpty()) { + Messages.send(sender, "leaderboard-no-entries"); + return; + } + + Messages.send(sender, "header", this.tracker.getName()); + int ranking = 1; + for (Map.Entry entry : records.entrySet()) { + Record record = entry.getKey(); + + Util.sendTrackerMessage(sender, "leaderboard", ranking++, record); + } + }); + } + + private void rank(CommandSender sender, String playerName) { + OfflinePlayer target; + if (!(sender instanceof Player) && (playerName == null || playerName.isBlank())) { + Messages.send(sender, "command-player-not-found", ""); + return; + } else if (sender instanceof Player player && (playerName == null || playerName.isBlank())) { + target = player; + } else { + target = Bukkit.getServer().getOfflinePlayerIfCached(playerName); + } + + if (target == null) { + CompletableFuture.supplyAsync(() -> Bukkit.getOfflinePlayer(playerName)).thenCompose(this.tracker::getRecord).whenCompleteAsync((record, e) -> { + if (record == null) { + Messages.send(sender, "player-has-no-record", playerName); + return; + } + + this.rank(sender, record); + }, Bukkit.getScheduler().getMainThreadExecutor(BattleTracker.getInstance())); + } else { + tracker.getRecord(target).whenCompleteAsync((record, e) -> { + if (record == null) { + Messages.send(sender, "player-has-no-record", playerName); + return; + } + + this.rank(sender, record); + }, Bukkit.getScheduler().getMainThreadExecutor(BattleTracker.getInstance())); + } + } + + private void rank(CommandSender sender, Record record) { + Util.sendTrackerMessage(sender, "rank", -1, record); + } + + private void versus(CommandSender sender, String arg) { + String[] args = arg.split(" "); + if (args.length == 0) { + Messages.send(sender, "command-player-not-found", ""); + return; + } + + if (args.length == 1) { + if (sender instanceof Player player) { + String playerName = args[0]; + CompletableFuture.supplyAsync(() -> Bukkit.getOfflinePlayer(playerName)).whenCompleteAsync((p, e) -> { + if (e != null) { + BattleTracker.getInstance().error("Failed to get versus tally for {} and {}", playerName, player.getName(), e); + return; + } + + if (p == null) { + Messages.send(sender, "command-player-not-found", playerName); + return; + } + + this.versus(sender, player, p); + }, Bukkit.getScheduler().getMainThreadExecutor(BattleTracker.getInstance())); + } else { + Messages.send(sender, "command-player-not-found", ""); + } + } else if (args.length == 2) { + CompletableFuture player1Future = CompletableFuture.supplyAsync(() -> Bukkit.getOfflinePlayer(args[0])); + CompletableFuture player2Future = CompletableFuture.supplyAsync(() -> Bukkit.getOfflinePlayer(args[1])); + + CompletableFuture.allOf( + player1Future, + player2Future + ).whenCompleteAsync((players, e) -> { + if (e != null) { + BattleTracker.getInstance().error("Failed to get versus tally for {} and {}", args[0], args[1], e); + return; + } + + OfflinePlayer player1 = player1Future.join(); + OfflinePlayer player2 = player2Future.join(); + if (player1 == null) { + Messages.send(sender, "command-player-not-found", args[0]); + return; + } + + if (player2 == null) { + Messages.send(sender, "command-player-not-found", args[1]); + return; + } + + this.versus(sender, player1, player2); + }, Bukkit.getScheduler().getMainThreadExecutor(BattleTracker.getInstance())); + } + } + + private void recap(CommandSender sender, String argument) { + String[] args = argument.split(" "); + String arg = args.length >= 1 ? args[0] : null; + Player player = arg == null || arg.isBlank() ? null : Bukkit.getPlayer(arg); + if (player == null && sender instanceof Player senderPlayer) { + player = senderPlayer; + } + + if (player == null) { + Messages.send(sender, "command-player-not-found", (arg == null || arg.isBlank()) ? "" : arg); + return; + } + + Recap recap = this.tracker.feature(Recap.class).orElseThrow(() -> new IllegalStateException("Recap feature is not enabled!")); + if (!recap.enabled()) { + Messages.send(sender, "recap-not-enabled"); + return; + } + + BattleRecap battleRecap = recap.getPreviousRecap(player); + if (battleRecap == null || battleRecap.getLastEntry() == null) { + Messages.send(sender, "recap-no-recap"); + return; + } + + if (args.length >= 2) { + boolean sent = false; + switch (args[1].toLowerCase(Locale.ROOT)) { + case "item": + RecapRoundup.recapItem(sender, battleRecap); + sent = true; + break; + case "entity": + RecapRoundup.recapEntity(sender, battleRecap); + sent = true; + break; + case "cause": + RecapRoundup.recapSource(sender, battleRecap); + sent = true; + break; + case "player": + RecapRoundup.recapPlayer(sender, battleRecap); + sent = true; + break; + default: + break; + } + + if (sent) { + RecapRoundup.sendFooter(sender, this.tracker, battleRecap); + return; + } + } + + recap.showRecap(sender, this.tracker, battleRecap); + } + + private void versus(CommandSender sender, OfflinePlayer player1, OfflinePlayer player2) { + CompletableFuture future = this.tracker.getVersusTally(player1, player2); + future.whenComplete((tally, e) -> { + if (e != null) { + BattleTracker.getInstance().error("Failed to get versus tally for {} and {}", player1.getName(), player2.getName(), e); + return; + } + + if (tally == null) { + Messages.send(sender, "player-has-no-tally", player1.getName(), player2.getName()); + return; + } + + CompletableFuture record1Future = this.tracker.getRecord(player1); + CompletableFuture record2Future = this.tracker.getRecord(player2); + + CompletableFuture.allOf( + record1Future, + record2Future + ).whenCompleteAsync((records, ex) -> { + if (ex != null) { + BattleTracker.getInstance().error("Failed to get records for {} and {}", player1.getName(), player2.getName(), ex); + return; + } + + Record record1 = record1Future.join(); + Record record2 = record2Future.join(); + if (record1 == null) { + Messages.send(sender, "player-has-no-record", player1.getName()); + return; + } + + if (record2 == null) { + Messages.send(sender, "player-has-no-record", player2.getName()); + return; + } + + this.versus0(sender, player1, record1, player2, record2, tally); + }, Bukkit.getScheduler().getMainThreadExecutor(BattleTracker.getInstance())); + }); + } + + private void versus0(CommandSender sender, OfflinePlayer player1, Record record1, OfflinePlayer player2, Record record2, VersusTally tally) { + DecimalFormat format = new DecimalFormat("0.##"); + + Messages.send(sender, "header", Messages.getPlain("versus-tally")); + Messages.send(sender, "versus", Map.of( + "player", record1.getName(), + "target", record2.getName(), + "player_rating", format.format(record1.getRating()), + "target_rating", format.format(record2.getRating()) + )); + + Map replacements = new HashMap<>(); + replacements.put("kills", format.format(tally.getStat(StatType.KILLS))); + replacements.put("deaths", format.format(tally.getStat(StatType.DEATHS))); + + // Since versus tallies are only stored one way, we need to flip the value + // in the scenario that the "1st" player instead the 2nd player + if (tally.id2().equals(player1.getUniqueId())) { + replacements.put("player", player2.getName()); + replacements.put("target", player1.getName()); + } else { + replacements.put("player", player1.getName()); + replacements.put("target", player2.getName()); + } + + Messages.send(sender, "versus-compare", replacements); + + CompletableFuture> future = this.tracker.getTallyEntries(player1.getUniqueId(), true); + future.whenComplete((entries, e) -> { + if (e != null) { + BattleTracker.getInstance().error("Failed to get tally entries for {}", player1.getName(), e); + return; + } + + if (entries == null || entries.isEmpty()) { + return; + } + + DateTimeFormatter formatter = DateTimeFormatter.ofPattern(Messages.getPlain("date-format")) + .withLocale(sender instanceof Player player ? player.locale() : Locale.ROOT) + .withZone(ZoneId.systemDefault()); + + Messages.send(sender, "versus-history"); + + // Sort entries by most recent + entries.stream() + .filter(entry -> { + // Ensure the entries are against the same two players + return (entry.id1().equals(player1.getUniqueId()) && entry.id2().equals(player2.getUniqueId())) || + (entry.id1().equals(player2.getUniqueId()) && entry.id2().equals(player1.getUniqueId())); + }) + .sorted((e1, e2) -> e2.timestamp().compareTo(e1.timestamp())) + .limit(5) // Limit to top 5 + .forEach(entry -> { + // If the player is the first player, they won + if (entry.id1().equals(player1.getUniqueId())) { + Messages.send(sender, "versus-history-entry-win", Map.of( + "player", player1.getName(), + "target", player2.getName(), + "date", formatter.format(entry.timestamp()) + )); + } else { + Messages.send(sender, "versus-history-entry-loss", Map.of( + "player", player1.getName(), + "target", player2.getName(), + "date", formatter.format(entry.timestamp()) + )); + } + }); + }); + } + + record SimpleExecutor(String description, Arguments args, BiConsumer consumer) implements Executor { + + @Override + public String describeArgs() { + return this.args.describe(); + } + } + + private static boolean hasPermission(CommandSender sender, String node) { + return sender.hasPermission("battletracker.command." + node); + } + + public interface Executor { + String description(); + + String describeArgs(); + } + + public static class Arguments { + private final List arguments = new ArrayList<>(); + + private Arguments() { + } + + public String describe() { + if (this.arguments.isEmpty()) { + return ""; + } + + return this.arguments.stream() + .map(argument -> argument.required() ? "<" + argument.name() + ">" : "[" + argument.name() + "]") + .reduce((a, b) -> a + " " + b) + .orElse(""); + } + + private Arguments(boolean required, String... arguments) { + for (String argument : arguments) { + this.arguments.add(new Argument(argument, required)); + } + } + + public Arguments required(String... arguments) { + for (String argument : arguments) { + this.arguments.add(new Argument(argument, true)); + } + + return this; + } + + public Arguments optional(String... arguments) { + for (String argument : arguments) { + this.arguments.add(new Argument(argument, false)); + } + + return this; + } + + private Arguments(Argument... arguments) { + this.arguments.addAll(List.of(arguments)); + } + + public static Arguments of(String... arguments) { + return new Arguments(true, arguments); + } + + public static Arguments of(Argument... arguments) { + return new Arguments(arguments); + } + + public static Arguments ofOptional(String... arguments) { + return new Arguments(false, arguments); + } + + public static Arguments empty() { + return new Arguments(); + } + + public record Argument(String name, boolean required) { + } + } +} diff --git a/src/main/java/org/battleplugins/tracker/TrackerLoader.java b/src/main/java/org/battleplugins/tracker/TrackerLoader.java new file mode 100644 index 0000000..21ef1b8 --- /dev/null +++ b/src/main/java/org/battleplugins/tracker/TrackerLoader.java @@ -0,0 +1,86 @@ +package org.battleplugins.tracker; + +import org.battleplugins.tracker.feature.Killstreaks; +import org.battleplugins.tracker.feature.Rampage; +import org.battleplugins.tracker.feature.message.DeathMessages; +import org.battleplugins.tracker.feature.recap.Recap; +import org.battleplugins.tracker.listener.PvEListener; +import org.battleplugins.tracker.listener.PvPListener; +import org.battleplugins.tracker.stat.calculator.RatingCalculator; +import org.bukkit.command.PluginCommand; +import org.bukkit.configuration.Configuration; + +import java.nio.file.Path; +import java.util.EnumSet; +import java.util.List; +import java.util.Locale; +import java.util.Set; + +record TrackerLoader(BattleTracker battleTracker, Configuration configuration, Path trackerPath) { + + public void load() { + String name = this.configuration.getString("name"); + String calculatorName = this.configuration.getString("calculator"); + RatingCalculator calculator = this.battleTracker.getCalculator(calculatorName); + if (calculator == null) { + this.battleTracker.warn("Rating calculator {} not found!", calculatorName); + return; + } + + List trackedData = this.configuration.getStringList("tracked-statistics"); + if (trackedData.isEmpty()) { + this.battleTracker.warn("No tracked data found for tracker {}!", name); + return; + } + + Set dataTypes = EnumSet.noneOf(TrackedDataType.class); + for (String data : trackedData) { + try { + TrackedDataType type = TrackedDataType.valueOf(data.toUpperCase(Locale.ROOT)); + dataTypes.add(type); + } catch (IllegalArgumentException e) { + this.battleTracker.warn("Unknown tracked data type {} for tracker {}!", data, name); + } + } + + List disabledWorlds = this.configuration.getStringList("disabled-worlds"); + + Tracker tracker = new SqlTracker(this.battleTracker, name, calculator, dataTypes, disabledWorlds); + if (this.configuration.isConfigurationSection("killstreaks")) { + tracker.registerFeature(Killstreaks.load(this.configuration.getConfigurationSection("killstreaks"))); + } + + if (this.configuration.isConfigurationSection("rampage")) { + tracker.registerFeature(Rampage.load(this.configuration.getConfigurationSection("rampage"))); + } + + if (this.configuration.isConfigurationSection("death-messages")) { + tracker.registerFeature(DeathMessages.load(this.configuration.getConfigurationSection("death-messages"))); + } + + if (this.configuration.isConfigurationSection("recap")) { + tracker.registerFeature(Recap.load(this.configuration.getConfigurationSection("recap"))); + } else { + // Recaps are always enabled as they are used throughout the tracker for + // retrieving player damages. However, whether they are displayed is determined + // by the configuration. + throw new IllegalArgumentException("Recap configuration not found!"); + } + + // Register command + PluginCommand command = this.battleTracker.getCommand(tracker.getName().toLowerCase(Locale.ROOT)); + TrackerExecutor executor = new TrackerExecutor(tracker); + command.setExecutor(executor); + + if (tracker.tracksData(TrackedDataType.PVP)) { + this.battleTracker.registerListener(tracker, new PvPListener(tracker)); + } + + if (tracker.tracksData(TrackedDataType.PVE) || tracker.tracksData(TrackedDataType.WORLD)) { + this.battleTracker.registerListener(tracker, new PvEListener(tracker)); + } + + this.battleTracker.registerTracker(tracker); + this.battleTracker.info("Loaded tracker: {}.", name); + } +} diff --git a/src/main/java/org/battleplugins/tracker/event/BattleTrackerPostInitializeEvent.java b/src/main/java/org/battleplugins/tracker/event/BattleTrackerPostInitializeEvent.java new file mode 100644 index 0000000..2bc5c3b --- /dev/null +++ b/src/main/java/org/battleplugins/tracker/event/BattleTrackerPostInitializeEvent.java @@ -0,0 +1,38 @@ +package org.battleplugins.tracker.event; + +import org.battleplugins.tracker.BattleTracker; +import org.bukkit.event.Event; +import org.bukkit.event.HandlerList; +import org.jetbrains.annotations.NotNull; + +/** + * Called when BattleTracker is fully initialized. + */ +public class BattleTrackerPostInitializeEvent extends Event { + private final static HandlerList HANDLERS = new HandlerList(); + + private final BattleTracker battleTracker; + + public BattleTrackerPostInitializeEvent(BattleTracker battleTracker) { + this.battleTracker = battleTracker; + } + + /** + * Returns the {@link BattleTracker} instance. + * + * @return the BattleTracker instance + */ + public BattleTracker getBattleTracker() { + return battleTracker; + } + + @NotNull + @Override + public HandlerList getHandlers() { + return HANDLERS; + } + + public static HandlerList getHandlerList() { + return HANDLERS; + } +} diff --git a/src/main/java/org/battleplugins/tracker/event/BattleTrackerPreInitializeEvent.java b/src/main/java/org/battleplugins/tracker/event/BattleTrackerPreInitializeEvent.java new file mode 100644 index 0000000..8ffffa1 --- /dev/null +++ b/src/main/java/org/battleplugins/tracker/event/BattleTrackerPreInitializeEvent.java @@ -0,0 +1,38 @@ +package org.battleplugins.tracker.event; + +import org.battleplugins.tracker.BattleTracker; +import org.bukkit.event.Event; +import org.bukkit.event.HandlerList; +import org.jetbrains.annotations.NotNull; + +/** + * Called when BattleTracker is starting its initialization. + */ +public class BattleTrackerPreInitializeEvent extends Event { + private final static HandlerList HANDLERS = new HandlerList(); + + private final BattleTracker battleTracker; + + public BattleTrackerPreInitializeEvent(BattleTracker battleTracker) { + this.battleTracker = battleTracker; + } + + /** + * Returns the {@link BattleTracker} instance. + * + * @return the BattleTracker instance + */ + public BattleTracker getBattleTracker() { + return battleTracker; + } + + @NotNull + @Override + public HandlerList getHandlers() { + return HANDLERS; + } + + public static HandlerList getHandlerList() { + return HANDLERS; + } +} diff --git a/src/main/java/org/battleplugins/tracker/event/TallyRecordEvent.java b/src/main/java/org/battleplugins/tracker/event/TallyRecordEvent.java new file mode 100644 index 0000000..20ec490 --- /dev/null +++ b/src/main/java/org/battleplugins/tracker/event/TallyRecordEvent.java @@ -0,0 +1,74 @@ +package org.battleplugins.tracker.event; + +import org.battleplugins.tracker.Tracker; +import org.battleplugins.tracker.stat.Record; +import org.bukkit.event.Event; +import org.bukkit.event.HandlerList; +import org.jetbrains.annotations.NotNull; + +/** + * Called when a tally is recorded for two different + * players. + */ +public class TallyRecordEvent extends Event { + private final static HandlerList HANDLERS = new HandlerList(); + + private final Tracker tracker; + private final Record victor; + private final Record loser; + private final boolean tie; + + public TallyRecordEvent(Tracker tracker, Record victor, Record loser, boolean tie) { + this.tracker = tracker; + this.victor = victor; + this.loser = loser; + this.tie = tie; + } + + /** + * Returns the {@link Tracker} instance this + * tally was recorded in. + * + * @return the Tracker instance + */ + public Tracker getTracker() { + return this.tracker; + } + + /** + * Returns the victor of the tally. + * + * @return the victor of the tally + */ + public Record getVictor() { + return this.victor; + } + + /** + * Returns the loser of the tally. + * + * @return the loser of the tally + */ + public Record getLoser() { + return this.loser; + } + + /** + * Returns whether the tally was a tie. + * + * @return whether the tally was a tie + */ + public boolean isTie() { + return this.tie; + } + + @NotNull + @Override + public HandlerList getHandlers() { + return HANDLERS; + } + + public static HandlerList getHandlerList() { + return HANDLERS; + } +} diff --git a/src/main/java/org/battleplugins/tracker/event/TrackerDeathEvent.java b/src/main/java/org/battleplugins/tracker/event/TrackerDeathEvent.java new file mode 100644 index 0000000..a59cf33 --- /dev/null +++ b/src/main/java/org/battleplugins/tracker/event/TrackerDeathEvent.java @@ -0,0 +1,107 @@ +package org.battleplugins.tracker.event; + +import org.battleplugins.tracker.Tracker; +import org.bukkit.entity.Entity; +import org.bukkit.event.HandlerList; +import org.bukkit.event.entity.PlayerDeathEvent; +import org.bukkit.event.player.PlayerEvent; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.Optional; + +/** + * Called when a player dies and has their data + * tracked by BattleTracker. + */ +public class TrackerDeathEvent extends PlayerEvent { + private final static HandlerList HANDLERS = new HandlerList(); + + private final Tracker tracker; + private final DeathType deathType; + private final Entity killer; + private final PlayerDeathEvent deathEvent; + + public TrackerDeathEvent(Tracker tracker, DeathType deathType, @Nullable Entity killer, PlayerDeathEvent deathEvent) { + super(deathEvent.getEntity()); + + this.tracker = tracker; + this.deathType = deathType; + this.killer = killer; + this.deathEvent = deathEvent; + } + + /** + * Returns the {@link Tracker} instance this + * tally was recorded in. + * + * @return the Tracker instance + */ + public Tracker getTracker() { + return this.tracker; + } + + /** + * Returns the type of death the player experienced. + * + * @return the type of death + */ + public DeathType getDeathType() { + return this.deathType; + } + + /** + * Returns the entity that killed the player. + * + * @return the entity that killed the player + */ + public Optional killer() { + return Optional.ofNullable(this.killer); + } + + /** + * Returns the entity that killed the player. + * + * @return the entity that killed the player + */ + @Nullable + public Entity getKiller() { + return this.killer; + } + + /** + * Returns the backing {@link PlayerDeathEvent}. + * + * @return the backing PlayerDeathEvent + */ + public PlayerDeathEvent getDeathEvent() { + return this.deathEvent; + } + + /** + * Returns the type of death the player experienced. + */ + public enum DeathType { + /** + * The player died to another player. + */ + PLAYER, + /** + * The player died to an entity. + */ + ENTITY, + /** + * The player died to the world. + */ + WORLD + } + + @Override + public @NotNull HandlerList getHandlers() { + return HANDLERS; + } + + public static HandlerList getHandlerList() { + return HANDLERS; + } +} diff --git a/src/main/java/org/battleplugins/tracker/event/feature/DeathMessageEvent.java b/src/main/java/org/battleplugins/tracker/event/feature/DeathMessageEvent.java new file mode 100644 index 0000000..66eeb1d --- /dev/null +++ b/src/main/java/org/battleplugins/tracker/event/feature/DeathMessageEvent.java @@ -0,0 +1,64 @@ +package org.battleplugins.tracker.event.feature; + +import net.kyori.adventure.text.Component; +import org.battleplugins.tracker.Tracker; +import org.bukkit.entity.Player; +import org.bukkit.event.HandlerList; +import org.bukkit.event.player.PlayerEvent; +import org.jetbrains.annotations.NotNull; + +/** + * Called when a player dies and has their death message + * displayed by BattleTracker. + */ +public class DeathMessageEvent extends PlayerEvent { + private final static HandlerList HANDLERS = new HandlerList(); + + private Component deathMessage; + private final Tracker tracker; + + public DeathMessageEvent(Player player, Component deathMessage, Tracker tracker) { + super(player); + + this.deathMessage = deathMessage; + this.tracker = tracker; + } + + /** + * Returns the {@link Tracker} instance this + * death message was displayed for. + * + * @return the Tracker instance + */ + public Tracker getTracker() { + return this.tracker; + } + + /** + * Returns the death message that will be displayed. + * + * @return the death message + */ + public Component getDeathMessage() { + return deathMessage; + } + + /** + * Sets the death message that will be displayed. + * + * @param deathMessage the death message + */ + public void setDeathMessage(Component deathMessage) { + this.deathMessage = deathMessage; + } + + @NotNull + @Override + public HandlerList getHandlers() { + return HANDLERS; + } + + public static HandlerList getHandlerList() { + return HANDLERS; + } +} diff --git a/src/main/java/org/battleplugins/tracker/feature/Feature.java b/src/main/java/org/battleplugins/tracker/feature/Feature.java new file mode 100644 index 0000000..4e948c2 --- /dev/null +++ b/src/main/java/org/battleplugins/tracker/feature/Feature.java @@ -0,0 +1,12 @@ +package org.battleplugins.tracker.feature; + +import org.battleplugins.tracker.BattleTracker; + +public interface Feature { + + boolean enabled(); + + void onEnable(BattleTracker battleTracker); + + void onDisable(BattleTracker battleTracker); +} diff --git a/src/main/java/org/battleplugins/tracker/feature/Killstreaks.java b/src/main/java/org/battleplugins/tracker/feature/Killstreaks.java new file mode 100644 index 0000000..a4c2d60 --- /dev/null +++ b/src/main/java/org/battleplugins/tracker/feature/Killstreaks.java @@ -0,0 +1,87 @@ +package org.battleplugins.tracker.feature; + +import net.kyori.adventure.text.Component; +import org.battleplugins.tracker.BattleTracker; +import org.battleplugins.tracker.Tracker; +import org.battleplugins.tracker.event.TrackerDeathEvent; +import org.battleplugins.tracker.feature.message.MessageAudience; +import org.battleplugins.tracker.stat.Record; +import org.battleplugins.tracker.stat.StatType; +import org.battleplugins.tracker.util.MessageUtil; +import org.bukkit.configuration.ConfigurationSection; +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.Listener; + +import java.util.HashMap; +import java.util.Map; + +public record Killstreaks( + boolean enabled, + int minimumKills, + int killstreakMessageInterval, + MessageAudience audience, + Map messages +) implements TrackerFeature { + + @Override + public void onEnable(BattleTracker battleTracker, Tracker tracker) { + battleTracker.registerListener(tracker, new KillstreakListener(tracker, this)); + } + + public static Killstreaks load(ConfigurationSection section) { + boolean enabled = section.getBoolean("enabled"); + if (!enabled) { + return new Killstreaks(false, 0, 0, MessageAudience.GLOBAL, Map.of()); + } + + int minimumKills = section.getInt("minimum-kills"); + int killstreakMessageInterval = section.getInt("killstreak-message-interval"); + MessageAudience audience = MessageAudience.get(section.getString("audience")); + + Map messages = new HashMap<>(); + ConfigurationSection messagesSection = section.getConfigurationSection("messages"); + messagesSection.getKeys(false).forEach(key -> { + if (!messagesSection.isString(key)) { + throw new IllegalArgumentException("Message " + key + " is not a string!"); + } + + messages.put(key, MessageUtil.deserialize(messagesSection.getString(key))); + }); + + return new Killstreaks(true, minimumKills, killstreakMessageInterval, audience, messages); + } + + private record KillstreakListener(Tracker tracker, Killstreaks killstreaks) implements Listener { + + @EventHandler + public void onTrackerDeath(TrackerDeathEvent event) { + if (!event.getTracker().equals(this.tracker)) { + return; + } + + if (!(event.getKiller() instanceof Player killer)) { + return; + } + + Record record = this.tracker.getRecord(killer); + float streak = record.getStat(StatType.STREAK); + if (streak >= this.killstreaks.minimumKills()) { + if ((int) streak % this.killstreaks.killstreakMessageInterval() != 0) { + return; + } + + String killsStr = Integer.toString(Float.valueOf(streak).intValue()); + Component message = this.killstreaks.messages().get(killsStr); + if (message == null) { + message = this.killstreaks.messages().get("default"); + } + + message = message.replaceText(builder -> builder.matchLiteral("%player%").once().replacement(killer.name())); + message = message.replaceText(builder -> builder.matchLiteral("%kills%").once().replacement(killsStr)); + + this.killstreaks.audience().broadcastMessage(message, killer, event.getPlayer()); + } + } + } +} diff --git a/src/main/java/org/battleplugins/tracker/feature/Rampage.java b/src/main/java/org/battleplugins/tracker/feature/Rampage.java new file mode 100644 index 0000000..77a6aef --- /dev/null +++ b/src/main/java/org/battleplugins/tracker/feature/Rampage.java @@ -0,0 +1,96 @@ +package org.battleplugins.tracker.feature; + +import net.kyori.adventure.text.Component; +import org.battleplugins.tracker.BattleTracker; +import org.battleplugins.tracker.Tracker; +import org.battleplugins.tracker.event.TrackerDeathEvent; +import org.battleplugins.tracker.feature.message.MessageAudience; +import org.battleplugins.tracker.util.MessageUtil; +import org.bukkit.configuration.ConfigurationSection; +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.Listener; + +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; + +public record Rampage( + boolean enabled, + int rampageTime, + MessageAudience audience, + Map messages +) implements TrackerFeature { + + @Override + public void onEnable(BattleTracker battleTracker, Tracker tracker) { + battleTracker.registerListener(tracker, new RampageListener(tracker, this)); + } + + public static Rampage load(ConfigurationSection section) { + boolean enabled = section.getBoolean("enabled"); + if (!enabled) { + return new Rampage(false, 0, MessageAudience.GLOBAL, Map.of()); + } + + int rampageTime = section.getInt("rampage-time"); + MessageAudience audience = MessageAudience.get(section.getString("audience")); + + Map messages = new HashMap<>(); + ConfigurationSection messagesSection = section.getConfigurationSection("messages"); + messagesSection.getKeys(false).forEach(key -> { + if (!messagesSection.isString(key)) { + throw new IllegalArgumentException("Message " + key + " is not a string!"); + } + + messages.put(key, MessageUtil.deserialize(messagesSection.getString(key))); + }); + + return new Rampage(true, rampageTime, audience, messages); + } + + private static class RampageListener implements Listener { + private final Tracker tracker; + private final Rampage rampage; + + private final Map lastKill = new HashMap<>(); + private final Map killCount = new HashMap<>(); + + private RampageListener(Tracker tracker, Rampage rampage) { + this.tracker = tracker; + this.rampage = rampage; + } + + @EventHandler + public void onTrackerDeath(TrackerDeathEvent event) { + if (!event.getTracker().equals(this.tracker)) { + return; + } + + if (!(event.getKiller() instanceof Player killer)) { + return; + } + + UUID killerUUID = killer.getUniqueId(); + long lastKillTime = this.lastKill.getOrDefault(killerUUID, 0L); + if (System.currentTimeMillis() - lastKillTime > this.rampage.rampageTime() * 1000L) { + this.killCount.put(killerUUID, 0); + } + + this.lastKill.put(killerUUID, System.currentTimeMillis()); + int killCount = this.killCount.getOrDefault(killerUUID, 0) + 1; + this.killCount.put(killerUUID, killCount); + + if (killCount > 1) { + Component message = this.rampage.messages().get(Integer.toString(killCount)); + if (message == null) { + message = this.rampage.messages().get("default"); + } + + message = message.replaceText(builder -> builder.matchLiteral("%player%").once().replacement(killer.name())); + + this.rampage.audience().broadcastMessage(message, killer, event.getPlayer()); + } + } + } +} diff --git a/src/main/java/org/battleplugins/tracker/feature/TrackerFeature.java b/src/main/java/org/battleplugins/tracker/feature/TrackerFeature.java new file mode 100644 index 0000000..ac6de65 --- /dev/null +++ b/src/main/java/org/battleplugins/tracker/feature/TrackerFeature.java @@ -0,0 +1,14 @@ +package org.battleplugins.tracker.feature; + +import org.battleplugins.tracker.BattleTracker; +import org.battleplugins.tracker.Tracker; + +/** + * Represents a feature that can be loaded by BattleTracker. + */ +public interface TrackerFeature { + + boolean enabled(); + + void onEnable(BattleTracker battleTracker, Tracker tracker); +} diff --git a/src/main/java/org/battleplugins/tracker/feature/battlearena/ArenaTracker.java b/src/main/java/org/battleplugins/tracker/feature/battlearena/ArenaTracker.java new file mode 100644 index 0000000..7dccad7 --- /dev/null +++ b/src/main/java/org/battleplugins/tracker/feature/battlearena/ArenaTracker.java @@ -0,0 +1,35 @@ +package org.battleplugins.tracker.feature.battlearena; + +import org.battleplugins.tracker.BattleTracker; +import org.battleplugins.tracker.SqlTracker; +import org.battleplugins.tracker.TrackedDataType; +import org.battleplugins.tracker.sql.TrackerSqlSerializer; +import org.battleplugins.tracker.stat.StatType; +import org.battleplugins.tracker.stat.calculator.RatingCalculator; + +import java.util.List; +import java.util.Set; + +public class ArenaTracker extends SqlTracker { + public static StatType WINS = StatType.create("wins", "Wins", true); + public static StatType LOSSES = StatType.create("losses", "Losses", true); + + public ArenaTracker(BattleTracker plugin, String name, RatingCalculator calculator, Set trackedData, List disabledWorlds) { + super(plugin, name, calculator, trackedData, disabledWorlds); + } + + @Override + protected TrackerSqlSerializer createSerializer() { + List generalStats = List.of( + WINS, LOSSES, + StatType.KILLS, StatType.DEATHS, StatType.TIES, + StatType.MAX_STREAK, StatType.MAX_RANKING, StatType.RATING, + StatType.MAX_RATING, StatType.MAX_KD_RATIO + ); + return new TrackerSqlSerializer( + this, + generalStats, + List.of(WINS, LOSSES, StatType.TIES) + ); + } +} diff --git a/src/main/java/org/battleplugins/tracker/feature/battlearena/BattleArenaFeature.java b/src/main/java/org/battleplugins/tracker/feature/battlearena/BattleArenaFeature.java new file mode 100644 index 0000000..f49a5fa --- /dev/null +++ b/src/main/java/org/battleplugins/tracker/feature/battlearena/BattleArenaFeature.java @@ -0,0 +1,38 @@ +package org.battleplugins.tracker.feature.battlearena; + +import org.battleplugins.tracker.BattleTracker; +import org.battleplugins.tracker.feature.Feature; +import org.bukkit.Bukkit; + +public class BattleArenaFeature implements Feature { + private final boolean enabled; + + private BattleArenaHandler handler; + + public BattleArenaFeature() { + this.enabled = Bukkit.getServer().getPluginManager().getPlugin("BattleArena") != null; + } + + @Override + public boolean enabled() { + return this.enabled; + } + + @Override + public void onEnable(BattleTracker battleTracker) { + if (!this.enabled) { + battleTracker.info("BattleArena not found. Not tracking arena statistics."); + return; + } + + this.handler = new BattleArenaHandler(battleTracker); + battleTracker.info("BattleArena found. Tracking arena statistics."); + } + + @Override + public void onDisable(BattleTracker battleTracker) { + if (this.handler != null) { + this.handler.onDisable(); + } + } +} diff --git a/src/main/java/org/battleplugins/tracker/feature/battlearena/BattleArenaHandler.java b/src/main/java/org/battleplugins/tracker/feature/battlearena/BattleArenaHandler.java new file mode 100644 index 0000000..81e082d --- /dev/null +++ b/src/main/java/org/battleplugins/tracker/feature/battlearena/BattleArenaHandler.java @@ -0,0 +1,32 @@ +package org.battleplugins.tracker.feature.battlearena; + +import org.battleplugins.arena.messages.Message; +import org.battleplugins.arena.options.ArenaOptionType; +import org.battleplugins.arena.options.types.BooleanArenaOption; +import org.battleplugins.tracker.BattleTracker; +import org.battleplugins.tracker.message.Messages; +import org.battleplugins.tracker.util.MessageUtil; +import org.bukkit.Bukkit; +import org.bukkit.event.HandlerList; + +public class BattleArenaHandler { + public static final ArenaOptionType TRACK_STATISTICS = ArenaOptionType.create("track-statistics", BooleanArenaOption::new); + + private final BattleTracker battleTracker; + + public BattleArenaHandler(BattleTracker battleTracker) { + this.battleTracker = battleTracker; + + Bukkit.getPluginManager().registerEvents(new BattleArenaListener(battleTracker), battleTracker); + } + + public void onDisable() { + HandlerList.getRegisteredListeners(this.battleTracker).stream() + .filter(listener -> listener.getListener() instanceof BattleArenaListener) + .forEach(l -> HandlerList.unregisterAll(l.getListener())); + } + + public static Message convertMessage(String message) { + return org.battleplugins.arena.messages.Messages.wrap(MessageUtil.serialize(Messages.get(message))); + } +} diff --git a/src/main/java/org/battleplugins/tracker/feature/battlearena/BattleArenaListener.java b/src/main/java/org/battleplugins/tracker/feature/battlearena/BattleArenaListener.java new file mode 100644 index 0000000..f2d8889 --- /dev/null +++ b/src/main/java/org/battleplugins/tracker/feature/battlearena/BattleArenaListener.java @@ -0,0 +1,285 @@ +package org.battleplugins.tracker.feature.battlearena; + +import org.battleplugins.arena.Arena; +import org.battleplugins.arena.ArenaPlayer; +import org.battleplugins.arena.competition.JoinResult; +import org.battleplugins.arena.competition.LiveCompetition; +import org.battleplugins.arena.competition.phase.CompetitionPhaseType; +import org.battleplugins.arena.event.arena.ArenaCreateExecutorEvent; +import org.battleplugins.arena.event.arena.ArenaDrawEvent; +import org.battleplugins.arena.event.arena.ArenaInitializeEvent; +import org.battleplugins.arena.event.arena.ArenaVictoryEvent; +import org.battleplugins.arena.event.player.ArenaKillEvent; +import org.battleplugins.arena.event.player.ArenaLeaveEvent; +import org.battleplugins.arena.event.player.ArenaPreJoinEvent; +import org.battleplugins.arena.options.types.BooleanArenaOption; +import org.battleplugins.tracker.BattleTracker; +import org.battleplugins.tracker.TrackedDataType; +import org.battleplugins.tracker.Tracker; +import org.battleplugins.tracker.stat.Record; +import org.battleplugins.tracker.stat.StatType; +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.Listener; + +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.function.Consumer; +import java.util.stream.Collectors; + +public class BattleArenaListener implements Listener { + private final BattleTracker battleTracker; + private final Map> pendingExecutors = new HashMap<>(); + + public BattleArenaListener(BattleTracker battleTracker) { + this.battleTracker = battleTracker; + } + + @EventHandler + public void onArenaInitialize(ArenaInitializeEvent event) { + Arena arena = event.getArena(); + + // Statistic tracking is disabled + if (!arena.option(BattleArenaHandler.TRACK_STATISTICS).map(BooleanArenaOption::isEnabled).orElse(true)) { + return; + } + + Consumer pendingExecutor = this.pendingExecutors.remove(arena); + this.battleTracker.registerTracker( + event.getArena().getName().toLowerCase(Locale.ROOT), + () -> { + Tracker tracker = new ArenaTracker( + this.battleTracker, + arena.getName(), + this.battleTracker.getCalculator("elo"), + Set.of(TrackedDataType.PVP), + List.of() + ); + + if (pendingExecutor != null) { + pendingExecutor.accept(tracker); + } + + return tracker; + } + ); + + this.battleTracker.info("Enabled tracking for arena: {}.", arena.getName()); + } + + @EventHandler + public void onCreateExecutor(ArenaCreateExecutorEvent event) { + Arena arena = event.getArena(); + + // Statistic tracking is disabled + if (!arena.option(BattleArenaHandler.TRACK_STATISTICS).map(BooleanArenaOption::isEnabled).orElse(true)) { + return; + } + + this.pendingExecutors.put(arena, tracker -> event.registerSubExecutor(new TrackerSubExecutor(arena, tracker))); + } + + @EventHandler + public void onArenaJoin(ArenaPreJoinEvent event) { + Tracker tracker = this.battleTracker.getTracker(event.getArena().getName()); + if (tracker == null) { + return; + } + + if (this.battleTracker.getCombatLog().isInCombat(event.getPlayer())) { + event.setResult(new JoinResult(false, BattleArenaHandler.convertMessage("combat-log-cannot-join-arena"))); + } + } + + @EventHandler + public void onArenaLeave(ArenaLeaveEvent event) { + // If player leaves or quits, we want to decrement their elo + if (event.getCause() != ArenaLeaveEvent.Cause.COMMAND && event.getCause() != ArenaLeaveEvent.Cause.DISCONNECT) { + return; + } + + // Player is not in an in-game phase, so we don't want to decrement their elo + if (!CompetitionPhaseType.INGAME.equals(event.getCompetition().getPhaseManager().getCurrentPhase().getType())) { + return; + } + + Tracker tracker = this.battleTracker.getTracker(event.getArena().getName()); + if (tracker == null) { + return; + } + + Player player = event.getPlayer(); + Record record = tracker.getRecord(player); + if (!record.isTracking()) { + return; + } + + // Update rating + Record[] records = event.getCompetition().getPlayers() + .stream() + .filter(p -> !p.equals(event.getArenaPlayer())) + .map(p -> tracker.getRecord(p.getPlayer())) + .toArray(Record[]::new); + + tracker.getRatingCalculator().updateRating(records, new Record[] { record }, false); + tracker.setValue(StatType.RATING, record.getRating(), player); + } + + @EventHandler + public void onArenaKill(ArenaKillEvent event) { + Tracker tracker = this.battleTracker.getTracker(event.getArena().getName()); + if (tracker == null) { + return; + } + + Player killer = event.getKiller().getPlayer(); + Player killed = event.getKilled().getPlayer(); + + Record killerRecord = tracker.getRecord(killer); + Record killedRecord = tracker.getRecord(killed); + + if (killerRecord.isTracking()) { + killerRecord.incrementValue(StatType.KILLS); + } + + if (killedRecord.isTracking()) { + killedRecord.incrementValue(StatType.DEATHS); + } + + // Update ratios + tracker.setValue(StatType.KD_RATIO, killerRecord.getStat(StatType.KILLS) / Math.max(1, killerRecord.getStat(StatType.DEATHS)), killer); + tracker.setValue(StatType.KD_RATIO, killedRecord.getStat(StatType.KILLS) / Math.max(1, killedRecord.getStat(StatType.DEATHS)), killed); + + float killerKdr = killerRecord.getStat(StatType.KD_RATIO); + float killerMaxKdr = killerRecord.getStat(StatType.MAX_KD_RATIO); + + if (killerKdr > killerMaxKdr) { + tracker.setValue(StatType.MAX_KD_RATIO, killerKdr, killer); + } + + tracker.setValue(StatType.STREAK, 0, killed); + tracker.incrementValue(StatType.STREAK, killer); + + float killerStreak = killerRecord.getStat(StatType.STREAK); + float killerMaxStreak = killerRecord.getStat(StatType.MAX_STREAK); + + if (killerStreak > killerMaxStreak) { + tracker.setValue(StatType.MAX_STREAK, killerStreak, killer); + } + } + + @EventHandler + public void onArenaVictory(ArenaVictoryEvent event) { + // Development note: ArenaVictoryEvent will always be called in conjunction + // with the ArenaLoseEvent, so we can process all our logic here + + if (!(event.getCompetition() instanceof LiveCompetition liveCompetition)) { + return; + } + + Tracker tracker = this.battleTracker.getTracker(event.getArena().getName()); + if (tracker == null) { + return; + } + + Record[] victorRecords = event.getVictors() + .stream() + .map(player -> tracker.getRecord(player.getPlayer())) + .toArray(Record[]::new); + + Set losers = liveCompetition.getPlayers() + .stream() + .filter(p -> !event.getVictors().contains(p)) + .collect(Collectors.toSet()); + + Record[] loserRecords = losers.stream() + .map(player -> tracker.getRecord(player.getPlayer())) + .toArray(Record[]::new); + + // Update ratings + tracker.getRatingCalculator().updateRating(victorRecords, loserRecords, false); + + for (ArenaPlayer victor : event.getVictors()) { + Player victorPlayer = victor.getPlayer(); + Record victorRecord = tracker.getRecord(victorPlayer); + + if (!victorRecord.isTracking()) { + continue; + } + + victorRecord.incrementValue(ArenaTracker.WINS); + + float victorRating = victorRecord.getRating(); + float victorMaxRating = victorRecord.getStat(StatType.MAX_RATING); + + tracker.setValue(StatType.RATING, victorRecord.getRating(), victorPlayer); + + if (victorRating > victorMaxRating) { + tracker.setValue(StatType.MAX_RATING, victorRating, victorPlayer); + } + } + + for (ArenaPlayer loser : losers) { + Player loserPlayer = loser.getPlayer(); + Record loserRecord = tracker.getRecord(loserPlayer); + + if (!loserRecord.isTracking()) { + continue; + } + + loserRecord.incrementValue(ArenaTracker.LOSSES); + + float loserRating = loserRecord.getRating(); + float loserMaxRating = loserRecord.getStat(StatType.MAX_RATING); + + tracker.setValue(StatType.RATING, loserRecord.getRating(), loserPlayer); + + if (loserRating > loserMaxRating) { + tracker.setValue(StatType.MAX_RATING, loserRating, loserPlayer); + } + } + } + + @EventHandler + public void onDraw(ArenaDrawEvent event) { + if (!(event.getCompetition() instanceof LiveCompetition liveCompetition)) { + return; + } + + Tracker tracker = this.battleTracker.getTracker(event.getArena().getName()); + if (tracker == null) { + return; + } + + Record[] records = liveCompetition.getPlayers() + .stream() + .map(player -> tracker.getRecord(player.getPlayer())) + .toArray(Record[]::new); + + // Update ratings + tracker.getRatingCalculator().updateRating(records, true); + + for (ArenaPlayer player : liveCompetition.getPlayers()) { + Player bukkitPlayer = player.getPlayer(); + Record record = tracker.getRecord(bukkitPlayer); + + if (!record.isTracking()) { + continue; + } + + record.incrementValue(StatType.TIES); + + float victorRating = record.getRating(); + float victorMaxRating = record.getStat(StatType.MAX_RATING); + + tracker.setValue(StatType.RATING, record.getRating(), bukkitPlayer); + + if (victorRating > victorMaxRating) { + tracker.setValue(StatType.MAX_RATING, victorRating, bukkitPlayer); + } + } + } +} diff --git a/src/main/java/org/battleplugins/tracker/feature/battlearena/TrackerSubExecutor.java b/src/main/java/org/battleplugins/tracker/feature/battlearena/TrackerSubExecutor.java new file mode 100644 index 0000000..25d3a2a --- /dev/null +++ b/src/main/java/org/battleplugins/tracker/feature/battlearena/TrackerSubExecutor.java @@ -0,0 +1,96 @@ +package org.battleplugins.tracker.feature.battlearena; + +import net.kyori.adventure.text.Component; +import org.battleplugins.arena.Arena; +import org.battleplugins.arena.command.ArenaCommand; +import org.battleplugins.arena.command.Argument; +import org.battleplugins.arena.command.SubCommandExecutor; +import org.battleplugins.arena.util.PaginationCalculator; +import org.battleplugins.tracker.BattleTracker; +import org.battleplugins.tracker.Tracker; +import org.battleplugins.tracker.message.Messages; +import org.battleplugins.tracker.stat.Record; +import org.battleplugins.tracker.stat.StatType; +import org.battleplugins.tracker.util.Util; +import org.bukkit.Bukkit; +import org.bukkit.OfflinePlayer; +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; + +import java.util.Map; +import java.util.concurrent.CompletableFuture; + +public class TrackerSubExecutor implements SubCommandExecutor { + private final Arena arena; + private final Tracker tracker; + + public TrackerSubExecutor(Arena arena, Tracker tracker) { + this.arena = arena; + this.tracker = tracker; + } + + @ArenaCommand(commands = "top", description = "View the top players in this arena.", permissionNode = "top") + public void top(Player player) { + this.top(player, 5); + } + + @ArenaCommand(commands = "top", description = "View the top players in this arena.", permissionNode = "top") + public void top(Player player, @Argument(name = "max", description = "The maximum players to show.") int max) { + int amount = max <= 0 ? 5 : Math.min(100, max); + Util.getSortedRecords(this.tracker, amount, StatType.RATING).whenComplete((records, e) -> { + if (records.isEmpty()) { + Messages.send(player, "leaderboard-no-entries"); + return; + } + + player.sendMessage(PaginationCalculator.center(Messages.get("header", this.arena.getName()), Component.space())); + + int ranking = 1; + for (Map.Entry entry : records.entrySet()) { + Record record = entry.getKey(); + + Util.sendTrackerMessage(player, "leaderboard-arena", ranking++, record); + } + }); + } + + @ArenaCommand(commands = "rank", description = "View the rank of a player.", permissionNode = "rank") + public void rank(Player player) { + this.rank(player, (String) null); + } + + @ArenaCommand(commands = "rank", description = "View the rank of a player.", permissionNode = "rank") + public void rank(Player player, @Argument(name = "name", description = "The name of the player.") String playerName) { + OfflinePlayer target; + if ((playerName == null || playerName.isBlank())) { + Messages.send(player, "command-player-not-found", ""); + return; + } else { + target = Bukkit.getServer().getOfflinePlayerIfCached(playerName); + } + + if (target == null) { + CompletableFuture.supplyAsync(() -> Bukkit.getOfflinePlayer(playerName)).thenCompose(this.tracker::getRecord).whenCompleteAsync((record, e) -> { + if (record == null) { + Messages.send(player, "player-has-no-record", playerName); + return; + } + + this.rank(player, record); + }, Bukkit.getScheduler().getMainThreadExecutor(BattleTracker.getInstance())); + } else { + this.tracker.getRecord(target).whenCompleteAsync((record, e) -> { + if (record == null) { + Messages.send(player, "player-has-no-record", playerName); + return; + } + + this.rank(player, record); + }, Bukkit.getScheduler().getMainThreadExecutor(BattleTracker.getInstance())); + } + } + + private void rank(CommandSender sender, Record record) { + Util.sendTrackerMessage(sender, "rank-arena", -1, record); + } +} diff --git a/src/main/java/org/battleplugins/tracker/feature/combatlog/CombatLog.java b/src/main/java/org/battleplugins/tracker/feature/combatlog/CombatLog.java new file mode 100644 index 0000000..567a41c --- /dev/null +++ b/src/main/java/org/battleplugins/tracker/feature/combatlog/CombatLog.java @@ -0,0 +1,352 @@ +package org.battleplugins.tracker.feature.combatlog; + +import net.kyori.adventure.bossbar.BossBar; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.title.TitlePart; +import org.battleplugins.tracker.BattleTracker; +import org.battleplugins.tracker.feature.Feature; +import org.battleplugins.tracker.message.Messages; +import org.battleplugins.tracker.util.MessageType; +import org.battleplugins.tracker.util.Util; +import org.bukkit.NamespacedKey; +import org.bukkit.Registry; +import org.bukkit.configuration.ConfigurationSection; +import org.bukkit.entity.Entity; +import org.bukkit.entity.EntityType; +import org.bukkit.entity.Player; +import org.bukkit.entity.Projectile; +import org.bukkit.entity.Tameable; +import org.bukkit.event.EventHandler; +import org.bukkit.event.HandlerList; +import org.bukkit.event.Listener; +import org.bukkit.event.entity.EntityDamageByEntityEvent; +import org.bukkit.event.entity.PlayerDeathEvent; +import org.bukkit.event.player.PlayerCommandPreprocessEvent; +import org.bukkit.event.player.PlayerQuitEvent; +import org.bukkit.metadata.FixedMetadataValue; +import org.bukkit.metadata.MetadataValue; +import org.bukkit.scheduler.BukkitTask; +import org.jetbrains.annotations.Nullable; + +import java.time.Duration; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; + +public final class CombatLog implements Feature { + private final boolean enabled; + private final List disabledWorlds; + private final int combatTime; + private final boolean combatSelf; + private final boolean combatEntities; + private final boolean combatPlayers; + private final @Nullable MessageType displayMethod; + private final boolean allowPermissionBypass; + private final Set disabledEntities; + private final List disabledCommands; + + private CombatLogListener listener; + + public CombatLog( + boolean enabled, + List disabledWorlds, + int combatTime, + boolean combatSelf, + boolean combatEntities, + boolean combatPlayers, + @Nullable MessageType displayMethod, + boolean allowPermissionBypass, + Set disabledEntities, + List disabledCommands + ) { + this.enabled = enabled; + this.disabledWorlds = disabledWorlds; + this.combatTime = combatTime; + this.combatSelf = combatSelf; + this.combatEntities = combatEntities; + this.combatPlayers = combatPlayers; + this.displayMethod = displayMethod; + this.allowPermissionBypass = allowPermissionBypass; + this.disabledEntities = disabledEntities; + this.disabledCommands = disabledCommands; + } + + @Override + public void onEnable(BattleTracker battleTracker) { + battleTracker.getServer().getPluginManager().registerEvents(this.listener = new CombatLogListener(battleTracker, this), battleTracker); + } + + @Override + public void onDisable(BattleTracker battleTracker) { + if (this.listener != null) { + this.listener.onUnload(); + } + } + + public boolean isInCombat(Player player) { + if (this.listener == null) { + return false; + } + + return this.listener.combatTasks.containsKey(player); + } + + public static CombatLog load(ConfigurationSection section) { + boolean enabled = section.getBoolean("enabled"); + if (!enabled) { + return new CombatLog(false, List.of(), 0, false, false, false, MessageType.ACTION_BAR, false, Set.of(), List.of()); + } + + List disabledWorlds = section.getStringList("disabled-worlds"); + int combatTime = section.getInt("combat-time"); + boolean combatSelf = section.getBoolean("combat-self"); + boolean combatEntities = section.getBoolean("combat-entities"); + boolean combatPlayers = section.getBoolean("combat-players"); + MessageType displayMethod = Optional.ofNullable(section.getString("display-method")) + .map(method -> MessageType.valueOf(method.toUpperCase(Locale.ROOT))) + .orElse(null); + boolean allowPermissionBypass = section.getBoolean("allow-permission-bypass"); + + Set disabledEntities = section.getStringList("disabled-entities") + .stream() + .map(key -> Registry.ENTITY_TYPE.get(NamespacedKey.fromString(key))) + .collect(Collectors.toSet()); + + List disabledCommands = section.getStringList("disabled-commands"); + + return new CombatLog(true, disabledWorlds, combatTime, combatSelf, combatEntities, combatPlayers, displayMethod, allowPermissionBypass, disabledEntities, disabledCommands); + } + + @Override + public boolean enabled() { + return this.enabled; + } + + private static class CombatLogListener implements Listener { + private static final String BOSS_BAR_META_KEY = "combat-log-bar"; + + private final Map combatTasks = new HashMap<>(); + + private final BattleTracker battleTracker; + private final CombatLog combatLog; + + private BukkitTask tickTask; + + private CombatLogListener(BattleTracker battleTracker, CombatLog combatLog) { + this.battleTracker = battleTracker; + this.combatLog = combatLog; + + if (combatLog.displayMethod == null) { + return; + } + + this.tickTask = this.battleTracker.getServer().getScheduler().runTaskTimer(this.battleTracker, this::tick, 0L, 20L); + } + + public void onUnload() { + HandlerList.unregisterAll(this); + + if (this.tickTask != null) { + this.tickTask.cancel(); + } + } + + private void tick() { + for (Map.Entry entry : this.combatTasks.entrySet()) { + Duration remainingTime = Duration.ofSeconds(this.combatLog.combatTime) + .minus(Duration.ofMillis(System.currentTimeMillis() - entry.getValue().enteredCombat())) + .plusSeconds(1); + + if (remainingTime.isZero() || remainingTime.isNegative()) { + continue; + } + + Component message = Messages.get("combat-log-remaining-time", Util.toTimeString(remainingTime)); + + Player player = entry.getKey(); + switch (this.combatLog.displayMethod) { + case ACTION_BAR -> player.sendActionBar(message); + case CHAT -> player.sendMessage(message); + case TITLE -> player.sendTitlePart(TitlePart.TITLE, message); + case SUBTITLE -> { + player.sendTitlePart(TitlePart.TITLE, Component.space()); + player.sendTitlePart(TitlePart.SUBTITLE, message); + } + case BOSSBAR -> { + player.getMetadata(BOSS_BAR_META_KEY).stream() + .map(MetadataValue::value) + .filter(value -> value instanceof BossBar) + .map(value -> (BossBar) value) + .findFirst() + .ifPresentOrElse(bar -> { + bar.name(message); + + float progress = (float) remainingTime.toSeconds() / (float) this.combatLog.combatTime; + bar.progress(progress); + }, () -> { + BossBar bar = BossBar.bossBar(message, 1.0f, BossBar.Color.BLUE, BossBar.Overlay.PROGRESS); + player.showBossBar(bar); + + player.setMetadata(BOSS_BAR_META_KEY, new FixedMetadataValue(this.battleTracker, bar)); + }); + } + } + } + } + + @EventHandler(ignoreCancelled = true) + public void onEntityDamageByEntity(EntityDamageByEntityEvent event) { + if (this.combatLog.disabledWorlds.contains(event.getEntity().getWorld().getName())) { + return; + } + + if (!(event.getEntity() instanceof Player player)) { + return; + } + + Entity damager = this.getTrueDamager(event, true); + if (damager == null) { + return; + } + + if (this.combatLog.disabledEntities.contains(damager.getType())) { + return; + } + + if (damager instanceof Player damagerPlayer) { + if (!this.combatLog.combatPlayers) { + return; + } + + if (damagerPlayer.equals(player) && !this.combatLog.combatSelf) { + return; + } + + } else if (!this.combatLog.combatEntities) { + return; + } + + this.enterCombat(player); + if (damager instanceof Player damagerPlayer) { + this.enterCombat(damagerPlayer); + } + } + + @EventHandler + public void onDeath(PlayerDeathEvent event) { + if (this.combatLog.disabledWorlds.contains(event.getEntity().getWorld().getName())) { + return; + } + + if (event.getPlayer().getLastDamageCause() instanceof EntityDamageByEntityEvent damageEvent) { + Entity damager = this.getTrueDamager(damageEvent, false); + if (damager instanceof Player damagerPlayer && this.combatTasks.containsKey(damagerPlayer)) { + this.exitCombat(damagerPlayer); + } + } + + if (this.combatTasks.containsKey(event.getEntity())) { + this.exitCombat(event.getEntity()); + } + } + + @EventHandler + public void onCommandPreProcess(PlayerCommandPreprocessEvent event) { + if (!this.combatTasks.containsKey(event.getPlayer())) { + return; + } + + if (this.combatLog.disabledCommands.stream().anyMatch(cmd -> event.getMessage().startsWith("/" + cmd))) { + event.setCancelled(true); + + Messages.send(event.getPlayer(), "combat-log-cannot-run-command"); + } + } + + @EventHandler + public void onQuit(PlayerQuitEvent event) { + Player player = event.getPlayer(); + CombatEntry entry = this.combatTasks.get(player); + if (entry != null) { + entry.task().cancel(); + this.combatTasks.remove(player); + + // If player has a combat log bypass, exclude them from + // losing their items on logout + if (this.combatLog.allowPermissionBypass && player.hasPermission("battletracker.combatlog.bypass")) { + return; + } + + // Kill the player if they log out in combat + player.setHealth(0); + } + } + + private void enterCombat(Player player) { + CombatEntry entry = this.combatTasks.get(player); + if (entry != null) { + entry.task().cancel(); + } else { + Messages.send(player, "combat-log-entered-combat"); + } + + BukkitTask task = this.battleTracker.getServer().getScheduler().runTaskLater( + this.battleTracker, + () -> this.exitCombat(player), + this.combatLog.combatTime * 20L + ); + + this.combatTasks.put(player, new CombatEntry(task, System.currentTimeMillis())); + } + + private void exitCombat(Player player) { + CombatEntry entry = this.combatTasks.remove(player); + if (entry == null) { + return; + } + + entry.task().cancel(); + + Messages.send(player, "combat-log-exited-combat"); + + // Remove bossbar if it exists + player.getMetadata(BOSS_BAR_META_KEY).stream() + .map(MetadataValue::value) + .filter(value -> value instanceof BossBar) + .map(value -> (BossBar) value) + .findFirst() + .ifPresent(bar -> { + player.hideBossBar(bar); + + player.removeMetadata(BOSS_BAR_META_KEY, this.battleTracker); + }); + } + + private Entity getTrueDamager(EntityDamageByEntityEvent event, boolean checkIgnored) { + Entity damager = event.getDamager(); + if (event.getDamager() instanceof Projectile projectile) { + if (checkIgnored && this.combatLog.disabledEntities.contains(projectile.getType())) { + return null; + } + + if (projectile.getShooter() instanceof Entity entity) { + damager = entity; + } + } + + if (damager instanceof Tameable tameable && tameable.isTamed() && tameable.getOwner() instanceof Entity owner) { + damager = owner; + } + + return damager; + } + + public record CombatEntry(BukkitTask task, long enteredCombat) { + } + } +} diff --git a/src/main/java/org/battleplugins/tracker/feature/damageindicators/DamageIndicators.java b/src/main/java/org/battleplugins/tracker/feature/damageindicators/DamageIndicators.java new file mode 100644 index 0000000..a7eb46a --- /dev/null +++ b/src/main/java/org/battleplugins/tracker/feature/damageindicators/DamageIndicators.java @@ -0,0 +1,102 @@ +package org.battleplugins.tracker.feature.damageindicators; + +import net.kyori.adventure.text.Component; +import org.battleplugins.tracker.BattleTracker; +import org.battleplugins.tracker.feature.Feature; +import org.battleplugins.tracker.util.MessageUtil; +import org.battleplugins.tracker.util.Util; +import org.bukkit.configuration.ConfigurationSection; +import org.bukkit.entity.ArmorStand; +import org.bukkit.entity.Entity; +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.HandlerList; +import org.bukkit.event.Listener; +import org.bukkit.event.entity.EntityDamageByEntityEvent; +import org.bukkit.scheduler.BukkitRunnable; + +import java.util.List; +import java.util.concurrent.ThreadLocalRandom; + +public record DamageIndicators( + boolean enabled, + List disabledWorlds, + Component format +) implements Feature { + + @Override + public void onEnable(BattleTracker battleTracker) { + battleTracker.getServer().getPluginManager().registerEvents(new DamageIndicatorListener(battleTracker, this), battleTracker); + } + + @Override + public void onDisable(BattleTracker battleTracker) { + HandlerList.getRegisteredListeners(battleTracker).stream() + .filter(listener -> listener.getListener() instanceof DamageIndicatorListener) + .forEach(l -> HandlerList.unregisterAll(l.getListener())); + } + + public static DamageIndicators load(ConfigurationSection section) { + boolean enabled = section.getBoolean("enabled"); + if (!enabled) { + return new DamageIndicators(false, List.of(), Component.empty()); + } + + List disabledWorlds = section.getStringList("disabled-worlds"); + String format = section.getString("format", ""); + return new DamageIndicators(true, disabledWorlds, MessageUtil.MINI_MESSAGE.deserialize(format)); + } + + private record DamageIndicatorListener(BattleTracker battleTracker, + DamageIndicators damageIndicators) implements Listener { + + @EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true) + public void onEntityDamage(EntityDamageByEntityEvent event) { + if (!(event.getDamager() instanceof Player damager)) { + return; + } + + Entity damaged = event.getEntity(); + + double xRand = ThreadLocalRandom.current().nextDouble(0.4, 0.7) / (ThreadLocalRandom.current().nextBoolean() ? 1 : -1); + double yRand = ThreadLocalRandom.current().nextDouble(0.5, 1.5); + double zRand = ThreadLocalRandom.current().nextDouble(0.4, 0.7) / (ThreadLocalRandom.current().nextBoolean() ? 1 : -1); + + ArmorStand indicator = damaged.getWorld().spawn(damaged.getLocation().clone().add(xRand, yRand, zRand), ArmorStand.class, entity -> { + entity.customName(this.damageIndicators.format.replaceText(builder -> + builder.matchLiteral("{damage}").once().replacement(Component.text(Util.DAMAGE_FORMAT.format(event.getFinalDamage()))) + )); + + entity.setCustomNameVisible(true); + entity.setMarker(true); + entity.setPersistent(false); + entity.setInvisible(true); + entity.setGravity(false); + entity.setSmall(true); + entity.setVisibleByDefault(false); + }); + + damager.showEntity(this.battleTracker, indicator); + + new BukkitRunnable() { + int counter = 0; + + @Override + public void run() { + if (counter++ > 40) { + indicator.remove(); + this.cancel(); + return; + } + + indicator.teleport(indicator.getLocation().clone().subtract(0, 0.06, 0)); + if (indicator.isOnGround()) { + indicator.remove(); + this.cancel(); + } + } + }.runTaskTimer(this.battleTracker, 1, 1); + } + } +} diff --git a/src/main/java/org/battleplugins/tracker/feature/message/DeathMessages.java b/src/main/java/org/battleplugins/tracker/feature/message/DeathMessages.java new file mode 100644 index 0000000..9fcbead --- /dev/null +++ b/src/main/java/org/battleplugins/tracker/feature/message/DeathMessages.java @@ -0,0 +1,29 @@ +package org.battleplugins.tracker.feature.message; + +import org.battleplugins.tracker.BattleTracker; +import org.battleplugins.tracker.Tracker; +import org.battleplugins.tracker.feature.TrackerFeature; +import org.bukkit.configuration.ConfigurationSection; + +public record DeathMessages( + boolean enabled, + MessageAudience audience, + PlayerMessages playerMessages, + EntityMessages entityMessages, + WorldMessages worldMessages +) implements TrackerFeature { + + @Override + public void onEnable(BattleTracker battleTracker, Tracker tracker) { + battleTracker.registerListener(tracker, new DeathMessagesListener(this, tracker)); + } + + public static DeathMessages load(ConfigurationSection section) { + boolean enabled = section.getBoolean("enabled"); + MessageAudience audience = MessageAudience.get(section.getString("audience")); + PlayerMessages playerMessages = PlayerMessages.load(section.getConfigurationSection("player")); + EntityMessages entityMessages = EntityMessages.load(section.getConfigurationSection("entity")); + WorldMessages worldMessages = WorldMessages.load(section.getConfigurationSection("world")); + return new DeathMessages(enabled, audience, playerMessages, entityMessages, worldMessages); + } +} diff --git a/src/main/java/org/battleplugins/tracker/feature/message/DeathMessagesListener.java b/src/main/java/org/battleplugins/tracker/feature/message/DeathMessagesListener.java new file mode 100644 index 0000000..8299205 --- /dev/null +++ b/src/main/java/org/battleplugins/tracker/feature/message/DeathMessagesListener.java @@ -0,0 +1,157 @@ +package org.battleplugins.tracker.feature.message; + +import net.kyori.adventure.text.Component; +import org.battleplugins.tracker.Tracker; +import org.battleplugins.tracker.event.TrackerDeathEvent; +import org.battleplugins.tracker.event.feature.DeathMessageEvent; +import org.battleplugins.tracker.util.ItemCollection; +import org.battleplugins.tracker.util.Util; +import org.bukkit.entity.Entity; +import org.bukkit.entity.EntityType; +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; +import org.bukkit.event.entity.EntityDamageEvent; +import org.bukkit.inventory.ItemStack; +import org.jetbrains.annotations.Nullable; + +import java.util.List; +import java.util.Map; + +public class DeathMessagesListener implements Listener { + private final DeathMessages deathMessages; + private final Tracker tracker; + + public DeathMessagesListener(DeathMessages deathMessages, Tracker tracker) { + this.deathMessages = deathMessages; + this.tracker = tracker; + } + + @EventHandler(priority = EventPriority.LOWEST) + public void onTrackerDeath(TrackerDeathEvent event) { + if (!event.getTracker().equals(this.tracker)) { + return; + } + + if (event.getDeathType() == TrackerDeathEvent.DeathType.PLAYER && this.deathMessages.playerMessages().enabled()) { + this.onPlayerDeath(event); + } else if (event.getDeathType() == TrackerDeathEvent.DeathType.ENTITY && this.deathMessages.entityMessages().enabled()) { + this.onEntityDeath(event); + } else if (event.getDeathType() == TrackerDeathEvent.DeathType.WORLD && this.deathMessages.worldMessages().enabled()) { + this.onWorldDeath(event); + } + } + + private void onPlayerDeath(TrackerDeathEvent event) { + PlayerMessages messages = this.deathMessages.playerMessages(); + Player player = event.getDeathEvent().getPlayer(); + + // If we've recorded a PVP death, we can assume the killer is a player and make + // a few assumptions about the killer player. + Player killer = (Player) event.getKiller(); + + ItemStack item = killer.getInventory().getItem(killer.getActiveItemHand()); + Component deathMessage = null; + for (Map.Entry> entry : messages.messages().entrySet()) { + if (!entry.getKey().contains(item.getType())) { + continue; + } + + Component message = Util.getRandom(entry.getValue()); + deathMessage = replace(message, killer, player, item); + } + + if (deathMessage == null) { + Component defaultMessage = Util.getRandom(messages.defaultMessages()); + deathMessage = replace(defaultMessage, killer, player, item); + } + + event.getDeathEvent().deathMessage(null); + broadcastMessage(this.tracker, this.deathMessages.audience(), deathMessage, player, killer); + } + + private void onEntityDeath(TrackerDeathEvent event) { + EntityMessages messages = this.deathMessages.entityMessages(); + Player player = event.getDeathEvent().getPlayer(); + + // If we've recorded a PVE death, we can assume the killer is a player and make + // a few assumptions about the killer player. + Entity killer = event.getKiller(); + + Component deathMessage = null; + for (Map.Entry> entry : messages.messages().entrySet()) { + if (entry.getKey() != killer.getType()) { + continue; + } + + Component message = Util.getRandom(entry.getValue()); + deathMessage = replace(message, player, player, null); + } + + if (deathMessage == null) { + Component defaultMessage = Util.getRandom(messages.defaultMessages()); + deathMessage = replace(defaultMessage, player, player, null); + } + + event.getDeathEvent().deathMessage(null); + broadcastMessage(this.tracker, this.deathMessages.audience(), deathMessage, player, player); + } + + private void onWorldDeath(TrackerDeathEvent event) { + WorldMessages messages = this.deathMessages.worldMessages(); + Player player = event.getDeathEvent().getPlayer(); + + EntityDamageEvent lastDamageCause = player.getLastDamageCause(); + EntityDamageEvent.DamageCause cause = lastDamageCause == null ? null : lastDamageCause.getCause(); + + Component deathMessage = null; + for (Map.Entry> entry : messages.messages().entrySet()) { + if (entry.getKey() != cause) { + continue; + } + + Component message = Util.getRandom(entry.getValue()); + deathMessage = replace(message, player, player, null); + } + + if (deathMessage == null) { + Component defaultMessage = Util.getRandom(messages.defaultMessages()); + deathMessage = replace(defaultMessage, player, player, null); + } + + event.getDeathEvent().deathMessage(null); + broadcastMessage(this.tracker, this.deathMessages.audience(), deathMessage, player, player); + } + + private static void broadcastMessage(Tracker tracker, MessageAudience audience, Component message, Player player, Player target) { + DeathMessageEvent event = new DeathMessageEvent(player, message, tracker); + event.callEvent(); + + if (event.getDeathMessage().equals(Component.empty())) { + return; + } + + audience.broadcastMessage(event.getDeathMessage(), player, target); + } + + private static Component replace(Component component, Entity player, Entity target, @Nullable ItemStack item) { + component = component.replaceText(builder -> builder.matchLiteral("%player%").once().replacement(player.name())); + component = component.replaceText(builder -> builder.matchLiteral("%target%").once().replacement(target.name())); + if (item != null) { + Component itemName; + if (item.hasItemMeta() && item.getItemMeta().hasDisplayName()) { + itemName = item.getItemMeta().displayName(); + } else { + itemName = Component.translatable(item.getType()); + } + + itemName = itemName.hoverEvent(item.asHoverEvent()); + + Component finalItemName = itemName; + component = component.replaceText(builder -> builder.matchLiteral("%item%").once().replacement(finalItemName)); + } + + return component; + } +} diff --git a/src/main/java/org/battleplugins/tracker/feature/message/EntityMessages.java b/src/main/java/org/battleplugins/tracker/feature/message/EntityMessages.java new file mode 100644 index 0000000..cb20abe --- /dev/null +++ b/src/main/java/org/battleplugins/tracker/feature/message/EntityMessages.java @@ -0,0 +1,51 @@ +package org.battleplugins.tracker.feature.message; + +import net.kyori.adventure.text.Component; +import org.battleplugins.tracker.util.MessageUtil; +import org.bukkit.NamespacedKey; +import org.bukkit.Registry; +import org.bukkit.configuration.ConfigurationSection; +import org.bukkit.entity.EntityType; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +public record EntityMessages( + boolean enabled, + Map> messages, + List defaultMessages +) { + + public static EntityMessages load(ConfigurationSection section) { + boolean enabled = section.getBoolean("enabled"); + if (!enabled) { + return new EntityMessages(false, Map.of(), List.of()); + } + + Map> messages = new HashMap<>(); + List defaultMessages = section.getStringList("messages.default") + .stream() + .map(MessageUtil::deserialize) + .toList(); + + ConfigurationSection messagesSection = section.getConfigurationSection("messages"); + messagesSection.getKeys(false).forEach(key -> { + if (!messagesSection.isList(key)) { + throw new IllegalArgumentException("Section " + key + " is not a list of messages!"); + } + + if (key.equalsIgnoreCase("default")) { + return; + } + + List messageList = messagesSection.getStringList(key); + messages.put(Registry.ENTITY_TYPE.get(NamespacedKey.fromString(key)), messageList.stream() + .map(MessageUtil::deserialize) + .collect(Collectors.toList())); + }); + + return new EntityMessages(true, messages, defaultMessages); + } +} diff --git a/src/main/java/org/battleplugins/tracker/feature/message/MessageAudience.java b/src/main/java/org/battleplugins/tracker/feature/message/MessageAudience.java new file mode 100644 index 0000000..f99522e --- /dev/null +++ b/src/main/java/org/battleplugins/tracker/feature/message/MessageAudience.java @@ -0,0 +1,69 @@ +package org.battleplugins.tracker.feature.message; + +import net.kyori.adventure.text.Component; +import org.bukkit.Bukkit; +import org.bukkit.entity.Player; +import org.jetbrains.annotations.Nullable; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Function; + +/** + * Represents a message audience. + */ +public final class MessageAudience { + private static final Map AUDIENCES = new HashMap<>(); + + public static final MessageAudience GLOBAL = new MessageAudience("global", player -> List.copyOf(Bukkit.getOnlinePlayers())); + public static final MessageAudience WORLD = new MessageAudience("world", player -> List.copyOf(player.getWorld().getPlayers())); + public static final MessageAudience LOCAL = new MessageAudience("local", List::of); + + private final String name; + private final Function> audienceProvider; + + public MessageAudience(String name, Function> audienceProvider) { + this.name = name; + this.audienceProvider = audienceProvider; + + AUDIENCES.put(name, this); + } + + public String getName() { + return this.name; + } + + public List getAudience(Player player) { + return this.audienceProvider.apply(player); + } + + public void broadcastMessage(Component message, Player player, @Nullable Player target) { + List viewers; + + // Special logic for target, since we need the context from + // the death event logic earlier + if (this == MessageAudience.LOCAL) { + if (target == null) { + viewers = List.of(player); + } else { + viewers = List.of(player, target); + } + } else { + viewers = this.getAudience(player); + } + + for (Player viewer : viewers) { + viewer.sendMessage(message); + } + } + + public static MessageAudience create(String name, Function> audienceProvider) { + return new MessageAudience(name, audienceProvider); + } + + @Nullable + public static MessageAudience get(String name) { + return AUDIENCES.get(name); + } +} diff --git a/src/main/java/org/battleplugins/tracker/feature/message/PlayerMessages.java b/src/main/java/org/battleplugins/tracker/feature/message/PlayerMessages.java new file mode 100644 index 0000000..a052a21 --- /dev/null +++ b/src/main/java/org/battleplugins/tracker/feature/message/PlayerMessages.java @@ -0,0 +1,49 @@ +package org.battleplugins.tracker.feature.message; + +import net.kyori.adventure.text.Component; +import org.battleplugins.tracker.util.ItemCollection; +import org.battleplugins.tracker.util.MessageUtil; +import org.bukkit.configuration.ConfigurationSection; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +public record PlayerMessages( + boolean enabled, + Map> messages, + List defaultMessages +) { + + public static PlayerMessages load(ConfigurationSection section) { + boolean enabled = section.getBoolean("enabled"); + if (!enabled) { + return new PlayerMessages(false, Map.of(), List.of()); + } + + Map> messages = new HashMap<>(); + List defaultMessages = section.getStringList("messages.default") + .stream() + .map(MessageUtil::deserialize) + .toList(); + + ConfigurationSection messagesSection = section.getConfigurationSection("messages"); + messagesSection.getKeys(false).forEach(key -> { + if (!messagesSection.isList(key)) { + throw new IllegalArgumentException("Section " + key + " is not a list of messages!"); + } + + if (key.equalsIgnoreCase("default")) { + return; + } + + List messageList = messagesSection.getStringList(key); + messages.put(ItemCollection.fromString(key), messageList.stream() + .map(MessageUtil::deserialize) + .collect(Collectors.toList())); + }); + + return new PlayerMessages(true, messages, defaultMessages); + } +} diff --git a/src/main/java/org/battleplugins/tracker/feature/message/WorldMessages.java b/src/main/java/org/battleplugins/tracker/feature/message/WorldMessages.java new file mode 100644 index 0000000..4e841d3 --- /dev/null +++ b/src/main/java/org/battleplugins/tracker/feature/message/WorldMessages.java @@ -0,0 +1,50 @@ +package org.battleplugins.tracker.feature.message; + +import net.kyori.adventure.text.Component; +import org.battleplugins.tracker.util.MessageUtil; +import org.bukkit.configuration.ConfigurationSection; +import org.bukkit.event.entity.EntityDamageEvent; + +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.stream.Collectors; + +public record WorldMessages( + boolean enabled, + Map> messages, + List defaultMessages +) { + + public static WorldMessages load(ConfigurationSection section) { + boolean enabled = section.getBoolean("enabled"); + if (!enabled) { + return new WorldMessages(false, Map.of(), List.of()); + } + + Map> messages = new HashMap<>(); + List defaultMessages = section.getStringList("messages.default") + .stream() + .map(MessageUtil::deserialize) + .toList(); + + ConfigurationSection messagesSection = section.getConfigurationSection("messages"); + messagesSection.getKeys(false).forEach(key -> { + if (!messagesSection.isList(key)) { + throw new IllegalArgumentException("Section " + key + " is not a list of messages!"); + } + + if (key.equalsIgnoreCase("default")) { + return; + } + + List messageList = messagesSection.getStringList(key); + messages.put(EntityDamageEvent.DamageCause.valueOf(key.toUpperCase(Locale.ROOT)), messageList.stream() + .map(MessageUtil::deserialize) + .collect(Collectors.toList())); + }); + + return new WorldMessages(true, messages, defaultMessages); + } +} diff --git a/src/main/java/org/battleplugins/tracker/feature/recap/BattleRecap.java b/src/main/java/org/battleplugins/tracker/feature/recap/BattleRecap.java new file mode 100644 index 0000000..653e114 --- /dev/null +++ b/src/main/java/org/battleplugins/tracker/feature/recap/BattleRecap.java @@ -0,0 +1,145 @@ +package org.battleplugins.tracker.feature.recap; + +import org.bukkit.Material; +import org.bukkit.entity.Player; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.PlayerInventory; +import org.jetbrains.annotations.Nullable; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +public class BattleRecap { + private final List entries = new ArrayList<>(); + private final double startingHealth; + private final Instant createTime; + private final String recapOwner; + + private PlayerInventory inventory; + private ItemStack[] inventorySnapshot; + + public BattleRecap(Player player) { + this(player.getInventory(), player.getHealth(), player.getName()); + } + + public BattleRecap(PlayerInventory inventory, double startingHealth, String recapOwner) { + this.inventory = inventory; + this.startingHealth = startingHealth; + this.recapOwner = recapOwner; + this.createTime = Instant.now(); + } + + public void record(RecapEntry entry) { + this.entries.add(entry); + } + + public Optional lastEntry() { + return Optional.ofNullable(this.getLastEntry()); + } + + @Nullable + public RecapEntry getLastEntry() { + if (this.entries.isEmpty()) { + return null; + } + + return this.entries.get(this.entries.size() - 1); + } + + public List getEntries() { + return List.copyOf(this.entries); + } + + public List getCombinedEntries() { + List entries = new ArrayList<>(); + RecapEntry lastEntry = null; + for (RecapEntry entry : this.entries) { + // We only want to combine the health gain entries, since they can get excessive + if (entry.kind() == RecapEntry.Kind.LOSS) { + if (lastEntry != null) { + entries.add(lastEntry); + lastEntry = null; + } + + entries.add(entry); + continue; + } + + if (lastEntry != null) { + if (lastEntry.kind() == RecapEntry.Kind.GAIN) { + lastEntry = lastEntry.toBuilder() + .amount(lastEntry.amount() + entry.amount()) + .logTime(entry.logTime()) + .build(); + } else { + lastEntry = entry; + } + } else { + lastEntry = entry; + } + } + + if (lastEntry != null) { + entries.add(lastEntry); + } + + return entries; + } + + public double getStartingHealth() { + return this.startingHealth; + } + + public String getRecapOwner() { + return this.recapOwner; + } + + public Instant getCreateTime() { + return this.createTime; + } + + public void markDeath() { + this.inventorySnapshot = this.getInventorySnapshot(); + this.inventory = null; + } + + public ItemStack[] getInventorySnapshot() { + if (this.inventorySnapshot != null) { + return this.inventorySnapshot; + } + + ItemStack[] contents = this.inventory.getStorageContents(); + + // Size is contents + armor + main & offhand + ItemStack[] snapshot = new ItemStack[contents.length + 6]; + + // Main inventory contents + for (int i = 0; i < contents.length; i++) { + ItemStack item = contents[i]; + if (item != null) { + snapshot[i] = item.clone(); + } + } + + // Armor contents + snapshot[contents.length] = nullify(this.inventory.getHelmet()); + snapshot[contents.length + 1] = nullify(this.inventory.getChestplate()); + snapshot[contents.length + 2] = nullify(this.inventory.getLeggings()); + snapshot[contents.length + 3] = nullify(this.inventory.getBoots()); + + // Hand + offhand contents + snapshot[contents.length + 4] = nullify(this.inventory.getItemInMainHand()); + snapshot[contents.length + 5] = nullify(this.inventory.getItemInOffHand()); + return snapshot; + } + + private static ItemStack nullify(ItemStack item) { + if (item != null && item.getType() == Material.AIR) { + return null; + } + + return item; + } +} diff --git a/src/main/java/org/battleplugins/tracker/feature/recap/EntitySnapshot.java b/src/main/java/org/battleplugins/tracker/feature/recap/EntitySnapshot.java new file mode 100644 index 0000000..3c42c01 --- /dev/null +++ b/src/main/java/org/battleplugins/tracker/feature/recap/EntitySnapshot.java @@ -0,0 +1,13 @@ +package org.battleplugins.tracker.feature.recap; + +import net.kyori.adventure.text.Component; +import org.bukkit.entity.Entity; +import org.bukkit.entity.EntityType; +import org.bukkit.entity.Player; + +public record EntitySnapshot(EntityType type, Component displayedName) { + + public EntitySnapshot(Entity entity) { + this(entity.getType(), entity instanceof Player player ? player.name() : Component.translatable(entity.getType())); + } +} diff --git a/src/main/java/org/battleplugins/tracker/feature/recap/Recap.java b/src/main/java/org/battleplugins/tracker/feature/recap/Recap.java new file mode 100644 index 0000000..362c5bc --- /dev/null +++ b/src/main/java/org/battleplugins/tracker/feature/recap/Recap.java @@ -0,0 +1,287 @@ +package org.battleplugins.tracker.feature.recap; + +import net.kyori.adventure.audience.Audience; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.JoinConfiguration; +import net.kyori.adventure.text.event.ClickCallback; +import net.kyori.adventure.text.event.ClickEvent; +import net.kyori.adventure.text.event.HoverEvent; +import net.kyori.adventure.text.format.NamedTextColor; +import net.kyori.adventure.text.format.TextDecoration; +import org.battleplugins.tracker.BattleTracker; +import org.battleplugins.tracker.TrackedDataType; +import org.battleplugins.tracker.Tracker; +import org.battleplugins.tracker.event.feature.DeathMessageEvent; +import org.battleplugins.tracker.feature.TrackerFeature; +import org.battleplugins.tracker.message.Messages; +import org.battleplugins.tracker.util.TrackerInventoryHolder; +import org.battleplugins.tracker.util.Util; +import org.bukkit.Material; +import org.bukkit.configuration.ConfigurationSection; +import org.bukkit.entity.EntityType; +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.Listener; +import org.bukkit.event.entity.EntityRegainHealthEvent; +import org.bukkit.event.entity.PlayerDeathEvent; +import org.bukkit.event.player.PlayerQuitEvent; +import org.bukkit.inventory.Inventory; +import org.bukkit.inventory.ItemStack; +import org.bukkit.persistence.PersistentDataType; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.time.Duration; +import java.time.Instant; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.ListIterator; +import java.util.Locale; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; +import java.util.function.Consumer; + +public record Recap( + boolean enabled, + DisplayContent displayContent, + boolean hoverRecap, + Map previousRecaps, + Map recaps +) implements TrackerFeature { + + public Recap(boolean enabled, DisplayContent displayContent, boolean hoverRecap) { + this(enabled, displayContent, hoverRecap, new HashMap<>(), new HashMap<>()); + } + + public static Recap load(ConfigurationSection section) { + return new Recap( + section.getBoolean("enabled", true), + DisplayContent.valueOf(section.getString("display-content", "ALL").toUpperCase(Locale.ROOT)), + section.getBoolean("hover-recap", true) + ); + } + + @Override + public void onEnable(BattleTracker battleTracker, Tracker tracker) { + battleTracker.registerListener(tracker, new RecapListener(tracker, this)); + } + + public BattleRecap getRecap(Player player) { + return this.recaps.computeIfAbsent(player.getUniqueId(), uuid -> new BattleRecap(player)); + } + + public Optional previousRecap(Player player) { + return Optional.ofNullable(this.getPreviousRecap(player)); + } + + @Nullable + public BattleRecap getPreviousRecap(Player player) { + return this.previousRecaps.get(player.getUniqueId()); + } + + @NotNull + public static ItemStack getRecapItem(Duration deathDuration, BattleRecap battleRecap) { + ItemStack recapItem = new ItemStack(Material.BOOK); + recapItem.editMeta(meta -> { + meta.displayName(Messages.get("recap-info").decoration(TextDecoration.ITALIC, false)); + + List lore = new ArrayList<>(); + lore.add(Messages.get("recap-death-time", Util.toTimeStringShort(deathDuration))); + lore.add(Messages.get("recap-starting-health", Util.formatHealth(battleRecap.getStartingHealth(), false))); + lore.add(Component.empty()); + lore.add(Messages.get("recap-damage-log")); + + processRecapEntry(battleRecap.getCombinedEntries(), 10, true, lore::add); + + lore.add(Component.empty()); + lore.add(Messages.get("recap-click-for-more")); + + // Make sure the lore lines are not italic + ListIterator itr = lore.listIterator(); + while (itr.hasNext()) { + itr.set(itr.next().decoration(TextDecoration.ITALIC, false)); + } + + meta.lore(lore); + meta.getPersistentDataContainer().set(TrackerInventoryHolder.RECAP_KEY, PersistentDataType.BOOLEAN, true); + }); + + return recapItem; + } + + public static void processRecapEntry(List entries, int max, boolean showItem, Consumer componentConsumer) { + for (int i = entries.size() - 1; i >= Math.max(0, entries.size() - max); i--) { + RecapEntry entry = entries.get(i); + ItemStack item = entry.itemUsed(); + + // TODO: Translation support + Component cause = null; + if (entry.causingEntity() != null) { + cause = entry.causingEntity().displayedName(); + if (entry.sourceEntity() != null && !entry.sourceEntity().equals(entry.causingEntity())) { + cause = entry.sourceEntity().displayedName() + .append(Component.text(" (")) + .append(entry.causingEntity().displayedName()) + .append(Component.text(")")); + } else if (showItem && item != null && entry.causingEntity().type() == EntityType.PLAYER) { + Component itemName; + if (item.hasItemMeta() && item.getItemMeta().hasDisplayName()) { + itemName = item.getItemMeta().displayName(); + } else { + itemName = Component.translatable(item.getType()); + } + + cause = cause.append(Component.text(" (").append(itemName).append(Component.text(")"))); + } + } + + if (cause == null && entry.damageCause() != null) { + cause = Component.text(Util.capitalize(entry.damageCause().name().toLowerCase(Locale.ROOT).replace("_", " "))); + } + + boolean loss = entry.kind() == RecapEntry.Kind.LOSS; + Component recapLog = Messages.get("recap-log", Map.of( + "health", Component.text(Util.formatHealth(entry.amount(), loss), loss ? NamedTextColor.RED : NamedTextColor.GREEN), + "time", Util.toTimeStringShort(Duration.between(entry.logTime(), Instant.now().plusSeconds(1))), + "damage_cause", cause == null ? Component.empty() : cause + )); + + componentConsumer.accept(recapLog); + } + } + + public void showRecap(Audience audience, Tracker tracker, BattleRecap battleRecap) { + Duration deathDuration = Duration.between(battleRecap.getLastEntry().logTime(), Instant.now()); + + ItemStack recapItem = Recap.getRecapItem(deathDuration, battleRecap); + + Inventory inventory = TrackerInventoryHolder.create(TrackerInventoryHolder.RECAP_KEY, tracker, 54, Messages.get("recap"), handler -> { + handler.onClick(TrackerInventoryHolder.RECAP_KEY, () -> { + if (tracker.tracksData(TrackedDataType.PVP)) { + RecapRoundup.recapPlayer(audience, battleRecap); + } else if (tracker.tracksData(TrackedDataType.PVE)) { + RecapRoundup.recapEntity(audience, battleRecap); + } else { + RecapRoundup.recapSource(audience, battleRecap); + } + + RecapRoundup.sendFooter(audience, tracker, battleRecap); + }); + }); + Recap.DisplayContent displayContent = this.displayContent; + if (displayContent == Recap.DisplayContent.ALL) { + if (!(audience instanceof Player senderPlayer)) { + Messages.send(audience, "command-must-be-player"); + return; + } + + ItemStack[] snapshot = battleRecap.getInventorySnapshot(); + for (int i = 0; i < snapshot.length; i++) { + inventory.setItem(i, snapshot[i]); + } + + inventory.setItem(52, recapItem); + + senderPlayer.openInventory(inventory); + } else if (displayContent == Recap.DisplayContent.ARMOR) { + if (!(audience instanceof Player senderPlayer)) { + Messages.send(audience, "command-must-be-player"); + return; + } + + ItemStack empty = new ItemStack(Material.BONE); + empty.editMeta(meta -> meta.displayName(Component.empty())); + + ItemStack[] snapshot = battleRecap.getInventorySnapshot(); + inventory.setItem(13, Optional.ofNullable(snapshot[36]).orElse(empty)); + inventory.setItem(22, Optional.ofNullable(snapshot[37]).orElse(empty)); + inventory.setItem(31, Optional.ofNullable(snapshot[38]).orElse(empty)); + inventory.setItem(40, Optional.ofNullable(snapshot[39]).orElse(empty)); + + inventory.setItem(21, Optional.ofNullable(snapshot[40]).orElse(empty)); + inventory.setItem(23, Optional.ofNullable(snapshot[41]).orElse(empty)); + + inventory.setItem(25, recapItem); + + senderPlayer.openInventory(inventory); + } else if (displayContent == Recap.DisplayContent.RECAP) { + // TODO: Send paginated chat message + } + } + + public enum DisplayContent { + ALL, + ARMOR, + RECAP + } + + private record RecapListener(Tracker tracker, Recap recap) implements Listener { + + @EventHandler + public void onQuit(PlayerQuitEvent event) { + this.recap.recaps.remove(event.getPlayer().getUniqueId()); + } + + @EventHandler + public void onDeath(PlayerDeathEvent event) { + Player player = event.getEntity(); + BattleRecap recap = this.recap.getRecap(player); + recap.markDeath(); + + this.recap.previousRecaps.put(player.getUniqueId(), recap); + this.recap.recaps.remove(player.getUniqueId()); + } + + @EventHandler + public void onHealthRegain(EntityRegainHealthEvent event) { + if (!(event.getEntity() instanceof Player player)) { + return; + } + + BattleRecap recap = this.recap.getRecap(player); + recap.record(RecapEntry.builder() + .amount(event.getAmount()) + .logTime(Instant.now()) + .kind(RecapEntry.Kind.GAIN) + .build()); + } + + @EventHandler + public void onDeathMessage(DeathMessageEvent event) { + if (!this.tracker.equals(event.getTracker())) { + return; + } + + if (!this.recap.hoverRecap) { + return; + } + + Component deathMessage = event.getDeathMessage(); + if (deathMessage == null) { + return; + } + + BattleRecap recap = this.recap.previousRecaps.get(event.getPlayer().getUniqueId()); + if (recap == null) { + return; + } + + List lines = new ArrayList<>(); + processRecapEntry(recap.getCombinedEntries(), 5, false, lines::add); + + deathMessage = deathMessage.hoverEvent( + HoverEvent.showText(Messages.get("recap-damage-log") + .append(Component.newline()) + .append(Component.join(JoinConfiguration.newlines(), lines)) + .append(Component.newline()) + .append(Component.newline()) + .append(Messages.get("recap-click-for-more")) + )); + + deathMessage = deathMessage.clickEvent(ClickEvent.callback(audience -> this.recap.showRecap(audience, this.tracker, recap), builder -> builder.lifetime(Duration.ofMinutes(5)).uses(ClickCallback.UNLIMITED_USES))); + event.setDeathMessage(deathMessage); + } + } +} diff --git a/src/main/java/org/battleplugins/tracker/feature/recap/RecapEntry.java b/src/main/java/org/battleplugins/tracker/feature/recap/RecapEntry.java new file mode 100644 index 0000000..2bbaf78 --- /dev/null +++ b/src/main/java/org/battleplugins/tracker/feature/recap/RecapEntry.java @@ -0,0 +1,102 @@ +package org.battleplugins.tracker.feature.recap; + +import org.bukkit.entity.Entity; +import org.bukkit.event.entity.EntityDamageEvent; +import org.bukkit.inventory.ItemStack; +import org.jetbrains.annotations.Nullable; + +import java.time.Instant; + +public record RecapEntry( + @Nullable EntityDamageEvent.DamageCause damageCause, + double amount, + @Nullable EntitySnapshot causingEntity, + @Nullable EntitySnapshot sourceEntity, + @Nullable ItemStack itemUsed, + Instant logTime, + Kind kind +) { + + public RecapEntry(EntityDamageEvent.DamageCause damageCause, double amount, Entity causingEntity, Entity sourceEntity, ItemStack itemUsed, Instant logTime, Kind kind) { + this(damageCause, amount, new EntitySnapshot(causingEntity), new EntitySnapshot(sourceEntity), itemUsed, logTime, kind); + } + + public Builder toBuilder() { + return new Builder() + .damageCause(this.damageCause) + .amount(this.amount) + .causingEntity(this.causingEntity) + .sourceEntity(this.sourceEntity) + .itemUsed(this.itemUsed) + .logTime(this.logTime) + .kind(this.kind); + } + + public enum Kind { + GAIN, + LOSS + } + + public static Builder builder() { + return new Builder(); + } + + public static class Builder { + private EntityDamageEvent.DamageCause damageCause; + private double amount; + private EntitySnapshot causingEntity; + private EntitySnapshot sourceEntity; + private ItemStack itemUsed; + private Instant logTime; + private Kind kind = Kind.LOSS; + + public Builder damageCause(EntityDamageEvent.DamageCause cause) { + this.damageCause = cause; + return this; + } + + public Builder amount(double amount) { + this.amount = amount; + return this; + } + + public Builder causingEntity(Entity causingEntity) { + this.causingEntity = new EntitySnapshot(causingEntity); + return this; + } + + public Builder causingEntity(EntitySnapshot causingEntity) { + this.causingEntity = causingEntity; + return this; + } + + public Builder sourceEntity(Entity sourceEntity) { + this.sourceEntity = new EntitySnapshot(sourceEntity); + return this; + } + + public Builder sourceEntity(EntitySnapshot sourceEntity) { + this.sourceEntity = sourceEntity; + return this; + } + + public Builder itemUsed(ItemStack itemUsed) { + this.itemUsed = itemUsed; + return this; + } + + public Builder logTime(Instant logTime) { + this.logTime = logTime; + return this; + } + + public Builder kind(Kind kind) { + this.kind = kind; + return this; + } + + public RecapEntry build() { + return new RecapEntry(this.damageCause, this.amount, this.causingEntity, this.sourceEntity, this.itemUsed, this.logTime, this.kind); + } + } +} diff --git a/src/main/java/org/battleplugins/tracker/feature/recap/RecapRoundup.java b/src/main/java/org/battleplugins/tracker/feature/recap/RecapRoundup.java new file mode 100644 index 0000000..21887b3 --- /dev/null +++ b/src/main/java/org/battleplugins/tracker/feature/recap/RecapRoundup.java @@ -0,0 +1,232 @@ +package org.battleplugins.tracker.feature.recap; + +import it.unimi.dsi.fastutil.Hash; +import it.unimi.dsi.fastutil.objects.Object2ObjectOpenCustomHashMap; +import net.kyori.adventure.audience.Audience; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.TranslatableComponent; +import org.battleplugins.tracker.TrackedDataType; +import org.battleplugins.tracker.Tracker; +import org.battleplugins.tracker.message.Messages; +import org.battleplugins.tracker.util.Util; +import org.bukkit.Material; +import org.bukkit.entity.EntityType; +import org.bukkit.event.entity.EntityDamageEvent; +import org.bukkit.inventory.ItemStack; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +public class RecapRoundup { + + public static void recapItem(Audience sender, BattleRecap recap) { + Messages.send(sender, "header", Messages.getPlain("recap-damage-item")); + Map> entriesByStack = new Object2ObjectOpenCustomHashMap<>(new Hash.Strategy<>() { + + @Override + public int hashCode(ItemStack o) { + if (o.getType() == Material.AIR) { + return o.getType().hashCode(); + } + + int hash = 1; + + hash = hash * 31 + o.getType().hashCode(); + hash = hash * 31 + (o.hasItemMeta() ? (o.getItemMeta().hashCode()) : 0); + return hash; + } + + @Override + public boolean equals(ItemStack a, ItemStack b) { + if (a == null && b == null) { + return true; + } + + if (a == null && b.getType() == Material.AIR) { + return true; + } + + if (a != null && a.getType() == Material.AIR && b == null) { + return true; + } + + if (a != null && b != null && a.getType() == Material.AIR && b.getType() == Material.AIR) { + return true; + } + + return a != null && a.isSimilar(b); + } + }); + + for (RecapEntry entry : recap.getEntries()) { + if (entry.itemUsed() == null || entry.kind() != RecapEntry.Kind.LOSS) { + continue; + } + + entriesByStack.computeIfAbsent(entry.itemUsed(), k -> new ArrayList<>()).add(entry); + } + + for (Map.Entry> entry : entriesByStack.entrySet()) { + double damage = entry.getValue().stream().mapToDouble(RecapEntry::amount).sum(); + if (damage == 0) { + continue; + } + + TranslatableComponent itemComponent = Component.translatable(entry.getKey()); + if (entry.getKey().getType() != Material.AIR) { + itemComponent = itemComponent.hoverEvent(entry.getKey()); + } + + Messages.send(sender, "recap-log-item", Map.of( + "item", itemComponent, + "hits", Integer.toString(entry.getValue().size()), + "damage", Util.HEALTH_FORMAT.format(damage) + )); + } + + Messages.send(sender, "recap-log-general", Map.of( + "time", Util.toTimeStringShort(Duration.between(recap.getCreateTime(), recap.getLastEntry().logTime())), + "health", Util.formatHealth(recap.getEntries().stream().filter(entry -> entry.kind() == RecapEntry.Kind.GAIN).mapToDouble(RecapEntry::amount).sum(), false), + "damage", Util.HEALTH_FORMAT.format(recap.getEntries().stream().filter(entry -> entry.kind() == RecapEntry.Kind.LOSS).mapToDouble(RecapEntry::amount).sum()) + )); + } + + public static void recapPlayer(Audience sender, BattleRecap recap) { + Messages.send(sender, "header", Messages.getPlain("recap-damage-player")); + Map> entriesByStack = new HashMap<>(); + + for (RecapEntry entry : recap.getEntries()) { + if (entry.kind() != RecapEntry.Kind.LOSS) { + continue; + } + + if (entry.causingEntity() == null) { + continue; + } + + entriesByStack.computeIfAbsent(entry.causingEntity(), k -> new ArrayList<>()).add(entry); + } + + for (Map.Entry> entry : entriesByStack.entrySet()) { + if (entry.getKey().type() != EntityType.PLAYER) { + continue; + } + + double damage = entry.getValue().stream().mapToDouble(RecapEntry::amount).sum(); + if (damage == 0) { + continue; + } + + Messages.send(sender, "recap-log-player", Map.of( + "player", entry.getKey().displayedName(), + "hits", Integer.toString(entry.getValue().size()), + "damage", Util.HEALTH_FORMAT.format(damage) + )); + } + + Messages.send(sender, "recap-log-general", Map.of( + "time", Util.toTimeStringShort(Duration.between(recap.getCreateTime(), recap.getLastEntry().logTime())), + "health", Util.formatHealth(recap.getEntries().stream().filter(entry -> entry.kind() == RecapEntry.Kind.GAIN).mapToDouble(RecapEntry::amount).sum(), false), + "damage", Util.HEALTH_FORMAT.format(recap.getEntries().stream().filter(entry -> entry.kind() == RecapEntry.Kind.LOSS).mapToDouble(RecapEntry::amount).sum()) + )); + } + + public static void recapEntity(Audience sender, BattleRecap recap) { + Messages.send(sender, "header", Messages.getPlain("recap-damage-entity")); + Map> entriesByStack = new HashMap<>(); + + for (RecapEntry entry : recap.getEntries()) { + if (entry.kind() != RecapEntry.Kind.LOSS) { + continue; + } + + if (entry.causingEntity() == null) { + continue; + } + + entriesByStack.computeIfAbsent(entry.causingEntity(), k -> new ArrayList<>()).add(entry); + } + + for (Map.Entry> entry : entriesByStack.entrySet()) { + double damage = entry.getValue().stream().mapToDouble(RecapEntry::amount).sum(); + if (damage == 0) { + continue; + } + + Component entityComponent = entry.getKey().displayedName(); + if (entry.getKey().type() == EntityType.PLAYER) { + entityComponent = entityComponent.append(Component.text(" (")) + .append(Component.translatable(entry.getKey().type())) + .append(Component.text(")")); + } + + Messages.send(sender, "recap-log-entity", Map.of( + "entity", entityComponent, + "hits", Integer.toString(entry.getValue().size()), + "damage", Util.HEALTH_FORMAT.format(damage) + )); + } + + Messages.send(sender, "recap-log-general", Map.of( + "time", Util.toTimeStringShort(Duration.between(recap.getCreateTime(), recap.getLastEntry().logTime())), + "health", Util.formatHealth(recap.getEntries().stream().filter(entry -> entry.kind() == RecapEntry.Kind.GAIN).mapToDouble(RecapEntry::amount).sum(), false), + "damage", Util.HEALTH_FORMAT.format(recap.getEntries().stream().filter(entry -> entry.kind() == RecapEntry.Kind.LOSS).mapToDouble(RecapEntry::amount).sum()) + )); + } + + public static void recapSource(Audience sender, BattleRecap recap) { + Messages.send(sender, "header", Messages.getPlain("recap-damage-cause")); + Map> entriesByStack = new HashMap<>(); + + for (RecapEntry entry : recap.getEntries()) { + if (entry.kind() != RecapEntry.Kind.LOSS) { + continue; + } + + if (entry.damageCause() == null) { + continue; + } + + entriesByStack.computeIfAbsent(entry.damageCause(), k -> new ArrayList<>()).add(entry); + } + + for (Map.Entry> entry : entriesByStack.entrySet()) { + double damage = entry.getValue().stream().mapToDouble(RecapEntry::amount).sum(); + if (damage == 0) { + continue; + } + + // TODO: Translation support + Component causeComponent = Component.text(Util.capitalize(entry.getKey().name().toLowerCase(Locale.ROOT).replace("_", " "))); + Messages.send(sender, "recap-log-cause", Map.of( + "cause", causeComponent, + "hits", Integer.toString(entry.getValue().size()), + "damage", Util.HEALTH_FORMAT.format(damage) + )); + } + + Messages.send(sender, "recap-log-general", Map.of( + "time", Util.toTimeStringShort(Duration.between(recap.getCreateTime(), recap.getLastEntry().logTime())), + "health", Util.formatHealth(recap.getEntries().stream().filter(entry -> entry.kind() == RecapEntry.Kind.GAIN).mapToDouble(RecapEntry::amount).sum(), false), + "damage", Util.HEALTH_FORMAT.format(recap.getEntries().stream().filter(entry -> entry.kind() == RecapEntry.Kind.LOSS).mapToDouble(RecapEntry::amount).sum()) + )); + } + + public static void sendFooter(Audience audience, Tracker tracker, BattleRecap recap) { + if (tracker.tracksData(TrackedDataType.PVP)) { + Messages.send(audience, "recap-footer-pvp", Map.of( + "tracker", tracker.getName().toLowerCase(Locale.ROOT), + "player", recap.getRecapOwner() + )); + } else { + Messages.send(audience, "recap-footer-pve", Map.of( + "tracker", tracker.getName().toLowerCase(Locale.ROOT), + "player", recap.getRecapOwner() + )); + } + } +} diff --git a/src/main/java/org/battleplugins/tracker/listener/PvEListener.java b/src/main/java/org/battleplugins/tracker/listener/PvEListener.java new file mode 100644 index 0000000..f0bb1ff --- /dev/null +++ b/src/main/java/org/battleplugins/tracker/listener/PvEListener.java @@ -0,0 +1,207 @@ +package org.battleplugins.tracker.listener; + +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer; +import org.battleplugins.tracker.TrackedDataType; +import org.battleplugins.tracker.Tracker; +import org.battleplugins.tracker.event.TrackerDeathEvent; +import org.battleplugins.tracker.feature.recap.Recap; +import org.battleplugins.tracker.feature.recap.RecapEntry; +import org.battleplugins.tracker.stat.Record; +import org.battleplugins.tracker.stat.StatType; +import org.battleplugins.tracker.util.Util; +import org.bukkit.entity.AnimalTamer; +import org.bukkit.entity.Entity; +import org.bukkit.entity.EntityType; +import org.bukkit.entity.LivingEntity; +import org.bukkit.entity.Player; +import org.bukkit.entity.Projectile; +import org.bukkit.entity.Tameable; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; +import org.bukkit.event.entity.EntityDamageByEntityEvent; +import org.bukkit.event.entity.EntityDamageEvent; +import org.bukkit.event.entity.EntityDeathEvent; +import org.bukkit.event.entity.PlayerDeathEvent; +import org.bukkit.inventory.ItemStack; + +import java.time.Instant; +import java.util.HashMap; +import java.util.Locale; +import java.util.UUID; + +public class PvEListener implements Listener { + private final Tracker tracker; + + public PvEListener(Tracker tracker) { + this.tracker = tracker; + } + + @EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true) + public void onDeath(PlayerDeathEvent event) { + Player killed = event.getEntity(); + if (this.tracker.getDisabledWorlds().contains(killed.getWorld().getName())) { + return; + } + + TrackedDataType dataType = TrackedDataType.PVE; + + EntityDamageEvent lastDamageCause = killed.getLastDamageCause(); + Entity killer = null; + String killerName; + if (lastDamageCause instanceof EntityDamageByEntityEvent damageEvent) { + EntityType killerType; + + killer = damageEvent.getDamager(); + if (killer instanceof Player) { + return; + } + + killerType = killer.getType(); + if (killer instanceof Projectile projectile && projectile.getShooter() instanceof Entity entity) { + if (projectile.getShooter() instanceof Player) { + return; + } + + killer = entity; + killerType = projectile.getType(); + } + + if (killer instanceof Tameable tameable && tameable.isTamed()) { + return; // only players can tame animals + } + + killerName = PlainTextComponentSerializer.plainText().serialize(Component.translatable(killerType)); + } else { + dataType = TrackedDataType.WORLD; + + // TODO: Translation support, and use new damage API when it is + // ready for widespread adoption + if (lastDamageCause == null) { + killerName = "Unknown"; + } else { + killerName = Util.capitalize(lastDamageCause.getCause().name().toLowerCase(Locale.ROOT).replace("_", " ")); + } + } + + if (!this.tracker.tracksData(dataType)) { + return; + } + + Record killerRecord = this.tracker.getOrCreateRecord(killed); + if (killerRecord.isTracking()) { + this.tracker.incrementValue(StatType.DEATHS, killed); + } + + Record record = new Record(this.tracker, UUID.randomUUID(), killerName, new HashMap<>()); + record.setRating(this.tracker.getRatingCalculator().getDefaultRating()); + this.tracker.getRatingCalculator().updateRating(record, killerRecord, false); + + new TrackerDeathEvent(this.tracker, dataType == TrackedDataType.PVE ? TrackerDeathEvent.DeathType.ENTITY : TrackerDeathEvent.DeathType.WORLD, killer, event).callEvent(); + } + + @EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true) + public void onEntityDeath(EntityDeathEvent event) { + Entity killed = event.getEntity(); + if (this.tracker.getDisabledWorlds().contains(killed.getWorld().getName())) { + return; + } + + if (killed instanceof Player) { + return; + } + + if (!this.tracker.tracksData(TrackedDataType.PVE)) { + return; + } + + if (!(killed.getLastDamageCause() instanceof EntityDamageByEntityEvent lastDamageCause)) { + return; + } + + if (!(lastDamageCause.getDamager() instanceof Player killer)) { + return; + } + + Record killerRecord = this.tracker.getOrCreateRecord(killer); + if (killerRecord.isTracking()) { + this.tracker.incrementValue(StatType.KILLS, killer); + } + + String killerName = PlainTextComponentSerializer.plainText().serialize(Component.translatable(killer.getType())); + + Record record = new Record(this.tracker, UUID.randomUUID(), killerName, new HashMap<>()); + record.setRating(this.tracker.getRatingCalculator().getDefaultRating()); + this.tracker.getRatingCalculator().updateRating(killerRecord, record, false); + } + + @EventHandler(priority = EventPriority.MONITOR) + public void onEntityDamage(EntityDamageEvent event) { + if (!(event.getEntity() instanceof Player player)) { + return; + } + + if (this.tracker.getDisabledWorlds().contains(event.getEntity().getWorld().getName())) { + return; + } + + Recap recap = this.tracker.getFeature(Recap.class); + if (recap == null) { + return; + } + + if (!(event instanceof EntityDamageByEntityEvent entityEvent)) { + if (!this.tracker.tracksData(TrackedDataType.WORLD)) { + return; + } + + recap.getRecap(player).record(RecapEntry.builder() + .damageCause(event.getCause()) + .amount(event.getFinalDamage()) + .logTime(Instant.now()) + .build() + ); + + return; + } + + if (!this.tracker.tracksData(TrackedDataType.PVE)) { + return; + } + + Entity causingEntity = entityEvent.getDamager(); + Entity sourceEntity = causingEntity; + if (causingEntity instanceof Projectile proj) { + if (proj.getShooter() instanceof Entity shooter) { + sourceEntity = shooter; + } + } + + if (causingEntity instanceof Tameable tameable && tameable.isTamed()) { + AnimalTamer owner = tameable.getOwner(); + if (owner instanceof Entity entity) { + sourceEntity = entity; + } + } + + if (sourceEntity instanceof Player) { + return; + } + + ItemStack itemUsed = null; + if (causingEntity instanceof LivingEntity livingEntity && livingEntity.getEquipment() != null) { + itemUsed = livingEntity.getEquipment().getItemInMainHand(); + } + + recap.getRecap(player).record(RecapEntry.builder() + .damageCause(event.getCause()) + .causingEntity(causingEntity) + .sourceEntity(sourceEntity) + .amount(event.getFinalDamage()) + .itemUsed(itemUsed) + .logTime(Instant.now()) + .build() + ); + } +} diff --git a/src/main/java/org/battleplugins/tracker/listener/PvPListener.java b/src/main/java/org/battleplugins/tracker/listener/PvPListener.java new file mode 100644 index 0000000..b9049db --- /dev/null +++ b/src/main/java/org/battleplugins/tracker/listener/PvPListener.java @@ -0,0 +1,150 @@ +package org.battleplugins.tracker.listener; + +import org.battleplugins.tracker.TrackedDataType; +import org.battleplugins.tracker.Tracker; +import org.battleplugins.tracker.event.TrackerDeathEvent; +import org.battleplugins.tracker.feature.recap.Recap; +import org.battleplugins.tracker.feature.recap.RecapEntry; +import org.battleplugins.tracker.stat.Record; +import org.battleplugins.tracker.stat.StatType; +import org.battleplugins.tracker.stat.TallyEntry; +import org.bukkit.entity.AnimalTamer; +import org.bukkit.entity.Entity; +import org.bukkit.entity.Player; +import org.bukkit.entity.Projectile; +import org.bukkit.entity.Tameable; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; +import org.bukkit.event.entity.EntityDamageByEntityEvent; +import org.bukkit.event.entity.EntityDamageEvent; +import org.bukkit.event.entity.PlayerDeathEvent; +import org.bukkit.inventory.ItemStack; + +import java.time.Instant; + +public class PvPListener implements Listener { + private final Tracker tracker; + + public PvPListener(Tracker tracker) { + this.tracker = tracker; + } + + @EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true) + public void onDeath(PlayerDeathEvent event) { + if (!this.tracker.tracksData(TrackedDataType.PVP)) { + return; + } + + Player killed = event.getEntity(); + if (this.tracker.getDisabledWorlds().contains(killed.getWorld().getName())) { + return; + } + + EntityDamageEvent lastDamageCause = killed.getLastDamageCause(); + Player killer = null; + if (lastDamageCause instanceof EntityDamageByEntityEvent damageByEntityEvent) { + Entity damager = damageByEntityEvent.getDamager(); + if (damager instanceof Player) { + killer = (Player) damager; + } + + if (damager instanceof Projectile proj) { + if (proj.getShooter() instanceof Player player) { + killer = player; + } + } + + if (damager instanceof Tameable tameable && tameable.isTamed()) { + AnimalTamer owner = tameable.getOwner(); + if (owner instanceof Player player) { + killer = player; + } + } + } + + if (killer == null) { + return; + } + + Record killerRecord = this.tracker.getOrCreateRecord(killer); + Record killedRecord = this.tracker.getOrCreateRecord(killed); + + if (killerRecord.isTracking()) { + this.tracker.incrementValue(StatType.KILLS, killer); + } + + if (killedRecord.isTracking()) { + this.tracker.incrementValue(StatType.DEATHS, killed); + } + + this.tracker.updateRating(killer, killed, false); + + new TrackerDeathEvent(this.tracker, TrackerDeathEvent.DeathType.PLAYER, killer, event).callEvent(); + + Player finalKiller = killer; + this.tracker.getOrCreateVersusTally(killer, killed).whenComplete((versusTally, e) -> { + // The format is killer : killed : stat1 : stat2 .... + // If the killer is in place of the killed, we need to swap the values + boolean addToKills = !versusTally.id2().equals(finalKiller.getUniqueId()); + if (addToKills) { + this.tracker.modifyTally(versusTally, ctx -> ctx.recordStat(StatType.KILLS, versusTally.getStat(StatType.KILLS) + 1)); + } else { + this.tracker.modifyTally(versusTally, ctx -> ctx.recordStat(StatType.DEATHS, versusTally.getStat(StatType.DEATHS) + 1)); + } + + // Record a tally entry at the current timestamp + TallyEntry entry = new TallyEntry(finalKiller, killed, false, Instant.now()); + this.tracker.recordTallyEntry(entry); + }); + } + + @EventHandler(priority = EventPriority.MONITOR) + public void onEntityDamage(EntityDamageByEntityEvent event) { + if (!this.tracker.tracksData(TrackedDataType.PVP)) { + return; + } + + if (!(event.getEntity() instanceof Player damaged)) { + return; + } + + if (this.tracker.getDisabledWorlds().contains(damaged.getWorld().getName())) { + return; + } + + Recap recap = this.tracker.getFeature(Recap.class); + if (recap == null) { + return; + } + + Entity sourceEntity = event.getDamager(); + if (event.getDamager() instanceof Projectile proj) { + if (proj.getShooter() instanceof Player shooter) { + sourceEntity = shooter; + } + } + + if (event.getDamager() instanceof Tameable tameable && tameable.isTamed()) { + AnimalTamer owner = tameable.getOwner(); + if (owner instanceof Player player) { + sourceEntity = player; + } + } + + if (!(sourceEntity instanceof Player player)) { + return; + } + + ItemStack itemUsed = player.getInventory().getItemInMainHand(); + recap.getRecap(damaged).record(RecapEntry.builder() + .damageCause(event.getCause()) + .causingEntity(event.getDamager()) + .sourceEntity(sourceEntity) + .amount(event.getFinalDamage()) + .itemUsed(itemUsed) + .logTime(Instant.now()) + .build() + ); + } +} diff --git a/src/main/java/org/battleplugins/tracker/message/Messages.java b/src/main/java/org/battleplugins/tracker/message/Messages.java new file mode 100644 index 0000000..930babf --- /dev/null +++ b/src/main/java/org/battleplugins/tracker/message/Messages.java @@ -0,0 +1,157 @@ +package org.battleplugins.tracker.message; + +import net.kyori.adventure.audience.Audience; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.ComponentLike; +import net.kyori.adventure.text.event.ClickEvent; +import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer; +import org.battleplugins.tracker.BattleTracker; +import org.battleplugins.tracker.util.MessageUtil; +import org.bukkit.configuration.file.FileConfiguration; +import org.bukkit.configuration.file.YamlConfiguration; + +import java.io.File; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +public class Messages { + private static final Map MESSAGES = new HashMap<>(); + + public static void load(Path messagesPath) { + MESSAGES.clear(); + + File messagesFile = messagesPath.toFile(); + FileConfiguration messagesConfig = YamlConfiguration.loadConfiguration(messagesFile); + + for (String key : messagesConfig.getKeys(false)) { + String messageText = messagesConfig.getString(key); + if (messageText == null) { + BattleTracker.getInstance().warn("Message key {} has no value in messages file! Skipping", key); + continue; + } + + Component message = MessageUtil.MINI_MESSAGE.deserialize(messageText); + MESSAGES.put(key, message); + } + } + + public static Component get(String key, String... replacements) { + Component message = MESSAGES.get(key); + if (message == null) { + BattleTracker.getInstance().warn("Unknown message key {} in messages file! Skipping", key); + return Component.empty(); + } + + for (String replacement : replacements) { + message = message.replaceText(builder -> builder.matchLiteral("{}").once().replacement(Component.text(replacement))); + } + + return message; + } + + public static Component get(String key, Map replacements) { + Component message = MESSAGES.get(key); + if (message == null) { + BattleTracker.getInstance().warn("Unknown message key {} in messages file! Skipping", key); + return Component.empty(); + } + + for (Map.Entry entry : replacements.entrySet()) { + if (entry.getValue() instanceof ComponentLike componentLike) { + message = message.replaceText(builder -> builder.matchLiteral("%" + entry.getKey() + "%").once().replacement(componentLike)); + } else { + message = message.replaceText(builder -> builder.matchLiteral("%" + entry.getKey() + "%").once().replacement(Component.text(entry.getValue().toString()))); + } + } + + return message; + } + + public static String getPlain(String key, String... replacements) { + Component message = MESSAGES.get(key); + if (message == null) { + BattleTracker.getInstance().warn("Unknown message key {} in messages file! Skipping", key); + return ""; + } + + for (String replacement : replacements) { + message = message.replaceText(builder -> builder.matchLiteral("{}").once().replacement(replacement)); + } + + return PlainTextComponentSerializer.plainText().serialize(MESSAGES.get(key)); + } + + public static void send(Audience audience, String key, Map replacements) { + Component message = MESSAGES.get(key); + if (message == null) { + BattleTracker.getInstance().warn("Unknown message key {} in messages file! Skipping", key); + return; + } + + for (Map.Entry entry : replacements.entrySet()) { + if (entry.getValue() instanceof ComponentLike componentLike) { + message = message.replaceText(builder -> builder.matchLiteral("%" + entry.getKey() + "%").once().replacement(componentLike)); + } else { + message = message.replaceText(builder -> builder.matchLiteral("%" + entry.getKey() + "%").once().replacement(Component.text(entry.getValue().toString()))); + } + } + + message = processClickEvent(message, replacements); + audience.sendMessage(message); + } + + public static void send(Audience audience, String key) { + send(audience, key, Map.of()); + } + + public static void send(Audience audience, String key, String... replacements) { + send(audience, key, Arrays.stream(replacements).map(Component::text).toArray(Component[]::new)); + } + + public static void send(Audience audience, String key, Component... replacements) { + Component message = MESSAGES.get(key); + if (message == null) { + BattleTracker.getInstance().warn("Unknown message key {} in messages file! Skipping", key); + return; + } + + for (Component replacement : replacements) { + message = message.replaceText(builder -> builder.matchLiteral("{}").once().replacement(replacement)); + } + + audience.sendMessage(message); + } + + private static Component processClickEvent(Component component, Map replacements) { + ClickEvent clickEvent = component.clickEvent(); + if (clickEvent != null) { + for (Map.Entry entry : replacements.entrySet()) { + clickEvent = ClickEvent.clickEvent(clickEvent.action(), clickEvent.value().replace("%" + entry.getKey() + "%", entry.getValue().toString())); + } + + component = component.clickEvent(clickEvent); + } + + List children = new ArrayList<>(); + for (Component child : component.children()) { + ClickEvent childClickEvent = child.clickEvent(); + if (childClickEvent != null) { + for (Map.Entry entry : replacements.entrySet()) { + childClickEvent = ClickEvent.clickEvent(childClickEvent.action(), childClickEvent.value().replace("%" + entry.getKey() + "%", entry.getValue().toString())); + } + + child = child.clickEvent(childClickEvent); + } + + child = processClickEvent(child, replacements); + children.add(child); + } + + component = component.children(children); + return component; + } +} diff --git a/src/main/java/org/battleplugins/tracker/sql/DbCache.java b/src/main/java/org/battleplugins/tracker/sql/DbCache.java new file mode 100644 index 0000000..ab93f3a --- /dev/null +++ b/src/main/java/org/battleplugins/tracker/sql/DbCache.java @@ -0,0 +1,280 @@ +package org.battleplugins.tracker.sql; + +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.Collection; +import java.util.List; +import java.util.Set; +import java.util.concurrent.CompletableFuture; +import java.util.function.Consumer; +import java.util.function.Function; +import java.util.function.Predicate; + +/** + * A cache holding data from a database. + *

+ * This is an async, thread-safe cache that can be used to store data + * from a database. This cache is designed for used of data that is + * frequently accessed and should be stored in memory for faster access. + *

+ * This cache has flushing capabilities, meaning that the cache can be + * saved to the database at any time. This is useful for saving data + * that has been modified in the cache. Additionally, this can be scaled + * or modified in cases where lots of data is loaded. + */ +public interface DbCache { + + /** + * Creates a new Set cache. + * + * @param the value of the cache + * @return a new Set cache + */ + static SetCache createSet() { + return new DbCacheSet<>(); + } + + /** + * Creates a new Map cache. + * + * @param the key of the cache + * @param the value of the cache + * @return a new Map cache + */ + static MapCache createMap() { + return new DbCacheMap<>(); + } + + /** + * Creates a new Multimap cache. + * + * @param the key of the cache + * @param the value of the cache + * @return a new Multimap cache + */ + static MultimapCache createMultimap() { + return new DbCacheMultimap<>(); + } + + interface SetCache extends DbCache { + + /** + * Adds a value to the cache. + * + * @param value the value to add + */ + void add(V value); + + /** + * Modifies a value in the cache. + * + * @param value the value to modify + */ + void modify(V value); + + /** + * Locks a value in the cache. + *

+ * This will ensure that an entry is not removed + * from the cache until it is unlocked. + * + * @param value the value to lock + */ + void lock(V value); + + /** + * Unlocks a value in the cache. + *

+ * This will allow an entry to be removed from + * the cache. + * + * @param value the value to unlock + */ + void unlock(V value); + + /** + * Returns a cached value from the cache immediately. + *

+ * This method should be used when the value is expected to be + * in the cache. If the value is not in the cache, this method + * will return null. + * + * @param predicate the predicate to get the value from + * @return the value from the cache, or null if the value is not in the cache + */ + @Nullable + V getCached(Predicate predicate); + + /** + * Returns a value from the cache or loads it if it is not in the cache. + *

+ * This method should be used when the value is not guaranteed to be + * in the cache. If the value is in the cache, this method will + * return the value immediately. If the value is not in the cache, + * this method will load the value from the database and return it. + * + * @param predicate the predicate to get the value from. If the predicate returns + * false, the value will be loaded from the database + * @param loader the loader to load the value from the database + * @return the value from the cache or the value loaded from the database + */ + CompletableFuture getOrLoad(Predicate predicate, CompletableFuture loader); + + /** + * Saves the cache to the database. + *

+ * The consumer will be called for each value in the cache that + * has been modified. This is useful for saving data that has been + * modified in the cache. + * + * @param value the value to save + */ + void save(Consumer value); + + /** + * Flushes the cache. + *

+ * This method will remove the key from the cache and save the + * values to the database. This is useful for saving data that + * has been modified in the cache. + *

+ * NOTE: This should be used in conjunction with the + * {@link #save} method as flushing with + * unsaved entries will not damageCause the objects to be flushed + * from memory. + * + * @param all whether to flush all entries + */ + void flush(boolean all); + } + + interface MapBase extends DbCache { + + /** + * Returns a {@link Set} of all the keys in + * the cache. + * + * @return a set of all the keys in the cache + */ + Set keySet(); + + /** + * Puts a value into the cache. + * + * @param key the key to store the value under + * @param value the value to store + */ + void put(K key, V value); + + /** + * Locks a key in the cache. + *

+ * This will ensure that an entry is not removed + * from the cache until it is unlocked. + * + * @param key the key to lock + */ + void lock(K key); + + /** + * Unlocks a key in the cache. + *

+ * This will allow an entry to be removed from + * the cache. + * + * @param key the key to unlock + */ + void unlock(K key); + + /** + * Removes an entry from the cache. + * + * @param key the key of the entry + */ + void remove(K key); + + /** + * Returns a cached value from the cache immediately. + *

+ * This method should be used when the value is expected to be + * in the cache. If the value is not in the cache, this method + * will return null. + * + * @param key the key to get the value from + * @return the value from the cache, or null if the value is not in the cache + */ + @Nullable + C getCached(K key); + + /** + * Returns a value from the cache or loads it if it is not in the cache. + *

+ * This method should be used when the value is not guaranteed to be + * in the cache. If the value is in the cache, this method will + * return the value immediately. If the value is not in the cache, + * this method will load the value from the database and return it. + * + * @param key the key to get the value from + * @param loader the loader to load the value from the database + * @return the value from the cache or the value loaded from the database + */ + CompletableFuture getOrLoad(K key, CompletableFuture loader); + + /** + * Bulk loads data into this cache. + * + * @param loader the loader to load the data from the database + * @param keyFunction the function to get the key from the value + * @return the loaded data + */ + CompletableFuture> loadBulk(CompletableFuture> loader, Function keyFunction); + + /** + * Saves the cache to the database. + *

+ * The consumer will be called for each value in the cache that + * has been modified. This is useful for saving data that has been + * modified in the cache. + * + * @param key the key to save the value under + * @param value the value to save + */ + void save(K key, Consumer value); + + /** + * Flushes the cache. + *

+ * This method will remove the key from the cache and save the + * values to the database. This is useful for saving data that + * has been modified in the cache. + *

+ * NOTE: This should be used in conjunction with the + * {@link #save(Object, Consumer)} method as flushing with + * unsaved entries will not damageCause the objects to be flushed + * from memory. + * + * @param key the key to flush + * @param all whether to flush all entries + */ + void flush(K key, boolean all); + } + + interface MapCache extends MapBase { + } + + interface MultimapCache extends MapBase> { + + @NotNull + @Override + List getCached(K key); + + /** + * Puts a collection of values into the cache. + * + * @param key the key to store the values under + * @param values the values to store + */ + void putAll(K key, Collection values); + } +} diff --git a/src/main/java/org/battleplugins/tracker/sql/DbCacheMap.java b/src/main/java/org/battleplugins/tracker/sql/DbCacheMap.java new file mode 100644 index 0000000..ef55fa5 --- /dev/null +++ b/src/main/java/org/battleplugins/tracker/sql/DbCacheMap.java @@ -0,0 +1,131 @@ +package org.battleplugins.tracker.sql; + +import org.battleplugins.tracker.BattleTracker; + +import java.util.Collection; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Consumer; +import java.util.function.Function; + +class DbCacheMap implements DbCache.MapCache { + private final Map> entries = new ConcurrentHashMap<>(); + + @Override + public Set keySet() { + return this.entries.keySet(); + } + + @Override + public void put(K key, V value) { + this.entries.put(key, new DbValue<>(value, true)); + } + + @Override + public void remove(K key) { + this.entries.remove(key); + } + + @Override + public V getCached(K key) { + DbValue dbValue = this.entries.get(key); + if (dbValue == null) { + return null; + } + + dbValue.resetLastAccess(); + return dbValue.value; + } + + @Override + public CompletableFuture getOrLoad(K key, CompletableFuture loader) { + if (this.entries.containsKey(key)) { + return CompletableFuture.completedFuture(this.getCached(key)); + } + + return loader.thenApply(value -> { + if (value == null) { + return null; + } + + this.entries.put(key, new DbValue<>(value, false)); + return value; + }); + } + + @Override + public CompletableFuture> loadBulk(CompletableFuture> loader, Function keyFunction) { + return loader.thenApply(values -> { + for (V value : values) { + K key = keyFunction.apply(value); + + // If we have this data in our cache already, let's use + // the cache as the source of truth, since the data may + // have been updated + if (this.entries.containsKey(key)) { + continue; + } + + this.entries.put(key, new DbValue<>(value, false)); + } + + return values; + }); + } + + @Override + public void save(K key, Consumer valueConsumer) { + DbValue value = this.entries.get(key); + if (value == null) { + BattleTracker.getInstance().warn("No value found in cache for key {}", key); + return; + } + + if (value.dirty) { + valueConsumer.accept(value.value); + value.dirty = false; + } + } + + @Override + public void flush(K key, boolean all) { + DbValue dbValue = this.entries.get(key); + if (dbValue == null) { + this.entries.remove(key); + return; + } + + if (!all && !dbValue.shouldFlush()) { + return; + } + + // If the db value is locked, do not flush + if (dbValue.locked) { + return; + } + + if (!dbValue.dirty) { + this.entries.remove(key); + } else { + BattleTracker.getInstance().warn("Unsaved DB value found in cache: {} for key {}", dbValue.value, key); + } + } + + @Override + public void lock(K key) { + DbValue dbValue = this.entries.get(key); + if (dbValue != null) { + dbValue.lock(); + } + } + + @Override + public void unlock(K key) { + DbValue dbValue = this.entries.get(key); + if (dbValue != null) { + dbValue.unlock(); + } + } +} diff --git a/src/main/java/org/battleplugins/tracker/sql/DbCacheMultimap.java b/src/main/java/org/battleplugins/tracker/sql/DbCacheMultimap.java new file mode 100644 index 0000000..d4c7323 --- /dev/null +++ b/src/main/java/org/battleplugins/tracker/sql/DbCacheMultimap.java @@ -0,0 +1,187 @@ +package org.battleplugins.tracker.sql; + +import com.google.common.collect.ListMultimap; +import com.google.common.collect.MultimapBuilder; +import com.google.common.collect.Multimaps; +import org.battleplugins.tracker.BattleTracker; +import org.jetbrains.annotations.NotNull; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashSet; +import java.util.Iterator; +import java.util.List; +import java.util.Set; +import java.util.concurrent.CompletableFuture; +import java.util.function.Consumer; +import java.util.function.Function; + +class DbCacheMultimap implements DbCache.MultimapCache { + private final ListMultimap> entries = Multimaps.synchronizedListMultimap( + MultimapBuilder.hashKeys() + .arrayListValues() + .build() + ); + + private final Set lockedKeys = new HashSet<>(); + private final Set loadedKeys = new HashSet<>(); + + @Override + public Set keySet() { + return this.entries.keySet(); + } + + @Override + public void put(K key, V value) { + this.entries.put(key, new DbValue<>(value, true)); + } + + @Override + public void lock(K key) { + this.lockedKeys.add(key); + } + + @Override + public void unlock(K key) { + this.lockedKeys.remove(key); + } + + @Override + public void remove(K key) { + this.entries.removeAll(key); + } + + @Override + public void putAll(K key, Collection values) { + values.forEach(value -> this.put(key, value)); + } + + @NotNull + @Override + public List getCached(K key) { + if (!this.entries.containsKey(key)) { + return List.of(); + } + + List> entries = this.entries.get(key); + if (entries.isEmpty()) { + return List.of(); + } + + List cached = new ArrayList<>(entries.size()); + for (DbValue entry : entries) { + cached.add(entry.value); + entry.resetLastAccess(); + } + + return cached; + } + + @Override + public CompletableFuture> getOrLoad(K key, CompletableFuture> loader) { + if (this.loadedKeys.contains(key)) { + return CompletableFuture.completedFuture(this.getCached(key)); + } + + List cachedAndLoaded = new ArrayList<>(); + if (this.entries.containsKey(key)) { + List> cachedData = this.entries.get(key); + cachedAndLoaded.addAll(cachedData.stream() + .peek(DbValue::resetLastAccess) + .map(dbValue -> dbValue.value) + .toList()); + } + + return loader.thenApply(value -> { + if (value == null) { + return List.of(); + } + + for (V v : value) { + this.entries.put(key, new DbValue<>(v, false)); + } + + this.loadedKeys.add(key); + + // If there is cached data, we need to merge the cached data + // with the loaded data. This will only be called once, so if + // we take a slight performance hit on load, that's fine, as the + // data will be cached for future use + if (!cachedAndLoaded.isEmpty()) { + cachedAndLoaded.addAll(value); + return cachedAndLoaded; + } + + return value; + }); + } + + @Override + public CompletableFuture>> loadBulk(CompletableFuture>> loader, Function, K> keyFunction) { + return loader.thenApply(values -> { + for (List value : values) { + K key = keyFunction.apply(value); + for (V v : value) { + // If we have this data in our cache already, let's use + // the cache as the source of truth, since the data may + // have been updated + if (this.entries.containsEntry(key, v)) { + continue; + } + + this.entries.put(key, new DbValue<>(v, false)); + } + + this.loadedKeys.add(key); + } + + return values; + }); + } + + @Override + public void save(K key, Consumer valueConsumer) { + this.entries.get(key).forEach(dbValue -> { + if (dbValue.dirty) { + valueConsumer.accept(dbValue.value); + + dbValue.dirty = false; + } + }); + + // If this key has never been loaded from the database + // before, we need to flush the data from the cache as + // keeping it here will mean that if a db load is called, + // the data could exist twice in our cached instance + if (!this.loadedKeys.contains(key)) { + this.flush(key, true); + } + } + + @Override + public void flush(K key, boolean all) { + this.loadedKeys.remove(key); + if (!this.entries.containsKey(key)) { + return; + } + + Iterator> iterator = this.entries.get(key).iterator(); + while (iterator.hasNext()) { + DbValue dbValue = iterator.next(); + if (!all && !dbValue.shouldFlush()) { + continue; + } + + // If the db value is locked, do not flush + if (this.lockedKeys.contains(key)) { + continue; + } + + if (!dbValue.dirty) { + iterator.remove(); + } else { + BattleTracker.getInstance().warn("Unsaved DB value found in cache: {} for key {}", dbValue.value, key); + } + } + } +} diff --git a/src/main/java/org/battleplugins/tracker/sql/DbCacheSet.java b/src/main/java/org/battleplugins/tracker/sql/DbCacheSet.java new file mode 100644 index 0000000..0d718d0 --- /dev/null +++ b/src/main/java/org/battleplugins/tracker/sql/DbCacheSet.java @@ -0,0 +1,108 @@ +package org.battleplugins.tracker.sql; + +import org.battleplugins.tracker.BattleTracker; +import org.jetbrains.annotations.Nullable; + +import java.util.Iterator; +import java.util.Set; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Consumer; +import java.util.function.Predicate; + +class DbCacheSet implements DbCache.SetCache { + private final Set> entries = ConcurrentHashMap.newKeySet(); + + @Override + public void add(V value) { + this.entries.add(new DbValue<>(value, true)); + } + + @Override + public void modify(V value) { + for (DbValue entry : this.entries) { + if (entry.value.equals(value)) { + entry.dirty = true; + entry.resetLastAccess(); + return; + } + } + } + + @Override + public void lock(V value) { + for (DbValue entry : this.entries) { + if (entry.value.equals(value)) { + entry.lock(); + return; + } + } + } + + @Override + public void unlock(V value) { + for (DbValue entry : this.entries) { + if (entry.value.equals(value)) { + entry.unlock(); + return; + } + } + } + + @Nullable + @Override + public V getCached(Predicate predicate) { + for (DbValue entry : this.entries) { + if (predicate.test(entry.value)) { + entry.resetLastAccess(); + return entry.value; + } + } + + return null; + } + + @Override + public CompletableFuture getOrLoad(Predicate predicate, CompletableFuture loader) { + V cached = this.getCached(predicate); + if (cached != null) { + return CompletableFuture.completedFuture(cached); + } + + return loader.thenApply(value -> { + if (value == null) { + return null; + } + + this.entries.add(new DbValue<>(value, false)); + return value; + }); + } + + @Override + public void save(Consumer valueConsumer) { + this.entries.forEach(dbValue -> { + if (dbValue.dirty) { + valueConsumer.accept(dbValue.value); + dbValue.dirty = false; + } + }); + } + + @Override + public void flush(boolean all) { + Iterator> iterator = this.entries.iterator(); + while (iterator.hasNext()) { + DbValue dbValue = iterator.next(); + if (!all && !dbValue.shouldFlush()) { + continue; + } + + if (!dbValue.dirty) { + iterator.remove(); + } else { + BattleTracker.getInstance().warn("Unsaved DB value found in cache: {}", dbValue.value); + } + } + } +} diff --git a/src/main/java/org/battleplugins/tracker/sql/DbValue.java b/src/main/java/org/battleplugins/tracker/sql/DbValue.java new file mode 100644 index 0000000..d5e3ccf --- /dev/null +++ b/src/main/java/org/battleplugins/tracker/sql/DbValue.java @@ -0,0 +1,32 @@ +package org.battleplugins.tracker.sql; + +import org.battleplugins.tracker.BattleTracker; + +class DbValue { + final V value; + boolean dirty; + boolean locked; + long lastAccess = System.currentTimeMillis(); + + public DbValue(V value, boolean dirty) { + this.value = value; + this.dirty = dirty; + } + + public void lock() { + this.locked = true; + } + + public void unlock() { + this.locked = false; + } + + public void resetLastAccess() { + this.lastAccess = System.currentTimeMillis(); + } + + public boolean shouldFlush() { + long staleEntryTime = BattleTracker.getInstance().getMainConfig().getAdvanced().staleEntryTime() * 1000L; + return !this.dirty && System.currentTimeMillis() - this.lastAccess > staleEntryTime; + } +} \ No newline at end of file diff --git a/src/main/java/org/battleplugins/tracker/sql/SqlSerializer.java b/src/main/java/org/battleplugins/tracker/sql/SqlSerializer.java new file mode 100644 index 0000000..c86f892 --- /dev/null +++ b/src/main/java/org/battleplugins/tracker/sql/SqlSerializer.java @@ -0,0 +1,678 @@ +package org.battleplugins.tracker.sql; + +import org.apache.commons.dbcp2.ConnectionFactory; +import org.apache.commons.dbcp2.DriverManagerConnectionFactory; +import org.apache.commons.dbcp2.PoolableConnection; +import org.apache.commons.dbcp2.PoolableConnectionFactory; +import org.apache.commons.dbcp2.PoolingDataSource; +import org.apache.commons.pool2.impl.GenericObjectPool; +import org.apache.commons.pool2.impl.GenericObjectPoolConfig; + +import javax.sql.DataSource; +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Statement; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CompletableFuture; + +/** + * Handles serializing SQL data to a database. + * + * @author alkarin_v + */ +public abstract class SqlSerializer { + protected static final int TIMEOUT = 5; + + public enum SqlType { + MYSQL("MySQL", "com.mysql.jdbc.Driver"), + SQLITE("SQLite", "org.sqlite.JDBC"); + + private final String name; + private final String driver; + + SqlType(String name, String driver) { + this.name = name; + this.driver = driver; + } + + public String getName() { + return this.name; + } + + public String getDriver() { + return this.driver; + } + } + + private DataSource dataSource; + + protected String db = "minecraft"; + protected SqlType type = SqlType.MYSQL; + + protected String url = "localhost"; + protected String port = "3306"; + protected String username = "root"; + protected String password = ""; + + private String createDatabase = "CREATE DATABASE IF NOT EXISTS `" + db + "`"; + + public String getUrl() { + return this.url; + } + + public void setUrl(String url) { + this.url = url; + } + + public String getPort() { + return this.port; + } + + public void setPort(String port) { + this.port = port; + } + + public String getUsername() { + return this.username; + } + + public void setUsername(String username) { + this.username = username; + } + + public String getPassword() { + return this.password; + } + + public void setPassword(String password) { + this.password = password; + } + + public void setType(SqlType type) { + this.type = type; + } + + public SqlType getType() { + return this.type; + } + + public String getDb() { + return this.db; + } + + public void setDb(String db) { + this.db = db; + this.createDatabase = "CREATE DATABASE IF NOT EXISTS `" + db + "`"; + } + + public record ResultSetConnection(ResultSet rs, Connection con) { + } + + protected void close(ResultSetConnection rscon) { + try { + rscon.rs.close(); + } catch (SQLException e) { + e.printStackTrace(); + } + } + + public Connection getConnection(boolean displayErrors) throws SQLException { + return this.getConnection(displayErrors, true); + } + + public Connection getConnection() throws SQLException { + return this.getConnection(true, true); + } + + public Connection getConnection(boolean displayErrors, boolean autoCommit) throws SQLException { + if (this.dataSource == null) { + throw new java.sql.SQLException("Connection is null. Did you intiliaze your SQL connection?"); + } + + try { + Connection con = this.dataSource.getConnection(); + con.setAutoCommit(autoCommit); + return con; + } catch (SQLException e1) { + if (displayErrors) + e1.printStackTrace(); + return null; + } + } + + public void closeConnection(ResultSetConnection rscon) { + if (rscon == null || rscon.con == null) { + return; + } + + try { + rscon.con.close(); + } catch (SQLException e) { + e.printStackTrace(); + } + } + + public void closeConnection(Connection con) { + if (con == null) { + return; + } + + try { + con.close(); + } catch (SQLException e) { + e.printStackTrace(); + } + } + + protected boolean init() { + Connection con = null; // Our database connection + try { + Class.forName(this.type.getDriver()); + } catch (ClassNotFoundException e1) { + e1.printStackTrace(); + return false; + } + + String connectionString; + String datasourceString; + int minIdle; + int maxActive; + switch (this.type) { + case SQLITE: + datasourceString = connectionString = "jdbc:sqlite:" + this.url + "/" + this.db + ".sqlite"; + maxActive = 1; + minIdle = -1; + break; + case MYSQL: + default: + minIdle = 10; + maxActive = 20; + datasourceString = "jdbc:mysql://" + this.url + ":" + this.port + "/" + this.db + "?autoReconnect=true"; + connectionString = "jdbc:mysql://" + this.url + ":" + this.port + "?autoReconnect=true"; + break; + } + + try { + this.dataSource = setupDataSource(datasourceString, this.username, this.password, minIdle, maxActive); + } catch (Exception e) { + e.printStackTrace(); + return false; + } + + if (this.type == SqlType.MYSQL) { + String strStmt = this.createDatabase; + try { + con = DriverManager.getConnection(connectionString, this.username, this.password); + Statement st = con.createStatement(); + st.executeUpdate(strStmt); + } catch (SQLException e) { + e.printStackTrace(); + return false; + } finally { + this.closeConnection(con); + } + } + + return true; + } + + public static PoolingDataSource setupDataSource(String connectURI, String username, String password, + int minIdle, int maxTotal) { + ConnectionFactory connectionFactory = new DriverManagerConnectionFactory(connectURI, username, password); + PoolableConnectionFactory factory = new PoolableConnectionFactory(connectionFactory, null); + factory.setValidationQuery("SELECT 1"); + + GenericObjectPoolConfig poolConfig = new GenericObjectPoolConfig<>(); + if (minIdle != -1) { + poolConfig.setMinIdle(minIdle); + } + + poolConfig.setMaxTotal(maxTotal); + poolConfig.setTestOnBorrow(true); // Test before the connection is made + + // Object pool + GenericObjectPool connectionPool = new GenericObjectPool<>(factory, poolConfig); + factory.setPool(connectionPool); + + // Pooling data source + return new PoolingDataSource<>(connectionPool); + } + + protected boolean createTable(String tableName, String sqlCreateTable, String... sqlUpdates) { + // Check to see if our table exists; + Boolean exists; + if (this.type == SqlType.SQLITE) { + exists = this.getBoolean("SELECT count(name) FROM sqlite_master WHERE type='table' AND name='" + tableName + "';"); + } else { + List objs = this.getObjects("SHOW TABLES LIKE '" + tableName + "';"); + exists = objs != null && objs.size() == 1; + } + + if (exists != null && exists) { + return true; // If the table exists nothing left to do + } + + // Create our table and index + String strStmt = sqlCreateTable; + Statement statement; + int result = 0; + Connection connection; + try { + connection = this.getConnection(); + } catch (SQLException e) { + e.printStackTrace(); + return false; + } + + try { + statement = connection.createStatement(); + result = statement.executeUpdate(strStmt); + } catch (SQLException e) { + e.printStackTrace(); + this.closeConnection(connection); + return false; + } + // Updates and indexes + if (sqlUpdates != null) { + for (String sqlUpdate : sqlUpdates) { + if (sqlUpdate == null) { + continue; + } + + strStmt = sqlUpdate; + try { + statement = connection.createStatement(); + result = statement.executeUpdate(strStmt); + } catch (SQLException e) { + e.printStackTrace(); + this.closeConnection(connection); + return false; + } + } + } + + this.closeConnection(connection); + return true; + } + + /** + * Check to see whether the database has a particular column + * + * @param table the table to check + * @param column the column to check + * @return Boolean: whether the column exists + */ + protected Boolean hasColumn(String table, String column) { + String statement; + Boolean columnExists; + switch (type) { + case MYSQL: + statement = "SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = ? " + + "AND TABLE_NAME = ? AND COLUMN_NAME = ?"; + columnExists = this.getBoolean(true, 2, statement, db, table, column); + return columnExists != null && columnExists; + case SQLITE: + // After hours, I have discovered that SQL can NOT bind tables... + // so explicitly put in the table. + // UPDATE: on Windows machines you need to explicitly put in the column too... + statement = "SELECT COUNT(" + column + ") FROM '" + table + "'"; + try { + columnExists = this.getBoolean(false, 2, statement); + // If we got any non error response... we have the table + return columnExists != null; + } catch (Exception e) { + return false; + } + } + return false; + } + + protected Boolean hasTable(String tableName) { + Boolean exists; + if (type == SqlType.SQLITE) { + exists = this.getBoolean("SELECT count(name) FROM sqlite_master WHERE type='table' AND name='" + tableName + "'"); + } else { + List objs = this.getObjects("SHOW TABLES LIKE '" + tableName + "';"); + exists = objs != null && objs.size() == 1; + } + return exists; + } + + protected ResultSetConnection executeQuery(String strRawStmt, Object... varArgs) { + return this.executeQuery(true, TIMEOUT, strRawStmt, varArgs); + } + + /** + * Execute the given query + * + * @param strRawStmt the raw statement to execute + * @param varArgs the arguments to pass into the statement + * @return the ResultSetConnection + */ + protected ResultSetConnection executeQuery(boolean displayErrors, Integer timeoutSeconds, + String strRawStmt, Object... varArgs) { + Connection con; + try { + con = this.getConnection(); + } catch (SQLException e) { + e.printStackTrace(); + return null; + } + + return this.executeQuery(con, displayErrors, timeoutSeconds, strRawStmt, varArgs); + } + + /** + * Execute the given query + * + * @param strRawStmt the raw statement to execute + * @param varArgs the arguments to pass into the statement + * @return the ResultSetConnection + */ + protected ResultSetConnection executeQuery(Connection con, boolean displayErrors, Integer timeoutSeconds, + String strRawStmt, Object... varArgs) { + PreparedStatement statement; + ResultSetConnection result = null; + + try { + statement = getStatement(displayErrors, strRawStmt, con, varArgs); + statement.setQueryTimeout(timeoutSeconds); + ResultSet rs = statement.executeQuery(); + result = new ResultSetConnection(rs, con); + } catch (Exception e) { + if (displayErrors) { + e.printStackTrace(); + } + } + return result; + } + + protected void executeUpdate(boolean async, String strRawStmt, Object... varArgs) { + if (async) { + new Thread(() -> { + try { + this.executeUpdate(strRawStmt, varArgs); + } catch (Exception e) { + e.printStackTrace(); + } + }).start(); + } else { + try { + this.executeUpdate(strRawStmt, varArgs); + } catch (Exception e) { + e.printStackTrace(); + } + } + } + + protected int executeUpdate(String strRawStmt, Object... varArgs) { + int result = -1; + Connection con; + try { + con = getConnection(); + } catch (SQLException e) { + e.printStackTrace(); + return -1; + } + + PreparedStatement ps; + try { + ps = this.getStatement(strRawStmt, con, varArgs); + result = ps.executeUpdate(); + } catch (Exception e) { + e.printStackTrace(); + } finally { + this.closeConnection(con); + } + + return result; + } + + protected CompletableFuture executeBatch(boolean async, String updateStatement, List> batch) { + CompletableFuture future = new CompletableFuture<>(); + if (async) { + new Thread(() -> { + try { + this.executeBatch(updateStatement, batch); + future.complete(null); + } catch (Exception e) { + future.completeExceptionally(e); + } + }).start(); + } else { + try { + this.executeBatch(updateStatement, batch); + future.complete(null); + } catch (Exception e) { + future.completeExceptionally(e); + } + } + + return future; + } + + protected void executeBatch(String updateStatement, List> batch) { + Connection con; + try { + con = this.getConnection(); + } catch (SQLException e) { + e.printStackTrace(); + return; + } + PreparedStatement ps = null; + try { + con.setAutoCommit(false); + } catch (Exception e) { + e.printStackTrace(); + } + try { + ps = con.prepareStatement(updateStatement); + } catch (Exception e) { + e.printStackTrace(); + } + + for (List update : batch) { + try { + for (int i = 0; i < update.size(); i++) { + ps.setObject(i + 1, update.get(i)); + } + ps.addBatch(); + } catch (Exception e) { + System.err.println("statement = " + ps); + e.printStackTrace(); + } + } + try { + ps.executeBatch(); + con.commit(); + } catch (Exception e) { + e.printStackTrace(); + } finally { + this.closeConnection(con); + } + } + + protected PreparedStatement getStatement(String strRawStmt, Connection con, Object... varArgs) { + return this.getStatement(true, strRawStmt, con, varArgs); + } + + protected PreparedStatement getStatement(boolean displayErrors, String strRawStmt, Connection con, Object... varArgs) { + PreparedStatement ps = null; + try { + ps = con.prepareStatement(strRawStmt); + for (int i = 0; i < varArgs.length; i++) { + ps.setObject(i + 1, varArgs[i]); + } + } catch (Exception e) { + if (displayErrors) { + e.printStackTrace(); + } + } + return ps; + } + + public Double getDouble(String query, Object... varArgs) { + ResultSetConnection rscon = this.executeQuery(query, varArgs); + if (rscon == null || rscon.con == null) + return null; + try { + ResultSet rs = rscon.rs; + while (rs.next()) { + return rs.getDouble(1); + } + } catch (SQLException e) { + e.printStackTrace(); + } finally { + try { + rscon.con.close(); + } catch (Exception e) { + e.printStackTrace(); + } + } + return null; + } + + public Integer getInteger(String query, Object... varArgs) { + ResultSetConnection rscon = this.executeQuery(query, varArgs); + if (rscon == null || rscon.con == null) + return null; + try { + ResultSet rs = rscon.rs; + while (rs.next()) { + return rs.getInt(1); + } + } catch (SQLException e) { + e.printStackTrace(); + } finally { + try { + rscon.con.close(); + } catch (Exception e) { + e.printStackTrace(); + } + } + return null; + } + + public Short getShort(String query, Object... varArgs) { + ResultSetConnection rscon = this.executeQuery(query, varArgs); + if (rscon == null || rscon.con == null) + return null; + try { + ResultSet rs = rscon.rs; + while (rs.next()) { + return rs.getShort(1); + } + } catch (SQLException e) { + e.printStackTrace(); + } finally { + try { + rscon.con.close(); + } catch (Exception e) { + e.printStackTrace(); + } + } + return null; + } + + public Long getLong(String query, Object... varArgs) { + ResultSetConnection rscon = this.executeQuery(query, varArgs); + if (rscon == null || rscon.con == null) + return null; + try { + ResultSet rs = rscon.rs; + while (rs.next()) { + return rs.getLong(1); + } + } catch (SQLException e) { + e.printStackTrace(); + } finally { + try { + rscon.con.close(); + } catch (Exception e) { + e.printStackTrace(); + } + } + return null; + } + + public Boolean getBoolean(String query, Object... varArgs) { + return this.getBoolean(true, TIMEOUT, query, varArgs); + } + + protected Boolean getBoolean(boolean displayErrors, Integer timeoutSeconds, + String query, Object... varArgs) { + ResultSetConnection rscon = this.executeQuery(displayErrors, timeoutSeconds, query, varArgs); + if (rscon == null || rscon.con == null) { + return null; + } + try { + ResultSet rs = rscon.rs; + while (rs.next()) { + int i = rs.getInt(1); + return i > 0; + } + } catch (SQLException e) { + if (displayErrors) + e.printStackTrace(); + } finally { + try { + rscon.con.close(); + } catch (Exception e) { + e.printStackTrace(); + } + } + return null; + } + + public String getString(String query, Object... varArgs) { + ResultSetConnection rscon = this.executeQuery(query, varArgs); + if (rscon == null || rscon.con == null) + return null; + try { + ResultSet rs = rscon.rs; + while (rs.next()) { + return rs.getString(1); + } + } catch (SQLException e) { + e.printStackTrace(); + } finally { + try { + rscon.con.close(); + } catch (Exception e) { + e.printStackTrace(); + } + } + return null; + } + + public List getObjects(String query, Object... varArgs) { + ResultSetConnection rscon = this.executeQuery(query, varArgs); + if (rscon == null || rscon.con == null) + return null; + try { + ResultSet rs = rscon.rs; + while (rs.next()) { + java.sql.ResultSetMetaData rsmd = rs.getMetaData(); + int nCol = rsmd.getColumnCount(); + List objs = new ArrayList<>(nCol); + for (int i = 0; i < nCol; i++) { + objs.add(rs.getObject(i + 1)); + } + return objs; + } + } catch (SQLException e) { + e.printStackTrace(); + } finally { + try { + rscon.con.close(); + } catch (Exception e) { + e.printStackTrace(); + } + } + return null; + } +} diff --git a/src/main/java/org/battleplugins/tracker/sql/TrackerSqlSerializer.java b/src/main/java/org/battleplugins/tracker/sql/TrackerSqlSerializer.java new file mode 100644 index 0000000..3cbd061 --- /dev/null +++ b/src/main/java/org/battleplugins/tracker/sql/TrackerSqlSerializer.java @@ -0,0 +1,468 @@ +package org.battleplugins.tracker.sql; + +import org.battleplugins.tracker.BattleTracker; +import org.battleplugins.tracker.SqlTracker; +import org.battleplugins.tracker.stat.Record; +import org.battleplugins.tracker.stat.StatType; +import org.battleplugins.tracker.stat.TallyEntry; +import org.battleplugins.tracker.stat.VersusTally; +import org.jetbrains.annotations.Blocking; + +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.stream.Collectors; + +/** + * Main SQL serializer for Trackers. + * + * @author alkarin_v + */ +public class TrackerSqlSerializer extends SqlSerializer { + + public static String TABLE_PREFIX; + + public static String DATABASE; + public static String URL; + public static String PORT; + public static String USERNAME; + public static String PASSWORD; + + public static SqlType TYPE; + + private static final int MAX_LENGTH = 100; + + private final String overallTable; + private final String tallyTable; + private final String versusTable; + + private final SqlTracker tracker; + + private final List overallColumns; + private final List versusColumns; + + public TrackerSqlSerializer(SqlTracker tracker) { + this( + tracker, + List.of(StatType.KILLS, StatType.DEATHS, StatType.TIES, StatType.MAX_STREAK, + StatType.MAX_RANKING, StatType.RATING, StatType.MAX_RATING, StatType.MAX_KD_RATIO + ), + List.of(StatType.KILLS, StatType.DEATHS, StatType.TIES) + ); + } + + public TrackerSqlSerializer(SqlTracker tracker, List overallColumns, List versusColumns) { + this.overallColumns = overallColumns.stream().map(StatType::getKey).toList(); + this.versusColumns = versusColumns.stream().map(StatType::getKey).toList(); + + this.tracker = tracker; + + this.overallTable = TABLE_PREFIX + tracker.getName().toLowerCase() + "_overall"; + this.tallyTable = TABLE_PREFIX + tracker.getName().toLowerCase() + "_tally"; + this.versusTable = TABLE_PREFIX + tracker.getName().toLowerCase() + "_versus"; + + this.init(); + } + + @Override + protected boolean init() { + this.setDb(DATABASE); + this.setType(TYPE); + this.setUrl(URL); + this.setPort(PORT); + this.setUsername(USERNAME); + this.setPassword(PASSWORD); + + super.init(); + + this.setupOverallTable(); + this.setupVersusTable(); + this.setupTallyTable(); + + return true; + } + + public CompletableFuture loadRecord(UUID uuid) { + return CompletableFuture.supplyAsync(() -> { + ResultSetConnection connection = this.executeQuery("SELECT * FROM " + this.overallTable + " WHERE id = ?", uuid.toString()); + try { + ResultSet resultSet = connection.rs(); + if (resultSet.next()) { + return this.createRecord(connection); + } + } catch (Exception e) { + e.printStackTrace(); + } finally { + this.closeConnection(connection); + } + + return null; + }); + } + + public CompletableFuture> getTopRecords(int limit, StatType orderBy) { + return CompletableFuture.supplyAsync(() -> { + List records = new ArrayList<>(); + ResultSetConnection connection = this.executeQuery("SELECT * FROM " + this.overallTable + " ORDER BY CAST(" + orderBy.getKey() + " AS REAL) DESC LIMIT ?", limit); + try { + ResultSet resultSet = connection.rs(); + while (resultSet.next()) { + records.add(this.createRecord(connection)); + } + } catch (Exception e) { + e.printStackTrace(); + } finally { + this.closeConnection(connection); + } + + return records; + }); + } + + @Blocking + public Record createRecord(ResultSetConnection connection) throws SQLException { + ResultSet resultSet = connection.rs(); + Map columns = new HashMap<>(); + for (String column : this.overallColumns) { + columns.put(StatType.get(column), Float.parseFloat(resultSet.getString(column))); + } + + return new Record(this.tracker, UUID.fromString(resultSet.getString("id")), resultSet.getString("name"), columns); + } + + public void removeRecord(UUID uuid) { + this.executeUpdate(true, "DELETE FROM " + this.overallTable + " WHERE id = ?", uuid.toString()); + } + + public CompletableFuture> loadTallyEntries(UUID uuid) { + return CompletableFuture.supplyAsync(() -> { + List entries = new ArrayList<>(); + ResultSetConnection connection = this.executeQuery("SELECT * FROM " + this.tallyTable + " WHERE id1 = ? OR id2 = ?", uuid.toString(), uuid.toString()); + + try { + ResultSet resultSet = connection.rs(); + while (resultSet.next()) { + entries.add(this.createTallyEntry(connection)); + } + } catch (Exception e) { + e.printStackTrace(); + } finally { + this.closeConnection(connection); + } + + return entries; + }); + } + + @Blocking + private TallyEntry createTallyEntry(ResultSetConnection connection) throws SQLException { + ResultSet resultSet = connection.rs(); + return new TallyEntry( + UUID.fromString(resultSet.getString("id1")), + UUID.fromString(resultSet.getString("id2")), + resultSet.getBoolean("tie"), + resultSet.getTimestamp("timestamp").toInstant() + ); + } + + public CompletableFuture loadVersusTally(UUID uuid1, UUID uuid2) { + return CompletableFuture.supplyAsync(() -> { + // Need to check if both id1 AND id2 = uuid1 or uuid2, or vice versa + ResultSetConnection connection = this.executeQuery("SELECT * FROM " + this.versusTable + " WHERE (id1 = ? AND id2 = ?) OR (id1 = ? AND id2 = ?)", uuid1.toString(), uuid2.toString(), uuid2.toString(), uuid1.toString()); + + try { + ResultSet resultSet = connection.rs(); + if (resultSet.next()) { + return this.createVersusTally(connection); + } + } catch (Exception e) { + e.printStackTrace(); + } finally { + this.closeConnection(connection); + } + + return null; + }); + } + + @Blocking + private VersusTally createVersusTally(ResultSetConnection connection) throws SQLException { + ResultSet resultSet = connection.rs(); + Map columns = new HashMap<>(); + for (String column : this.versusColumns) { + if (column.equalsIgnoreCase("infinity")) { // sometimes kdr gets saved as 'infinity' + columns.put(StatType.get(column), Float.POSITIVE_INFINITY); + continue; + } + + columns.put(StatType.get(column), Float.parseFloat(resultSet.getString(column))); + } + + return new VersusTally(this.tracker, + UUID.fromString(resultSet.getString("id1")), + UUID.fromString(resultSet.getString("id2")), + columns + ); + } + + public CompletableFuture save(UUID uuid) { + return this.saveTotals(uuid); + } + + public CompletableFuture saveAll() { + return this.saveTotals(this.tracker.getRecords().keySet().toArray(UUID[]::new)); + } + + public CompletableFuture saveTotals(UUID... uuids) { + if (uuids == null || uuids.length == 0) { + return CompletableFuture.completedFuture(null); + } + + List> overallBatch = new ArrayList<>(); + List> versusBatch = new ArrayList<>(); + List> tallyBatch = new ArrayList<>(); + + List> batches = new ArrayList<>(); + for (UUID uuid : uuids) { + Record record = this.tracker.getRecords().getCached(uuid); + if (record == null) { + BattleTracker.getInstance().warn("Failed to save record for " + uuid + " as they had no record saved."); + continue; + } + + // +2 in array for name and id + String[] overallObjectArray = new String[this.overallColumns.size() + 2]; + overallObjectArray[0] = record.getId().toString(); + overallObjectArray[1] = record.getName(); + for (int i = 0; i < this.overallColumns.size(); i++) { + String overallColumn = this.overallColumns.get(i); + overallObjectArray[i + 2] = record.getStatistics().get(StatType.get(overallColumn)).toString(); + } + + overallBatch.add(List.of(overallObjectArray)); + this.executeBatch(true, this.constructInsertOverallStatement(), overallBatch); + + this.tracker.getTallies().save(versusTally -> { + if (!versusTally.id1().equals(uuid) && !versusTally.id2().equals(uuid)) { + return; + } + + // +4 in array for double name and id + String[] versusObjectArray = new String[this.versusColumns.size() + 2]; + versusObjectArray[0] = versusTally.id1().toString(); + versusObjectArray[1] = versusTally.id2().toString(); + + for (int i = 0; i < this.versusColumns.size(); i++) { + String versusColumn = this.versusColumns.get(i); + versusObjectArray[i + 2] = Optional.ofNullable(versusTally.statistics().get(StatType.get(versusColumn))).orElse(0f).toString(); + } + + versusBatch.add(List.of(versusObjectArray)); + batches.add(this.executeBatch(true, this.constructInsertVersusStatement(), versusBatch)); + }); + + this.tracker.getTallyEntries().save(uuid, entry -> { + String[] tallyObjectArray = new String[4]; + tallyObjectArray[0] = entry.id1().toString(); + tallyObjectArray[1] = entry.id2().toString(); + tallyObjectArray[2] = Boolean.toString(entry.tie()); + tallyObjectArray[3] = Long.toString(entry.timestamp().toEpochMilli()); + + tallyBatch.add(List.of(tallyObjectArray)); + batches.add(this.executeBatch(true, this.constructInsertTallyStatement(), tallyBatch)); + }); + } + + return CompletableFuture.allOf(batches.toArray(CompletableFuture[]::new)); + } + + public List getOverallColumns() { + return this.overallColumns; + } + + private String constructInsertOverallStatement() { + StringBuilder builder = new StringBuilder(); + switch (this.getType()) { + case MYSQL: + String insertOverall = "INSERT INTO " + this.overallTable + " VALUES (?, ?, "; + builder.append(insertOverall); + for (int i = 0; i < this.overallColumns.size(); i++) { + if ((i + 1) < this.overallColumns.size()) { + builder.append("?, "); + } else { + builder.append("?)"); + } + } + + builder.append(" ON DUPLICATE KEY UPDATE "); + builder.append("id = VALUES(id), "); + builder.append("name = VALUES(name), "); + for (int i = 0; i < this.overallColumns.size(); i++) { + if ((i + 1) < this.overallColumns.size()) { + builder.append(this.overallColumns.get(i)).append(" = VALUES(").append(this.overallColumns.get(i)).append("), "); + } else { + builder.append(this.overallColumns.get(i)).append(" = VALUES(").append(this.overallColumns.get(i)).append(")"); + } + } + + break; + case SQLITE: + builder.append("INSERT OR REPLACE INTO ").append(this.overallTable).append(" VALUES ("); + builder.append("?, "); + builder.append("?, "); + for (int i = 0; i < this.overallColumns.size(); i++) { + if ((i + 1) < this.overallColumns.size()) { + builder.append("?, "); + } else { + builder.append("?)"); + } + } + + break; + } + + return builder.toString(); + } + + private String constructInsertVersusStatement() { + StringBuilder builder = new StringBuilder(); + switch (this.getType()) { + case MYSQL: + String insertOverall = "INSERT INTO " + this.versusTable + " VALUES (?, ?, ?, ?, "; + builder.append(insertOverall); + for (int i = 0; i < this.versusColumns.size(); i++) { + if ((i + 1) < this.versusColumns.size()) { + builder.append("?, "); + } else { + builder.append("?)"); + } + } + + builder.append(" ON DUPLICATE KEY UPDATE "); + builder.append("id1 = VALUES(id1), "); + builder.append("id2 = VALUES(id2), "); + for (int i = 0; i < this.versusColumns.size(); i++) { + if ((i + 1) < this.versusColumns.size()) { + builder.append(this.versusColumns.get(i)).append(" = VALUES(").append(this.versusColumns.get(i)).append("), "); + } else { + builder.append(this.versusColumns.get(i)).append(" = VALUES(").append(this.versusColumns.get(i)).append(")"); + } + } + break; + case SQLITE: + builder.append("INSERT OR REPLACE INTO ").append(this.versusTable).append(" VALUES ("); + builder.append("?, "); + builder.append("?, "); + for (int i = 0; i < this.versusColumns.size(); i++) { + if ((i + 1) < this.versusColumns.size()) { + builder.append("?, "); + } else { + builder.append("?)"); + } + } + + break; + } + + return builder.toString(); + } + + private String constructInsertTallyStatement() { + return switch (this.getType()) { + case MYSQL -> "INSERT INTO " + this.tallyTable + " VALUES (?, ?, ?, ?)"; + case SQLITE -> "INSERT OR REPLACE INTO " + this.tallyTable + " VALUES (?, ?, ?, ?)"; + }; + } + + @Blocking + private void setupOverallTable() { + String createOverall = "CREATE TABLE IF NOT EXISTS " + this.overallTable + " (" + + "id VARCHAR(" + MAX_LENGTH + "), name VARCHAR(" + MAX_LENGTH + "), "; + + StringBuilder createStringBuilder = new StringBuilder(); + createStringBuilder.append(createOverall); + for (String column : this.overallColumns) { + createStringBuilder.append(column).append(" VARCHAR(").append(MAX_LENGTH).append("), "); + } + + createStringBuilder.append(" PRIMARY KEY (id))"); + + try { + this.createTable(this.overallTable, createStringBuilder.toString()); + } catch (Exception e) { + // Log.err("Failed to create tables!"); + e.printStackTrace(); + } + } + + @Blocking + private void setupVersusTable() { + String createVersus = "CREATE TABLE IF NOT EXISTS " + this.versusTable + "(" + + "id1 VARCHAR (" + MAX_LENGTH + ") NOT NULL," + + "id2 VARCHAR (" + MAX_LENGTH + ") NOT NULL, "; + + StringBuilder createStringBuilder = new StringBuilder(); + createStringBuilder.append(createVersus); + for (String column : this.versusColumns) { + createStringBuilder.append(column) + .append(" VARCHAR(").append(MAX_LENGTH).append("), "); + } + + createStringBuilder.append(" PRIMARY KEY (id1, id2))"); + + try { + this.createTable(this.versusTable, createStringBuilder.toString()); + } catch (Exception e) { + // Log.err("Failed to create tables!"); + e.printStackTrace(); + } + } + + @Blocking + private void setupTallyTable() { + String createTally = "CREATE TABLE IF NOT EXISTS " + this.tallyTable + "(" + + "id1 VARCHAR (" + MAX_LENGTH + ") NOT NULL, " + + "id2 VARCHAR (" + MAX_LENGTH + ") NOT NULL, " + + "tie BOOLEAN DEFAULT FALSE, " + + "timestamp TIMESTAMP NOT NULL, " + + "PRIMARY KEY (id1, id2, timestamp)"; + + if (this.type == SqlType.MYSQL) { + createTally += ", INDEX (id1), INDEX (id2))"; + } else { + createTally += ")"; + } + + try { + this.createTable(this.tallyTable, createTally); + if (this.type == SqlType.SQLITE) { + this.executeUpdate("CREATE INDEX IF NOT EXISTS id1_index ON " + this.tallyTable + " (id1)"); + this.executeUpdate("CREATE INDEX IF NOT EXISTS id2_index ON " + this.tallyTable + " (id2)"); + } + } catch (Exception e) { + // Log.err("Failed to create tables!"); + e.printStackTrace(); + } + } + + private static CompletableFuture supplyAsync(SqlSupplier supplier) { + return CompletableFuture.supplyAsync(() -> { + try { + return supplier.get(); + } catch (SQLException e) { + throw new RuntimeException(e); + } + }); + } + + public interface SqlSupplier { + + T get() throws SQLException; + } +} diff --git a/src/main/java/org/battleplugins/tracker/stat/Record.java b/src/main/java/org/battleplugins/tracker/stat/Record.java new file mode 100644 index 0000000..7376baa --- /dev/null +++ b/src/main/java/org/battleplugins/tracker/stat/Record.java @@ -0,0 +1,144 @@ +package org.battleplugins.tracker.stat; + +import org.battleplugins.tracker.Tracker; + +import java.util.Map; +import java.util.Objects; +import java.util.UUID; + +/** + * Stores and holds tracker data for a player. + */ +public class Record { + protected Tracker tracker; + protected UUID id; + protected String name; + protected final Map statistics; + private boolean tracking = true; + + public Record(Tracker tracker, UUID id, String name, Map statistics) { + this.tracker = tracker; + this.id = id; + this.name = name; + this.statistics = statistics; + + // Populate untracked records + this.setValue(StatType.KD_RATIO, this.getStat(StatType.KILLS) / Math.max(1, this.getStat(StatType.DEATHS))); + } + + /** + * Returns the ID of the record. + * + * @return the ID of the record + */ + public UUID getId() { + return this.id; + } + + /** + * Returns the name of the record. + * + * @return the name of the record + */ + public String getName() { + return this.name; + } + + /** + * Returns the statistics of the record. + * + * @return the statistics of the record + */ + public Map getStatistics() { + return Map.copyOf(this.statistics); + } + + /** + * Returns whether this record should + * be tracked. + * + * @return whether this record should be tracked + */ + public boolean isTracking() { + return this.tracking; + } + + /** + * Sets whether this record should be tracked. + * + * @param tracking whether this record should be tracked + */ + public void setTracking(boolean tracking) { + this.tracking = tracking; + } + + /** + * Returns if the {@link StatType} is in the record. + * + * @param stat the stat to check + * @return if the StatType is in the record + */ + public boolean hasStat(StatType stat) { + return this.statistics.containsKey(stat); + } + + /** + * Returns the value for the specified {@link StatType}. + * + * @param stat the StatType to get the value of + * @return the value for the specified StatType + */ + public float getStat(StatType stat) { + return this.statistics.getOrDefault(stat, 0.0f); + } + + /** + * Sets the value of the given {@link StatType}. + * + * @param stat the stat to set the value for + * @param value the (new) value of the StatType + */ + public void setValue(StatType stat, float value) { + this.statistics.put(stat, value); + } + + /** + * Increments the value of the given {@link StatType}. + * + * @param stat the stat to increment the value for + */ + public void incrementValue(StatType stat) { + this.setValue(stat, this.getStat(stat) + 1); + } + + /** + * Returns the rating of the record. + * + * @return the rating of the record + */ + public float getRating() { + return this.statistics.get(StatType.RATING); + } + + /** + * Sets the rating of the record. + * + * @param rating the rating of the record + */ + public void setRating(float rating) { + this.statistics.put(StatType.RATING, rating); + } + + @Override + public boolean equals(Object object) { + if (this == object) return true; + if (object == null || getClass() != object.getClass()) return false; + Record record = (Record) object; + return Objects.equals(this.id, record.id); + } + + @Override + public int hashCode() { + return Objects.hash(this.id); + } +} diff --git a/src/main/java/org/battleplugins/tracker/stat/StatType.java b/src/main/java/org/battleplugins/tracker/stat/StatType.java new file mode 100644 index 0000000..6814e7d --- /dev/null +++ b/src/main/java/org/battleplugins/tracker/stat/StatType.java @@ -0,0 +1,77 @@ +package org.battleplugins.tracker.stat; + +import org.jetbrains.annotations.Nullable; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * Represents a statistic type. + */ +public final class StatType { + private static final Map STAT_TYPES = new LinkedHashMap<>(); + + public static StatType KILLS = new StatType("kills", "Kills", true); + public static StatType DEATHS = new StatType("deaths", "Deaths", true); + public static StatType TIES = new StatType("ties", "Ties", true); + public static StatType STREAK = new StatType("streak", "Streak", false); + public static StatType MAX_STREAK = new StatType("max_streak", "Max Streak", true); + public static StatType RANKING = new StatType("ranking", "Ranking", false); + public static StatType MAX_RANKING = new StatType("max_ranking", "Max Ranking", true); + public static StatType RATING = new StatType("rating", "Rating", true); + public static StatType MAX_RATING = new StatType("max_rating", "Max Rating", true); + public static StatType KD_RATIO = new StatType("kd_ratio", "K/D Ratio", false); + public static StatType MAX_KD_RATIO = new StatType("max_kd_ratio", "Max K/D Ratio", true); + + private final String key; + private final String name; + private final boolean tracked; + + StatType(String key, String name, boolean tracked) { + this.key = key; + this.name = name; + this.tracked = tracked; + + STAT_TYPES.put(key, this); + } + + public String getKey() { + return this.key; + } + + public String getName() { + return this.name; + } + + public boolean isTracked() { + return this.tracked; + } + + @Nullable + public static StatType get(String name) { + return STAT_TYPES.get(name); + } + + public static StatType create(String key, String name, boolean tracked) { + return new StatType(key, name, tracked); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || this.getClass() != o.getClass()) return false; + StatType that = (StatType) o; + return this.key.equals(that.key); + } + + @Override + public int hashCode() { + return this.key.hashCode(); + } + + public static List values() { + // Need to ensure the order is retained + return STAT_TYPES.values().stream().toList(); + } +} diff --git a/src/main/java/org/battleplugins/tracker/stat/TallyContext.java b/src/main/java/org/battleplugins/tracker/stat/TallyContext.java new file mode 100644 index 0000000..bd7b38c --- /dev/null +++ b/src/main/java/org/battleplugins/tracker/stat/TallyContext.java @@ -0,0 +1,6 @@ +package org.battleplugins.tracker.stat; + +public interface TallyContext { + + void recordStat(StatType statType, float value); +} diff --git a/src/main/java/org/battleplugins/tracker/stat/TallyEntry.java b/src/main/java/org/battleplugins/tracker/stat/TallyEntry.java new file mode 100644 index 0000000..41229f6 --- /dev/null +++ b/src/main/java/org/battleplugins/tracker/stat/TallyEntry.java @@ -0,0 +1,21 @@ +package org.battleplugins.tracker.stat; + +import org.bukkit.OfflinePlayer; + +import java.time.Instant; +import java.util.UUID; + +/** + * A tally entry storing information about two players. + * + * @param id1 the id of the victor in the tally + * @param id2 the id of the loser in the tally + * @param tie whether the tally resulted in a tie + * @param timestamp the timestamp when this tally was recorded + */ +public record TallyEntry(UUID id1, UUID id2, boolean tie, Instant timestamp) { + + public TallyEntry(OfflinePlayer player1, OfflinePlayer player2, boolean tie, Instant timestamp) { + this(player1.getUniqueId(), player2.getUniqueId(), tie, timestamp); + } +} diff --git a/src/main/java/org/battleplugins/tracker/stat/VersusTally.java b/src/main/java/org/battleplugins/tracker/stat/VersusTally.java new file mode 100644 index 0000000..4d72776 --- /dev/null +++ b/src/main/java/org/battleplugins/tracker/stat/VersusTally.java @@ -0,0 +1,44 @@ +package org.battleplugins.tracker.stat; + +import org.battleplugins.tracker.Tracker; +import org.bukkit.OfflinePlayer; + +import java.util.Map; +import java.util.Objects; +import java.util.UUID; + +/** + * A tally storing versus information about two players. + * + * @param tracker the tracker this tally is for + * @param id1 the first player's UUID + * @param id2 the second player's UUID + * @param statistics the statistics of the two players + */ +public record VersusTally(Tracker tracker, UUID id1, UUID id2, Map statistics) { + + public VersusTally(Tracker tracker, OfflinePlayer player1, OfflinePlayer player2, Map statistics) { + this(tracker, player1.getUniqueId(), player2.getUniqueId(), statistics); + } + + public float getStat(StatType statType) { + return this.statistics.getOrDefault(statType, 0.0F); + } + + public boolean isTallyFor(UUID uuid1, UUID uuid2) { + return (this.id1.equals(uuid1) && this.id2.equals(uuid2)) || (this.id1.equals(uuid2) && this.id2.equals(uuid1)); + } + + @Override + public boolean equals(Object object) { + if (this == object) return true; + if (object == null || getClass() != object.getClass()) return false; + VersusTally that = (VersusTally) object; + return Objects.equals(this.id1, that.id1) && Objects.equals(this.id2, that.id2); + } + + @Override + public int hashCode() { + return Objects.hash(this.id1, this.id2); + } +} \ No newline at end of file diff --git a/src/main/java/org/battleplugins/tracker/stat/calculator/EloCalculator.java b/src/main/java/org/battleplugins/tracker/stat/calculator/EloCalculator.java new file mode 100644 index 0000000..6805a47 --- /dev/null +++ b/src/main/java/org/battleplugins/tracker/stat/calculator/EloCalculator.java @@ -0,0 +1,145 @@ +package org.battleplugins.tracker.stat.calculator; + +import org.battleplugins.tracker.BattleTrackerConfig; +import org.battleplugins.tracker.stat.Record; + +/** + * Class for calculating for elo. + */ +public class EloCalculator implements RatingCalculator { + private final BattleTrackerConfig.Elo elo; + + public EloCalculator(BattleTrackerConfig.Elo elo) { + this.elo = elo; + } + + @Override + public String getName() { + return "elo"; + } + + @Override + public float getDefaultRating() { + return this.elo.defaultElo(); + } + + @Override + public void updateRating(Record killer, Record killed, boolean tie) { + float result = tie ? 0.5f : 1.0f; + float eloChange = this.getEloChange(killer, killed, result); + if (killer.isTracking()) { + killer.setRating(killer.getRating() + eloChange); + } + if (killed.isTracking()) { + killed.setRating(killed.getRating() - eloChange); + } + } + + @Override + public void updateRating(Record killer, Record[] killed, boolean tie) { + float result = tie ? 0.5f : 1.0f; + double eloWinner = 0; + double dampening = killed.length == 1 ? 1 : killed.length / 2.0D; + for (Record record : killed) { + double eloChange = this.getEloChange(killer, record, result) / dampening; + eloWinner += eloChange; + if (record.isTracking()) { + record.setRating(record.getRating() - (float) eloChange); + } + } + + if (killer.isTracking()) { + killer.setRating(killer.getRating() + (float) eloWinner); + } + } + + @Override + public void updateRating(Record[] killer, Record[] killed, boolean tie) { + float resultKiller = tie ? 0.5f : 1.0f; + float resultKilled = tie ? 0.5f : 0.0f; + + double dampening = killed.length == 1 ? 1 : killed.length / 2.0D; + + // Update ratings for the killers + for (Record killerRecord : killer) { + double totalEloChange = 0; + for (Record killedRecord : killed) { + double eloChange = this.getEloChange(killerRecord, killedRecord, resultKiller) / dampening; + totalEloChange += eloChange; + } + + if (killerRecord.isTracking()) { + killerRecord.setRating(killerRecord.getRating() + (float) totalEloChange); + } + } + + // Update ratings for the killed + for (Record killedRecord : killed) { + double totalEloChange = 0; + for (Record killerRecord : killer) { + double eloChange = this.getEloChange(killedRecord, killerRecord, resultKilled) / dampening; + totalEloChange += eloChange; + } + + if (killedRecord.isTracking()) { + killedRecord.setRating(killedRecord.getRating() + (float) totalEloChange); + } + } + } + + @Override + public void updateRating(Record[] records, boolean tie) { + float result = tie ? 0.5f : 1.0f; + + // Dampening factor for multiple players + double dampening = records.length == 1 ? 1 : records.length / 2.0D; + + for (int i = 0; i < records.length; i++) { + Record player = records[i]; + double totalEloChange = 0; + + for (int j = 0; j < records.length; j++) { + if (i != j) { // Ensure we don't compare the player to themselves + Record opponent = records[j]; + + // Only calculate Elo change if they are not friendly to each other + double eloChange = this.getEloChange(player, opponent, result) / dampening; + totalEloChange += eloChange; + } + } + + // Update the player's rating if they are being tracked + if (player.isTracking()) { + player.setRating(player.getRating() + (float) totalEloChange); + } + } + } + + private float getEloChange(Record killer, Record killed, float result) { + float di = killed.getRating() - killer.getRating(); + + float expected = (float) (1f / (1 + Math.pow(10, di / this.elo.spread()))); + return getK(killer.getRating()) * (result - expected); + } + + /** + * Returns the 'k' value for elo calculations + *

+ * ... + * + * @param elo the elo to take into consideration + * @return the 'k' value for elo calculations + */ + public static int getK(float elo) { + if (elo < 1600) { + return 50; + } else if (elo < 1800) { + return 35; + } else if (elo < 2000) { + return 20; + } else if (elo < 2500) { + return 10; + } + return 6; + } +} diff --git a/src/main/java/org/battleplugins/tracker/stat/calculator/RatingCalculator.java b/src/main/java/org/battleplugins/tracker/stat/calculator/RatingCalculator.java new file mode 100644 index 0000000..9fd80c9 --- /dev/null +++ b/src/main/java/org/battleplugins/tracker/stat/calculator/RatingCalculator.java @@ -0,0 +1,60 @@ +package org.battleplugins.tracker.stat.calculator; + +import org.battleplugins.tracker.stat.Record; + +import java.util.function.Predicate; + +/** + * Interface for rating calculators + */ +public interface RatingCalculator { + + /** + * Returns the name of the calculator. + * + * @return the name of the calculator + */ + String getName(); + + /** + * Returns the default rating. + * + * @return the default rating + */ + float getDefaultRating(); + + /** + * Updates the rating of the killer and the killed players. + * + * @param killer the killer's Record + * @param killed the player killed's Record + * @param tie if the final result is a tie + */ + void updateRating(Record killer, Record killed, boolean tie); + + /** + * Updates the rating of the killer and the killed players. + * + * @param killer the killer's Record + * @param killed an array of the players killed's Record + * @param tie if the final result is a tie + */ + void updateRating(Record killer, Record[] killed, boolean tie); + + /** + * Updates the rating of the killer and the killed players. + * + * @param killer the killer's Record + * @param killed an array of the players killed's Record + * @param tie if the final result is a tie + */ + void updateRating(Record[] killer, Record[] killed, boolean tie); + + /** + * Updates the rating of the players in the array. + * + * @param records the array of players to update + * @param tie if the final result is a tie + */ + void updateRating(Record[] records, boolean tie); +} diff --git a/src/main/java/org/battleplugins/tracker/util/CommandInjector.java b/src/main/java/org/battleplugins/tracker/util/CommandInjector.java new file mode 100644 index 0000000..f0bc4a3 --- /dev/null +++ b/src/main/java/org/battleplugins/tracker/util/CommandInjector.java @@ -0,0 +1,34 @@ +package org.battleplugins.tracker.util; + +import org.battleplugins.tracker.BattleTracker; +import org.bukkit.Bukkit; +import org.bukkit.command.PluginCommand; +import org.bukkit.plugin.Plugin; + +import java.lang.reflect.Constructor; +import java.lang.reflect.InvocationTargetException; +import java.util.List; + +public class CommandInjector { + + public static PluginCommand inject(String trackerName, String commandName, String... aliases) { + return inject(trackerName, commandName, "The main command for the " + trackerName + " tracker!", aliases); + } + + public static PluginCommand inject(String headerName, String commandName, String description, String... aliases) { + try { + Constructor constructor = PluginCommand.class.getDeclaredConstructor(String.class, Plugin.class); + constructor.setAccessible(true); + + PluginCommand pluginCommand = constructor.newInstance(commandName, BattleTracker.getInstance()); + pluginCommand.setAliases(List.of(aliases)); + pluginCommand.setDescription(description); + pluginCommand.setPermission("battletracker.command." + commandName); + + Bukkit.getCommandMap().register(commandName, "battletracker", pluginCommand); + return pluginCommand; + } catch (InstantiationException | IllegalAccessException | InvocationTargetException | NoSuchMethodException e) { + throw new RuntimeException("Failed to construct PluginCommand " + headerName, e); + } + } +} \ No newline at end of file diff --git a/src/main/java/org/battleplugins/tracker/util/ItemCollection.java b/src/main/java/org/battleplugins/tracker/util/ItemCollection.java new file mode 100644 index 0000000..66e60dd --- /dev/null +++ b/src/main/java/org/battleplugins/tracker/util/ItemCollection.java @@ -0,0 +1,74 @@ +package org.battleplugins.tracker.util; + +import org.bukkit.Bukkit; +import org.bukkit.Material; +import org.bukkit.NamespacedKey; +import org.bukkit.Tag; + +import java.util.Collection; +import java.util.HashSet; +import java.util.Objects; +import java.util.Set; + +public class ItemCollection { + private final Set materials = new HashSet<>(); + + ItemCollection(Collection materials) { + this.materials.addAll(materials); + } + + public Set getMaterials() { + return Set.copyOf(this.materials); + } + + public boolean contains(Material material) { + return this.materials.contains(material); + } + + @Override + public boolean equals(Object object) { + if (this == object) return true; + if (object == null || getClass() != object.getClass()) return false; + ItemCollection that = (ItemCollection) object; + return Objects.equals(this.materials, that.materials); + } + + @Override + public int hashCode() { + return Objects.hash(this.materials); + } + + public static ItemCollection of(Collection materials) { + return new ItemCollection(materials); + } + + public static ItemCollection fromString(String string) { + if (string.startsWith("#")) { + return fromTag(string); + } else { + return fromMaterial(string); + } + } + + private static ItemCollection fromTag(String tag) { + if (tag.startsWith("#")) { + tag = tag.substring(1); + } + + Tag itemTags = Bukkit.getTag(Tag.REGISTRY_ITEMS, NamespacedKey.fromString(tag), Material.class); + if (itemTags == null) { + throw new IllegalArgumentException("Invalid tag: " + tag); + } + + return ItemCollection.of(itemTags.getValues()); + } + + private static ItemCollection fromMaterial(String material) { + Material mat = Material.matchMaterial(material); + if (mat == null) { + throw new IllegalArgumentException("Invalid material: " + material); + } + + return ItemCollection.of(Set.of(mat)); + } +} diff --git a/src/main/java/org/battleplugins/tracker/util/MessageType.java b/src/main/java/org/battleplugins/tracker/util/MessageType.java new file mode 100644 index 0000000..a5ab72f --- /dev/null +++ b/src/main/java/org/battleplugins/tracker/util/MessageType.java @@ -0,0 +1,9 @@ +package org.battleplugins.tracker.util; + +public enum MessageType { + ACTION_BAR, + BOSSBAR, + CHAT, + TITLE, + SUBTITLE +} diff --git a/src/main/java/org/battleplugins/tracker/util/MessageUtil.java b/src/main/java/org/battleplugins/tracker/util/MessageUtil.java new file mode 100644 index 0000000..f994e00 --- /dev/null +++ b/src/main/java/org/battleplugins/tracker/util/MessageUtil.java @@ -0,0 +1,16 @@ +package org.battleplugins.tracker.util; + +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.minimessage.MiniMessage; + +public final class MessageUtil { + public static final MiniMessage MINI_MESSAGE = MiniMessage.miniMessage(); + + public static Component deserialize(String message) { + return MINI_MESSAGE.deserialize(message); + } + + public static String serialize(Component component) { + return MINI_MESSAGE.serialize(component); + } +} diff --git a/src/main/java/org/battleplugins/tracker/util/TrackerInventoryHolder.java b/src/main/java/org/battleplugins/tracker/util/TrackerInventoryHolder.java new file mode 100644 index 0000000..b5d4fa5 --- /dev/null +++ b/src/main/java/org/battleplugins/tracker/util/TrackerInventoryHolder.java @@ -0,0 +1,70 @@ +package org.battleplugins.tracker.util; + +import com.google.common.base.Suppliers; +import net.kyori.adventure.key.Key; +import net.kyori.adventure.text.Component; +import org.battleplugins.tracker.Tracker; +import org.bukkit.Bukkit; +import org.bukkit.NamespacedKey; +import org.bukkit.inventory.Inventory; +import org.bukkit.inventory.InventoryHolder; +import org.jetbrains.annotations.NotNull; + +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Consumer; +import java.util.function.Supplier; + +public final class TrackerInventoryHolder implements InventoryHolder { + public static final NamespacedKey RECAP_KEY = new NamespacedKey("battletracker", "recap"); + + private final Key key; + private final Tracker tracker; + private final Supplier supplier; + + private final Map onClick = new HashMap<>(); + + public TrackerInventoryHolder(Key key, Tracker tracker, Supplier supplier) { + this.key = key; + this.tracker = tracker; + this.supplier = Suppliers.memoize(supplier::get); + } + + public Key getKey() { + return this.key; + } + + public Tracker getTracker() { + return this.tracker; + } + + public void onClick(Key key, Runnable runnable) { + this.onClick.put(key, runnable); + } + + public void handleClick(Key key) { + Runnable runnable = this.onClick.get(key); + if (runnable == null) { + return; + } + + runnable.run(); + } + + @NotNull + @Override + public Inventory getInventory() { + return this.supplier.get(); + } + + public static Inventory create(Key key, Tracker tracker, int size, Component title, Consumer holderConsumer) { + AtomicReference ref = new AtomicReference<>(); + return ref.updateAndGet(nil -> { + TrackerInventoryHolder holder = new TrackerInventoryHolder(key, tracker, ref::get); + holderConsumer.accept(holder); + + return Bukkit.createInventory(holder, size, title); + }); + } +} diff --git a/src/main/java/org/battleplugins/tracker/util/Util.java b/src/main/java/org/battleplugins/tracker/util/Util.java new file mode 100644 index 0000000..f295b5f --- /dev/null +++ b/src/main/java/org/battleplugins/tracker/util/Util.java @@ -0,0 +1,170 @@ +package org.battleplugins.tracker.util; + +import org.battleplugins.tracker.Tracker; +import org.battleplugins.tracker.message.Messages; +import org.battleplugins.tracker.stat.Record; +import org.battleplugins.tracker.stat.StatType; +import org.bukkit.command.CommandSender; + +import java.text.DecimalFormat; +import java.time.Duration; +import java.util.AbstractMap; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ThreadLocalRandom; + +public final class Util { + private static final char HEART = '♥'; + public static final DecimalFormat HEALTH_FORMAT = new DecimalFormat("0.00"); + public static final DecimalFormat DAMAGE_FORMAT = new DecimalFormat("#.##"); + + public static String formatHealth(double health, boolean loss) { + return (loss ? "-" : "+") + HEALTH_FORMAT.format(health); + } + + public static CompletableFuture> getSortedRecords(Tracker tracker, int limit, StatType type) { + CompletableFuture> records = tracker.getTopRecords(limit, type); + return records.thenApply(list -> list.stream().collect(LinkedHashMap::new, (map, record) -> map.put(record, record.getStat(type)), Map::putAll)); + } + + public static void sendTrackerMessage(CommandSender sender, String messageKey, int ranking, Record record) { + DecimalFormat format = new DecimalFormat("0.##"); + Map replacements = new HashMap<>(StatType.values() + .stream() + .map(stat -> new AbstractMap.SimpleEntry<>(stat, record.getStat(stat))) + .collect( + LinkedHashMap::new, + (map, entry) -> map.put(entry.getKey().getKey(), format.format(entry.getValue())), + Map::putAll + ) + ); + + replacements.put("ranking", ranking); + replacements.put("player", record.getName()); + + Messages.send(sender, messageKey, replacements); + } + + public static String toTimeString(Duration duration) { + long seconds = duration.toSecondsPart(); + long minutes = duration.toMinutesPart(); + long hours = duration.toHoursPart(); + long days = duration.toDaysPart(); + + StringBuilder builder = new StringBuilder(); + if (days > 0) { + builder.append(days).append(" "); + if (days == 1) { + builder.append(Messages.getPlain("util-day")); + } else { + builder.append(Messages.getPlain("util-days")); + } + } + + if (hours > 0) { + if (!builder.isEmpty()) { + builder.append(", "); + } + + builder.append(hours).append(" "); + if (hours == 1) { + builder.append(Messages.getPlain("util-hour")); + } else { + builder.append(Messages.getPlain("util-hours")); + } + } + + if (minutes > 0) { + if (!builder.isEmpty()) { + builder.append(", "); + } + + builder.append(minutes).append(" "); + if (minutes == 1) { + builder.append(Messages.getPlain("util-minute")); + } else { + builder.append(Messages.getPlain("util-minutes")); + } + } + + if (seconds > 0) { + if (!builder.isEmpty()) { + builder.append(", "); + } + + builder.append(seconds).append(" "); + if (seconds == 1) { + builder.append(Messages.getPlain("util-second")); + } else { + builder.append(Messages.getPlain("util-seconds")); + } + } + + return builder.toString(); + } + + public static String toTimeStringShort(Duration duration) { + long seconds = duration.toSecondsPart(); + long minutes = duration.toMinutesPart(); + long hours = duration.toHoursPart(); + long days = duration.toDaysPart(); + + StringBuilder builder = new StringBuilder(); + if (days > 0) { + builder.append(days).append("d"); + } + + if (hours > 0) { + if (!builder.isEmpty()) { + builder.append(" "); + } + + builder.append(hours).append("h"); + } + + if (minutes > 0) { + if (!builder.isEmpty()) { + builder.append(" "); + } + + builder.append(minutes).append("m"); + } + + if (seconds > 0) { + if (!builder.isEmpty()) { + builder.append(" "); + } + + builder.append(seconds).append("s"); + } + + return builder.toString(); + } + + public static String capitalize(String string) { + if (string.isBlank()) { + return string; + } + + char[] buffer = string.toCharArray(); + boolean capitalizeNext = true; + for (int i = 0; i < buffer.length; i++) { + char nextChar = buffer[i]; + if (Character.isWhitespace(nextChar)) { + capitalizeNext = true; + } else if (capitalizeNext) { + buffer[i] = Character.toTitleCase(nextChar); + capitalizeNext = false; + } + } + + return new String(buffer); + } + + public static T getRandom(List list) { + return list.get(ThreadLocalRandom.current().nextInt(list.size())); + } +} diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml new file mode 100644 index 0000000..78d8190 --- /dev/null +++ b/src/main/resources/config.yml @@ -0,0 +1,39 @@ +# ----------------- +# Main configuration for BattleTracker +# +# Documentation: https://docs.battleplugins.org/shelves/battletracker +# Support: https://discord.gg/tMVPVJf +# GitHub: https://github.com/BattlePlugins/BattleTracker +# ----------------- +config-version: 3.0 # The config version, do not change! + +# Options for rating calculators. At the moment, only +# the elo rating calculator is supported. +rating: + elo: + default: 1250 + spread: 400 + +# Database options +database: + type: sqlite # sqlite or mysql + prefix: bt_ + db: tracker # Database name + url : localhost # Ignored if not mysql + port : "3306" # Ignored if not mysql + username: root + password: "" + +# Advanced options +advanced: + # Whether the database should flush a player's data + # when they leave the server. Only useful to enable on + # large server setups where memory is constrained + flush-on-leave: false + # Database auto-save interval. Default 5 minutes + save-interval: 300 + # How long a database entry should be kept in memory + # before it is considered stale and flushed. This is + # not a hard limit and stale objects are only removed + # during saves or when requested for another reason. + stale-entry-time: 600 diff --git a/src/main/resources/features/combat-log.yml b/src/main/resources/features/combat-log.yml new file mode 100644 index 0000000..5648c96 --- /dev/null +++ b/src/main/resources/features/combat-log.yml @@ -0,0 +1,66 @@ +# ----------------- +# Main configuration for combat logging from BattleTracker. +# +# Documentation: https://docs.battleplugins.org/shelves/battletracker +# Support: https://discord.gg/tMVPVJf +# GitHub: https://github.com/BattlePlugins/BattleTracker +# ----------------- +config-version: 1.0 # The config version, do not change! + +# Whether combat logging is enabled. +enabled: true + +# Worlds that combat logging is disabled in. +disabled-worlds: [] + +# How long a player will be tagged for combat once hit. +combat-time: 10 + +# Whether a player will be marked as in combat when they +# hit themselves with a projectile or by other means. +combat-self: false + +# Whether a player will be marked as in combat when they +# hit an entity, or are attacked by an entity. +combat-entities: true + +# Whether a player will be marked as in combat when they +# hit another player, or are attacked by another player. +combat-players: true + +# The type of message used to display to a player how long +# they have left in combat. +# - action_bar: an action bar message will be displayed +# - bossbar: a boss bar will be displayed +# - chat: a chat message will be displayed +# - title: a title message will be displayed +# - subtitle: a subtitle message will be displayed +# - none: no message will be displayed +display-method: action_bar + +# Whether users with the "battletracker.combatlog.bypass" permission +# can bypass the combat logging system. +allow-permission-bypass: false + +# Which entities will not mark a player as in combat when +# they are hit by them. +disabled-entities: + - egg + - ender_pearl + - snowball + +# Commands that are disabled when a player is in combat. +disabled-commands: + - back + - home + - spawn + - tp + - tpa + - tpaccept + - tpahere + - tpall + - tpdeny + - tppos + - warp + - warps + diff --git a/src/main/resources/features/damage-indicators.yml b/src/main/resources/features/damage-indicators.yml new file mode 100644 index 0000000..c2527da --- /dev/null +++ b/src/main/resources/features/damage-indicators.yml @@ -0,0 +1,17 @@ +# ----------------- +# Main configuration for damage indicators from BattleTracker. +# +# Documentation: https://docs.battleplugins.org/shelves/battletracker +# Support: https://discord.gg/tMVPVJf +# GitHub: https://github.com/BattlePlugins/BattleTracker +# ----------------- +config-version: 1.0 # The config version, do not change! + +# Whether damage indicators are enabled. +enabled: true + +# Worlds that damage indicators are disabled in. +disabled-worlds: [] + +# The format of the damage indicator displays. +format: "{damage} ❤" diff --git a/src/main/resources/messages.yml b/src/main/resources/messages.yml new file mode 100644 index 0000000..5390159 --- /dev/null +++ b/src/main/resources/messages.yml @@ -0,0 +1,52 @@ +header: "------------------[ {} ]------------------" +combat-log-entered-combat: "You have entered combat!" +combat-log-exited-combat: "You are no longer in combat!" +combat-log-remaining-time: "You have {} remaining in combat!" +combat-log-cannot-run-command: "You cannot run this command while in combat!" +combat-log-cannot-join-arena: "You cannot join an arena while in combat!" +command-must-be-player: "This command must be executed by a player!" +command-no-permission: "You do not have permission to execute this command!" +command-player-not-found: "The player {} could not be found!" +command-usage: "Invalid syntax! Usage: {}" +date-format: "MM/dd/yyyy hh:mm:ss a" +leaderboard: "#%ranking% %player% - %rating% Kills: %kills% Deaths: %deaths%" +leaderboard-arena: "#%ranking% %player% - %rating% Wins: %wins% Losses: %losses%" +leaderboard-no-entries: "This leaderboard has no entries!" +player-has-no-record: "The player {} has no record!" +player-has-no-tally: "The players {} and {} have no tally!" +rank: "%player% - %rating% (Max Rating: %max_rating%) Kills: %kills% Deaths: %deaths% KDR: %kd_ratio%" +rank-arena: "%player% - %rating% (Max Rating: %max_rating%) Wins: %wins% Losses: %losses% Kills: %kills% Deaths: %deaths% KDR: %kd_ratio%" +recap: "Recap" +recap-damage-log: "Damage Log:" +recap-click-for-more: "Click for more information..." +recap-footer-pvp: "-----------[ Players Entities Item Cause ]-----------" +recap-footer-pve: "---------------[ Entities Item Cause ]---------------" +recap-damage-entity: "Entity Recap" +recap-damage-item: "Item Recap" +recap-damage-cause: "Cause Recap" +recap-damage-player: "Player Recap" +recap-death-time: "Death time: {} ago" +recap-info: "Recap Information:" +recap-log: " %health% (%time%) %damage_cause%" +recap-log-cause: "%cause%: Occurrences: %hits% Damage: %damage%" +recap-log-general: "Recorded duration: %time%. Health regenerated: %health%. Damage taken: %damage%" +recap-log-item: "%item%: Hits: %hits% Damage: %damage%" +recap-log-entity: "%entity%: Hits: %hits% Damage: %damage%" +recap-log-player: "%player%: Hits: %hits% Damage: %damage%" +recap-no-recap: "There is no recap available for this player's last death!" +recap-not-enabled: "Recaps are not enabled for this tracker!" +recap-starting-health: "Starting health: {}" +util-hour: "hour" +util-hours: "hours" +util-millisecond: "millisecond" +util-milliseconds: "milliseconds" +util-minute: "minute" +util-minutes: "minutes" +util-second: "second" +util-seconds: "seconds" +versus: "%player% - %player_rating% vs %target% - %target_rating%" +versus-compare: "%player% has killed %target% %kills% times and been killed %deaths% times by them!" +versus-history: "History:" +versus-history-entry-win: "- %player% defeated %target% on %date%" +versus-history-entry-loss: "- %player% was defeated by %target% on %date%" +versus-tally: "Versus Tally" diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml new file mode 100644 index 0000000..390414a --- /dev/null +++ b/src/main/resources/plugin.yml @@ -0,0 +1,14 @@ +name: BattleTracker +main: org.battleplugins.tracker.BattleTracker +version: ${version} +description: A standalone plugin that tracks PVP & PVE statistics, provides customizable death messages and many features for all combat needs. +author: BattlePlugins +website: https://battleplugins.org +api-version: 1.19 +softdepend: [BattleArena] +commands: + battletracker: + description: The main BattleTracker command. + usage: / [args] + permission: battletracker.command + aliases: [bt] \ No newline at end of file diff --git a/src/main/resources/trackers/pve.yml b/src/main/resources/trackers/pve.yml new file mode 100644 index 0000000..ab1aec7 --- /dev/null +++ b/src/main/resources/trackers/pve.yml @@ -0,0 +1,383 @@ +# ----------------- +# Main configuration for the PVP tracker +# +# Documentation: https://docs.battleplugins.org/shelves/battletracker +# Support: https://discord.gg/tMVPVJf +# GitHub: https://github.com/BattlePlugins/BattleTracker +# ----------------- +config-version: 2.0 # The config version, do not change! + +# The name of the tracker +name: PvE +# Whether the damage recap should be enabled for this tracker +recap: + # Whether the damage recap should be enabled for this tracker + enabled: true + # The content to display in the recap + # - all: Displays all information about a player in the recap, including armor, inventory and damage + # - armor: Just shows the armor a player was wearing + # - recap: Just shows the damage recap of a player + display-content: armor + # Whether a damage recap should be shown when a player hovers over + # a player's death message. + hover-recap: true +# The data this tracker wil track +# - pvp: tracks player vs player statistics +# - pve: tracks player vs enemy statistics +# - world: tracks world statistics (i.e. damage due to lava, fall damage, etc.) +tracked-statistics: + - pve + - world +# Worlds that this tracker is disabled in. +disabled-worlds: [] +# The rating calculator to use +# - elo: the default rating calculator +calculator: elo +# Killstreaks +# - A killstreak occurs when a player kills a certain number of players in a row without dying +killstreaks: + enabled: false +# Rampages +# - A rampage occurs when a player kills a certain number of players in a short amount of time +rampage: + enabled: false +# Whether death messages processed by this tracker should be enabled +death-messages: + enabled: true + # The audience of the death messages + # - global: all players + # - world: only players in the same world + # - local: only the player and the target + # - arena: only players in the same arena (requires BattleArena) + audience: global + # World death messages + world: + # Enabled - this is for processing deaths from the world + enabled: true + messages: + fire: + - "%player% was fried to a crisp." + - "%player% burned to death." + - "%player% was consumed by flames." + fire_tick: + - "%player% tried to extinguish the flames, but failed." + lava: + - "%player% was no match for a pool of lava." + - "%player% became one with the lava." + fall: + - "%player% found out what the ground feels like." + - "%player% has fallen and can't get up." + - "%player% fell to their doom." + - "%player% fell from a high place." + - "%player% fell too far." + contact: + - "%player% should learn to not jump on a cactus." + block_explosion: + - "%player% has been shredded by explosives." + - "%player% was blown to bits by a block explosion." + suffocation: + - "%player% ran out of breath." + - "%player% should learn not to suffocate." + starvation: + - "%player% should learn to eat." + - "%player% starved to death." + - "%player% realized that the buffet was just a mirage." + - "%player% learned that skipping meals isn't the best survival strategy." + - "%player% found out the hard way that hunger pangs don't make good friends." + lightning: + - "%player% was struck down by a bolt of lightning." + - "The heavens unleashed their wrath on %player%." + - "%player% picked a fight with Zeus." + suicide: + - "hari kari suited %player%." + - "%player% chose the ignoble way out." + - "%player% committed toaster bath." + - "%player% committed funeral." + - "%player% committed lego step." + kill: + - "hari kari suited %player%." + - "%player% chose the ignoble way out." + - "%player% committed toaster bath." + - "%player% committed funeral." + - "%player% committed lego step." + drowning: + - "%player% needs to learn to swim." + - "Dog paddling wasn't enough for %player%." + - "Water was the end of poor %player%." + - "%player% was in over their head." + - "%player% thought they were a fish." + magic: + - "%player% found out that they weren't actually a wizard" + - "The spellbook of %player% was a bit too magical for them to handle!" + - "%player% tried casting a spell, but all they conjured was their own demise." + - "%player% learned that magic tricks aren't supposed to be life-threatening." + void: + - "The void has claimed %player%" + - "%player% fell into the eternal abyss of the void." + - "The void has swallowed %player% whole." + world_border: + - "%player% was pushed to the limit by the world border." + - "The world border claimed %player% as its own." + entity_attack: + - "In a fierce clash, %player% was overwhelmed by an entity." + - "%player% fell to a relentless entity's assault." + entity_sweep_attack: + - "%player% was swept away in the powerful sweep of an entity." + - "The sweep of an entity's attack proved fatal for %player%." + projectile: + - "%player% was struck down by a precision projectile." + - "A well-aimed projectile ended the journey of %player%." + melting: + - "Under relentless heat, %player% melted away." + - "%player% became a puddle of defeat due to melting." + entity_explosion: + - "%player% was blown to bits by an explosive entity." + - "%player% faced the fiery wrath of an entity's explosion." + poison: + - "%player% succumbed to a deadly dose of poison." + - "Poison took %player% slowly but surely." + wither: + - "%player% was withered away by dark, cursed magic." + - "The relentless withering effect claimed %player%." + falling_block: + - "%player% was crushed under a falling block's weight." + - "A heavy block fell upon %player%, sealing their fate." + thorns: + - "%player% felt the vengeful sting of thorns." + - "%player% was punished by the sharp retaliation of thorns." + dragon_breath: + - "The fiery breath of a dragon reduced %player% to ashes." + - "%player% was engulfed in the searing dragon's breath." + custom: + - "%player% met an enigmatic end of their own making." + - "The fate of %player% remains a mystery." + fly_into_wall: + - "%player% hit the wall at full speed and couldn't recover." + - "The wall proved too much for %player% to handle." + hot_floor: + - "%player% couldn't stand the searing heat of the magma floor." + - "A magma block turned %player% into a scorching memory." + cramming: + - "%player% was crushed under the weight of too many entities." + - "Entity cramming led to the demise of %player%." + dryout: + - "%player% withered away from dehydration." + - "Without water, %player% faced a grim end." + freeze: + - "%player% was frozen solid by the cold." + - "The cold claimed %player% as its own." + sonic_boom: + - "%player% was blasted by a powerful sonic boom." + - "The warden's sonic boom proved fatal for %player%." + default: + - "%player% died!" + - "%player% passed away." + - "%player% should learn not to die!" + - "%player% faced a mysterious end that even the bravest couldn't foresee!" + - "In the battle against the unknown, %player% met their match!" + - "%player% was lost in the chaos of an undisclosed demise." + - "The reasons behind %player%'s end remain shrouded in enigma." + - "%player% fell victim to a fate that defies explanation!" + entity: + # Enabled - this is for processing deaths from entities + enabled: true + messages: + lightning_bolt: + - "%player% was struck down by a bolt of lightning." + - "The heavens unleashed their wrath on %player%." + - "%player% picked a fight with the gods." + camel: + - "%player% discovered that camels don't take kindly to rude travelers." + - "%player% learned that camels can pack more than just a punch." + - "%player% faced a camel's kick and ended up in the desert of despair." + - "%player% found out the hard way that camels have a 'bumpy' sense of humor." + - "%player% got caught in the crossfire of a camel's cranky mood." + creeper: + - "The creeper blast vaporized %player%!" + - "%player% was caught in a creeper's surprise party, and it was a blast!" + - "%player% went out with a bang, courtesy of a creeper's explosive greeting." + - "The creeper's idea of a hug was a bit too intense for %player%." + - "%player% discovered that creepers throw the best (and worst) parties." + zombie: + - "%player% has left this world as a zombie." + - "Zombies have devoured %player%!" + - "The zombie horde have claimed %player%!" + skeleton: + - "%player% was pierced by a skeleton arrow." + - "%player% was no match for the skeleton." + spider: + - "Spiders have drained %player%!" + - "%player% was ensnared by a spider." + cave_spider: + - "Cave spiders have swarmed %player%!" + ender_dragon: + - "The ender dragon has annihilated %player%!" + - "%player% was incinerated by the ender dragon's fiery breath!" + - "The ender dragon's tail crushed %player%!" + - "%player% was swept away by the ender dragon's wings!" + - "The ender dragon's roar shattered %player%!" + blaze: + - "The blaze has crisped %player%!" + silverfish: + - "%player% was overwhelmed by silverfish." + enderman: + - "Endermen have taken %player% to the nether!" + - "%player% looked into the eyes of the Enderman." + snow_golem: + - "%player% was pelted by snowballs from a snow golem." + - "%player% got the cold shoulder from a snowman and didn't survive." + - "The snow golem's chilly reception turned %player% into a frosty pancake." + iron_golem: + - "%player% was smacked down by the iron golem's heavy hands." + - "%player% found out that iron golems have a solid way of dealing with intruders." + - "%player% got crushed under the iron golem's mighty iron fists." + - "The iron golem's strength turned %player% into a metal-cased memory." + - "%player% was tossed aside by the iron golem's unyielding embrace." + cat: + - "The cute kitty showed its power to %player%!" + - "%player% discovered that precious little cats have fangs too." + ocelot: + - "The cute ocelot showed its power to %player%!" + - "%player% discovered that ocelots have fangs." + wolf: + - "%player% was hunted down by the pack!" + - "%player% was devoured by wolves!" + giant: + - "%player% was smashed by a Giant!" + - "A giant has flattened %player%!" + slime: + - "%player% was liquified by a slime!" + - "%player% was enveloped by slime!" + ghast: + - "%player% was burned by ghasts!" + - "%player% learned the hard way that ghasts have a killer smile." + - "%player% got a taste of ghast's gourmet fireball cuisine." + zombified_piglin: + - "The zombified piglin horde has claimed %player%!" + - "%player% got caught in a zombified piglin's not-so-friendly hug." + - "The zombified piglin had a bone to pick with %player%, and it wasn't pretty." + magma_cube: + - "%player% has been lavaslimed!" + - "%player% was engulfed by a magma cube!" + - "%player% was squashed by a magma cube!" + - "A magma cube's fiery embrace engulfed %player%!" + drowned: + - "%player% has been suffocated by a drowned!" + - "A wet zombie has drenched %player%!" + guardian: + - "A guardian has claimed %player%!" + - "%player% got a taste of a guardian's pointy personality." + - "%player% found out that guardians have a shocking way of saying hello." + evoker: + - "%player% has found themselves evoked!" + - "%player% was on the receiving end of an evoker's spell-tacular performance!" + - "%player% got a firsthand taste of the evoker's voodoo magic." + evoker_fangs: + - "%player% has been evocated by the fierce fangs of an evoker!" + - "%player%'s life was claimed by an evoker fang!" + husk: + - "Husks have claimed %player%!" + - "%player% has been mummified by husks!" + llama_spit: + - "%player% had a bad day with a llama." + - "Llamas did not want to be friendly to %player%." + pillager: + - "%player% was not careful when saving the villagers!" + - "%player% lost against a puny pillager." + polar_bear: + - "%player% learned that polar bears are not only cute, but dangerous." + - "%player% tried to pet a polar bear." + ravager: + - "Ravagers have destroyed %player%!" + shulker: + - "Shulkers have split %player% in two!" + - "%player% was not careful when dealing with shulkers." + stray: + - "Shadow skeletons have phased into %player%." + - "%player% was not careful when dealing with strays." + vex: + - "%player% tried to fly with the vexes!" + - "Vexes showed %player% who was boss!" + - "%player% tried to make friends with vexes." + zombie_villager: + - "%player% couldn't tell the difference between a villager and a zombie." + - "%player% was not careful when dealing with zombie villagers." + phantom: + - "%player% was swooped down on by a phantom!" + - "A phantom has carried away %player%!" + panda: + - "%player% learned that pandas can be fierce!" + - "A panda showed %player% its strength!" + witch: + - "%player% was cursed by a witch's potion!" + - "A witch's brew spelled doom for %player%!" + vindicator: + - "%player% was axed by a vindicator!" + - "A vindicator's axe ended %player%!" + wither: + - "The wither has obliterated %player%!" + - "%player% stood no chance against the wither!" + - "The wither's devastating attack disintegrated %player%!" + - "%player% was engulfed in the wither's deadly aura!" + wither_skeleton: + - "A wither skeleton has slain %player%!" + - "%player% was struck down by a wither skeleton!" + hoglin: + - "%player% was gored by a hoglin!" + - "A hoglin's charge trampled %player%!" + zoglin: + - "%player% was mauled by a zoglin!" + - "A zoglin's fury tore apart %player%!" + strider: + - "%player% couldn't handle the heat and fell off a strider!" + - "A strider's lava ride was too hot for %player%!" + bee: + - "%player% was stung to death by bees!" + - "A swarm of bees overwhelmed %player%!" + piglin: + - "%player% was ambushed by piglins!" + - "A horde of piglins claimed %player%!" + piglin_brute: + - "%player% was brutalized by a piglin brute!" + - "A piglin brute's ferocity ended %player%!" + axolotl: + - "%player% was nibbled to death by axolotls!" + - "Axolotls showed no mercy to %player%!" + warden: + - "The warden sensed %player% and annihilated them!" + - "%player% was crushed by the warden!" + - "The warden's roar ended %player% in an instant!" + - "%player% fell victim to the warden's unstoppable power!" + frog: + - "%player% underestimated a frog's leap!" + - "A frog's tongue claimed %player%!" + tadpole: + - "%player% was overwhelmed by tadpoles!" + - "A tadpole took down %player%!" + goat: + - "%player% was rammed off a cliff by a goat!" + - "A goat's headbutt sent %player% flying!" + dolphin: + - "%player% was drowned by dolphins!" + - "Dolphins played too rough with %player%!" + sniffer: + - "%player% was caught off guard by the large nose of a sniffer!" + - "The ancient sniffer dug up the end for %player%!" + breeze: + - "%player% was swept away by an unexpected gust of breeze!" + - "A fierce breeze carried %player% to their final resting place!" + wind_charge: + - "%player% was struck down by a whirlwind from a wind charge!" + - "The force of a wind charge blew %player% away for good!" + primed_tnt: + - "%player% was blown to bits by TNT!" + - "%player% mistakenly thought they were a demolitions expert." + - "%player% was caught in the blast of explosives!" + default: + - "%player% has been slain." + - "%player% did not survive this time!" + - "%player% was on the losing side of a monster mash." + - "The ultimate showdown: %player% vs. an angry mob. Spoiler: The mob won." + player: + # Disabled - see pvp.yml + enabled: false diff --git a/src/main/resources/trackers/pvp.yml b/src/main/resources/trackers/pvp.yml new file mode 100644 index 0000000..f84eaf3 --- /dev/null +++ b/src/main/resources/trackers/pvp.yml @@ -0,0 +1,112 @@ +# ----------------- +# Main configuration for the PVP tracker +# +# Documentation: https://docs.battleplugins.org/shelves/battletracker +# Support: https://discord.gg/tMVPVJf +# GitHub: https://github.com/BattlePlugins/BattleTracker +# ----------------- +config-version: 2.0 # The config version, do not change! + +# The name of the tracker +name: PvP +# Recap options +recap: + # Whether the damage recap should be enabled for this tracker + enabled: true + # The content to display in the recap + # - all: Displays all information about a player in the recap, including armor, inventory and damage + # - armor: Just shows the armor a player was wearing + # - recap: Just shows the damage recap of a player + display-content: armor + # Whether a damage recap should be shown when a player hovers over + # a player's death message. + hover-recap: true +# The data this tracker wil track +# - pvp: tracks player vs player statistics +# - pve: tracks player vs enemy statistics +# - world: tracks world statistics (i.e. damage due to lava, fall damage, etc.) +tracked-statistics: + - pvp +# Worlds that this tracker is disabled in. +disabled-worlds: [] +# The rating calculator to use +# - elo: the default rating calculator +calculator: elo +# Killstreaks +# - A killstreak occurs when a player kills a certain number of players in a row without dying +killstreaks: + enabled: true + minimum-kills: 5 + killstreak-message-interval: 5 + # The audience of the death messages + # - global: all players + # - world: only players in the same world + # - local: only the player and the target + # - arena: only players in the same arena (requires BattleArena) + audience: global + messages: + default: "%player% is on a killstreak of %kills% kills!" + 10: "%player% is on a crazy killstreak of %kills% kills!" + 20: "%player% is on an unstoppable killstreak of %kills% kills!" +# Rampages +# - A rampage occurs when a player kills a certain number of players in a short amount of time +rampage: + enabled: true + rampage-time: 30 + # The audience of the death messages + # - global: all players + # - world: only players in the same world + # - local: only the player and the target + # - arena: only players in the same arena (requires BattleArena) + audience: global + messages: + default: "%player% is on a rampage!" + 3: "%player% is on a crazy rampage!" + 4: "%player% is on an insane rampage!" + 5: "%player% is on an unstoppable rampage!" +# Whether death messages processed by this tracker should be enabled +death-messages: + enabled: true + # The audience of the death messages + # - global: all players + # - world: only players in the same world + # - local: only the player and the target + # - arena: only players in the same arena (requires BattleArena) + audience: global + # World death messages + world: + # Disabled - see pve.yml + enabled: false + entity: + # Disabled - see pve.yml + enabled: false + player: + # Enabled - this is for processing deaths from players + enabled: true + messages: + bow: + - "%player% feathered %target% with arrows using a %item%!" + - "%player%'s arrows pierced through %target% with their %item%!" + air: + - "%player% pummeled %target% with their bare hands!" + - "%target% was no match for the combat skills of %player%!" + - "%player% showed %target% that fists are weapons too!" + - "%player%'s fists broke down %target%!" + "#swords": + - "%player% sliced through %target% with a %item%!" + - "%player% cut down %target% using their %item%!" + - "%player% showed %target% the sharpness of their %item%!" + - "%player% hacked %target% to death with a %item%!" + "#axes": + - "%player% chopped %target% down with a %item%!" + - "%player% used %target% as mere lumber!" + - "%player% cleaved %target% with a %item%!" + - "%player% split %target% in half with a %item%!" + default: + - "%player% killed %target%!" + - "%player% took down %target%!" + - "%player% defeated %target%!" + - "%player% eliminated %target%!" + - "%target% was slain by %player%!" + - "%target% was killed by %player%!" + - "%target% was no match for %player%!"