From d4a93071fe5abe1ec0547bb55e864f6c1d1c50cd Mon Sep 17 00:00:00 2001 From: Andreas Troelsen Date: Wed, 30 Jun 2021 23:54:00 +0200 Subject: [PATCH] Initial commit. --- .github/workflows/build.yml | 33 + .gitignore | 17 + .mvn/wrapper/MavenWrapperDownloader.java | 117 +++ .mvn/wrapper/maven-wrapper.properties | 2 + LICENSE | 674 ++++++++++++++++++ README.md | 189 +++++ mvnw | 310 ++++++++ mvnw.cmd | 182 +++++ pom.xml | 242 +++++++ .../org/mobarena/stats/MobArenaStats.java | 24 + .../mobarena/stats/MobArenaStatsPlugin.java | 193 +++++ .../stats/command/ArenaStatsCommand.java | 57 ++ .../command/DeleteSessionStatsCommand.java | 56 ++ .../mobarena/stats/command/ExportCommand.java | 57 ++ .../stats/command/GlobalStatsCommand.java | 39 + .../mobarena/stats/command/ImportCommand.java | 130 ++++ .../stats/command/PlayerStatsCommand.java | 58 ++ .../stats/platform/AsyncBukkitExecutor.java | 23 + .../stats/platform/SyncBukkitExecutor.java | 23 + .../stats/session/PlayerConclusion.java | 23 + .../stats/session/PlayerSessionStats.java | 38 + .../org/mobarena/stats/session/Session.java | 130 ++++ .../stats/session/SessionConclusion.java | 17 + .../stats/session/SessionListener.java | 190 +++++ .../mobarena/stats/session/SessionStats.java | 23 + .../mobarena/stats/session/SessionStore.java | 86 +++ .../org/mobarena/stats/session/StatsUtil.java | 33 + .../org/mobarena/stats/store/ArenaStats.java | 31 + .../stats/store/CachingStatsStore.java | 67 ++ .../org/mobarena/stats/store/GlobalStats.java | 22 + .../org/mobarena/stats/store/PlayerStats.java | 22 + .../org/mobarena/stats/store/StatsExport.java | 27 + .../org/mobarena/stats/store/StatsImport.java | 21 + .../org/mobarena/stats/store/StatsStore.java | 49 ++ .../stats/store/StatsStoreFactory.java | 22 + .../stats/store/StatsStoreRegistry.java | 41 ++ .../stats/store/csv/CsvStatsStore.java | 253 +++++++ .../stats/store/jdbc/JdbcStatsStore.java | 245 +++++++ .../mobarena/stats/store/jdbc/Migrations.java | 31 + .../stats/store/jdbc/SchemaMigrator.java | 139 ++++ .../mobarena/stats/store/jdbc/Statement.java | 19 + .../mobarena/stats/store/jdbc/Statements.java | 32 + .../store/mariadb/MariadbStatsStore.java | 39 + .../stats/store/mysql/MysqlStatsStore.java | 37 + .../stats/store/sqlite/SqliteStatsStore.java | 38 + .../mobarena/stats/util/ResourceLoader.java | 118 +++ src/main/resources/config.yml | 59 ++ .../resources/mysql/delete-session-data.sql | 3 + .../resources/mysql/find-all-migrations.sql | 4 + src/main/resources/mysql/find-arena-stats.sql | 21 + .../resources/mysql/find-global-stats.sql | 16 + .../mysql/find-player-sessions-by-id.sql | 5 + .../resources/mysql/find-player-stats.sql | 9 + src/main/resources/mysql/find-sessions.sql | 4 + src/main/resources/mysql/insert-migration.sql | 15 + .../resources/mysql/insert-player-data.sql | 37 + .../resources/mysql/insert-session-data.sql | 15 + .../mysql/migration/V1__baseline.sql | 7 + .../migration/V2__add_sessions_table.sql | 16 + .../V3__add_player_sessions_table.sql | 25 + src/main/resources/plugin.yml | 6 + .../resources/sqlite/delete-session-data.sql | 3 + .../resources/sqlite/find-all-migrations.sql | 4 + .../resources/sqlite/find-arena-stats.sql | 21 + .../resources/sqlite/find-global-stats.sql | 16 + .../sqlite/find-player-sessions-by-id.sql | 5 + .../resources/sqlite/find-player-stats.sql | 9 + src/main/resources/sqlite/find-sessions.sql | 4 + .../resources/sqlite/insert-migration.sql | 15 + .../resources/sqlite/insert-player-data.sql | 37 + .../resources/sqlite/insert-session-data.sql | 15 + .../sqlite/migration/V1__baseline.sql | 7 + .../migration/V2__add_sessions_table.sql | 16 + .../V3__add_player_sessions_table.sql | 25 + .../org/mobarena/stats/session/Mocks.java | 26 + .../stats/session/SessionListenerTest.java | 346 +++++++++ .../stats/session/SessionStoreTest.java | 61 ++ .../mobarena/stats/session/SessionTest.java | 283 ++++++++ .../mobarena/stats/session/StatsUtilTest.java | 55 ++ .../mobarena/stats/store/StatsExportTest.java | 22 + .../mobarena/stats/store/StatsImportTest.java | 23 + .../mobarena/stats/store/StatsStoreIT.java | 366 ++++++++++ .../stats/store/jdbc/StatementsTest.java | 41 ++ .../store/mariadb/MariadbStatsStoreIT.java | 50 ++ .../store/mariadb/MariadbStatsStoreTest.java | 37 + .../stats/store/mysql/MysqlStatsStoreIT.java | 50 ++ .../store/mysql/MysqlStatsStoreTest.java | 37 + .../store/sqlite/SqliteStatsStoreIT.java | 44 ++ .../store/sqlite/SqliteStatsStoreTest.java | 38 + .../stats/util/ResourceLoaderTest.java | 213 ++++++ .../dummy/migration/V1__baseline.sql | 2 + .../dummy/migration/V2__new_stuff.sql | 2 + .../dummy/migration/V3__changed_stuff.sql | 2 + src/test/resources/dummy/query.sql | 2 + src/test/resources/plugin.yml | 4 + 95 files changed, 6342 insertions(+) create mode 100644 .github/workflows/build.yml create mode 100644 .gitignore create mode 100644 .mvn/wrapper/MavenWrapperDownloader.java create mode 100644 .mvn/wrapper/maven-wrapper.properties create mode 100644 LICENSE create mode 100644 README.md create mode 100755 mvnw create mode 100644 mvnw.cmd create mode 100644 pom.xml create mode 100644 src/main/java/org/mobarena/stats/MobArenaStats.java create mode 100644 src/main/java/org/mobarena/stats/MobArenaStatsPlugin.java create mode 100644 src/main/java/org/mobarena/stats/command/ArenaStatsCommand.java create mode 100644 src/main/java/org/mobarena/stats/command/DeleteSessionStatsCommand.java create mode 100644 src/main/java/org/mobarena/stats/command/ExportCommand.java create mode 100644 src/main/java/org/mobarena/stats/command/GlobalStatsCommand.java create mode 100644 src/main/java/org/mobarena/stats/command/ImportCommand.java create mode 100644 src/main/java/org/mobarena/stats/command/PlayerStatsCommand.java create mode 100644 src/main/java/org/mobarena/stats/platform/AsyncBukkitExecutor.java create mode 100644 src/main/java/org/mobarena/stats/platform/SyncBukkitExecutor.java create mode 100644 src/main/java/org/mobarena/stats/session/PlayerConclusion.java create mode 100644 src/main/java/org/mobarena/stats/session/PlayerSessionStats.java create mode 100644 src/main/java/org/mobarena/stats/session/Session.java create mode 100644 src/main/java/org/mobarena/stats/session/SessionConclusion.java create mode 100644 src/main/java/org/mobarena/stats/session/SessionListener.java create mode 100644 src/main/java/org/mobarena/stats/session/SessionStats.java create mode 100644 src/main/java/org/mobarena/stats/session/SessionStore.java create mode 100644 src/main/java/org/mobarena/stats/session/StatsUtil.java create mode 100644 src/main/java/org/mobarena/stats/store/ArenaStats.java create mode 100644 src/main/java/org/mobarena/stats/store/CachingStatsStore.java create mode 100644 src/main/java/org/mobarena/stats/store/GlobalStats.java create mode 100644 src/main/java/org/mobarena/stats/store/PlayerStats.java create mode 100644 src/main/java/org/mobarena/stats/store/StatsExport.java create mode 100644 src/main/java/org/mobarena/stats/store/StatsImport.java create mode 100644 src/main/java/org/mobarena/stats/store/StatsStore.java create mode 100644 src/main/java/org/mobarena/stats/store/StatsStoreFactory.java create mode 100644 src/main/java/org/mobarena/stats/store/StatsStoreRegistry.java create mode 100644 src/main/java/org/mobarena/stats/store/csv/CsvStatsStore.java create mode 100644 src/main/java/org/mobarena/stats/store/jdbc/JdbcStatsStore.java create mode 100644 src/main/java/org/mobarena/stats/store/jdbc/Migrations.java create mode 100644 src/main/java/org/mobarena/stats/store/jdbc/SchemaMigrator.java create mode 100644 src/main/java/org/mobarena/stats/store/jdbc/Statement.java create mode 100644 src/main/java/org/mobarena/stats/store/jdbc/Statements.java create mode 100644 src/main/java/org/mobarena/stats/store/mariadb/MariadbStatsStore.java create mode 100644 src/main/java/org/mobarena/stats/store/mysql/MysqlStatsStore.java create mode 100644 src/main/java/org/mobarena/stats/store/sqlite/SqliteStatsStore.java create mode 100644 src/main/java/org/mobarena/stats/util/ResourceLoader.java create mode 100644 src/main/resources/config.yml create mode 100644 src/main/resources/mysql/delete-session-data.sql create mode 100644 src/main/resources/mysql/find-all-migrations.sql create mode 100644 src/main/resources/mysql/find-arena-stats.sql create mode 100644 src/main/resources/mysql/find-global-stats.sql create mode 100644 src/main/resources/mysql/find-player-sessions-by-id.sql create mode 100644 src/main/resources/mysql/find-player-stats.sql create mode 100644 src/main/resources/mysql/find-sessions.sql create mode 100644 src/main/resources/mysql/insert-migration.sql create mode 100644 src/main/resources/mysql/insert-player-data.sql create mode 100644 src/main/resources/mysql/insert-session-data.sql create mode 100644 src/main/resources/mysql/migration/V1__baseline.sql create mode 100644 src/main/resources/mysql/migration/V2__add_sessions_table.sql create mode 100644 src/main/resources/mysql/migration/V3__add_player_sessions_table.sql create mode 100644 src/main/resources/plugin.yml create mode 100644 src/main/resources/sqlite/delete-session-data.sql create mode 100644 src/main/resources/sqlite/find-all-migrations.sql create mode 100644 src/main/resources/sqlite/find-arena-stats.sql create mode 100644 src/main/resources/sqlite/find-global-stats.sql create mode 100644 src/main/resources/sqlite/find-player-sessions-by-id.sql create mode 100644 src/main/resources/sqlite/find-player-stats.sql create mode 100644 src/main/resources/sqlite/find-sessions.sql create mode 100644 src/main/resources/sqlite/insert-migration.sql create mode 100644 src/main/resources/sqlite/insert-player-data.sql create mode 100644 src/main/resources/sqlite/insert-session-data.sql create mode 100644 src/main/resources/sqlite/migration/V1__baseline.sql create mode 100644 src/main/resources/sqlite/migration/V2__add_sessions_table.sql create mode 100644 src/main/resources/sqlite/migration/V3__add_player_sessions_table.sql create mode 100644 src/test/java/org/mobarena/stats/session/Mocks.java create mode 100644 src/test/java/org/mobarena/stats/session/SessionListenerTest.java create mode 100644 src/test/java/org/mobarena/stats/session/SessionStoreTest.java create mode 100644 src/test/java/org/mobarena/stats/session/SessionTest.java create mode 100644 src/test/java/org/mobarena/stats/session/StatsUtilTest.java create mode 100644 src/test/java/org/mobarena/stats/store/StatsExportTest.java create mode 100644 src/test/java/org/mobarena/stats/store/StatsImportTest.java create mode 100644 src/test/java/org/mobarena/stats/store/StatsStoreIT.java create mode 100644 src/test/java/org/mobarena/stats/store/jdbc/StatementsTest.java create mode 100644 src/test/java/org/mobarena/stats/store/mariadb/MariadbStatsStoreIT.java create mode 100644 src/test/java/org/mobarena/stats/store/mariadb/MariadbStatsStoreTest.java create mode 100644 src/test/java/org/mobarena/stats/store/mysql/MysqlStatsStoreIT.java create mode 100644 src/test/java/org/mobarena/stats/store/mysql/MysqlStatsStoreTest.java create mode 100644 src/test/java/org/mobarena/stats/store/sqlite/SqliteStatsStoreIT.java create mode 100644 src/test/java/org/mobarena/stats/store/sqlite/SqliteStatsStoreTest.java create mode 100644 src/test/java/org/mobarena/stats/util/ResourceLoaderTest.java create mode 100644 src/test/resources/dummy/migration/V1__baseline.sql create mode 100644 src/test/resources/dummy/migration/V2__new_stuff.sql create mode 100644 src/test/resources/dummy/migration/V3__changed_stuff.sql create mode 100644 src/test/resources/dummy/query.sql create mode 100644 src/test/resources/plugin.yml diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..51f89b4 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,33 @@ +name: build + +on: + workflow_dispatch: + push: + branches: + - dev + - master + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v2 + + - name: Cache dependencies + uses: actions/cache@v2 + with: + path: ~/.m2/repository + key: ${{ runner.os }}-maven-${{ hashFiles('**/pom.xml') }} + restore-keys: | + ${{ runner.os }}-maven- + + - name: Build, test, package + run: ./mvnw -B package --file pom.xml + + - name: Upload artifact + uses: actions/upload-artifact@v2 + with: + name: MobArenaStats.jar + path: target/MobArenaStats.jar diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f3f8600 --- /dev/null +++ b/.gitignore @@ -0,0 +1,17 @@ +# Java +*.class + +# Maven +target/ + +# Artifacts +*.jar +*.zip + +# IntelliJ +.idea/ +*.iml +out/ + +# Residue +*.db diff --git a/.mvn/wrapper/MavenWrapperDownloader.java b/.mvn/wrapper/MavenWrapperDownloader.java new file mode 100644 index 0000000..b901097 --- /dev/null +++ b/.mvn/wrapper/MavenWrapperDownloader.java @@ -0,0 +1,117 @@ +/* + * Copyright 2007-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import java.net.*; +import java.io.*; +import java.nio.channels.*; +import java.util.Properties; + +public class MavenWrapperDownloader { + + private static final String WRAPPER_VERSION = "0.5.6"; + /** + * Default URL to download the maven-wrapper.jar from, if no 'downloadUrl' is provided. + */ + private static final String DEFAULT_DOWNLOAD_URL = "https://repo.maven.apache.org/maven2/io/takari/maven-wrapper/" + + WRAPPER_VERSION + "/maven-wrapper-" + WRAPPER_VERSION + ".jar"; + + /** + * Path to the maven-wrapper.properties file, which might contain a downloadUrl property to + * use instead of the default one. + */ + private static final String MAVEN_WRAPPER_PROPERTIES_PATH = + ".mvn/wrapper/maven-wrapper.properties"; + + /** + * Path where the maven-wrapper.jar will be saved to. + */ + private static final String MAVEN_WRAPPER_JAR_PATH = + ".mvn/wrapper/maven-wrapper.jar"; + + /** + * Name of the property which should be used to override the default download url for the wrapper. + */ + private static final String PROPERTY_NAME_WRAPPER_URL = "wrapperUrl"; + + public static void main(String args[]) { + System.out.println("- Downloader started"); + File baseDirectory = new File(args[0]); + System.out.println("- Using base directory: " + baseDirectory.getAbsolutePath()); + + // If the maven-wrapper.properties exists, read it and check if it contains a custom + // wrapperUrl parameter. + File mavenWrapperPropertyFile = new File(baseDirectory, MAVEN_WRAPPER_PROPERTIES_PATH); + String url = DEFAULT_DOWNLOAD_URL; + if(mavenWrapperPropertyFile.exists()) { + FileInputStream mavenWrapperPropertyFileInputStream = null; + try { + mavenWrapperPropertyFileInputStream = new FileInputStream(mavenWrapperPropertyFile); + Properties mavenWrapperProperties = new Properties(); + mavenWrapperProperties.load(mavenWrapperPropertyFileInputStream); + url = mavenWrapperProperties.getProperty(PROPERTY_NAME_WRAPPER_URL, url); + } catch (IOException e) { + System.out.println("- ERROR loading '" + MAVEN_WRAPPER_PROPERTIES_PATH + "'"); + } finally { + try { + if(mavenWrapperPropertyFileInputStream != null) { + mavenWrapperPropertyFileInputStream.close(); + } + } catch (IOException e) { + // Ignore ... + } + } + } + System.out.println("- Downloading from: " + url); + + File outputFile = new File(baseDirectory.getAbsolutePath(), MAVEN_WRAPPER_JAR_PATH); + if(!outputFile.getParentFile().exists()) { + if(!outputFile.getParentFile().mkdirs()) { + System.out.println( + "- ERROR creating output directory '" + outputFile.getParentFile().getAbsolutePath() + "'"); + } + } + System.out.println("- Downloading to: " + outputFile.getAbsolutePath()); + try { + downloadFileFromURL(url, outputFile); + System.out.println("Done"); + System.exit(0); + } catch (Throwable e) { + System.out.println("- Error downloading"); + e.printStackTrace(); + System.exit(1); + } + } + + private static void downloadFileFromURL(String urlString, File destination) throws Exception { + if (System.getenv("MVNW_USERNAME") != null && System.getenv("MVNW_PASSWORD") != null) { + String username = System.getenv("MVNW_USERNAME"); + char[] password = System.getenv("MVNW_PASSWORD").toCharArray(); + Authenticator.setDefault(new Authenticator() { + @Override + protected PasswordAuthentication getPasswordAuthentication() { + return new PasswordAuthentication(username, password); + } + }); + } + URL website = new URL(urlString); + ReadableByteChannel rbc; + rbc = Channels.newChannel(website.openStream()); + FileOutputStream fos = new FileOutputStream(destination); + fos.getChannel().transferFrom(rbc, 0, Long.MAX_VALUE); + fos.close(); + rbc.close(); + } + +} diff --git a/.mvn/wrapper/maven-wrapper.properties b/.mvn/wrapper/maven-wrapper.properties new file mode 100644 index 0000000..642d572 --- /dev/null +++ b/.mvn/wrapper/maven-wrapper.properties @@ -0,0 +1,2 @@ +distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.6.3/apache-maven-3.6.3-bin.zip +wrapperUrl=https://repo.maven.apache.org/maven2/io/takari/maven-wrapper/0.5.6/maven-wrapper-0.5.6.jar 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..5b67fbe --- /dev/null +++ b/README.md @@ -0,0 +1,189 @@ +# MobArenaStats [![Build Status](https://github.com/garbagemule/MobArenaStats/actions/workflows/build.yml/badge.svg?branch=master)](https://github.com/garbagemule/MobArenaStats/actions/workflows/build.yml) + +MobArenaStats is a _plugin extension_ for [MobArena](https://github.com/garbagemule/MobArena). +The extension collects stats from MobArena sessions into persistent storage such as MySQL, MariaDB, and SQLite databases. +It hooks into MobArena's command handler to provide commands for querying and managing the stats. + + +## Getting Started + +Download a copy of the latest MobArenaStats build and place it in your server's `plugins` folder. +You can grab a build from the _Artifacts_ section of the latest run of the [build workflow](https://github.com/garbagemule/MobArenaStats/actions/workflows/build.yml) in GitHub Actions, or you can join the MobArena Discord server and grab one from the `#test-builds` channel. + +By default, MobArenaStats uses a local SQLite database called `stats.db` located in the MobArenaStats plugin folder. +To set up a different data store, please refer to the [Configuration](#configuration) section below. + + +## Configuration + +Upon first run, a `config.yml` file will be generated in the MobArenaStats plugin folder. +It consists of a single section, the `store` section, which is used to set up the data store. + +MobArenaStats _natively_ supports a couple of different data stores: + +- SQLite +- MySQL +- MariaDB +- CSV files + +The following sections describe the store-specific config properties. + + +### SQLite + +[SQLite](https://www.sqlite.org/) is a database engine embedded in a library. +It uses a single file on disk to store all of its data. + +By default, MobArenaStats uses the filename `stats.db` inside its own plugin folder, but this can be altered with the `filename` property, which is the _relative path_ from the plugin folder. + +#### Example +An example SQLite database configuration that stores its data in a file called `mobarena_stats.db` in the server root folder (`../..` is the server root relative to the plugin folder). + +```yml +store: + type: sqlite + filename: ../../mobarena_stats.db +``` + + +### MySQL + +[MySQL](https://www.mysql.com/) is one of the most well-known relational databases and a common sight in database offerings from various providers. + +MobArenaStats requires a `host`, a `port`, a `database` name, and credentials in the form of a `username` and a `password` to connect to a MySQL database. +By default, the plugin tries to connect to `localhost` on port 3306 with a database called `mobarena_stats`, but these can all be altered. +There are no defaults for the `username` and `password`. + +**Note:** The `database` must be created manually! + +MobArenaStats is tested with MySQL 5.7. + +#### Example +An example MySQL database configuration that connects to a database called `mastats` on localhost port 1337 with username `bob` and password `saget`. + +```yml +store: + type: mysql + host: localhost + port: 1337 + database: mastats + username: bob + password: saget +``` + + +### MariaDB + +[MariaDB](https://mariadb.org/) is a fork of MySQL by the original MySQL authors that aims to be free and open-source forever. + +MobArenaStats supports MariaDB via the [drop-in replacement](https://en.wikipedia.org/wiki/Drop-in_replacement) compatibility with MySQL. +This means that the configuration of MariaDB databases is _exactly_ the same as for [MySQL](#mysql) databases (including setting `type: mysql`). + +MobArenaStats is tested with MariaDB 10.4. + + +### CSV files + +MobArenaStats can persist session stats on the file system in the CSV format. +It stores two files, `sessions.csv` and `players.csv`, with overall and player-specific session data, respectively. +By default, the plugin stores the data files in a local `data` subfolder in the plugin folder, and it uses semicolons (`;`) to separate values. + +**Note:** CSV stores _do not_ support data queries! + +#### Example +An example of a CSV configuration that stores the data files in a `mobarena_stats` folder in the server root (`../..` is the server root relative to the plugin folder) and uses commas (`,`) to separate values. + +```yml +store: + type: csv + folder: ../../mobarena_stats + separator: , +``` + + +## Commands + +MobArenaStats introduces new subcommands to the `/ma` base command in MobArena. +These include player commands for querying the data store, and server admin commands for exporting from and importing into data stores. + +The following sections describe each command (and the permission required to run it) and its arguments, if any. + + +### Queries + +Query commands dip into the data store to fetch global stats, arena-specific stats, or player-specific stats. + +#### Global stats + +- `/ma global-stats` +- `mobarenastats.command.global-stats` + +Get a summary of session stats across all arenas and all players: + +- Total sessions: total number of unique arena sessions +- Total duration: sum of all session durations +- Total kills: sum of all kills made in arena sessions +- Total waves: total number of waves spawned in arena sessions + +#### Arena stats + +- `/ma arena-stats ` +- `mobarenastats.command.arena-stats` + +Get a summary of stats across all sessions in the arena denoted by the given ``: + +- Highest wave: the highest wave number spawned in the arena +- Longest duration: the duration of the longest session in the arena +- Most kills: the highest number of total kills in a session in the arena +- Total sessions: total number of unique sessions in the arena +- Total duration: sum of durations for all sessions in the arena +- Total kills: sum of all kills made in the arena +- Total waves: total number of waves spawned in the arena + +#### Player stats + +- `/ma player-stats ` +- `mobarenastats.command.player-stats` + +Get a summary of session stats for the player with the given ``: + +- Total sessions: total number of unique arena sessions for the player +- Total duration: sum of all session durations for the player +- Total kills: sum of all kills made in arena sessions by the player +- Total waves: total number of waves spawned in arena sessions with the player + + +### Import & Export + +All data stores can export their stats, and all data stores can import stats exported from other data stores. +The plugin always exports data as SQLite database files, and it can only import files that follow the same format and naming convention. + +#### Export + +- `/ma export-stats` +- `mobarenastats.command.export-stats` + +Export the stats in the current data store to an SQLite database file. +All data exports follow the naming convention `stats.export-.db`, where `` is a UNIX timestamp of the time that the export was started. + +**Note:** Exporting stats is a slow operation and will take time proportional to the amount of data in the current store. +The operation runs _off_ the main thread, so it should not impact performance. + +#### Import + +- `/ma import-stats ` +- `mobarenastats.command.import-stats` + +Import the stats from the data export denoted by the given ``. +The file must be an SQLite database created with the [export command](#export). + +**Note:** It is not recommended to import stats into a non-empty data store. +Always import into an empty store unless you know what you are doing. + +**Note:** Importing stats is a very slow operation and will take time proportional to the amount of data in the export file. +The operation runs _off_ the main thread, so it should not impact performance. + + +## Getting Help + +If you run into problems or need help with something, feel free to hop on the MobArena Discord server: [Instant Invite](https://discord.gg/5tnwQvC) diff --git a/mvnw b/mvnw new file mode 100755 index 0000000..41c0f0c --- /dev/null +++ b/mvnw @@ -0,0 +1,310 @@ +#!/bin/sh +# ---------------------------------------------------------------------------- +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# ---------------------------------------------------------------------------- + +# ---------------------------------------------------------------------------- +# Maven Start Up Batch script +# +# Required ENV vars: +# ------------------ +# JAVA_HOME - location of a JDK home dir +# +# Optional ENV vars +# ----------------- +# M2_HOME - location of maven2's installed home dir +# MAVEN_OPTS - parameters passed to the Java VM when running Maven +# e.g. to debug Maven itself, use +# set MAVEN_OPTS=-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=8000 +# MAVEN_SKIP_RC - flag to disable loading of mavenrc files +# ---------------------------------------------------------------------------- + +if [ -z "$MAVEN_SKIP_RC" ] ; then + + if [ -f /etc/mavenrc ] ; then + . /etc/mavenrc + fi + + if [ -f "$HOME/.mavenrc" ] ; then + . "$HOME/.mavenrc" + fi + +fi + +# OS specific support. $var _must_ be set to either true or false. +cygwin=false; +darwin=false; +mingw=false +case "`uname`" in + CYGWIN*) cygwin=true ;; + MINGW*) mingw=true;; + Darwin*) darwin=true + # Use /usr/libexec/java_home if available, otherwise fall back to /Library/Java/Home + # See https://developer.apple.com/library/mac/qa/qa1170/_index.html + if [ -z "$JAVA_HOME" ]; then + if [ -x "/usr/libexec/java_home" ]; then + export JAVA_HOME="`/usr/libexec/java_home`" + else + export JAVA_HOME="/Library/Java/Home" + fi + fi + ;; +esac + +if [ -z "$JAVA_HOME" ] ; then + if [ -r /etc/gentoo-release ] ; then + JAVA_HOME=`java-config --jre-home` + fi +fi + +if [ -z "$M2_HOME" ] ; then + ## resolve links - $0 may be a link to maven's home + PRG="$0" + + # need this for relative symlinks + while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG="`dirname "$PRG"`/$link" + fi + done + + saveddir=`pwd` + + M2_HOME=`dirname "$PRG"`/.. + + # make it fully qualified + M2_HOME=`cd "$M2_HOME" && pwd` + + cd "$saveddir" + # echo Using m2 at $M2_HOME +fi + +# For Cygwin, ensure paths are in UNIX format before anything is touched +if $cygwin ; then + [ -n "$M2_HOME" ] && + M2_HOME=`cygpath --unix "$M2_HOME"` + [ -n "$JAVA_HOME" ] && + JAVA_HOME=`cygpath --unix "$JAVA_HOME"` + [ -n "$CLASSPATH" ] && + CLASSPATH=`cygpath --path --unix "$CLASSPATH"` +fi + +# For Mingw, ensure paths are in UNIX format before anything is touched +if $mingw ; then + [ -n "$M2_HOME" ] && + M2_HOME="`(cd "$M2_HOME"; pwd)`" + [ -n "$JAVA_HOME" ] && + JAVA_HOME="`(cd "$JAVA_HOME"; pwd)`" +fi + +if [ -z "$JAVA_HOME" ]; then + javaExecutable="`which javac`" + if [ -n "$javaExecutable" ] && ! [ "`expr \"$javaExecutable\" : '\([^ ]*\)'`" = "no" ]; then + # readlink(1) is not available as standard on Solaris 10. + readLink=`which readlink` + if [ ! `expr "$readLink" : '\([^ ]*\)'` = "no" ]; then + if $darwin ; then + javaHome="`dirname \"$javaExecutable\"`" + javaExecutable="`cd \"$javaHome\" && pwd -P`/javac" + else + javaExecutable="`readlink -f \"$javaExecutable\"`" + fi + javaHome="`dirname \"$javaExecutable\"`" + javaHome=`expr "$javaHome" : '\(.*\)/bin'` + JAVA_HOME="$javaHome" + export JAVA_HOME + fi + fi +fi + +if [ -z "$JAVACMD" ] ; then + 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 + else + JAVACMD="`which java`" + fi +fi + +if [ ! -x "$JAVACMD" ] ; then + echo "Error: JAVA_HOME is not defined correctly." >&2 + echo " We cannot execute $JAVACMD" >&2 + exit 1 +fi + +if [ -z "$JAVA_HOME" ] ; then + echo "Warning: JAVA_HOME environment variable is not set." +fi + +CLASSWORLDS_LAUNCHER=org.codehaus.plexus.classworlds.launcher.Launcher + +# traverses directory structure from process work directory to filesystem root +# first directory with .mvn subdirectory is considered project base directory +find_maven_basedir() { + + if [ -z "$1" ] + then + echo "Path not specified to find_maven_basedir" + return 1 + fi + + basedir="$1" + wdir="$1" + while [ "$wdir" != '/' ] ; do + if [ -d "$wdir"/.mvn ] ; then + basedir=$wdir + break + fi + # workaround for JBEAP-8937 (on Solaris 10/Sparc) + if [ -d "${wdir}" ]; then + wdir=`cd "$wdir/.."; pwd` + fi + # end of workaround + done + echo "${basedir}" +} + +# concatenates all lines of a file +concat_lines() { + if [ -f "$1" ]; then + echo "$(tr -s '\n' ' ' < "$1")" + fi +} + +BASE_DIR=`find_maven_basedir "$(pwd)"` +if [ -z "$BASE_DIR" ]; then + exit 1; +fi + +########################################################################################## +# Extension to allow automatically downloading the maven-wrapper.jar from Maven-central +# This allows using the maven wrapper in projects that prohibit checking in binary data. +########################################################################################## +if [ -r "$BASE_DIR/.mvn/wrapper/maven-wrapper.jar" ]; then + if [ "$MVNW_VERBOSE" = true ]; then + echo "Found .mvn/wrapper/maven-wrapper.jar" + fi +else + if [ "$MVNW_VERBOSE" = true ]; then + echo "Couldn't find .mvn/wrapper/maven-wrapper.jar, downloading it ..." + fi + if [ -n "$MVNW_REPOURL" ]; then + jarUrl="$MVNW_REPOURL/io/takari/maven-wrapper/0.5.6/maven-wrapper-0.5.6.jar" + else + jarUrl="https://repo.maven.apache.org/maven2/io/takari/maven-wrapper/0.5.6/maven-wrapper-0.5.6.jar" + fi + while IFS="=" read key value; do + case "$key" in (wrapperUrl) jarUrl="$value"; break ;; + esac + done < "$BASE_DIR/.mvn/wrapper/maven-wrapper.properties" + if [ "$MVNW_VERBOSE" = true ]; then + echo "Downloading from: $jarUrl" + fi + wrapperJarPath="$BASE_DIR/.mvn/wrapper/maven-wrapper.jar" + if $cygwin; then + wrapperJarPath=`cygpath --path --windows "$wrapperJarPath"` + fi + + if command -v wget > /dev/null; then + if [ "$MVNW_VERBOSE" = true ]; then + echo "Found wget ... using wget" + fi + if [ -z "$MVNW_USERNAME" ] || [ -z "$MVNW_PASSWORD" ]; then + wget "$jarUrl" -O "$wrapperJarPath" + else + wget --http-user=$MVNW_USERNAME --http-password=$MVNW_PASSWORD "$jarUrl" -O "$wrapperJarPath" + fi + elif command -v curl > /dev/null; then + if [ "$MVNW_VERBOSE" = true ]; then + echo "Found curl ... using curl" + fi + if [ -z "$MVNW_USERNAME" ] || [ -z "$MVNW_PASSWORD" ]; then + curl -o "$wrapperJarPath" "$jarUrl" -f + else + curl --user $MVNW_USERNAME:$MVNW_PASSWORD -o "$wrapperJarPath" "$jarUrl" -f + fi + + else + if [ "$MVNW_VERBOSE" = true ]; then + echo "Falling back to using Java to download" + fi + javaClass="$BASE_DIR/.mvn/wrapper/MavenWrapperDownloader.java" + # For Cygwin, switch paths to Windows format before running javac + if $cygwin; then + javaClass=`cygpath --path --windows "$javaClass"` + fi + if [ -e "$javaClass" ]; then + if [ ! -e "$BASE_DIR/.mvn/wrapper/MavenWrapperDownloader.class" ]; then + if [ "$MVNW_VERBOSE" = true ]; then + echo " - Compiling MavenWrapperDownloader.java ..." + fi + # Compiling the Java class + ("$JAVA_HOME/bin/javac" "$javaClass") + fi + if [ -e "$BASE_DIR/.mvn/wrapper/MavenWrapperDownloader.class" ]; then + # Running the downloader + if [ "$MVNW_VERBOSE" = true ]; then + echo " - Running MavenWrapperDownloader.java ..." + fi + ("$JAVA_HOME/bin/java" -cp .mvn/wrapper MavenWrapperDownloader "$MAVEN_PROJECTBASEDIR") + fi + fi + fi +fi +########################################################################################## +# End of extension +########################################################################################## + +export MAVEN_PROJECTBASEDIR=${MAVEN_BASEDIR:-"$BASE_DIR"} +if [ "$MVNW_VERBOSE" = true ]; then + echo $MAVEN_PROJECTBASEDIR +fi +MAVEN_OPTS="$(concat_lines "$MAVEN_PROJECTBASEDIR/.mvn/jvm.config") $MAVEN_OPTS" + +# For Cygwin, switch paths to Windows format before running java +if $cygwin; then + [ -n "$M2_HOME" ] && + M2_HOME=`cygpath --path --windows "$M2_HOME"` + [ -n "$JAVA_HOME" ] && + JAVA_HOME=`cygpath --path --windows "$JAVA_HOME"` + [ -n "$CLASSPATH" ] && + CLASSPATH=`cygpath --path --windows "$CLASSPATH"` + [ -n "$MAVEN_PROJECTBASEDIR" ] && + MAVEN_PROJECTBASEDIR=`cygpath --path --windows "$MAVEN_PROJECTBASEDIR"` +fi + +# Provide a "standardized" way to retrieve the CLI args that will +# work with both Windows and non-Windows executions. +MAVEN_CMD_LINE_ARGS="$MAVEN_CONFIG $@" +export MAVEN_CMD_LINE_ARGS + +WRAPPER_LAUNCHER=org.apache.maven.wrapper.MavenWrapperMain + +exec "$JAVACMD" \ + $MAVEN_OPTS \ + -classpath "$MAVEN_PROJECTBASEDIR/.mvn/wrapper/maven-wrapper.jar" \ + "-Dmaven.home=${M2_HOME}" "-Dmaven.multiModuleProjectDirectory=${MAVEN_PROJECTBASEDIR}" \ + ${WRAPPER_LAUNCHER} $MAVEN_CONFIG "$@" diff --git a/mvnw.cmd b/mvnw.cmd new file mode 100644 index 0000000..8611571 --- /dev/null +++ b/mvnw.cmd @@ -0,0 +1,182 @@ +@REM ---------------------------------------------------------------------------- +@REM Licensed to the Apache Software Foundation (ASF) under one +@REM or more contributor license agreements. See the NOTICE file +@REM distributed with this work for additional information +@REM regarding copyright ownership. The ASF licenses this file +@REM to you under the Apache License, Version 2.0 (the +@REM "License"); you may not use this file except in compliance +@REM with the License. You may obtain a copy of the License at +@REM +@REM http://www.apache.org/licenses/LICENSE-2.0 +@REM +@REM Unless required by applicable law or agreed to in writing, +@REM software distributed under the License is distributed on an +@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +@REM KIND, either express or implied. See the License for the +@REM specific language governing permissions and limitations +@REM under the License. +@REM ---------------------------------------------------------------------------- + +@REM ---------------------------------------------------------------------------- +@REM Maven Start Up Batch script +@REM +@REM Required ENV vars: +@REM JAVA_HOME - location of a JDK home dir +@REM +@REM Optional ENV vars +@REM M2_HOME - location of maven2's installed home dir +@REM MAVEN_BATCH_ECHO - set to 'on' to enable the echoing of the batch commands +@REM MAVEN_BATCH_PAUSE - set to 'on' to wait for a keystroke before ending +@REM MAVEN_OPTS - parameters passed to the Java VM when running Maven +@REM e.g. to debug Maven itself, use +@REM set MAVEN_OPTS=-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=8000 +@REM MAVEN_SKIP_RC - flag to disable loading of mavenrc files +@REM ---------------------------------------------------------------------------- + +@REM Begin all REM lines with '@' in case MAVEN_BATCH_ECHO is 'on' +@echo off +@REM set title of command window +title %0 +@REM enable echoing by setting MAVEN_BATCH_ECHO to 'on' +@if "%MAVEN_BATCH_ECHO%" == "on" echo %MAVEN_BATCH_ECHO% + +@REM set %HOME% to equivalent of $HOME +if "%HOME%" == "" (set "HOME=%HOMEDRIVE%%HOMEPATH%") + +@REM Execute a user defined script before this one +if not "%MAVEN_SKIP_RC%" == "" goto skipRcPre +@REM check for pre script, once with legacy .bat ending and once with .cmd ending +if exist "%HOME%\mavenrc_pre.bat" call "%HOME%\mavenrc_pre.bat" +if exist "%HOME%\mavenrc_pre.cmd" call "%HOME%\mavenrc_pre.cmd" +:skipRcPre + +@setlocal + +set ERROR_CODE=0 + +@REM To isolate internal variables from possible post scripts, we use another setlocal +@setlocal + +@REM ==== START VALIDATION ==== +if not "%JAVA_HOME%" == "" goto OkJHome + +echo. +echo Error: JAVA_HOME not found in your environment. >&2 +echo Please set the JAVA_HOME variable in your environment to match the >&2 +echo location of your Java installation. >&2 +echo. +goto error + +:OkJHome +if exist "%JAVA_HOME%\bin\java.exe" goto init + +echo. +echo Error: JAVA_HOME is set to an invalid directory. >&2 +echo JAVA_HOME = "%JAVA_HOME%" >&2 +echo Please set the JAVA_HOME variable in your environment to match the >&2 +echo location of your Java installation. >&2 +echo. +goto error + +@REM ==== END VALIDATION ==== + +:init + +@REM Find the project base dir, i.e. the directory that contains the folder ".mvn". +@REM Fallback to current working directory if not found. + +set MAVEN_PROJECTBASEDIR=%MAVEN_BASEDIR% +IF NOT "%MAVEN_PROJECTBASEDIR%"=="" goto endDetectBaseDir + +set EXEC_DIR=%CD% +set WDIR=%EXEC_DIR% +:findBaseDir +IF EXIST "%WDIR%"\.mvn goto baseDirFound +cd .. +IF "%WDIR%"=="%CD%" goto baseDirNotFound +set WDIR=%CD% +goto findBaseDir + +:baseDirFound +set MAVEN_PROJECTBASEDIR=%WDIR% +cd "%EXEC_DIR%" +goto endDetectBaseDir + +:baseDirNotFound +set MAVEN_PROJECTBASEDIR=%EXEC_DIR% +cd "%EXEC_DIR%" + +:endDetectBaseDir + +IF NOT EXIST "%MAVEN_PROJECTBASEDIR%\.mvn\jvm.config" goto endReadAdditionalConfig + +@setlocal EnableExtensions EnableDelayedExpansion +for /F "usebackq delims=" %%a in ("%MAVEN_PROJECTBASEDIR%\.mvn\jvm.config") do set JVM_CONFIG_MAVEN_PROPS=!JVM_CONFIG_MAVEN_PROPS! %%a +@endlocal & set JVM_CONFIG_MAVEN_PROPS=%JVM_CONFIG_MAVEN_PROPS% + +:endReadAdditionalConfig + +SET MAVEN_JAVA_EXE="%JAVA_HOME%\bin\java.exe" +set WRAPPER_JAR="%MAVEN_PROJECTBASEDIR%\.mvn\wrapper\maven-wrapper.jar" +set WRAPPER_LAUNCHER=org.apache.maven.wrapper.MavenWrapperMain + +set DOWNLOAD_URL="https://repo.maven.apache.org/maven2/io/takari/maven-wrapper/0.5.6/maven-wrapper-0.5.6.jar" + +FOR /F "tokens=1,2 delims==" %%A IN ("%MAVEN_PROJECTBASEDIR%\.mvn\wrapper\maven-wrapper.properties") DO ( + IF "%%A"=="wrapperUrl" SET DOWNLOAD_URL=%%B +) + +@REM Extension to allow automatically downloading the maven-wrapper.jar from Maven-central +@REM This allows using the maven wrapper in projects that prohibit checking in binary data. +if exist %WRAPPER_JAR% ( + if "%MVNW_VERBOSE%" == "true" ( + echo Found %WRAPPER_JAR% + ) +) else ( + if not "%MVNW_REPOURL%" == "" ( + SET DOWNLOAD_URL="%MVNW_REPOURL%/io/takari/maven-wrapper/0.5.6/maven-wrapper-0.5.6.jar" + ) + if "%MVNW_VERBOSE%" == "true" ( + echo Couldn't find %WRAPPER_JAR%, downloading it ... + echo Downloading from: %DOWNLOAD_URL% + ) + + powershell -Command "&{"^ + "$webclient = new-object System.Net.WebClient;"^ + "if (-not ([string]::IsNullOrEmpty('%MVNW_USERNAME%') -and [string]::IsNullOrEmpty('%MVNW_PASSWORD%'))) {"^ + "$webclient.Credentials = new-object System.Net.NetworkCredential('%MVNW_USERNAME%', '%MVNW_PASSWORD%');"^ + "}"^ + "[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12; $webclient.DownloadFile('%DOWNLOAD_URL%', '%WRAPPER_JAR%')"^ + "}" + if "%MVNW_VERBOSE%" == "true" ( + echo Finished downloading %WRAPPER_JAR% + ) +) +@REM End of extension + +@REM Provide a "standardized" way to retrieve the CLI args that will +@REM work with both Windows and non-Windows executions. +set MAVEN_CMD_LINE_ARGS=%* + +%MAVEN_JAVA_EXE% %JVM_CONFIG_MAVEN_PROPS% %MAVEN_OPTS% %MAVEN_DEBUG_OPTS% -classpath %WRAPPER_JAR% "-Dmaven.multiModuleProjectDirectory=%MAVEN_PROJECTBASEDIR%" %WRAPPER_LAUNCHER% %MAVEN_CONFIG% %* +if ERRORLEVEL 1 goto error +goto end + +:error +set ERROR_CODE=1 + +:end +@endlocal & set ERROR_CODE=%ERROR_CODE% + +if not "%MAVEN_SKIP_RC%" == "" goto skipRcPost +@REM check for post script, once with legacy .bat ending and once with .cmd ending +if exist "%HOME%\mavenrc_post.bat" call "%HOME%\mavenrc_post.bat" +if exist "%HOME%\mavenrc_post.cmd" call "%HOME%\mavenrc_post.cmd" +:skipRcPost + +@REM pause the script if MAVEN_BATCH_PAUSE is set to 'on' +if "%MAVEN_BATCH_PAUSE%" == "on" pause + +if "%MAVEN_TERMINATE_CMD%" == "on" exit %ERROR_CODE% + +exit /B %ERROR_CODE% diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..bfb7bc4 --- /dev/null +++ b/pom.xml @@ -0,0 +1,242 @@ + + + 4.0.0 + + org.mobarena + mobarena-stats + 1.0-SNAPSHOT + + + UTF-8 + + + + + + org.jdbi + jdbi3-core + 3.20.1 + + + + + org.slf4j + slf4j-simple + 1.7.31 + + + + + org.bukkit + bukkit + 1.13-R0.1-SNAPSHOT + provided + + + + + com.github.garbagemule + MobArena + 0.105 + provided + + + + + mysql + mysql-connector-java + 8.0.25 + provided + + + + + org.xerial + sqlite-jdbc + 3.34.0 + provided + + + + + org.junit.jupiter + junit-jupiter + 5.7.2 + test + + + + + org.hamcrest + hamcrest-library + 2.2 + test + + + + + org.mockito + mockito-junit-jupiter + 3.11.2 + test + + + + + org.testcontainers + testcontainers + 1.15.3 + test + + + + + org.testcontainers + junit-jupiter + 1.15.3 + test + + + + + org.testcontainers + mysql + 1.15.3 + test + + + + + org.testcontainers + mariadb + 1.15.3 + test + + + + + org.mariadb.jdbc + mariadb-java-client + 2.7.3 + test + + + + + + spigot-repo + https://hub.spigotmc.org/nexus/content/groups/public/ + + + + jitpack.io + https://jitpack.io + + + + + + + src/main/resources + true + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.8.1 + + 11 + + + + + org.apache.maven.plugins + maven-surefire-plugin + 2.22.2 + + + + org.apache.maven.plugins + maven-shade-plugin + 3.2.4 + + + package + + shade + + + MobArenaStats + true + false + + + org.jdbi + org.mobarena.stats.libs.jdbi + + + com.github.benmanes.caffeine + org.mobarena.stats.libs.caffeine + + + io.leangen.geantyref + org.mobarena.stats.libs.geantryref + + + org.antlr + org.mobarena.stats.libs.antlr + + + org.slf4j + org.mobarena.stats.libs.slf4j + + + org.checkerframework + org.mobarena.stats.libs.checkerframework + + + + + + + + com.github.ben-manes.caffeine:caffeine + + ** + + + + *:* + + module-info.class + META-INF/* + + + + + + + + + + org.apache.maven.plugins + maven-failsafe-plugin + 3.0.0-M5 + + + + integration-test + verify + + + + + + + + diff --git a/src/main/java/org/mobarena/stats/MobArenaStats.java b/src/main/java/org/mobarena/stats/MobArenaStats.java new file mode 100644 index 0000000..e256095 --- /dev/null +++ b/src/main/java/org/mobarena/stats/MobArenaStats.java @@ -0,0 +1,24 @@ +package org.mobarena.stats; + +import org.mobarena.stats.store.StatsStore; +import org.mobarena.stats.store.StatsStoreRegistry; + +import java.io.File; +import java.util.concurrent.Executor; +import java.util.logging.Logger; + +public interface MobArenaStats { + + Logger getLogger(); + + Executor getSyncExecutor(); + + Executor getAsyncExecutor(); + + StatsStore getStatsStore(); + + StatsStoreRegistry getStatsStoreRegistry(); + + File getDataFolder(); + +} diff --git a/src/main/java/org/mobarena/stats/MobArenaStatsPlugin.java b/src/main/java/org/mobarena/stats/MobArenaStatsPlugin.java new file mode 100644 index 0000000..ed27e36 --- /dev/null +++ b/src/main/java/org/mobarena/stats/MobArenaStatsPlugin.java @@ -0,0 +1,193 @@ +package org.mobarena.stats; + +import com.garbagemule.MobArena.MobArena; +import com.garbagemule.MobArena.commands.CommandHandler; +import org.bukkit.command.PluginCommand; +import org.bukkit.configuration.ConfigurationSection; +import org.bukkit.event.HandlerList; +import org.bukkit.plugin.PluginManager; +import org.mobarena.stats.command.ArenaStatsCommand; +import org.mobarena.stats.command.DeleteSessionStatsCommand; +import org.mobarena.stats.command.ExportCommand; +import org.mobarena.stats.command.GlobalStatsCommand; +import org.mobarena.stats.command.ImportCommand; +import org.mobarena.stats.command.PlayerStatsCommand; +import org.mobarena.stats.platform.AsyncBukkitExecutor; +import org.mobarena.stats.platform.SyncBukkitExecutor; +import org.mobarena.stats.session.SessionListener; +import org.mobarena.stats.session.SessionStore; +import org.mobarena.stats.store.StatsStore; +import org.bukkit.plugin.java.JavaPlugin; +import org.bukkit.scheduler.BukkitScheduler; +import org.mobarena.stats.store.StatsStoreRegistry; +import org.mobarena.stats.store.csv.CsvStatsStore; +import org.mobarena.stats.store.jdbc.JdbcStatsStore; +import org.mobarena.stats.store.mariadb.MariadbStatsStore; +import org.mobarena.stats.store.mysql.MysqlStatsStore; +import org.mobarena.stats.store.sqlite.SqliteStatsStore; + +import java.io.File; +import java.util.concurrent.Executor; +import java.util.logging.Level; + +public class MobArenaStatsPlugin extends JavaPlugin implements MobArenaStats { + + // The sad state of affairs is that MobArena's command framework has no + // support for registering commands by instance, but only by class, which + // means that we can't properly inject dependencies and have to resort to + // the Singleton Pattern. + private static MobArenaStats instance; + + private StatsStoreRegistry statsStoreRegistry; + + private Executor syncExecutor; + private Executor asyncExecutor; + + private SessionStore sessionStore; + private StatsStore statsStore; + private SessionListener sessionListener; + + @Override + public void onLoad() { + createStatsStoreRegistry(); + registerStatsStoreFactories(); + } + + private void createStatsStoreRegistry() { + statsStoreRegistry = StatsStoreRegistry.create(this); + } + + private void registerStatsStoreFactories() { + statsStoreRegistry.register("csv", CsvStatsStore::create); + statsStoreRegistry.register("jdbc", JdbcStatsStore::create); + statsStoreRegistry.register("sqlite", SqliteStatsStore::create); + statsStoreRegistry.register("mysql", MysqlStatsStore::create); + statsStoreRegistry.register("mariadb", MariadbStatsStore::create); + } + + @Override + public void onEnable() { + setup(); + reload(); + } + + private void setup() { + try { + instance = this; + + createDataFolder(); + createConfigFile(); + setupExecutors(); + setupCommands(); + } catch (Exception up) { + // If setup fails, we can't recover, so throw up + throw new RuntimeException(up); + } + } + + private void createDataFolder() { + File dir = getDataFolder(); + if (!dir.exists()) { + if (!dir.mkdir()) { + throw new IllegalStateException("Failed to create plugin data folder!"); + } + getLogger().info("Data folder created."); + } + } + + private void createConfigFile() { + File file = new File(getDataFolder(), "config.yml"); + if (!file.exists()) { + saveResource("config.yml", false); + getLogger().info("config.yml created."); + } + } + + private void setupExecutors() { + BukkitScheduler scheduler = getServer().getScheduler(); + if (syncExecutor == null) { + syncExecutor = new SyncBukkitExecutor(this, scheduler); + } + if (asyncExecutor == null) { + asyncExecutor = new AsyncBukkitExecutor(this, scheduler); + } + } + + private void setupCommands() { + PluginManager manager = getServer().getPluginManager(); + MobArena mobarena = (MobArena) manager.getPlugin("MobArena"); + + PluginCommand command = mobarena.getCommand("ma"); + CommandHandler handler = (CommandHandler) command.getExecutor(); + + // User commands + handler.register(ArenaStatsCommand.class); + handler.register(GlobalStatsCommand.class); + handler.register(PlayerStatsCommand.class); + + // Admin commands + handler.register(DeleteSessionStatsCommand.class); + handler.register(ExportCommand.class); + handler.register(ImportCommand.class); + } + + private void reload() { + try { + createSessionStore(); + loadStatsStore(); + registerSessionListener(); + } catch (Exception e) { + getLogger().log(Level.SEVERE, "Load failure", e); + } + } + + private void createSessionStore() { + if (sessionStore != null) { + sessionStore.clear(); + } + + sessionStore = SessionStore.createNew(); + getLogger().info("Session store created."); + } + + private void loadStatsStore() throws Exception { + ConfigurationSection config = getConfig(); + ConfigurationSection section = config.getConfigurationSection("store"); + if (section == null) { + throw new IllegalArgumentException("No store section in config-file."); + } + + statsStore = statsStoreRegistry.create(section); + } + + private void registerSessionListener() { + if (sessionListener != null) { + HandlerList.unregisterAll(sessionListener); + } + + sessionListener = new SessionListener(sessionStore, statsStore, asyncExecutor, getLogger()); + getServer().getPluginManager().registerEvents(sessionListener, this); + getLogger().info("Session listener registered."); + } + + public StatsStoreRegistry getStatsStoreRegistry() { + return statsStoreRegistry; + } + + public Executor getSyncExecutor() { + return syncExecutor; + } + + public Executor getAsyncExecutor() { + return asyncExecutor; + } + + public StatsStore getStatsStore() { + return statsStore; + } + + public static MobArenaStats getInstance() { + return instance; + } + +} diff --git a/src/main/java/org/mobarena/stats/command/ArenaStatsCommand.java b/src/main/java/org/mobarena/stats/command/ArenaStatsCommand.java new file mode 100644 index 0000000..f82b51c --- /dev/null +++ b/src/main/java/org/mobarena/stats/command/ArenaStatsCommand.java @@ -0,0 +1,57 @@ +package org.mobarena.stats.command; + +import com.garbagemule.MobArena.commands.Command; +import com.garbagemule.MobArena.commands.CommandInfo; +import com.garbagemule.MobArena.framework.ArenaMaster; +import com.garbagemule.MobArena.util.Slugs; +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; +import org.mobarena.stats.MobArenaStats; +import org.mobarena.stats.MobArenaStatsPlugin; +import org.mobarena.stats.store.ArenaStats; +import org.mobarena.stats.store.StatsStore; + +import java.util.List; + +@CommandInfo( + name = "arena-stats", + pattern = "arena-stats", + usage = "/ma arena-stats ", + desc = "show overall stats for the given arena", + permission = "mobarenastats.command.arena-stats" +) +public class ArenaStatsCommand implements Command { + + @Override + public boolean execute(ArenaMaster am, CommandSender sender, String... args) { + // :( + MobArenaStats plugin = MobArenaStatsPlugin.getInstance(); + + if (args.length < 1) { + return false; + } + String slug = Slugs.create(args[0]); + + plugin.getAsyncExecutor().execute(() -> { + StatsStore store = plugin.getStatsStore(); + ArenaStats stats = store.getArenaStats(slug); + sender.sendMessage("Stats for arena " + slug + ":"); + sender.sendMessage("- Highest wave: " + stats.highestWave); + sender.sendMessage("- Longest duration: " + stats.highestSeconds + " secs"); + sender.sendMessage("- Most kills: " + stats.highestKills); + sender.sendMessage("- Total sessions: " + stats.totalSessions); + sender.sendMessage("- Total duration: " + stats.totalSeconds + " secs"); + sender.sendMessage("- Total kills: " + stats.totalKills); + sender.sendMessage("- Total waves: " + stats.totalWaves); + }); + + return true; + } + + @Override + public List tab(ArenaMaster am, Player player, String... args) { + // TODO: tab complete arena slugs? + return Command.super.tab(am, player, args); + } + +} diff --git a/src/main/java/org/mobarena/stats/command/DeleteSessionStatsCommand.java b/src/main/java/org/mobarena/stats/command/DeleteSessionStatsCommand.java new file mode 100644 index 0000000..9a5b282 --- /dev/null +++ b/src/main/java/org/mobarena/stats/command/DeleteSessionStatsCommand.java @@ -0,0 +1,56 @@ +package org.mobarena.stats.command; + +import com.garbagemule.MobArena.Messenger; +import com.garbagemule.MobArena.commands.Command; +import com.garbagemule.MobArena.commands.CommandInfo; +import com.garbagemule.MobArena.framework.ArenaMaster; +import org.bukkit.ChatColor; +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; +import org.mobarena.stats.MobArenaStats; +import org.mobarena.stats.MobArenaStatsPlugin; +import org.mobarena.stats.store.StatsStore; + +import java.util.List; +import java.util.UUID; + +@CommandInfo( + name = "delete-session-stats", + pattern = "delete-session-stats", + usage = "/ma delete-session-stats ", + desc = "delete all stats collected for the given session", + permission = "mobarenastats.command.delete-session-stats" +) +public class DeleteSessionStatsCommand implements Command { + + @Override + public boolean execute(ArenaMaster am, CommandSender sender, String... args) { + // :( + MobArenaStats plugin = MobArenaStatsPlugin.getInstance(); + + // TODO: check args, handle non-UUID input error + UUID sessionId = UUID.fromString(args[0]); + + StatsStore store = plugin.getStatsStore(); + Messenger messenger = am.getGlobalMessenger(); + + plugin.getAsyncExecutor().execute(() -> { + store.delete(sessionId); + messenger.tell(sender, String.format( + "Stats for session %s%s%s deleted.", + ChatColor.YELLOW, + sessionId, + ChatColor.RESET + )); + }); + + return true; + } + + @Override + public List tab(ArenaMaster am, Player player, String... args) { + // TODO: tab complete session IDs? + return Command.super.tab(am, player, args); + } + +} diff --git a/src/main/java/org/mobarena/stats/command/ExportCommand.java b/src/main/java/org/mobarena/stats/command/ExportCommand.java new file mode 100644 index 0000000..8e54ecd --- /dev/null +++ b/src/main/java/org/mobarena/stats/command/ExportCommand.java @@ -0,0 +1,57 @@ +package org.mobarena.stats.command; + +import com.garbagemule.MobArena.Messenger; +import com.garbagemule.MobArena.commands.Command; +import com.garbagemule.MobArena.commands.CommandInfo; +import com.garbagemule.MobArena.framework.ArenaMaster; +import org.bukkit.ChatColor; +import org.bukkit.command.CommandSender; +import org.mobarena.stats.MobArenaStats; +import org.mobarena.stats.MobArenaStatsPlugin; +import org.mobarena.stats.store.StatsExport; +import org.mobarena.stats.store.StatsStore; +import org.mobarena.stats.store.StatsStoreRegistry; + +@CommandInfo( + name = "export-stats", + pattern = "export-stats", + usage = "/ma export-stats", + desc = "export the current stats store to a file in the given format", + permission = "mobarenastats.command.export-stats" +) +public class ExportCommand implements Command { + + @Override + public boolean execute(ArenaMaster am, CommandSender sender, String... args) { + // :( + MobArenaStats plugin = MobArenaStatsPlugin.getInstance(); + + StatsStore store = plugin.getStatsStore(); + StatsStoreRegistry registry = plugin.getStatsStoreRegistry(); + + Messenger messenger = am.getGlobalMessenger(); + messenger.tell(sender, "Exporting stats. This may take a while..."); + plugin.getAsyncExecutor().execute(() -> { + try { + String filename = StatsExport.run(store, registry); + + messenger.tell(sender, String.format( + "Export to %s%s%s complete.", + ChatColor.YELLOW, + filename, + ChatColor.RESET + )); + } catch (Exception e) { + messenger.tell(sender, String.format( + "Export %sfailed%s because:\n%s", + ChatColor.RED, + ChatColor.RESET, + e.getMessage() + )); + } + }); + + return true; + } + +} diff --git a/src/main/java/org/mobarena/stats/command/GlobalStatsCommand.java b/src/main/java/org/mobarena/stats/command/GlobalStatsCommand.java new file mode 100644 index 0000000..74aaf78 --- /dev/null +++ b/src/main/java/org/mobarena/stats/command/GlobalStatsCommand.java @@ -0,0 +1,39 @@ +package org.mobarena.stats.command; + +import com.garbagemule.MobArena.commands.Command; +import com.garbagemule.MobArena.commands.CommandInfo; +import com.garbagemule.MobArena.framework.ArenaMaster; +import org.bukkit.command.CommandSender; +import org.mobarena.stats.MobArenaStats; +import org.mobarena.stats.MobArenaStatsPlugin; +import org.mobarena.stats.store.GlobalStats; +import org.mobarena.stats.store.StatsStore; + +@CommandInfo( + name = "global-stats", + pattern = "global-stats", + usage = "/ma global-stats", + desc = "show stats across all sessions", + permission = "mobarenastats.command.global-stats" +) +public class GlobalStatsCommand implements Command { + + @Override + public boolean execute(ArenaMaster am, CommandSender sender, String... args) { + // :( + MobArenaStats plugin = MobArenaStatsPlugin.getInstance(); + + plugin.getAsyncExecutor().execute(() -> { + StatsStore store = plugin.getStatsStore(); + GlobalStats stats = store.getGlobalStats(); + sender.sendMessage("Global stats:"); + sender.sendMessage("- Total sessions: " + stats.totalSessions); + sender.sendMessage("- Total duration: " + stats.totalSeconds + " secs"); + sender.sendMessage("- Total kills: " + stats.totalKills); + sender.sendMessage("- Total waves: " + stats.totalWaves); + }); + + return true; + } + +} diff --git a/src/main/java/org/mobarena/stats/command/ImportCommand.java b/src/main/java/org/mobarena/stats/command/ImportCommand.java new file mode 100644 index 0000000..ba973a4 --- /dev/null +++ b/src/main/java/org/mobarena/stats/command/ImportCommand.java @@ -0,0 +1,130 @@ +package org.mobarena.stats.command; + +import com.garbagemule.MobArena.Messenger; +import com.garbagemule.MobArena.commands.Command; +import com.garbagemule.MobArena.commands.CommandInfo; +import com.garbagemule.MobArena.framework.ArenaMaster; +import org.bukkit.ChatColor; +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; +import org.mobarena.stats.MobArenaStats; +import org.mobarena.stats.MobArenaStatsPlugin; +import org.mobarena.stats.store.StatsExport; +import org.mobarena.stats.store.StatsImport; +import org.mobarena.stats.store.StatsStore; +import org.mobarena.stats.store.StatsStoreRegistry; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.stream.Collectors; + +@CommandInfo( + name = "import-stats", + pattern = "import-stats", + usage = "/ma import-stats ", + desc = "import stats from an database export file into the current stats store", + permission = "mobarenastats.command.import-stats" +) +public class ImportCommand implements Command { + + @Override + public boolean execute(ArenaMaster am, CommandSender sender, String... args) { + if (args.length < 1) { + return false; + } + + // :( + MobArenaStats plugin = MobArenaStatsPlugin.getInstance(); + + Path data = plugin.getDataFolder().toPath(); + Path file = data.resolve(args[0]); + if (!Files.exists(file)) { + sender.sendMessage(String.format( + "File %s%s%s not found.", + ChatColor.YELLOW, + args[0], + ChatColor.RESET + )); + return false; + } + + String filename = file.getFileName().toString(); + if (!filename.startsWith(StatsExport.FILENAME_PREFIX)) { + sender.sendMessage(String.format( + "Not a valid database export; filename must start with %s%s%s.", + ChatColor.YELLOW, + StatsExport.FILENAME_PREFIX, + ChatColor.RESET + )); + return true; + } + if (!filename.endsWith(StatsExport.FILENAME_SUFFIX)) { + sender.sendMessage(String.format( + "Not a valid database export; filename must end with %s%s%s.", + ChatColor.YELLOW, + StatsExport.FILENAME_SUFFIX, + ChatColor.RESET + )); + return true; + } + + StatsStore store = plugin.getStatsStore(); + StatsStoreRegistry registry = plugin.getStatsStoreRegistry(); + + Messenger messenger = am.getGlobalMessenger(); + messenger.tell(sender, String.format( + "Importing stats from %s%s%s. This may take a while...", + ChatColor.YELLOW, + filename, + ChatColor.RESET + )); + plugin.getAsyncExecutor().execute(() -> { + try { + StatsImport.run(registry, filename, store); + + messenger.tell(sender, String.format( + "Import from %s%s%s complete.", + ChatColor.YELLOW, + filename, + ChatColor.RESET + )); + } catch (Exception e) { + messenger.tell(sender, String.format( + "Import %sfailed%s because:\n%s", + ChatColor.RED, + ChatColor.RESET, + e.getMessage() + )); + } + }); + + return true; + } + + @Override + public List tab(ArenaMaster am, Player player, String... args) { + if (args.length > 1) { + return Collections.emptyList(); + } + + // :( + MobArenaStats plugin = MobArenaStatsPlugin.getInstance(); + + String[] files = plugin.getDataFolder().list(); + if (files == null || files.length == 0) { + return Collections.emptyList(); + } + + String prefix = (args.length == 1) ? args[0] : ""; + + return Arrays.stream(files) + .filter(filename -> filename.startsWith(prefix)) + .filter(filename -> filename.startsWith(StatsExport.FILENAME_PREFIX)) + .filter(filename -> filename.endsWith(StatsExport.FILENAME_SUFFIX)) + .collect(Collectors.toList()); + } + +} diff --git a/src/main/java/org/mobarena/stats/command/PlayerStatsCommand.java b/src/main/java/org/mobarena/stats/command/PlayerStatsCommand.java new file mode 100644 index 0000000..9b1daeb --- /dev/null +++ b/src/main/java/org/mobarena/stats/command/PlayerStatsCommand.java @@ -0,0 +1,58 @@ +package org.mobarena.stats.command; + +import com.garbagemule.MobArena.commands.Command; +import com.garbagemule.MobArena.commands.CommandInfo; +import com.garbagemule.MobArena.framework.ArenaMaster; +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; +import org.mobarena.stats.MobArenaStats; +import org.mobarena.stats.MobArenaStatsPlugin; +import org.mobarena.stats.store.PlayerStats; +import org.mobarena.stats.store.StatsStore; + +import java.util.List; + +@CommandInfo( + name = "player-stats", + pattern = "player-stats", + usage = "/ma player-stats ", + desc = "show overall stats for the given player", + permission = "mobarenastats.command.player-stats" +) +public class PlayerStatsCommand implements Command { + + @Override + public boolean execute(ArenaMaster am, CommandSender sender, String... args) { + // :( + MobArenaStats plugin = MobArenaStatsPlugin.getInstance(); + + String name; + if (args.length == 0) { + if (!(sender instanceof Player)) { + return false; + } + name = sender.getName(); + } else { + name = args[0]; + } + + plugin.getAsyncExecutor().execute(() -> { + StatsStore store = plugin.getStatsStore(); + PlayerStats stats = store.getPlayerStats(name); + sender.sendMessage("Stats for player " + name + ":"); + sender.sendMessage("- Total sessions: " + stats.totalSessions); + sender.sendMessage("- Total duration: " + stats.totalSeconds + " secs"); + sender.sendMessage("- Total kills: " + stats.totalKills); + sender.sendMessage("- Total waves: " + stats.totalWaves); + }); + + return true; + } + + @Override + public List tab(ArenaMaster am, Player player, String... args) { + // TODO: tab complete player names? + return Command.super.tab(am, player, args); + } + +} diff --git a/src/main/java/org/mobarena/stats/platform/AsyncBukkitExecutor.java b/src/main/java/org/mobarena/stats/platform/AsyncBukkitExecutor.java new file mode 100644 index 0000000..d99ca94 --- /dev/null +++ b/src/main/java/org/mobarena/stats/platform/AsyncBukkitExecutor.java @@ -0,0 +1,23 @@ +package org.mobarena.stats.platform; + +import org.bukkit.plugin.Plugin; +import org.bukkit.scheduler.BukkitScheduler; + +import java.util.concurrent.Executor; + +public class AsyncBukkitExecutor implements Executor { + + private final Plugin plugin; + private final BukkitScheduler scheduler; + + public AsyncBukkitExecutor(Plugin plugin, BukkitScheduler scheduler) { + this.plugin = plugin; + this.scheduler = scheduler; + } + + @Override + public void execute(Runnable command) { + scheduler.runTaskAsynchronously(plugin, command); + } + +} diff --git a/src/main/java/org/mobarena/stats/platform/SyncBukkitExecutor.java b/src/main/java/org/mobarena/stats/platform/SyncBukkitExecutor.java new file mode 100644 index 0000000..4655ada --- /dev/null +++ b/src/main/java/org/mobarena/stats/platform/SyncBukkitExecutor.java @@ -0,0 +1,23 @@ +package org.mobarena.stats.platform; + +import org.bukkit.plugin.Plugin; +import org.bukkit.scheduler.BukkitScheduler; + +import java.util.concurrent.Executor; + +public class SyncBukkitExecutor implements Executor { + + private final Plugin plugin; + private final BukkitScheduler scheduler; + + public SyncBukkitExecutor(Plugin plugin, BukkitScheduler scheduler) { + this.plugin = plugin; + this.scheduler = scheduler; + } + + @Override + public void execute(Runnable command) { + scheduler.runTask(plugin, command); + } + +} diff --git a/src/main/java/org/mobarena/stats/session/PlayerConclusion.java b/src/main/java/org/mobarena/stats/session/PlayerConclusion.java new file mode 100644 index 0000000..82df4f9 --- /dev/null +++ b/src/main/java/org/mobarena/stats/session/PlayerConclusion.java @@ -0,0 +1,23 @@ +package org.mobarena.stats.session; + +public enum PlayerConclusion { + + /** + * When an arena has a final wave and the given player reaches and + * completes that wave, the player session concludes in a victory. + */ + VICTORY, + + /** + * When the given player dies in an arena, the player's session will + * concludes in a defeat, even if other players are still alive. + */ + DEFEAT, + + /** + * When the given player leaves an ongoing arena, the player's session + * will conclude in a retreat, even if other players are still alive. + */ + RETREAT, + +} diff --git a/src/main/java/org/mobarena/stats/session/PlayerSessionStats.java b/src/main/java/org/mobarena/stats/session/PlayerSessionStats.java new file mode 100644 index 0000000..9930517 --- /dev/null +++ b/src/main/java/org/mobarena/stats/session/PlayerSessionStats.java @@ -0,0 +1,38 @@ +package org.mobarena.stats.session; + +import java.time.Instant; +import java.util.UUID; + +public class PlayerSessionStats { + + public final UUID sessionId; + public final UUID playerId; + public final String playerName; + + public String className; + + public Instant joinTime; + public Instant readyTime; + public Instant leaveTime; + public Instant deathTime; + + public int kills; + public int dmgDone; + public int dmgTaken; + public int swings; + public int hits; + public int lastWave; + + public PlayerConclusion conclusion; + + public PlayerSessionStats( + UUID sessionId, + UUID playerId, + String playerName + ) { + this.sessionId = sessionId; + this.playerId = playerId; + this.playerName = playerName; + } + +} diff --git a/src/main/java/org/mobarena/stats/session/Session.java b/src/main/java/org/mobarena/stats/session/Session.java new file mode 100644 index 0000000..2fc0b2b --- /dev/null +++ b/src/main/java/org/mobarena/stats/session/Session.java @@ -0,0 +1,130 @@ +package org.mobarena.stats.session; + +import com.garbagemule.MobArena.framework.Arena; +import org.bukkit.entity.Player; + +import java.time.Instant; +import java.util.Collection; +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; + +public class Session { + + final UUID sessionId; + final String arenaSlug; + + final SessionStats sessionStats; + final Map playerStats; + + public Session(UUID sessionId, String arenaSlug) { + this.sessionId = sessionId; + this.arenaSlug = arenaSlug; + + this.sessionStats = new SessionStats(sessionId, arenaSlug); + this.playerStats = new HashMap<>(); + } + + public void playerJoin(Player player) { + UUID playerId = player.getUniqueId(); + String playerName = player.getName(); + PlayerSessionStats stats = new PlayerSessionStats(sessionId, playerId, playerName); + playerStats.put(playerId, stats); + + stats.joinTime = Instant.now(); + } + + public void playerReady(Player player, String className) { + UUID playerId = player.getUniqueId(); + PlayerSessionStats stats = playerStats.get(playerId); + if (stats == null) { + return; + } + + stats.readyTime = Instant.now(); + stats.className = className; + } + + public void playerLeave(Arena arena, Player player) { + UUID playerId = player.getUniqueId(); + PlayerSessionStats stats = playerStats.get(playerId); + if (stats == null) { + return; + } + + stats.leaveTime = Instant.now(); + + if (stats.conclusion == null) { + stats.conclusion = PlayerConclusion.RETREAT; + } + + StatsUtil.copy(arena, player, stats); + } + + public void playerDeath(Arena arena, Player player) { + UUID playerId = player.getUniqueId(); + PlayerSessionStats stats = playerStats.get(playerId); + if (stats == null) { + return; + } + + stats.deathTime = Instant.now(); + + if (stats.conclusion == null) { + stats.conclusion = PlayerConclusion.DEFEAT; + } + + StatsUtil.copy(arena, player, stats); + } + + public void start() { + sessionStats.startTime = Instant.now(); + } + + public void wave(int wave) { + sessionStats.lastWave = wave; + } + + public void complete() { + sessionStats.conclusion = SessionConclusion.VICTORY; + + for (PlayerSessionStats playerStats : playerStats.values()) { + if (playerStats.conclusion == null) { + playerStats.conclusion = PlayerConclusion.VICTORY; + } + } + } + + public void end() { + sessionStats.endTime = Instant.now(); + + if (sessionStats.conclusion == null) { + sessionStats.conclusion = SessionConclusion.DEFEAT; + } + } + + public UUID getSessionId() { + return sessionId; + } + + public String getArenaSlug() { + return arenaSlug; + } + + public SessionStats getSessionStats() { + return sessionStats; + } + + public Collection getPlayerStats() { + return playerStats.values(); + } + + public PlayerSessionStats getPlayerStats(UUID playerId) { + return playerStats.get(playerId); + } + + public void setPlayerStats(UUID playerId, PlayerSessionStats stats) { + playerStats.put(playerId, stats); + } + +} diff --git a/src/main/java/org/mobarena/stats/session/SessionConclusion.java b/src/main/java/org/mobarena/stats/session/SessionConclusion.java new file mode 100644 index 0000000..32c27f3 --- /dev/null +++ b/src/main/java/org/mobarena/stats/session/SessionConclusion.java @@ -0,0 +1,17 @@ +package org.mobarena.stats.session; + +public enum SessionConclusion { + + /** + * When an arena has a final wave and one or more players reach and + * complete that wave, the session concludes in a victory. + */ + VICTORY, + + /** + * When the last player alive in an arena dies, the session concludes + * in a defeat, meaning the players "lost" the session. + */ + DEFEAT, + +} diff --git a/src/main/java/org/mobarena/stats/session/SessionListener.java b/src/main/java/org/mobarena/stats/session/SessionListener.java new file mode 100644 index 0000000..9e3a366 --- /dev/null +++ b/src/main/java/org/mobarena/stats/session/SessionListener.java @@ -0,0 +1,190 @@ +package org.mobarena.stats.session; + +import com.garbagemule.MobArena.ArenaClass; +import com.garbagemule.MobArena.ArenaPlayer; +import com.garbagemule.MobArena.events.ArenaCompleteEvent; +import com.garbagemule.MobArena.events.ArenaEndEvent; +import com.garbagemule.MobArena.events.ArenaPlayerDeathEvent; +import com.garbagemule.MobArena.events.ArenaPlayerJoinEvent; +import com.garbagemule.MobArena.events.ArenaPlayerLeaveEvent; +import com.garbagemule.MobArena.events.ArenaPlayerReadyEvent; +import com.garbagemule.MobArena.events.ArenaStartEvent; +import com.garbagemule.MobArena.events.NewWaveEvent; +import com.garbagemule.MobArena.framework.Arena; +import org.mobarena.stats.store.StatsStore; +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; + +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Executor; +import java.util.logging.Level; +import java.util.logging.Logger; + +public class SessionListener implements Listener { + + private final SessionStore sessionStore; + private final StatsStore statsStore; + private final Executor asyncExecutor; + private final Logger log; + + public SessionListener( + SessionStore sessionStore, + StatsStore statsStore, + Executor asyncExecutor, + Logger log + ) { + this.sessionStore = sessionStore; + this.statsStore = statsStore; + this.asyncExecutor = asyncExecutor; + this.log = log; + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void on(ArenaPlayerJoinEvent event) { + Arena arena = event.getArena(); + Player player = event.getPlayer(); + + Session session = sessionStore.getByArena(arena); + if (session == null) { + session = sessionStore.create(arena); + } + + session.playerJoin(player); + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void on(ArenaPlayerReadyEvent event) { + Arena arena = event.getArena(); + Player player = event.getPlayer(); + String className = getClassName(arena, player); + + Session session = sessionStore.getByArena(arena); + if (session == null) { + log.warning("Unexpected ready event for non-existent session of arena " + arena.getSlug()); + return; + } + + session.playerReady(player, className); + } + + private String getClassName(Arena arena, Player player) { + ArenaPlayer ap = arena.getArenaPlayer(player); + if (ap == null) { + return null; + } + + ArenaClass ac = ap.getArenaClass(); + if (ac == null) { + return null; + } + + return ac.getSlug(); + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void on(ArenaPlayerLeaveEvent event) { + Arena arena = event.getArena(); + Player player = event.getPlayer(); + + Session session = sessionStore.getByArena(arena); + if (session == null) { + log.warning("Unexpected leave event for non-existent session of arena " + arena.getSlug()); + return; + } + + session.playerLeave(arena, player); + + if (!arena.isRunning()) { + if (arena.getPlayersInLobby().size() <= 1) { + sessionStore.delete(session); + } + } + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void on(ArenaPlayerDeathEvent event) { + Arena arena = event.getArena(); + Player player = event.getPlayer(); + + Session session = sessionStore.getByArena(arena); + if (session == null) { + log.warning("Unexpected death event for non-existent session of arena " + arena.getSlug()); + return; + } + + session.playerDeath(arena, player); + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void on(ArenaStartEvent event) { + Arena arena = event.getArena(); + + Session session = sessionStore.getByArena(arena); + if (session == null) { + log.warning("Unexpected start event for non-existent session of arena " + arena.getSlug()); + return; + } + + session.start(); + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void on(NewWaveEvent event) { + Arena arena = event.getArena(); + int wave = event.getWaveNumber(); + + Session session = sessionStore.getByArena(arena); + if (session == null) { + log.warning("Unexpected wave event for non-existent session of arena " + arena.getSlug()); + return; + } + + session.wave(wave); + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void on(ArenaCompleteEvent event) { + Arena arena = event.getArena(); + + Session session = sessionStore.getByArena(arena); + if (session == null) { + log.warning("Unexpected complete event for non-existent session of arena " + arena.getSlug()); + return; + } + + session.complete(); + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void on(ArenaEndEvent event) { + Arena arena = event.getArena(); + + Session session = sessionStore.getByArena(arena); + if (session == null) { + log.warning("Unexpected end event for non-existent session of arena " + arena.getSlug()); + return; + } + + session.end(); + + if (!arena.isRunning()) { + // Session never started, so just clean up and bail + sessionStore.delete(session); + return; + } + + CompletableFuture.runAsync(() -> { + try { + statsStore.save(session); + sessionStore.delete(session); + log.info("Session (" + session.sessionId + ") for arena " + session.arenaSlug + " saved."); + } catch (Exception e) { + log.log(Level.SEVERE, "Failed to save session (" + session.sessionId + ") for arena " + session.arenaSlug, e); + sessionStore.delete(session); + } + }, asyncExecutor); + } + +} diff --git a/src/main/java/org/mobarena/stats/session/SessionStats.java b/src/main/java/org/mobarena/stats/session/SessionStats.java new file mode 100644 index 0000000..8d9f970 --- /dev/null +++ b/src/main/java/org/mobarena/stats/session/SessionStats.java @@ -0,0 +1,23 @@ +package org.mobarena.stats.session; + +import java.time.Instant; +import java.util.UUID; + +public class SessionStats { + + public final UUID sessionId; + public final String arenaSlug; + + public Instant startTime; + public Instant endTime; + + public int lastWave; + + public SessionConclusion conclusion; + + public SessionStats(UUID sessionId, String arenaSlug) { + this.sessionId = sessionId; + this.arenaSlug = arenaSlug; + } + +} diff --git a/src/main/java/org/mobarena/stats/session/SessionStore.java b/src/main/java/org/mobarena/stats/session/SessionStore.java new file mode 100644 index 0000000..1ba5467 --- /dev/null +++ b/src/main/java/org/mobarena/stats/session/SessionStore.java @@ -0,0 +1,86 @@ +package org.mobarena.stats.session; + +import com.garbagemule.MobArena.framework.Arena; + +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; + +/** + * In-memory store for on-going session data. + *

+ * The session store is the top-level bookkeeping entity for current sessions + * in that all {@link Session} objects are created and kept track of by this + * store. Unlike {@link org.mobarena.stats.store.StatsStore}, which persists + * its data, the session store is just an in-memory collection. + */ +public class SessionStore { + + private final Map slugToSession; + + private SessionStore() { + this.slugToSession = new HashMap<>(); + } + + /** + * Create a new {@link Session} instance for the given {@link Arena}. + *

+ * Note that only one session can be active per arena. The method throws + * if it is called with an arena instance that already has an associated + * on-going session. + * + * @param arena the arena to create a new session for + * @return a new session for the given arena + * @throws IllegalStateException if a session exists for the given arena + */ + public Session create(Arena arena) { + String arenaSlug = arena.getSlug(); + + if (slugToSession.containsKey(arenaSlug)) { + throw new IllegalStateException("A session for arena " + arenaSlug + " already exists"); + } + + UUID sessionId = UUID.randomUUID(); + Session session = new Session(sessionId, arenaSlug); + slugToSession.put(arenaSlug, session); + + return session; + } + + /** + * Delete the given {@link Session} from the store. + *

+ * When a session is deleted, it opens up the possibility of starting a + * new one for the associated arena. + * + * @param session a session to delete + */ + public void delete(Session session) { + slugToSession.remove(session.getArenaSlug()); + } + + /** + * Look up a {@link Session} by its associated {@link Arena}. + * + * @param arena the arena whose session to look up + * @return the associated session instance, or null + */ + public Session getByArena(Arena arena) { + return slugToSession.get(arena.getSlug()); + } + + /** + * Clear the internal session map. + *

+ * This method is called by MobArenaStats on reloads to try to clear any + * residue from old sessions. + */ + public void clear() { + slugToSession.clear(); + } + + public static SessionStore createNew() { + return new SessionStore(); + } + +} diff --git a/src/main/java/org/mobarena/stats/session/StatsUtil.java b/src/main/java/org/mobarena/stats/session/StatsUtil.java new file mode 100644 index 0000000..b3fff48 --- /dev/null +++ b/src/main/java/org/mobarena/stats/session/StatsUtil.java @@ -0,0 +1,33 @@ +package org.mobarena.stats.session; + +import com.garbagemule.MobArena.ArenaPlayer; +import com.garbagemule.MobArena.ArenaPlayerStatistics; +import com.garbagemule.MobArena.framework.Arena; +import org.bukkit.entity.Player; + +final class StatsUtil { + + private StatsUtil() { + // OK BOSS + } + + static void copy(Arena arena, Player player, PlayerSessionStats target) { + ArenaPlayer ap = arena.getArenaPlayer(player); + if (ap == null) { + return; + } + + ArenaPlayerStatistics aps = ap.getStats(); + if (aps == null) { + return; + } + + target.kills = aps.getInt("kills"); + target.dmgDone = aps.getInt("dmgDone"); + target.dmgTaken = aps.getInt("dmgTaken"); + target.swings = aps.getInt("swings"); + target.hits = aps.getInt("hits"); + target.lastWave = aps.getInt("lastWave"); + } + +} diff --git a/src/main/java/org/mobarena/stats/store/ArenaStats.java b/src/main/java/org/mobarena/stats/store/ArenaStats.java new file mode 100644 index 0000000..deea7fb --- /dev/null +++ b/src/main/java/org/mobarena/stats/store/ArenaStats.java @@ -0,0 +1,31 @@ +package org.mobarena.stats.store; + +public class ArenaStats { + + public final int highestWave; + public final int highestSeconds; + public final int highestKills; + public final int totalSessions; + public final long totalSeconds; + public final long totalKills; + public final long totalWaves; + + public ArenaStats( + int highestWave, + int highestSeconds, + int highestKills, + int totalSessions, + long totalSeconds, + long totalKills, + long totalWaves + ) { + this.highestWave = highestWave; + this.highestSeconds = highestSeconds; + this.highestKills = highestKills; + this.totalSessions = totalSessions; + this.totalSeconds = totalSeconds; + this.totalKills = totalKills; + this.totalWaves = totalWaves; + } + +} diff --git a/src/main/java/org/mobarena/stats/store/CachingStatsStore.java b/src/main/java/org/mobarena/stats/store/CachingStatsStore.java new file mode 100644 index 0000000..48a51b4 --- /dev/null +++ b/src/main/java/org/mobarena/stats/store/CachingStatsStore.java @@ -0,0 +1,67 @@ +package org.mobarena.stats.store; + +import org.mobarena.stats.session.Session; + +import java.io.IOException; +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; + +public class CachingStatsStore implements StatsStore { + + private final StatsStore delegate; + + private GlobalStats globalStats; + private final Map arenaStats; + private final Map playerStats; + + public CachingStatsStore(StatsStore delegate) { + this.delegate = delegate; + + this.globalStats = null; + this.arenaStats = new HashMap<>(); + this.playerStats = new HashMap<>(); + } + + @Override + public void save(Session session) throws IOException { + delegate.save(session); + + globalStats = null; + arenaStats.remove(session.getArenaSlug()); + session.getPlayerStats().forEach(stats -> playerStats.remove(stats.playerName)); + } + + @Override + public void delete(UUID sessionId) { + delegate.delete(sessionId); + + globalStats = null; + arenaStats.clear(); + playerStats.clear(); + } + + @Override + public GlobalStats getGlobalStats() { + if (globalStats == null) { + globalStats = delegate.getGlobalStats(); + } + return globalStats; + } + + @Override + public ArenaStats getArenaStats(String slug) { + return arenaStats.computeIfAbsent(slug, delegate::getArenaStats); + } + + @Override + public PlayerStats getPlayerStats(String name) { + return playerStats.computeIfAbsent(name, delegate::getPlayerStats); + } + + @Override + public void export(StatsStore target) throws IOException { + delegate.export(target); + } + +} diff --git a/src/main/java/org/mobarena/stats/store/GlobalStats.java b/src/main/java/org/mobarena/stats/store/GlobalStats.java new file mode 100644 index 0000000..100fae5 --- /dev/null +++ b/src/main/java/org/mobarena/stats/store/GlobalStats.java @@ -0,0 +1,22 @@ +package org.mobarena.stats.store; + +public class GlobalStats { + + public final int totalSessions; + public final long totalSeconds; + public final long totalKills; + public final long totalWaves; + + public GlobalStats( + int totalSessions, + long totalSeconds, + long totalKills, + long totalWaves + ) { + this.totalSessions = totalSessions; + this.totalSeconds = totalSeconds; + this.totalKills = totalKills; + this.totalWaves = totalWaves; + } + +} diff --git a/src/main/java/org/mobarena/stats/store/PlayerStats.java b/src/main/java/org/mobarena/stats/store/PlayerStats.java new file mode 100644 index 0000000..3d546e1 --- /dev/null +++ b/src/main/java/org/mobarena/stats/store/PlayerStats.java @@ -0,0 +1,22 @@ +package org.mobarena.stats.store; + +public class PlayerStats { + + public final int totalSessions; + public final long totalSeconds; + public final long totalKills; + public final long totalWaves; + + public PlayerStats( + int totalSessions, + long totalSeconds, + long totalKills, + long totalWaves + ) { + this.totalSessions = totalSessions; + this.totalSeconds = totalSeconds; + this.totalKills = totalKills; + this.totalWaves = totalWaves; + } + +} diff --git a/src/main/java/org/mobarena/stats/store/StatsExport.java b/src/main/java/org/mobarena/stats/store/StatsExport.java new file mode 100644 index 0000000..6569c17 --- /dev/null +++ b/src/main/java/org/mobarena/stats/store/StatsExport.java @@ -0,0 +1,27 @@ +package org.mobarena.stats.store; + +import org.bukkit.configuration.ConfigurationSection; +import org.bukkit.configuration.file.YamlConfiguration; + +public class StatsExport { + + public static final String FILENAME_PREFIX = "stats.export-"; + public static final String FILENAME_SUFFIX = ".db"; + + public static String run( + StatsStore store, + StatsStoreRegistry registry + ) throws Exception { + String filename = FILENAME_PREFIX + System.currentTimeMillis() + FILENAME_SUFFIX; + + ConfigurationSection config = new YamlConfiguration(); + config.set("type", "sqlite"); + config.set("filename", filename); + StatsStore output = registry.create(config); + + store.export(output); + + return filename; + } + +} diff --git a/src/main/java/org/mobarena/stats/store/StatsImport.java b/src/main/java/org/mobarena/stats/store/StatsImport.java new file mode 100644 index 0000000..878ebc6 --- /dev/null +++ b/src/main/java/org/mobarena/stats/store/StatsImport.java @@ -0,0 +1,21 @@ +package org.mobarena.stats.store; + +import org.bukkit.configuration.ConfigurationSection; +import org.bukkit.configuration.file.YamlConfiguration; + +public class StatsImport { + + public static void run( + StatsStoreRegistry registry, + String filename, + StatsStore store + ) throws Exception { + ConfigurationSection config = new YamlConfiguration(); + config.set("type", "sqlite"); + config.set("filename", filename); + StatsStore source = registry.create(config); + + source.export(store); + } + +} diff --git a/src/main/java/org/mobarena/stats/store/StatsStore.java b/src/main/java/org/mobarena/stats/store/StatsStore.java new file mode 100644 index 0000000..67b65b7 --- /dev/null +++ b/src/main/java/org/mobarena/stats/store/StatsStore.java @@ -0,0 +1,49 @@ +package org.mobarena.stats.store; + +import org.mobarena.stats.session.Session; + +import java.io.IOException; +import java.util.UUID; + +/** + * Persistent data store for session and player stats. + *

+ * All store operations are blocking, meaning any calls to the store + * should be handled off the main thread to prevent performance impacts. + */ +public interface StatsStore { + + /** + * Store the given {@link Session}'s data in the store. + *

+ * This method should only be called with a "finished" session, i.e. a + * session that has concluded and won't be altered after saving. + * + * @param session a session to store + * @throws IOException if the operation fails due to I/O + */ + void save(Session session) throws IOException; + + /** + * Delete all data about the session with the given ID. + *

+ * Removes all session and player data associated with the session of + * the given ID, meaning these stats will be lost forever. + * + * @param sessionId the ID of the session whose data to delete + */ + void delete(UUID sessionId); + + // TODO: docs + GlobalStats getGlobalStats(); + + // TODO: docs + ArenaStats getArenaStats(String slug); + + // TODO: docs + PlayerStats getPlayerStats(String name); + + // TODO: docs + void export(StatsStore target) throws IOException; + +} diff --git a/src/main/java/org/mobarena/stats/store/StatsStoreFactory.java b/src/main/java/org/mobarena/stats/store/StatsStoreFactory.java new file mode 100644 index 0000000..48cdef4 --- /dev/null +++ b/src/main/java/org/mobarena/stats/store/StatsStoreFactory.java @@ -0,0 +1,22 @@ +package org.mobarena.stats.store; + +import org.bukkit.configuration.ConfigurationSection; +import org.mobarena.stats.MobArenaStats; + +@FunctionalInterface +public interface StatsStoreFactory { + + /** + * Create a new stats store from the given configuration. + * + * @param config a configuration to set up the stats store + * @param plugin a plugin instance for dependencies + * @return a new stats store instance + * @throws Exception if store creation fails + */ + StatsStore create( + ConfigurationSection config, + MobArenaStats plugin + ) throws Exception; + +} diff --git a/src/main/java/org/mobarena/stats/store/StatsStoreRegistry.java b/src/main/java/org/mobarena/stats/store/StatsStoreRegistry.java new file mode 100644 index 0000000..7070b09 --- /dev/null +++ b/src/main/java/org/mobarena/stats/store/StatsStoreRegistry.java @@ -0,0 +1,41 @@ +package org.mobarena.stats.store; + +import org.bukkit.configuration.ConfigurationSection; +import org.mobarena.stats.MobArenaStatsPlugin; + +import java.util.HashMap; +import java.util.Map; + +public class StatsStoreRegistry { + + private final Map typeToFactory; + private final MobArenaStatsPlugin plugin; + + StatsStoreRegistry(MobArenaStatsPlugin plugin) { + this.typeToFactory = new HashMap<>(); + this.plugin = plugin; + } + + public void register(String type, StatsStoreFactory factory) { + typeToFactory.put(type.toLowerCase(), factory); + } + + public StatsStore create(ConfigurationSection config) throws Exception { + String type = config.getString("type"); + if (type == null || type.isEmpty()) { + throw new IllegalArgumentException("Missing 'type' in store configuration"); + } + + StatsStoreFactory factory = typeToFactory.get(type.toLowerCase()); + if (factory == null) { + throw new IllegalArgumentException("Unknown store type: " + type); + } + + return factory.create(config, plugin); + } + + public static StatsStoreRegistry create(MobArenaStatsPlugin plugin) { + return new StatsStoreRegistry(plugin); + } + +} diff --git a/src/main/java/org/mobarena/stats/store/csv/CsvStatsStore.java b/src/main/java/org/mobarena/stats/store/csv/CsvStatsStore.java new file mode 100644 index 0000000..322ecbc --- /dev/null +++ b/src/main/java/org/mobarena/stats/store/csv/CsvStatsStore.java @@ -0,0 +1,253 @@ +package org.mobarena.stats.store.csv; + +import org.bukkit.configuration.ConfigurationSection; +import org.mobarena.stats.MobArenaStats; +import org.mobarena.stats.session.PlayerConclusion; +import org.mobarena.stats.session.PlayerSessionStats; +import org.mobarena.stats.session.Session; +import org.mobarena.stats.session.SessionConclusion; +import org.mobarena.stats.session.SessionStats; +import org.mobarena.stats.store.ArenaStats; +import org.mobarena.stats.store.GlobalStats; +import org.mobarena.stats.store.PlayerStats; +import org.mobarena.stats.store.StatsStore; + +import java.io.BufferedWriter; +import java.io.File; +import java.io.FileWriter; +import java.io.IOException; +import java.io.PrintWriter; +import java.nio.file.Files; +import java.time.Instant; +import java.time.format.DateTimeFormatter; +import java.util.List; +import java.util.UUID; +import java.util.function.Function; +import java.util.logging.Logger; + +public class CsvStatsStore implements StatsStore { + + private static final String[] SESSION_FIELDS = { + "session_id", + "arena_slug", + "start_time", + "end_time", + "last_wave", + "conclusion" + }; + + private static final String[] PLAYER_SESSION_FIELDS = { + "session_id", + "player_id", + "player_name", + "class", + "join_time", + "ready_time", + "leave_time", + "death_time", + "kills", + "dmg_done", + "dmg_taken", + "swings", + "hits", + "last_wave", + "conclusion" + }; + + private final File folder; + private final File sessionsFile; + private final File playersFile; + private final String separator; + private final DateTimeFormatter formatter; + private final Logger log; + + private CsvStatsStore( + File folder, + String separator, + Logger log + ) { + this.folder = folder; + this.sessionsFile = new File(folder, "sessions.csv"); + this.playersFile = new File(folder, "players.csv"); + this.separator = separator; + this.formatter = DateTimeFormatter.ISO_INSTANT; + this.log = log; + } + + @Override + public void save(Session session) throws IOException { + try { + createDataFolder(); + saveArenaSession(session); + savePlayerSessions(session); + } catch (Exception e) { + throw new IOException(e); + } + } + + @Override + public void delete(UUID sessionId) { + throw new UnsupportedOperationException("Session deletion is not supported by the CSV data store"); + } + + private void createDataFolder() { + if (!folder.exists()) { + if (!folder.mkdirs()) { + throw new IllegalStateException("Failed to create stats data folder"); + } + } + } + + private void saveArenaSession(Session session) throws Exception { + boolean writeHeader = !sessionsFile.exists(); + + try (PrintWriter writer = new PrintWriter(new BufferedWriter(new FileWriter(sessionsFile, true)))) { + if (writeHeader) { + String line = String.join(separator, SESSION_FIELDS); + writer.println(line); + } + + SessionStats stats = session.getSessionStats(); + String line = String.join( + separator, + stats.sessionId.toString(), + stats.arenaSlug, + formatter.format(stats.startTime), + formatter.format(stats.endTime), + String.valueOf(stats.lastWave), + String.valueOf(stats.conclusion) + ); + writer.println(line); + log.info("Session stats written to disk (" + stats.sessionId + ")."); + } + } + + private void savePlayerSessions(Session session) throws Exception { + boolean writeHeader = !playersFile.exists(); + + try (PrintWriter writer = new PrintWriter(new BufferedWriter(new FileWriter(playersFile, true)))) { + if (writeHeader) { + String line = String.join(separator, PLAYER_SESSION_FIELDS); + writer.println(line); + } + + for (PlayerSessionStats stats : session.getPlayerStats()) { + String line = String.join( + separator, + stats.sessionId.toString(), + stats.playerId.toString(), + stats.playerName, + stats.className, + formatter.format(stats.joinTime), + formatter.format(stats.readyTime), + stats.leaveTime != null ? formatter.format(stats.leaveTime) : "", + stats.deathTime != null ? formatter.format(stats.deathTime) : "", + String.valueOf(stats.kills), + String.valueOf(stats.dmgDone), + String.valueOf(stats.dmgTaken), + String.valueOf(stats.swings), + String.valueOf(stats.hits), + String.valueOf(stats.lastWave), + String.valueOf(stats.conclusion) + ); + writer.println(line); + } + log.info("Player stats written to disk (" + session.getSessionStats().sessionId + ")."); + } + } + + @Override + public GlobalStats getGlobalStats() { + throw new UnsupportedOperationException("Queries are not supported by the CSV data store"); + } + + @Override + public ArenaStats getArenaStats(String slug) { + throw new UnsupportedOperationException("Queries are not supported by the CSV data store"); + } + + @Override + public PlayerStats getPlayerStats(String name) { + throw new UnsupportedOperationException("Queries are not supported by the CSV data store"); + } + + @Override + public void export(StatsStore target) throws IOException { + List sessionLines = Files.readAllLines(sessionsFile.toPath()); + List playerLines = Files.readAllLines(playersFile.toPath()); + + for (int i = 1; i < sessionLines.size(); i++) { + String sessionLine = sessionLines.get(i); + String[] sessionParts = sessionLine.split(separator); + + UUID sessionId = UUID.fromString(sessionParts[0]); + String arenaSlug = sessionParts[1]; + Session session = new Session(sessionId, arenaSlug); + { + SessionStats stats = session.getSessionStats(); + stats.startTime = Instant.parse(sessionParts[2]); + stats.endTime = Instant.parse(sessionParts[3]); + stats.lastWave = Integer.parseInt(sessionParts[4]); + stats.conclusion = SessionConclusion.valueOf(sessionParts[5]); + } + + String prefix = sessionId + separator; + for (int j = 1; j < playerLines.size(); j++) { + String playerLine = playerLines.get(j); + if (!playerLine.startsWith(prefix)) { + continue; + } + + String[] playerParts = playerLine.split(separator); + { + UUID playerId = UUID.fromString(playerParts[1]); + String playerName = playerParts[2]; + + PlayerSessionStats stats = new PlayerSessionStats(sessionId, playerId, playerName); + stats.className = playerParts[3]; + stats.joinTime = safe(playerParts[4], Instant::parse); + stats.readyTime = safe(playerParts[5], Instant::parse); + stats.leaveTime = safe(playerParts[6], Instant::parse); + stats.deathTime = safe(playerParts[7], Instant::parse); + stats.kills = Integer.parseInt(playerParts[8]); + stats.dmgDone = Integer.parseInt(playerParts[9]); + stats.dmgTaken = Integer.parseInt(playerParts[10]); + stats.swings = Integer.parseInt(playerParts[11]); + stats.hits = Integer.parseInt(playerParts[12]); + stats.lastWave = Integer.parseInt(playerParts[13]); + stats.conclusion = safe(playerParts[14], PlayerConclusion::valueOf, PlayerConclusion.DEFEAT); + + session.setPlayerStats(playerId, stats); + } + } + + target.save(session); + } + } + + private static R safe(T value, Function parser) { + return safe(value, parser, null); + } + + private static R safe(T value, Function parser, R def) { + try { + return parser.apply(value); + } catch (Exception e) { + return def; + } + } + + public static CsvStatsStore create( + ConfigurationSection config, + MobArenaStats plugin + ) { + String folder = config.getString("folder", "data"); + String separator = config.getString("separator", ";"); + + File root = new File(plugin.getDataFolder(), folder); + Logger log = plugin.getLogger(); + + return new CsvStatsStore(root, separator, log); + } + +} diff --git a/src/main/java/org/mobarena/stats/store/jdbc/JdbcStatsStore.java b/src/main/java/org/mobarena/stats/store/jdbc/JdbcStatsStore.java new file mode 100644 index 0000000..9c904db --- /dev/null +++ b/src/main/java/org/mobarena/stats/store/jdbc/JdbcStatsStore.java @@ -0,0 +1,245 @@ +package org.mobarena.stats.store.jdbc; + +import org.bukkit.configuration.ConfigurationSection; +import org.jdbi.v3.core.Jdbi; +import org.jdbi.v3.core.mapper.RowMapper; +import org.jdbi.v3.core.statement.PreparedBatch; +import org.mobarena.stats.MobArenaStats; +import org.mobarena.stats.session.PlayerConclusion; +import org.mobarena.stats.session.PlayerSessionStats; +import org.mobarena.stats.session.Session; +import org.mobarena.stats.session.SessionConclusion; +import org.mobarena.stats.session.SessionStats; +import org.mobarena.stats.store.ArenaStats; +import org.mobarena.stats.store.GlobalStats; +import org.mobarena.stats.store.PlayerStats; +import org.mobarena.stats.store.StatsStore; +import org.mobarena.stats.util.ResourceLoader; + +import java.io.IOException; +import java.sql.Timestamp; +import java.util.List; +import java.util.UUID; +import java.util.function.Function; +import java.util.logging.Logger; + +import static org.mobarena.stats.store.jdbc.Statement.DELETE_SESSION_DATA; +import static org.mobarena.stats.store.jdbc.Statement.FIND_ARENA_STATS; +import static org.mobarena.stats.store.jdbc.Statement.FIND_GLOBAL_STATS; +import static org.mobarena.stats.store.jdbc.Statement.FIND_PLAYER_SESSIONS_BY_ID; +import static org.mobarena.stats.store.jdbc.Statement.FIND_PLAYER_STATS; +import static org.mobarena.stats.store.jdbc.Statement.FIND_SESSIONS; +import static org.mobarena.stats.store.jdbc.Statement.INSERT_PLAYER_DATA; +import static org.mobarena.stats.store.jdbc.Statement.INSERT_SESSION_DATA; + +public class JdbcStatsStore implements StatsStore { + + private final Jdbi jdbi; + private final Statements statements; + + private JdbcStatsStore(Jdbi jdbi, Statements statements) { + this.jdbi = jdbi; + this.statements = statements; + } + + @Override + public synchronized void save(Session session) { + jdbi.useTransaction(handle -> { + // First the session data + handle.createUpdate(statements.get(INSERT_SESSION_DATA)) + .bind("session_id", session.getSessionId().toString()) + .bind("arena_slug", session.getArenaSlug()) + .bind("start_time", session.getSessionStats().startTime) + .bind("end_time", session.getSessionStats().endTime) + .bind("last_wave", session.getSessionStats().lastWave) + .bind("conclusion", session.getSessionStats().conclusion) + .execute(); + + // Then all of the player data + PreparedBatch batch = handle.prepareBatch(statements.get(INSERT_PLAYER_DATA)); + for (PlayerSessionStats player : session.getPlayerStats()) { + batch.bind("session_id", session.getSessionId().toString()); + batch.bind("player_id", player.playerId.toString()); + batch.bind("player_name", player.playerName); + batch.bind("class", player.className); + batch.bind("join_time", player.joinTime); + batch.bind("ready_time", player.readyTime); + batch.bind("leave_time", player.leaveTime); + batch.bind("death_time", player.deathTime); + batch.bind("kills", player.kills); + batch.bind("dmg_done", player.dmgDone); + batch.bind("dmg_taken", player.dmgTaken); + batch.bind("swings", player.swings); + batch.bind("hits", player.hits); + batch.bind("last_wave", player.lastWave); + batch.bind("conclusion", player.conclusion); + batch.add(); + } + batch.execute(); + }); + } + + @Override + public void delete(UUID sessionId) { + jdbi.useTransaction(handle -> handle + .createUpdate(statements.get(DELETE_SESSION_DATA)) + .bind("session_id", sessionId.toString()) + .execute() + ); + } + + @Override + public GlobalStats getGlobalStats() { + return jdbi.withHandle(handle -> handle + .createQuery(statements.get(FIND_GLOBAL_STATS)) + .map((rs, ctx) -> new GlobalStats( + rs.getInt("total_sessions"), + rs.getLong("total_seconds"), + rs.getLong("total_kills"), + rs.getLong("total_waves") + )) + .first() + ); + } + + @Override + public ArenaStats getArenaStats(String slug) { + return jdbi.withHandle(handle -> handle + .createQuery(statements.get(FIND_ARENA_STATS)) + .bind("arena_slug", slug) + .map((rs, ctx) -> new ArenaStats( + rs.getInt("highest_wave"), + rs.getInt("highest_seconds"), + rs.getInt("highest_kills"), + rs.getInt("total_sessions"), + rs.getLong("total_seconds"), + rs.getLong("total_kills"), + rs.getLong("total_waves") + )) + .first() + ); + } + + @Override + public PlayerStats getPlayerStats(String name) { + return jdbi.withHandle(handle -> handle + .createQuery(statements.get(FIND_PLAYER_STATS)) + .bind("player_name", name) + .map((rs, ctx) -> new PlayerStats( + rs.getInt("total_sessions"), + rs.getLong("total_seconds"), + rs.getLong("total_kills"), + rs.getLong("total_waves") + )) + .first() + ); + } + + @Override + public synchronized void export(StatsStore target) throws IOException { + jdbi.useHandle(handle -> { + int limit = 100; + int offset = 0; + + while (true) { + List sessions = handle.createQuery(statements.get(FIND_SESSIONS)) + .bind("limit", limit) + .bind("offset", offset) + .map(toSession()) + .list(); + + for (Session session : sessions) { + UUID sessionId = session.getSessionId(); + + handle.createQuery(statements.get(FIND_PLAYER_SESSIONS_BY_ID)) + .bind("session_id", session.getSessionId().toString()) + .map(toPlayerStats(sessionId)) + .forEach(stats -> session.setPlayerStats(stats.playerId, stats)); + + target.save(session); + } + + if (sessions.size() < limit) { + break; + } + + offset += limit; + } + }); + } + + private static RowMapper toSession() { + return (r, ctx) -> { + UUID sessionId = UUID.fromString(r.getString("session_id")); + String arenaSlug = r.getString("arena_slug"); + Session session = new Session(sessionId, arenaSlug); + + SessionStats stats = session.getSessionStats(); + stats.startTime = r.getTimestamp("start_time").toInstant(); + stats.endTime = r.getTimestamp("end_time").toInstant(); + stats.lastWave = r.getInt("last_wave"); + stats.conclusion = SessionConclusion.valueOf(r.getString("conclusion")); + + return session; + }; + } + + private static RowMapper toPlayerStats(UUID sessionId) { + return (r, ctx) -> { + UUID playerId = UUID.fromString(r.getString("player_id")); + String playerName = r.getString("player_name"); + + PlayerSessionStats stats = new PlayerSessionStats(sessionId, playerId, playerName); + stats.className = r.getString("class"); + stats.joinTime = safe(r.getTimestamp("join_time"), Timestamp::toInstant); + stats.readyTime = safe(r.getTimestamp("ready_time"), Timestamp::toInstant); + stats.leaveTime = safe(r.getTimestamp("leave_time"), Timestamp::toInstant); + stats.deathTime = safe(r.getTimestamp("death_time"), Timestamp::toInstant); + stats.kills = r.getInt("kills"); + stats.dmgDone = r.getInt("dmg_done"); + stats.dmgTaken = r.getInt("dmg_taken"); + stats.swings = r.getInt("swings"); + stats.hits = r.getInt("hits"); + stats.lastWave = r.getInt("last_wave"); + stats.conclusion = safe(r.getString("conclusion"), PlayerConclusion::valueOf, PlayerConclusion.DEFEAT); + + return stats; + }; + } + + private static R safe(T value, Function parser) { + return safe(value, parser, null); + } + + private static R safe(T value, Function parser, R def) { + try { + return parser.apply(value); + } catch (Exception e) { + return def; + } + } + + public static JdbcStatsStore create( + ConfigurationSection config, + MobArenaStats plugin + ) throws Exception { + String type = config.getString("type"); + String url = config.getString("url"); + String username = config.getString("username"); + String password = config.getString("password"); + Jdbi jdbi = Jdbi.create(url, username, password); + + // Load up migrations and statements for the given type + ResourceLoader loader = ResourceLoader.create(plugin.getClass().getClassLoader()); + Migrations migrations = Migrations.create(loader, type); + Statements statements = Statements.create(loader, type); + Logger log = plugin.getLogger(); + + // Bring database schema up to speed + SchemaMigrator migrator = new SchemaMigrator(jdbi, migrations, statements, log); + migrator.migrate(); + + return new JdbcStatsStore(jdbi, statements); + } + +} diff --git a/src/main/java/org/mobarena/stats/store/jdbc/Migrations.java b/src/main/java/org/mobarena/stats/store/jdbc/Migrations.java new file mode 100644 index 0000000..c067b93 --- /dev/null +++ b/src/main/java/org/mobarena/stats/store/jdbc/Migrations.java @@ -0,0 +1,31 @@ +package org.mobarena.stats.store.jdbc; + +import org.mobarena.stats.util.ResourceLoader; + +import java.io.IOException; +import java.net.URISyntaxException; +import java.util.List; + +class Migrations { + + private final ResourceLoader loader; + private final String type; + + private Migrations(ResourceLoader loader, String type) { + this.loader = loader; + this.type = type; + } + + List list() throws URISyntaxException, IOException { + return loader.list(type + "/migration"); + } + + String get(String filename) throws IOException { + return loader.loadString(type + "/migration/" + filename); + } + + static Migrations create(ResourceLoader loader, String type) { + return new Migrations(loader, type); + } + +} diff --git a/src/main/java/org/mobarena/stats/store/jdbc/SchemaMigrator.java b/src/main/java/org/mobarena/stats/store/jdbc/SchemaMigrator.java new file mode 100644 index 0000000..3221699 --- /dev/null +++ b/src/main/java/org/mobarena/stats/store/jdbc/SchemaMigrator.java @@ -0,0 +1,139 @@ +package org.mobarena.stats.store.jdbc; + +import org.jdbi.v3.core.Jdbi; +import org.jdbi.v3.core.statement.Batch; + +import java.io.IOException; +import java.net.URISyntaxException; +import java.sql.Connection; +import java.sql.DatabaseMetaData; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.time.Instant; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.logging.Logger; +import java.util.stream.Collectors; + +import static org.mobarena.stats.store.jdbc.Statement.FIND_ALL_MIGRATIONS; +import static org.mobarena.stats.store.jdbc.Statement.INSERT_MIGRATION; + +class SchemaMigrator { + + private final Jdbi jdbi; + private final Migrations migrations; + private final Statements statements; + private final Logger log; + + SchemaMigrator( + Jdbi jdbi, + Migrations migrations, + Statements statements, + Logger log + ) { + this.jdbi = jdbi; + this.migrations = migrations; + this.statements = statements; + this.log = log; + } + + void migrate() throws IOException, SQLException, URISyntaxException { + List filenames = migrations.list(); + List completed = getCompletedMigrations(); + + filenames.removeAll(completed); + + if (filenames.isEmpty()) { + log.info("Schema is up to date."); + return; + } + + if (completed.isEmpty()) { + log.info("Schema is has not yet been initialized, migrating..."); + } else { + log.info("Schema is " + filenames.size() + " version(s) behind, migrating..."); + } + + for (String filename : filenames) { + execute(filename); + } + + log.info("Schema migration complete."); + } + + private List getCompletedMigrations() throws SQLException { + return jdbi.withHandle(handle -> { + // We don't really have a good way to check if the database has + // migration info without making some actual queries, which will + // fail if it doesn't. Instead, we can use the database metadata + // (available via the underlying JDBC connection object) to find + // out if the schema migrations table exists. + Connection connection = handle.getConnection(); + DatabaseMetaData meta = connection.getMetaData(); + try (ResultSet tables = meta.getTables(null, null, "schema_migrations", null)) { + while (tables.next()) { + String name = tables.getString("TABLE_NAME"); + if (name.equals("schema_migrations")) { + // Jackpot! We found the table, now query it. + String sql = statements.get(FIND_ALL_MIGRATIONS); + return handle.createQuery(sql) + .map((r, ctx) -> r.getString("filename")) + .list(); + } + } + } + + // No migrations table means fresh database. + return Collections.emptyList(); + }); + } + + private void execute(String filename) throws IOException { + String content = migrations.get(filename); + + // Migration files may contain several different statements, + // but not all databases support multiple statements in a + // single update, so we split the file contents by semicolon + // and hold our breath while we invoke each part... + List parts = Arrays.stream(content.split(";")) + .map(String::trim) + .filter(s -> !s.isEmpty()) + .collect(Collectors.toList()); + + jdbi.useTransaction(handle -> { + Instant executed = Instant.now(); + try { + if (parts.size() == 1) { + handle.execute(parts.get(0)); + } else { + Batch batch = handle.createBatch(); + parts.forEach(batch::add); + batch.execute(); + } + + String sql = statements.get(INSERT_MIGRATION); + handle.createUpdate(sql) + .bind("filename", filename) + .bind("executed", executed) + .bind("success", true) + .bind("error", (String) null) + .execute(); + + log.info("\u2713 " + filename); + } catch (Exception e) { + String sql = statements.get(INSERT_MIGRATION); + handle.createUpdate(sql) + .bind("filename", filename) + .bind("executed", executed) + .bind("success", false) + .bind("error", e.getMessage()) + .execute(); + + log.severe("\u2717 " + filename); + throw new IllegalStateException("Migration failed: " + filename, e); + } + }); + } + +} diff --git a/src/main/java/org/mobarena/stats/store/jdbc/Statement.java b/src/main/java/org/mobarena/stats/store/jdbc/Statement.java new file mode 100644 index 0000000..ee2cc8a --- /dev/null +++ b/src/main/java/org/mobarena/stats/store/jdbc/Statement.java @@ -0,0 +1,19 @@ +package org.mobarena.stats.store.jdbc; + +public enum Statement { + + FIND_ALL_MIGRATIONS, + INSERT_MIGRATION, + + INSERT_SESSION_DATA, + INSERT_PLAYER_DATA, + DELETE_SESSION_DATA, + + FIND_ARENA_STATS, + FIND_GLOBAL_STATS, + FIND_PLAYER_STATS, + + FIND_SESSIONS, + FIND_PLAYER_SESSIONS_BY_ID, + +} diff --git a/src/main/java/org/mobarena/stats/store/jdbc/Statements.java b/src/main/java/org/mobarena/stats/store/jdbc/Statements.java new file mode 100644 index 0000000..4d8a004 --- /dev/null +++ b/src/main/java/org/mobarena/stats/store/jdbc/Statements.java @@ -0,0 +1,32 @@ +package org.mobarena.stats.store.jdbc; + +import org.mobarena.stats.util.ResourceLoader; + +import java.io.IOException; +import java.util.EnumMap; +import java.util.Map; + +class Statements { + + private final Map sql; + + private Statements(Map sql) { + this.sql = sql; + } + + String get(Statement statement) { + return sql.get(statement); + } + + static Statements create(ResourceLoader loader, String type) throws IOException { + EnumMap result = new EnumMap<>(Statement.class); + for (Statement statement : Statement.values()) { + // SCREAMING_SNAKE_CASE -> kebab-case, .sql file extension + String filename = statement.toString().toLowerCase().replace('_', '-') + ".sql"; + String sql = loader.loadString(type + "/" + filename); + result.put(statement, sql); + } + return new Statements(result); + } + +} diff --git a/src/main/java/org/mobarena/stats/store/mariadb/MariadbStatsStore.java b/src/main/java/org/mobarena/stats/store/mariadb/MariadbStatsStore.java new file mode 100644 index 0000000..39501b5 --- /dev/null +++ b/src/main/java/org/mobarena/stats/store/mariadb/MariadbStatsStore.java @@ -0,0 +1,39 @@ +package org.mobarena.stats.store.mariadb; + +import org.bukkit.configuration.ConfigurationSection; +import org.bukkit.configuration.MemoryConfiguration; +import org.mobarena.stats.MobArenaStats; +import org.mobarena.stats.store.jdbc.JdbcStatsStore; + +public class MariadbStatsStore { + + public static JdbcStatsStore create( + ConfigurationSection config, + MobArenaStats plugin + ) throws Exception { + ConfigurationSection copy = new MemoryConfiguration(); + for (String key : config.getKeys(false)) { + copy.set(key, config.get(key)); + } + + // Note the type override of "mysql" here. This ensures that we + // reuse the MySQL SQL files from the resources folder. + String url = getUrl(copy); + copy.set("type", "mysql"); + copy.set("url", url); + + return JdbcStatsStore.create(copy, plugin); + } + + static String getUrl(ConfigurationSection config) { + String host = config.getString("host", "localhost"); + int port = config.getInt("port", 3306); + String database = config.getString("database", "mobarena_stats"); + boolean ssl = config.getBoolean("ssl", false); + + String params = "useSSL=" + ssl; + + return "jdbc:mariadb://" + host + ":" + port + "/" + database + "?" + params; + } + +} diff --git a/src/main/java/org/mobarena/stats/store/mysql/MysqlStatsStore.java b/src/main/java/org/mobarena/stats/store/mysql/MysqlStatsStore.java new file mode 100644 index 0000000..a006156 --- /dev/null +++ b/src/main/java/org/mobarena/stats/store/mysql/MysqlStatsStore.java @@ -0,0 +1,37 @@ +package org.mobarena.stats.store.mysql; + +import org.bukkit.configuration.ConfigurationSection; +import org.bukkit.configuration.MemoryConfiguration; +import org.mobarena.stats.MobArenaStats; +import org.mobarena.stats.store.jdbc.JdbcStatsStore; + +public class MysqlStatsStore { + + public static JdbcStatsStore create( + ConfigurationSection config, + MobArenaStats plugin + ) throws Exception { + ConfigurationSection copy = new MemoryConfiguration(); + for (String key : config.getKeys(false)) { + copy.set(key, config.get(key)); + } + + String url = getUrl(copy); + copy.set("type", "mysql"); + copy.set("url", url); + + return JdbcStatsStore.create(copy, plugin); + } + + static String getUrl(ConfigurationSection config) { + String host = config.getString("host", "localhost"); + int port = config.getInt("port", 3306); + String database = config.getString("database", "mobarena_stats"); + boolean ssl = config.getBoolean("ssl", false); + + String params = "useSSL=" + ssl; + + return "jdbc:mysql://" + host + ":" + port + "/" + database + "?" + params; + } + +} diff --git a/src/main/java/org/mobarena/stats/store/sqlite/SqliteStatsStore.java b/src/main/java/org/mobarena/stats/store/sqlite/SqliteStatsStore.java new file mode 100644 index 0000000..9ab15c3 --- /dev/null +++ b/src/main/java/org/mobarena/stats/store/sqlite/SqliteStatsStore.java @@ -0,0 +1,38 @@ +package org.mobarena.stats.store.sqlite; + +import org.bukkit.configuration.ConfigurationSection; +import org.bukkit.configuration.MemoryConfiguration; +import org.mobarena.stats.MobArenaStats; +import org.mobarena.stats.store.jdbc.JdbcStatsStore; + +import java.io.File; + +public class SqliteStatsStore { + + public static JdbcStatsStore create( + ConfigurationSection config, + MobArenaStats plugin + ) throws Exception { + ConfigurationSection copy = new MemoryConfiguration(); + for (String key : config.getKeys(false)) { + copy.set(key, config.get(key)); + } + + File data = plugin.getDataFolder(); + String url = getUrl(copy, data); + copy.set("type", "sqlite"); + copy.set("url", url); + copy.addDefault("username", "sa"); + copy.addDefault("password", ""); + + return JdbcStatsStore.create(copy, plugin); + } + + static String getUrl(ConfigurationSection config, File data) { + String folder = data.getPath(); + String filename = config.getString("filename", "stats.db"); + + return "jdbc:sqlite:" + folder + "/" + filename; + } + +} diff --git a/src/main/java/org/mobarena/stats/util/ResourceLoader.java b/src/main/java/org/mobarena/stats/util/ResourceLoader.java new file mode 100644 index 0000000..8f31e3c --- /dev/null +++ b/src/main/java/org/mobarena/stats/util/ResourceLoader.java @@ -0,0 +1,118 @@ +package org.mobarena.stats.util; + +import java.io.ByteArrayOutputStream; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.InputStream; +import java.net.URI; +import java.net.URISyntaxException; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.nio.file.FileSystem; +import java.nio.file.FileSystems; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Collections; +import java.util.List; +import java.util.NoSuchElementException; +import java.util.stream.Collectors; + +/** + * Lists and loads resources via a given {@link ClassLoader}. + *

+ * The primary goal of this class is to provide a developer-friendly + * abstraction over Java's complex concept of "resources", allowing + * client components to focus their efforts on their own context. + *

+ * In general, we know what resources we're looking for, and we just + * want to load them into memory and apply them where needed, but we + * also want to "scan" resource "folders". The latter is fairly easy + * in a file system context, but in a jar-file, while still somewhat + * doable, becomes a nightmare to have to do again and again. That's + * where this class comes in, as a mild wrapper around something that + * can best be described as infuriating. + */ +public class ResourceLoader { + + private final ClassLoader loader; + + ResourceLoader(ClassLoader loader) { + this.loader = loader; + } + + /** + * Find all resources that match the given path. + * + * @param prefix a resource "prefix" to filter resources by + * @return a list of all resources that match the given prefix + * @throws URISyntaxException if the given path isn't a valid URI + * in the context of the class loader + * @throws IOException if an I/O error occurs during traversal + */ + public List list(String prefix) throws URISyntaxException, IOException { + URL url = loader.getResource(prefix); + if (url == null) { + throw new NoSuchElementException("No resources found at " + prefix); + } + + URI uri = url.toURI(); + if (uri.getScheme().equals("jar")) { + try (FileSystem fs = FileSystems.newFileSystem(uri, Collections.emptyMap())) { + Path path = fs.getPath(prefix); + return walk(path); + } + } else { + Path path = Paths.get(uri); + return walk(path); + } + } + + private List walk(Path path) throws IOException { + // When we traverse the path, we want to skip the folder + // denoted by the path itself, and this is always first + // in the stream. + return Files.walk(path, 1) + .skip(1) + .map(Path::getFileName) + .map(Path::toString) + .sorted() + .collect(Collectors.toList()); + } + + /** + * Load the resource at the given path, interpreting its contents + * as UTF-8 encoded text. + * + * @param path the path to the resource to load + * @return the contents of the resource at the given path + * @throws IOException if an I/O error occurs during loading + */ + public String loadString(String path) throws IOException { + InputStream is = loader.getResourceAsStream(path); + if (is == null) { + throw new FileNotFoundException("Resource not found: " + path); + } + + ByteArrayOutputStream output = new ByteArrayOutputStream(); + byte[] buffer = new byte[1024]; + int length; + while ((length = is.read(buffer)) != -1) { + output.write(buffer, 0, length); + } + + return output.toString(StandardCharsets.UTF_8.name()); + } + + /** + * Create a new resource loader with the given {@link ClassLoader} + * as its source. + * + * @param loader a class loader to use as a source of resources + * @return a new resource loader + */ + public static ResourceLoader create(ClassLoader loader) { + return new ResourceLoader(loader); + } + +} diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml new file mode 100644 index 0000000..6faf342 --- /dev/null +++ b/src/main/resources/config.yml @@ -0,0 +1,59 @@ +#---------------------------------------------------------------------- +# The store is where all the collected data is kept. Different store +# types are supported: +# +# - sqlite: stores stats in an SQLite database +# - mysql: stores stats in a MySQL/MariaDB database +# - csv: stores stats in local CSV files +# +# Stores may require configuration of additional properties, such as +# file paths or database credentials. +#---------------------------------------------------------------------- +store: + + #-------------------------------------------------------------------- + # Which type of store to use. + # + # Changing this value will _not_ result in an automatic conversion + # of existing store data. To transfer data to a different store, + # make an export of the current store first, then change type and + # import the exported data. + # + type: sqlite + #-------------------------------------------------------------------- + + #-------------------------------------------------------------------- + # SQLite store properties + # + # - filename: name of the database file, relative to plugin folder + # + filename: stats.db + #-------------------------------------------------------------------- + + #-------------------------------------------------------------------- + # MySQL/MariaDB store properties + # + # - host: where the database instance is hosted + # - port: database port number + # - database: name of the database (must exist!) + # - username: username of a valid database user + # - password: password of a valid database user + # - ssl: whether to use SSL for database connections + # + #host: localhost + #port: 3306 + #database: '' + #username: '' + #password: '' + #ssl: false + #-------------------------------------------------------------------- + + #-------------------------------------------------------------------- + # CSV store properties + # + # - folder: where to store data files, relative to plugin folder + # - separator: symbol to separate fields and values with + # + #folder: data + #separator: ';' + #-------------------------------------------------------------------- diff --git a/src/main/resources/mysql/delete-session-data.sql b/src/main/resources/mysql/delete-session-data.sql new file mode 100644 index 0000000..18d1274 --- /dev/null +++ b/src/main/resources/mysql/delete-session-data.sql @@ -0,0 +1,3 @@ +DELETE +FROM sessions +WHERE session_id = :session_id; diff --git a/src/main/resources/mysql/find-all-migrations.sql b/src/main/resources/mysql/find-all-migrations.sql new file mode 100644 index 0000000..29815a4 --- /dev/null +++ b/src/main/resources/mysql/find-all-migrations.sql @@ -0,0 +1,4 @@ +SELECT * +FROM schema_migrations +WHERE success = TRUE +ORDER BY filename; diff --git a/src/main/resources/mysql/find-arena-stats.sql b/src/main/resources/mysql/find-arena-stats.sql new file mode 100644 index 0000000..e6c6cf6 --- /dev/null +++ b/src/main/resources/mysql/find-arena-stats.sql @@ -0,0 +1,21 @@ +SELECT * +FROM + ( + SELECT + COUNT(1) AS total_sessions, + MAX(last_wave) AS highest_wave, + SUM(last_wave) AS total_waves, + MAX(TIMESTAMPDIFF(second, start_time, end_time)) AS highest_seconds, + SUM(TIMESTAMPDIFF(second, start_time, end_time)) AS total_seconds + FROM sessions + WHERE arena_slug = :arena_slug + ) AS t1, + ( + SELECT + SUM(p.kills) AS total_kills, + MAX(p.kills) AS highest_kills + FROM sessions s + JOIN player_sessions p + ON p.session_id = s.id + WHERE s.arena_slug = :arena_slug + ) AS t2; diff --git a/src/main/resources/mysql/find-global-stats.sql b/src/main/resources/mysql/find-global-stats.sql new file mode 100644 index 0000000..8447af2 --- /dev/null +++ b/src/main/resources/mysql/find-global-stats.sql @@ -0,0 +1,16 @@ +SELECT * +FROM + ( + SELECT + COUNT(1) AS total_sessions, + SUM(TIMESTAMPDIFF(second, start_time, end_time)) AS total_seconds, + SUM(last_wave) AS total_waves + FROM sessions + ) AS t1, + ( + SELECT + SUM(p.kills) AS total_kills + FROM sessions s + JOIN player_sessions p + ON p.session_id = s.id + ) AS t2; diff --git a/src/main/resources/mysql/find-player-sessions-by-id.sql b/src/main/resources/mysql/find-player-sessions-by-id.sql new file mode 100644 index 0000000..7a1e7aa --- /dev/null +++ b/src/main/resources/mysql/find-player-sessions-by-id.sql @@ -0,0 +1,5 @@ +SELECT p.* +FROM player_sessions p +JOIN sessions s + ON s.id = p.session_id +WHERE s.session_id = :session_id; diff --git a/src/main/resources/mysql/find-player-stats.sql b/src/main/resources/mysql/find-player-stats.sql new file mode 100644 index 0000000..4b8c357 --- /dev/null +++ b/src/main/resources/mysql/find-player-stats.sql @@ -0,0 +1,9 @@ +SELECT + COUNT(1) AS total_sessions, + SUM(TIMESTAMPDIFF(second, s.start_time, COALESCE(p.death_time, p.leave_time, s.end_time))) AS total_seconds, + SUM(p.kills) AS total_kills, + SUM(p.last_wave) AS total_waves +FROM sessions s +JOIN player_sessions p + ON p.session_id = s.id +WHERE p.player_name = :player_name; diff --git a/src/main/resources/mysql/find-sessions.sql b/src/main/resources/mysql/find-sessions.sql new file mode 100644 index 0000000..768dcde --- /dev/null +++ b/src/main/resources/mysql/find-sessions.sql @@ -0,0 +1,4 @@ +SELECT * +FROM sessions +LIMIT :limit +OFFSET :offset; diff --git a/src/main/resources/mysql/insert-migration.sql b/src/main/resources/mysql/insert-migration.sql new file mode 100644 index 0000000..a838703 --- /dev/null +++ b/src/main/resources/mysql/insert-migration.sql @@ -0,0 +1,15 @@ +INSERT INTO schema_migrations ( + filename, + executed, + success, + error +) VALUES ( + :filename, + :executed, + :success, + :error +) ON DUPLICATE KEY UPDATE + executed = VALUES(executed), + success = VALUES(success), + error = VALUES(error) +; diff --git a/src/main/resources/mysql/insert-player-data.sql b/src/main/resources/mysql/insert-player-data.sql new file mode 100644 index 0000000..a3dcbbe --- /dev/null +++ b/src/main/resources/mysql/insert-player-data.sql @@ -0,0 +1,37 @@ +INSERT INTO player_sessions ( + player_id, + player_name, + session_id, + class, + join_time, + ready_time, + leave_time, + death_time, + kills, + dmg_done, + dmg_taken, + swings, + hits, + last_wave, + conclusion +) VALUES ( + :player_id, + :player_name, + ( + SELECT id + FROM sessions s + WHERE s.session_id = :session_id + ), + :class, + :join_time, + :ready_time, + :leave_time, + :death_time, + :kills, + :dmg_done, + :dmg_taken, + :swings, + :hits, + :last_wave, + :conclusion +); diff --git a/src/main/resources/mysql/insert-session-data.sql b/src/main/resources/mysql/insert-session-data.sql new file mode 100644 index 0000000..76d885b --- /dev/null +++ b/src/main/resources/mysql/insert-session-data.sql @@ -0,0 +1,15 @@ +INSERT INTO sessions ( + session_id, + arena_slug, + start_time, + end_time, + last_wave, + conclusion +) VALUES ( + :session_id, + :arena_slug, + :start_time, + :end_time, + :last_wave, + :conclusion +); diff --git a/src/main/resources/mysql/migration/V1__baseline.sql b/src/main/resources/mysql/migration/V1__baseline.sql new file mode 100644 index 0000000..51cd468 --- /dev/null +++ b/src/main/resources/mysql/migration/V1__baseline.sql @@ -0,0 +1,7 @@ +-- Schema migrations +CREATE TABLE IF NOT EXISTS schema_migrations ( + filename VARCHAR(60) PRIMARY KEY, + executed TIMESTAMP NOT NULL, + success BOOLEAN NOT NULL, + error TEXT NULL +); diff --git a/src/main/resources/mysql/migration/V2__add_sessions_table.sql b/src/main/resources/mysql/migration/V2__add_sessions_table.sql new file mode 100644 index 0000000..333dc67 --- /dev/null +++ b/src/main/resources/mysql/migration/V2__add_sessions_table.sql @@ -0,0 +1,16 @@ +-- Overall session data +CREATE TABLE IF NOT EXISTS sessions ( + id INTEGER PRIMARY KEY AUTO_INCREMENT, + session_id CHAR(36) NOT NULL, + arena_slug VARCHAR(30) NOT NULL, + start_time DATETIME NOT NULL, + end_time DATETIME NOT NULL, + last_wave INTEGER NOT NULL, + conclusion VARCHAR(10) NOT NULL +); + +-- Create a unique index on the UUID for "player queries" +CREATE UNIQUE INDEX idx_sessions_session_id ON sessions (session_id); + +-- Create an index on the arena slug for "arena queries" +CREATE INDEX idx_sessions_arena_slug ON sessions (arena_slug); diff --git a/src/main/resources/mysql/migration/V3__add_player_sessions_table.sql b/src/main/resources/mysql/migration/V3__add_player_sessions_table.sql new file mode 100644 index 0000000..b55ae81 --- /dev/null +++ b/src/main/resources/mysql/migration/V3__add_player_sessions_table.sql @@ -0,0 +1,25 @@ +-- Player-specific session data. +CREATE TABLE IF NOT EXISTS player_sessions ( + session_id INTEGER NOT NULL, + player_id CHAR(36) NOT NULL, + player_name VARCHAR(30) NOT NULL, + class VARCHAR(30) NOT NULL, + join_time DATETIME NOT NULL, + ready_time DATETIME NULL, + leave_time DATETIME NULL, + death_time DATETIME NULL, + kills INTEGER NOT NULL, + dmg_done INTEGER NOT NULL, + dmg_taken INTEGER NOT NULL, + swings INTEGER NOT NULL, + hits INTEGER NOT NULL, + last_wave INTEGER NOT NULL, + conclusion VARCHAR(10) NOT NULL, + FOREIGN KEY (session_id) REFERENCES sessions (id) ON DELETE CASCADE +); + +-- Create an index on the player UUID for "online queries" +CREATE INDEX idx_player_sessions_player_id ON player_sessions (player_id); + +-- Create an index on the player name for "offline queries" +CREATE INDEX idx_player_sessions_player_name ON player_sessions (player_name); diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml new file mode 100644 index 0000000..7c5f160 --- /dev/null +++ b/src/main/resources/plugin.yml @@ -0,0 +1,6 @@ +name: MobArenaStats +author: garbagemule +main: org.mobarena.stats.MobArenaStatsPlugin +version: '${project.version}' +api-version: 1.13 +softdepend: [MobArena] diff --git a/src/main/resources/sqlite/delete-session-data.sql b/src/main/resources/sqlite/delete-session-data.sql new file mode 100644 index 0000000..18d1274 --- /dev/null +++ b/src/main/resources/sqlite/delete-session-data.sql @@ -0,0 +1,3 @@ +DELETE +FROM sessions +WHERE session_id = :session_id; diff --git a/src/main/resources/sqlite/find-all-migrations.sql b/src/main/resources/sqlite/find-all-migrations.sql new file mode 100644 index 0000000..29815a4 --- /dev/null +++ b/src/main/resources/sqlite/find-all-migrations.sql @@ -0,0 +1,4 @@ +SELECT * +FROM schema_migrations +WHERE success = TRUE +ORDER BY filename; diff --git a/src/main/resources/sqlite/find-arena-stats.sql b/src/main/resources/sqlite/find-arena-stats.sql new file mode 100644 index 0000000..19f60e7 --- /dev/null +++ b/src/main/resources/sqlite/find-arena-stats.sql @@ -0,0 +1,21 @@ +SELECT * +FROM + ( + SELECT + COUNT(1) AS total_sessions, + MAX(last_wave) AS highest_wave, + SUM(last_wave) AS total_waves, + MAX((end_time / 1000) - (start_time / 1000)) AS highest_seconds, + SUM((end_time / 1000) - (start_time / 1000)) AS total_seconds + FROM sessions + WHERE arena_slug = :arena_slug + ) AS t1, + ( + SELECT + SUM(p.kills) AS total_kills, + MAX(p.kills) AS highest_kills + FROM sessions s + JOIN player_sessions p + ON p.session_id = s.id + WHERE s.arena_slug = :arena_slug + ) AS t2; diff --git a/src/main/resources/sqlite/find-global-stats.sql b/src/main/resources/sqlite/find-global-stats.sql new file mode 100644 index 0000000..d225051 --- /dev/null +++ b/src/main/resources/sqlite/find-global-stats.sql @@ -0,0 +1,16 @@ +SELECT * +FROM + ( + SELECT + COUNT(1) AS total_sessions, + SUM((end_time / 1000) - (start_time / 1000)) AS total_seconds, + SUM(last_wave) AS total_waves + FROM sessions + ) AS t1, + ( + SELECT + SUM(p.kills) AS total_kills + FROM sessions s + JOIN player_sessions p + ON p.session_id = s.id + ) AS t2; diff --git a/src/main/resources/sqlite/find-player-sessions-by-id.sql b/src/main/resources/sqlite/find-player-sessions-by-id.sql new file mode 100644 index 0000000..7a1e7aa --- /dev/null +++ b/src/main/resources/sqlite/find-player-sessions-by-id.sql @@ -0,0 +1,5 @@ +SELECT p.* +FROM player_sessions p +JOIN sessions s + ON s.id = p.session_id +WHERE s.session_id = :session_id; diff --git a/src/main/resources/sqlite/find-player-stats.sql b/src/main/resources/sqlite/find-player-stats.sql new file mode 100644 index 0000000..fd42181 --- /dev/null +++ b/src/main/resources/sqlite/find-player-stats.sql @@ -0,0 +1,9 @@ +SELECT + COUNT(1) AS total_sessions, + SUM((COALESCE(p.death_time, p.leave_time, s.end_time) / 1000) - (start_time / 1000)) AS total_seconds, + SUM(p.kills) AS total_kills, + SUM(p.last_wave) AS total_waves +FROM sessions s +JOIN player_sessions p + ON p.session_id = s.id +WHERE p.player_name = :player_name; diff --git a/src/main/resources/sqlite/find-sessions.sql b/src/main/resources/sqlite/find-sessions.sql new file mode 100644 index 0000000..768dcde --- /dev/null +++ b/src/main/resources/sqlite/find-sessions.sql @@ -0,0 +1,4 @@ +SELECT * +FROM sessions +LIMIT :limit +OFFSET :offset; diff --git a/src/main/resources/sqlite/insert-migration.sql b/src/main/resources/sqlite/insert-migration.sql new file mode 100644 index 0000000..11cfd11 --- /dev/null +++ b/src/main/resources/sqlite/insert-migration.sql @@ -0,0 +1,15 @@ +INSERT INTO schema_migrations ( + filename, + executed, + success, + error +) VALUES ( + :filename, + :executed, + :success, + :error +) ON CONFLICT (filename) DO UPDATE SET + executed = excluded.executed, + success = excluded.success, + error = excluded.error +; diff --git a/src/main/resources/sqlite/insert-player-data.sql b/src/main/resources/sqlite/insert-player-data.sql new file mode 100644 index 0000000..a3dcbbe --- /dev/null +++ b/src/main/resources/sqlite/insert-player-data.sql @@ -0,0 +1,37 @@ +INSERT INTO player_sessions ( + player_id, + player_name, + session_id, + class, + join_time, + ready_time, + leave_time, + death_time, + kills, + dmg_done, + dmg_taken, + swings, + hits, + last_wave, + conclusion +) VALUES ( + :player_id, + :player_name, + ( + SELECT id + FROM sessions s + WHERE s.session_id = :session_id + ), + :class, + :join_time, + :ready_time, + :leave_time, + :death_time, + :kills, + :dmg_done, + :dmg_taken, + :swings, + :hits, + :last_wave, + :conclusion +); diff --git a/src/main/resources/sqlite/insert-session-data.sql b/src/main/resources/sqlite/insert-session-data.sql new file mode 100644 index 0000000..76d885b --- /dev/null +++ b/src/main/resources/sqlite/insert-session-data.sql @@ -0,0 +1,15 @@ +INSERT INTO sessions ( + session_id, + arena_slug, + start_time, + end_time, + last_wave, + conclusion +) VALUES ( + :session_id, + :arena_slug, + :start_time, + :end_time, + :last_wave, + :conclusion +); diff --git a/src/main/resources/sqlite/migration/V1__baseline.sql b/src/main/resources/sqlite/migration/V1__baseline.sql new file mode 100644 index 0000000..abc055b --- /dev/null +++ b/src/main/resources/sqlite/migration/V1__baseline.sql @@ -0,0 +1,7 @@ +-- Schema migrations +CREATE TABLE IF NOT EXISTS schema_migrations ( + filename TEXT PRIMARY KEY, + executed TIMESTAMP NOT NULL, + success INTEGER NOT NULL, + error TEXT NULL +); diff --git a/src/main/resources/sqlite/migration/V2__add_sessions_table.sql b/src/main/resources/sqlite/migration/V2__add_sessions_table.sql new file mode 100644 index 0000000..40cd921 --- /dev/null +++ b/src/main/resources/sqlite/migration/V2__add_sessions_table.sql @@ -0,0 +1,16 @@ +-- Overall session data +CREATE TABLE IF NOT EXISTS sessions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT NOT NULL, + arena_slug TEXT NOT NULL, + start_time TIMESTAMP NOT NULL, + end_time TIMESTAMP NOT NULL, + last_wave INTEGER NOT NULL, + conclusion TEXT NOT NULL +); + +-- Create a unique index on the UUID for "player queries" +CREATE UNIQUE INDEX idx_sessions_session_id ON sessions (session_id); + +-- Create an index on the arena slug for "arena queries" +CREATE INDEX idx_sessions_arena_slug ON sessions (arena_slug); diff --git a/src/main/resources/sqlite/migration/V3__add_player_sessions_table.sql b/src/main/resources/sqlite/migration/V3__add_player_sessions_table.sql new file mode 100644 index 0000000..d9980ad --- /dev/null +++ b/src/main/resources/sqlite/migration/V3__add_player_sessions_table.sql @@ -0,0 +1,25 @@ +-- Player-specific session data. +CREATE TABLE IF NOT EXISTS player_sessions ( + session_id INTEGER NOT NULL, + player_id TEXT NOT NULL, + player_name TEXT NOT NULL, + class TEXT NOT NULL, + join_time TIMESTAMP NOT NULL, + ready_time TIMESTAMP NULL, + leave_time TIMESTAMP NULL, + death_time TIMESTAMP NULL, + kills INTEGER NOT NULL, + dmg_done INTEGER NOT NULL, + dmg_taken INTEGER NOT NULL, + swings INTEGER NOT NULL, + hits INTEGER NOT NULL, + last_wave INTEGER NOT NULL, + conclusion TEXT NOT NULL, + FOREIGN KEY (session_id) REFERENCES sessions (id) ON DELETE CASCADE +); + +-- Create an index on the player UUID for "online queries" +CREATE INDEX idx_player_sessions_player_id ON player_sessions (player_id); + +-- Create an index on the player name for "offline queries" +CREATE INDEX idx_player_sessions_player_name ON player_sessions (player_name); diff --git a/src/test/java/org/mobarena/stats/session/Mocks.java b/src/test/java/org/mobarena/stats/session/Mocks.java new file mode 100644 index 0000000..424f5b3 --- /dev/null +++ b/src/test/java/org/mobarena/stats/session/Mocks.java @@ -0,0 +1,26 @@ +package org.mobarena.stats.session; + +import com.garbagemule.MobArena.framework.Arena; +import org.bukkit.entity.Player; + +import java.util.UUID; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class Mocks { + + static Arena arena(String arenaSlug) { + Arena arena = mock(Arena.class); + when(arena.getSlug()).thenReturn(arenaSlug); + return arena; + } + + static Player player(UUID playerId, String playerName) { + Player player = mock(Player.class); + when(player.getUniqueId()).thenReturn(playerId); + when(player.getName()).thenReturn(playerName); + return player; + } + +} diff --git a/src/test/java/org/mobarena/stats/session/SessionListenerTest.java b/src/test/java/org/mobarena/stats/session/SessionListenerTest.java new file mode 100644 index 0000000..c87f013 --- /dev/null +++ b/src/test/java/org/mobarena/stats/session/SessionListenerTest.java @@ -0,0 +1,346 @@ +package org.mobarena.stats.session; + +import com.garbagemule.MobArena.ArenaClass; +import com.garbagemule.MobArena.ArenaPlayer; +import com.garbagemule.MobArena.events.ArenaCompleteEvent; +import com.garbagemule.MobArena.events.ArenaEndEvent; +import com.garbagemule.MobArena.events.ArenaPlayerDeathEvent; +import com.garbagemule.MobArena.events.ArenaPlayerJoinEvent; +import com.garbagemule.MobArena.events.ArenaPlayerLeaveEvent; +import com.garbagemule.MobArena.events.ArenaPlayerReadyEvent; +import com.garbagemule.MobArena.events.ArenaStartEvent; +import com.garbagemule.MobArena.events.NewWaveEvent; +import com.garbagemule.MobArena.framework.Arena; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mobarena.stats.store.StatsStore; +import org.bukkit.entity.Player; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.io.IOException; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashSet; +import java.util.concurrent.Executor; +import java.util.logging.Level; +import java.util.logging.Logger; + +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class SessionListenerTest { + + SessionStore sessionStore; + StatsStore statsStore; + Executor asyncExecutor; + Logger log; + SessionListener subject; + + @BeforeEach + void setup() { + sessionStore = mock(SessionStore.class); + statsStore = mock(StatsStore.class); + asyncExecutor = Runnable::run; + log = mock(Logger.class); + subject = new SessionListener( + sessionStore, + statsStore, + asyncExecutor, + log + ); + } + + @Test + void freshJoinCreatesNewSessionAndCallsJoin() { + Player player = mock(Player.class); + Arena arena = mock(Arena.class); + Session session = mock(Session.class); + when(sessionStore.getByArena(arena)).thenReturn(null); + when(sessionStore.create(arena)).thenReturn(session); + ArenaPlayerJoinEvent event = new ArenaPlayerJoinEvent(player, arena); + + subject.on(event); + + verify(session).playerJoin(player); + } + + @Test + void nextJoinCallsPlayerJoinOnExistingSession() { + Player player = mock(Player.class); + Arena arena = mock(Arena.class); + Session session = mock(Session.class); + when(sessionStore.getByArena(arena)).thenReturn(session); + ArenaPlayerJoinEvent event = new ArenaPlayerJoinEvent(player, arena); + + subject.on(event); + + verify(sessionStore, never()).create(arena); + verify(session).playerJoin(player); + } + + @Test + void logsWarningIfPlayerReadyInNonExistentSession() { + Player player = mock(Player.class); + Arena arena = mock(Arena.class); + when(sessionStore.getByArena(arena)).thenReturn(null); + ArenaPlayerReadyEvent event = new ArenaPlayerReadyEvent(player, arena); + + subject.on(event); + + verify(log).warning(anyString()); + } + + @Test + void callsPlayerReady() { + String className = "knight"; + Player player = mock(Player.class); + ArenaPlayer ap = mock(ArenaPlayer.class); + ArenaClass ac = mock(ArenaClass.class); + Arena arena = mock(Arena.class); + Session session = mock(Session.class); + when(arena.getArenaPlayer(player)).thenReturn(ap); + when(ap.getArenaClass()).thenReturn(ac); + when(ac.getSlug()).thenReturn(className); + when(sessionStore.getByArena(arena)).thenReturn(session); + ArenaPlayerReadyEvent event = new ArenaPlayerReadyEvent(player, arena); + + subject.on(event); + + verify(session).playerReady(player, className); + } + + @Test + void logsWarningIfPlayerLeavesInNonExistentSession() { + Player player = mock(Player.class); + Arena arena = mock(Arena.class); + when(sessionStore.getByArena(arena)).thenReturn(null); + ArenaPlayerLeaveEvent event = new ArenaPlayerLeaveEvent(player, arena); + + subject.on(event); + + verify(log).warning(anyString()); + } + + @Test + void callsPlayerLeaveInLobby() { + Player player = mock(Player.class); + Arena arena = mock(Arena.class); + Session session = mock(Session.class); + when(arena.isRunning()).thenReturn(false); + when(arena.getPlayersInLobby()).thenReturn(Collections.singleton(player)); + when(sessionStore.getByArena(arena)).thenReturn(session); + ArenaPlayerLeaveEvent event = new ArenaPlayerLeaveEvent(player, arena); + + subject.on(event); + + verify(session).playerLeave(arena, player); + } + + @Test + void deletesSessionIfLastPlayerInLobby() { + Player player = mock(Player.class); + Arena arena = mock(Arena.class); + Session session = mock(Session.class); + when(arena.isRunning()).thenReturn(false); + when(arena.getPlayersInLobby()).thenReturn(Collections.singleton(player)); + when(sessionStore.getByArena(arena)).thenReturn(session); + ArenaPlayerLeaveEvent event = new ArenaPlayerLeaveEvent(player, arena); + + subject.on(event); + + verify(sessionStore).delete(session); + } + + @Test + void doesNotDeleteSessionIfMorePlayersInLobby() { + Player player = mock(Player.class); + Player other = mock(Player.class); + Arena arena = mock(Arena.class); + Session session = mock(Session.class); + when(arena.isRunning()).thenReturn(false); + when(arena.getPlayersInLobby()).thenReturn(new HashSet<>(Arrays.asList(player, other))); + when(sessionStore.getByArena(arena)).thenReturn(session); + ArenaPlayerLeaveEvent event = new ArenaPlayerLeaveEvent(player, arena); + + subject.on(event); + + verify(sessionStore, never()).delete(session); + } + + @Test + void callsPlayerLeaveInArenaButDoesNotDeleteSession() { + Player player = mock(Player.class); + Arena arena = mock(Arena.class); + Session session = mock(Session.class); + when(arena.isRunning()).thenReturn(true); + when(sessionStore.getByArena(arena)).thenReturn(session); + ArenaPlayerLeaveEvent event = new ArenaPlayerLeaveEvent(player, arena); + + subject.on(event); + + verify(session).playerLeave(arena, player); + verify(sessionStore, never()).delete(session); + } + + @Test + void logsWarningIfPlayerDiesInNonExistentSession() { + Player player = mock(Player.class); + Arena arena = mock(Arena.class); + when(sessionStore.getByArena(arena)).thenReturn(null); + ArenaPlayerDeathEvent event = new ArenaPlayerDeathEvent(player, arena, true); + + subject.on(event); + + verify(log).warning(anyString()); + } + + @Test + void callsPlayerDeath() { + Player player = mock(Player.class); + Arena arena = mock(Arena.class); + Session session = mock(Session.class); + when(sessionStore.getByArena(arena)).thenReturn(session); + ArenaPlayerDeathEvent event = new ArenaPlayerDeathEvent(player, arena, true); + + subject.on(event); + + verify(session).playerDeath(arena, player); + } + + @Test + void logsWarningIfArenaStartsWithoutSession() { + Arena arena = mock(Arena.class); + when(sessionStore.getByArena(arena)).thenReturn(null); + ArenaStartEvent event = new ArenaStartEvent(arena); + + subject.on(event); + + verify(log).warning(anyString()); + } + + @Test + void callsStart() { + Arena arena = mock(Arena.class); + Session session = mock(Session.class); + when(sessionStore.getByArena(arena)).thenReturn(session); + ArenaStartEvent event = new ArenaStartEvent(arena); + + subject.on(event); + + verify(session).start(); + } + + @Test + void logsWarningIfWaveSpawnsWithoutSession() { + Arena arena = mock(Arena.class); + int wave = 3; + when(sessionStore.getByArena(arena)).thenReturn(null); + NewWaveEvent event = new NewWaveEvent(arena, null, wave); + + subject.on(event); + + verify(log).warning(anyString()); + } + + @Test + void callsWave() { + Arena arena = mock(Arena.class); + int wave = 3; + Session session = mock(Session.class); + when(sessionStore.getByArena(arena)).thenReturn(session); + NewWaveEvent event = new NewWaveEvent(arena, null, wave); + + subject.on(event); + + verify(session).wave(wave); + } + + @Test + void logsWarningIfArenaCompletesWithoutSession() { + Arena arena = mock(Arena.class); + when(sessionStore.getByArena(arena)).thenReturn(null); + ArenaCompleteEvent event = new ArenaCompleteEvent(arena); + + subject.on(event); + + verify(log).warning(anyString()); + } + + @Test + void callsComplete() { + Arena arena = mock(Arena.class); + Session session = mock(Session.class); + when(sessionStore.getByArena(arena)).thenReturn(session); + ArenaCompleteEvent event = new ArenaCompleteEvent(arena); + + subject.on(event); + + verify(session).complete(); + } + + @Test + void logsWarningIfArenaEndsWithoutSession() { + Arena arena = mock(Arena.class); + when(sessionStore.getByArena(arena)).thenReturn(null); + ArenaEndEvent event = new ArenaEndEvent(arena); + + subject.on(event); + + verify(log).warning(anyString()); + } + + @Test + void callsEndAndDeletesSession() { + Arena arena = mock(Arena.class); + Session session = mock(Session.class); + when(sessionStore.getByArena(arena)).thenReturn(session); + ArenaEndEvent event = new ArenaEndEvent(arena); + + subject.on(event); + + verify(session).end(); + verify(sessionStore).delete(session); + } + + @Test + void doesNotSaveSessionIfNeverStarted() throws IOException { + Arena arena = mock(Arena.class); + Session session = mock(Session.class); + when(arena.isRunning()).thenReturn(false); + when(sessionStore.getByArena(arena)).thenReturn(session); + ArenaEndEvent event = new ArenaEndEvent(arena); + + subject.on(event); + + verify(statsStore, never()).save(session); + } + + @Test + void logsInfoIfSessionSaveSucceeds() { + Arena arena = mock(Arena.class); + Session session = mock(Session.class); + when(arena.isRunning()).thenReturn(true); + when(sessionStore.getByArena(arena)).thenReturn(session); + ArenaEndEvent event = new ArenaEndEvent(arena); + + subject.on(event); + + verify(log).info(anyString()); + } + + @Test + void logsErrorIfSessionSaveThrows() throws IOException { + Arena arena = mock(Arena.class); + Session session = mock(Session.class); + when(arena.isRunning()).thenReturn(true); + when(sessionStore.getByArena(arena)).thenReturn(session); + doThrow(new IOException()).when(statsStore).save(session); + ArenaEndEvent event = new ArenaEndEvent(arena); + + subject.on(event); + + verify(log).log(eq(Level.SEVERE), anyString(), any(IOException.class)); + } + +} diff --git a/src/test/java/org/mobarena/stats/session/SessionStoreTest.java b/src/test/java/org/mobarena/stats/session/SessionStoreTest.java new file mode 100644 index 0000000..1f7cfef --- /dev/null +++ b/src/test/java/org/mobarena/stats/session/SessionStoreTest.java @@ -0,0 +1,61 @@ +package org.mobarena.stats.session; + +import com.garbagemule.MobArena.framework.Arena; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static org.hamcrest.CoreMatchers.*; +import static org.hamcrest.MatcherAssert.*; +import static org.junit.jupiter.api.Assertions.*; + +class SessionStoreTest { + + SessionStore subject; + + @BeforeEach + void setup() { + subject = SessionStore.createNew(); + } + + @Test + void createThrowsIfSessionAlreadyExists() { + Arena arena = Mocks.arena("jungle"); + subject.create(arena); + + assertThrows( + IllegalStateException.class, + () -> subject.create(arena) + ); + } + + @Test + void getByArenaOnFreshStoreReturnsNull() { + Arena arena = Mocks.arena("castle"); + + Session result = subject.getByArena(arena); + + assertThat(result, is(nullValue())); + } + + @Test + void getByArenaAfterCreateReturnsSameSession() { + Arena arena = Mocks.arena("castle"); + + Session expected = subject.create(arena); + Session result = subject.getByArena(arena); + + assertThat(result, equalTo(expected)); + } + + @Test + void getByArenaAfterDeleteReturnsNull() { + Arena arena = Mocks.arena("castle"); + + Session session = subject.create(arena); + subject.delete(session); + Session result = subject.getByArena(arena); + + assertThat(result, is(nullValue())); + } + +} diff --git a/src/test/java/org/mobarena/stats/session/SessionTest.java b/src/test/java/org/mobarena/stats/session/SessionTest.java new file mode 100644 index 0000000..d82c063 --- /dev/null +++ b/src/test/java/org/mobarena/stats/session/SessionTest.java @@ -0,0 +1,283 @@ +package org.mobarena.stats.session; + +import com.garbagemule.MobArena.ArenaPlayer; +import com.garbagemule.MobArena.framework.Arena; +import org.bukkit.entity.Player; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.UUID; + +import static org.hamcrest.CoreMatchers.*; +import static org.hamcrest.MatcherAssert.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class SessionTest { + + Session subject; + + @BeforeEach + void setup() { + UUID sessionId = UUID.fromString("cafebabe-ea75-dead-beef-deadcafebabe"); + String arenaSlug = "castle"; + subject = new Session( + sessionId, + arenaSlug + ); + } + + @Test + void emptySessionHasNoPlayerStats() { + assertThat(subject.getPlayerStats().size(), equalTo(0)); + } + + @Test + void initPlayerStatsOnJoin() { + UUID playerId = UUID.fromString("cafebabe-ea75-dead-beef-deadcafebabe"); + String playerName = "garbagemule"; + Player player = Mocks.player(playerId, playerName); + + subject.playerJoin(player); + + PlayerSessionStats actual = subject.getPlayerStats(playerId); + assertThat(actual.playerId, equalTo(playerId)); + assertThat(actual.playerName, equalTo(playerName)); + assertThat(actual.className, nullValue()); + assertThat(actual.readyTime, nullValue()); + assertThat(actual.leaveTime, nullValue()); + assertThat(actual.deathTime, nullValue()); + assertThat(actual.kills, equalTo(0)); + assertThat(actual.dmgDone, equalTo(0)); + assertThat(actual.dmgTaken, equalTo(0)); + assertThat(actual.swings, equalTo(0)); + assertThat(actual.hits, equalTo(0)); + assertThat(actual.lastWave, equalTo(0)); + assertThat(actual.conclusion, nullValue()); + } + + @Test + void setJoinTimeOnJoin() { + UUID playerId = UUID.fromString("cafebabe-ea75-dead-beef-deadcafebabe"); + Player player = Mocks.player(playerId, "garbagemule"); + + subject.playerJoin(player); + + PlayerSessionStats actual = subject.getPlayerStats(playerId); + assertThat(actual.joinTime, notNullValue()); + } + + @Test + void setReadyTimeOnReady() { + UUID playerId = UUID.fromString("cafebabe-ea75-dead-beef-deadcafebabe"); + Player player = Mocks.player(playerId, "garbagemule"); + String className = "knight"; + subject.playerJoin(player); + + subject.playerReady(player, className); + + PlayerSessionStats actual = subject.getPlayerStats(playerId); + assertThat(actual.readyTime, notNullValue()); + } + + @Test + void setClassNameOnReady() { + UUID playerId = UUID.fromString("cafebabe-ea75-dead-beef-deadcafebabe"); + Player player = Mocks.player(playerId, "garbagemule"); + String className = "knight"; + subject.playerJoin(player); + + subject.playerReady(player, className); + + PlayerSessionStats actual = subject.getPlayerStats(playerId); + assertThat(actual.className, equalTo(className)); + } + + @Test + void setLeaveTimeOnLeave() { + UUID playerId = UUID.fromString("cafebabe-ea75-dead-beef-deadcafebabe"); + Player player = Mocks.player(playerId, "garbagemule"); + Arena arena = mock(Arena.class); + ArenaPlayer ap = mock(ArenaPlayer.class); + when(arena.getArenaPlayer(player)).thenReturn(ap); + subject.playerJoin(player); + + subject.playerLeave(arena, player); + + PlayerSessionStats actual = subject.getPlayerStats(playerId); + assertThat(actual.leaveTime, notNullValue()); + } + + @Test + void setRetreatOnLeaveIfNoOtherConclusion() { + UUID playerId = UUID.fromString("cafebabe-ea75-dead-beef-deadcafebabe"); + Player player = Mocks.player(playerId, "garbagemule"); + Arena arena = mock(Arena.class); + ArenaPlayer ap = mock(ArenaPlayer.class); + when(arena.getArenaPlayer(player)).thenReturn(ap); + subject.playerJoin(player); + + subject.playerLeave(arena, player); + + PlayerSessionStats actual = subject.getPlayerStats(playerId); + assertThat(actual.conclusion, equalTo(PlayerConclusion.RETREAT)); + } + + @Test + void dontOverwriteConclusionOnLeave() { + UUID playerId = UUID.fromString("cafebabe-ea75-dead-beef-deadcafebabe"); + Player player = Mocks.player(playerId, "garbagemule"); + Arena arena = mock(Arena.class); + ArenaPlayer ap = mock(ArenaPlayer.class); + when(arena.getArenaPlayer(player)).thenReturn(ap); + subject.playerJoin(player); + subject.getPlayerStats(playerId).conclusion = PlayerConclusion.VICTORY; + + subject.playerLeave(arena, player); + + PlayerSessionStats actual = subject.getPlayerStats(playerId); + assertThat(actual.conclusion, not(equalTo(PlayerConclusion.RETREAT))); + } + + @Test + void setDeathTimeOnDeath() { + UUID playerId = UUID.fromString("cafebabe-ea75-dead-beef-deadcafebabe"); + Player player = Mocks.player(playerId, "garbagemule"); + Arena arena = mock(Arena.class); + ArenaPlayer ap = mock(ArenaPlayer.class); + when(arena.getArenaPlayer(player)).thenReturn(ap); + subject.playerJoin(player); + + subject.playerDeath(arena, player); + + PlayerSessionStats actual = subject.getPlayerStats(playerId); + assertThat(actual.deathTime, notNullValue()); + } + + @Test + void setDefeatOnDeathIfNoOtherConclusion() { + UUID playerId = UUID.fromString("cafebabe-ea75-dead-beef-deadcafebabe"); + Player player = Mocks.player(playerId, "garbagemule"); + Arena arena = mock(Arena.class); + ArenaPlayer ap = mock(ArenaPlayer.class); + when(arena.getArenaPlayer(player)).thenReturn(ap); + subject.playerJoin(player); + + subject.playerDeath(arena, player); + + PlayerSessionStats actual = subject.getPlayerStats(playerId); + assertThat(actual.conclusion, equalTo(PlayerConclusion.DEFEAT)); + } + + @Test + void dontOverwriteConclusionOnDeath() { + UUID playerId = UUID.fromString("cafebabe-ea75-dead-beef-deadcafebabe"); + Player player = Mocks.player(playerId, "garbagemule"); + Arena arena = mock(Arena.class); + ArenaPlayer ap = mock(ArenaPlayer.class); + when(arena.getArenaPlayer(player)).thenReturn(ap); + subject.playerJoin(player); + subject.getPlayerStats(playerId).conclusion = PlayerConclusion.VICTORY; + + subject.playerDeath(arena, player); + + PlayerSessionStats actual = subject.getPlayerStats(playerId); + assertThat(actual.conclusion, not(equalTo(PlayerConclusion.DEFEAT))); + } + + @Test + void initSessionStatsOnCreate() { + SessionStats actual = subject.getSessionStats(); + assertThat(actual.sessionId, notNullValue()); + assertThat(actual.startTime, nullValue()); + assertThat(actual.endTime, nullValue()); + assertThat(actual.lastWave, equalTo(0)); + assertThat(actual.conclusion, nullValue()); + } + + @Test + void setStartTimeOnStart() { + subject.start(); + + SessionStats actual = subject.getSessionStats(); + assertThat(actual.startTime, notNullValue()); + } + + @Test + void setLastWaveTimeOnWave() { + int wave = 3; + + subject.wave(wave); + + SessionStats actual = subject.getSessionStats(); + assertThat(actual.lastWave, equalTo(wave)); + } + + @Test + void setVictoryOnComplete() { + subject.complete(); + + SessionStats actual = subject.getSessionStats(); + assertThat(actual.conclusion, equalTo(SessionConclusion.VICTORY)); + } + + @Test + void setSurvivorVictoryOnComplete() { + UUID playerId = UUID.fromString("ca11ab1e-cafe-babe-ea75-babecafebeef"); + Player player = Mocks.player(playerId, "garbagemule"); + subject.playerJoin(player); + + subject.complete(); + + PlayerSessionStats actual = subject.getPlayerStats(playerId); + assertThat(actual.conclusion, equalTo(PlayerConclusion.VICTORY)); + } + + @Test + void dontOverwriteCorpseConclusionOnComplete() { + UUID corpseId = UUID.fromString("deadbeef-dead-dead-dead-deadcafebeef"); + Player corpse = Mocks.player(corpseId, "trashdonkey"); + UUID survivorId = UUID.fromString("ca11ab1e-cafe-babe-ea75-babecafebeef"); + Player survivor = Mocks.player(survivorId, "garbagemule"); + Arena arena = mock(Arena.class); + ArenaPlayer ap = mock(ArenaPlayer.class); + when(arena.getArenaPlayer(corpse)).thenReturn(ap); + subject.playerJoin(corpse); + subject.playerJoin(survivor); + subject.playerDeath(arena, corpse); + + subject.complete(); + + PlayerSessionStats actual = subject.getPlayerStats(corpseId); + assertThat(actual.conclusion, equalTo(PlayerConclusion.DEFEAT)); + } + + @Test + void setEndTimeTimeOnEnd() { + subject.end(); + + SessionStats actual = subject.getSessionStats(); + assertThat(actual.endTime, notNullValue()); + } + + @Test + void setDefeatOnEnd() { + subject.end(); + + SessionStats actual = subject.getSessionStats(); + assertThat(actual.conclusion, equalTo(SessionConclusion.DEFEAT)); + } + + @Test + void dontOverwriteConclusionOnEnd() { + subject.complete(); + + subject.end(); + + SessionStats actual = subject.getSessionStats(); + assertThat(actual.conclusion, not(equalTo(SessionConclusion.DEFEAT))); + } + +} diff --git a/src/test/java/org/mobarena/stats/session/StatsUtilTest.java b/src/test/java/org/mobarena/stats/session/StatsUtilTest.java new file mode 100644 index 0000000..e242e9f --- /dev/null +++ b/src/test/java/org/mobarena/stats/session/StatsUtilTest.java @@ -0,0 +1,55 @@ +package org.mobarena.stats.session; + +import com.garbagemule.MobArena.ArenaPlayer; +import com.garbagemule.MobArena.ArenaPlayerStatistics; +import com.garbagemule.MobArena.framework.Arena; +import org.bukkit.entity.Player; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.UUID; + +import static org.hamcrest.CoreMatchers.*; +import static org.hamcrest.MatcherAssert.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class StatsUtilTest { + + @Test + void copiesStatsFromMobArenaObject() { + UUID sessionId = UUID.fromString("cafebabe-ea75-dead-beef-deadcafebabe"); + UUID playerId = UUID.fromString("babecafe-dead-beef-ea75-deadbeefbeef"); + String playerName = "garbagemule"; + int kills = 18; + int dmgDone = 1587; + int dmgTaken = 7159; + int swings = 1457; + int hits = 1337; + int lastWave = 11; + Player player = mock(Player.class); + Arena arena = mock(Arena.class); + ArenaPlayer ap = mock(ArenaPlayer.class); + ArenaPlayerStatistics aps = mock(ArenaPlayerStatistics.class); + when(arena.getArenaPlayer(player)).thenReturn(ap); + when(ap.getStats()).thenReturn(aps); + when(aps.getInt("kills")).thenReturn(kills); + when(aps.getInt("dmgDone")).thenReturn(dmgDone); + when(aps.getInt("dmgTaken")).thenReturn(dmgTaken); + when(aps.getInt("swings")).thenReturn(swings); + when(aps.getInt("hits")).thenReturn(hits); + when(aps.getInt("lastWave")).thenReturn(lastWave); + PlayerSessionStats target = new PlayerSessionStats(sessionId, playerId, playerName); + + StatsUtil.copy(arena, player, target); + + assertThat(target.kills, equalTo(kills)); + assertThat(target.dmgDone, equalTo(dmgDone)); + assertThat(target.dmgTaken, equalTo(dmgTaken)); + assertThat(target.swings, equalTo(swings)); + assertThat(target.hits, equalTo(hits)); + assertThat(target.lastWave, equalTo(lastWave)); + } + +} diff --git a/src/test/java/org/mobarena/stats/store/StatsExportTest.java b/src/test/java/org/mobarena/stats/store/StatsExportTest.java new file mode 100644 index 0000000..47ab768 --- /dev/null +++ b/src/test/java/org/mobarena/stats/store/StatsExportTest.java @@ -0,0 +1,22 @@ +package org.mobarena.stats.store; + +import org.bukkit.configuration.ConfigurationSection; +import org.junit.jupiter.api.Test; + +import static org.mockito.Mockito.*; + +class StatsExportTest { + + @Test + void exportsToTargetStore() throws Exception { + StatsStore store = mock(StatsStore.class); + StatsStore target = mock(StatsStore.class); + StatsStoreRegistry registry = mock(StatsStoreRegistry.class); + when(registry.create(any(ConfigurationSection.class))).thenReturn(target); + + StatsExport.run(store, registry); + + verify(store).export(target); + } + +} diff --git a/src/test/java/org/mobarena/stats/store/StatsImportTest.java b/src/test/java/org/mobarena/stats/store/StatsImportTest.java new file mode 100644 index 0000000..74eebef --- /dev/null +++ b/src/test/java/org/mobarena/stats/store/StatsImportTest.java @@ -0,0 +1,23 @@ +package org.mobarena.stats.store; + +import org.bukkit.configuration.ConfigurationSection; +import org.junit.jupiter.api.Test; + +import static org.mockito.Mockito.*; + +class StatsImportTest { + + @Test + void exportsFromSourceStore() throws Exception { + String filename = "stats.export-1234.db"; + StatsStore store = mock(StatsStore.class); + StatsStore source = mock(StatsStore.class); + StatsStoreRegistry registry = mock(StatsStoreRegistry.class); + when(registry.create(any(ConfigurationSection.class))).thenReturn(source); + + StatsImport.run(registry, filename, store); + + verify(source).export(store); + } + +} diff --git a/src/test/java/org/mobarena/stats/store/StatsStoreIT.java b/src/test/java/org/mobarena/stats/store/StatsStoreIT.java new file mode 100644 index 0000000..f1e301f --- /dev/null +++ b/src/test/java/org/mobarena/stats/store/StatsStoreIT.java @@ -0,0 +1,366 @@ +package org.mobarena.stats.store; + +import org.junit.jupiter.api.Test; +import org.mobarena.stats.session.PlayerConclusion; +import org.mobarena.stats.session.PlayerSessionStats; +import org.mobarena.stats.session.Session; +import org.mobarena.stats.session.SessionConclusion; +import org.mobarena.stats.session.SessionStats; + +import java.time.Instant; +import java.util.UUID; + +import static org.hamcrest.CoreMatchers.*; +import static org.hamcrest.MatcherAssert.*; + +/** + * Generic stats store integration test class. + *

+ * For stores with complete implementations, creating a dervied test class + * that follows the naming conventions of the Maven Failsafe Plugin will + * result in the tests being run against the store during the integration + * test phase. + *

+ * Derived classes must implement the abstract {@link #getStore()} method, + * which provides the parent class with a test subject. + * + * @see Maven Failsafe Plugin naming conventions + */ +public abstract class StatsStoreIT { + + /** + * The template method that delivers a store instance for use in all + * of the tests in this class. Called at the beginning of every test, + * this method is expected to return the same instance throughout the + * entire test run to save on time. + * + * @return a StatsStore instance + */ + public abstract StatsStore getStore(); + + /** + * A very basic test that saves a session and deletes is afterwards. + *

+ * Humble but important, if this test succeeds, writes should work just + * fine for the given database implementation. + */ + @Test + void simpleSessionSaveAndDelete() throws Exception { + StatsStore subject = getStore(); + + // Player + UUID id = UUID.fromString("deadbeef-ea75-dead-babe-deadbeef0001"); + String name = "alice"; + + // Create and save a session + UUID sessionId = UUID.fromString("cafebabe-ea75-dead-beef-deadbabe0001"); + String arenaSlug = "castle"; + Session session = new Session(sessionId, arenaSlug); + set(session, 300, 23, SessionConclusion.DEFEAT); + set(session, id, name, "tank", -59, 0, null, 672, 3, 6, PlayerConclusion.DEFEAT); + subject.save(session); + + // Delete the session again + subject.delete(sessionId); + } + + /** + * Two players join an arena, play different classes, and produce very + * different results. + *

+ * The goal of this test is to ensure that the session is captured "for" + * both players, and that the "globals" add up as expected (one session, + * max of waves, sum of kills). + */ + @Test + void twoPlayerSession() throws Exception { + StatsStore subject = getStore(); + + // Player 1 + UUID id1 = UUID.fromString("deadbeef-ea75-dead-cafe-deadbeef0002"); + String name1 = "bob"; + + // Player 2 + UUID id2 = UUID.fromString("deadbeef-ea75-dead-cafe-deadbeef0003"); + String name2 = "carol"; + + // Create and save the session + UUID sessionId = UUID.fromString("cafebabe-ea75-dead-beef-deadbabe0002"); + String arenaSlug = "island"; + Session session = new Session(sessionId, arenaSlug); + set(session, 610, 23, SessionConclusion.DEFEAT); + set(session, id1, name1, "tank", -197, -45, null, 310, 3, 6, PlayerConclusion.DEFEAT); + set(session, id2, name2, "archer", -99, 0, null, 610, 27, 23, PlayerConclusion.DEFEAT); + subject.save(session); + + try { + // For global stats, we expect to see: + // - Total sessions: 1 + // - Total duration: 610 secs + // - Total kills: 3 + 27 = 30 + // - Total waves: 23 + { + GlobalStats stats = subject.getGlobalStats(); + test(stats, 1, 610, 30, 23); + } + + // For arena-specific stats, because we only have a single + // session, we expect to see the same values for totals, + // but a real "high score" for the kills: + // - Highest wave: 23 + // - Longest duration: 610 secs + // - Highest kills: 27 + { + ArenaStats stats = subject.getArenaStats(arenaSlug); + test(stats, 23, 610, 27, 1, 610, 30, 23); + } + + // For player-specific stats, we expect to see individual numbers: + // - Total sessions: 1 for both + // - Total duration: 310 and 610 secs + // - Total kills: 3 and 27 + // - Total waves: 6 and 23 + { + PlayerStats stats = subject.getPlayerStats(name1); + test(stats, 1, 310, 3, 6); + } + { + PlayerStats stats = subject.getPlayerStats(name2); + test(stats, 1, 610, 27, 23); + } + } finally { + subject.delete(sessionId); + } + } + + /** + * A solid mix of arenas and players. + *

+ * This is "the big one" where multiple players join multiple arenas in + * various combinations, which means the stats should "stretch" in the + * extremes to show any inconsistencies. + */ + @Test + void multiPlayerMultiSession() throws Exception { + StatsStore subject = getStore(); + + // Player 1 + UUID id1 = UUID.fromString("deadbeef-ea75-dead-cafe-deadbeef0004"); + String name1 = "dennis"; + + // Player 2 + UUID id2 = UUID.fromString("deadbeef-ea75-dead-cafe-deadbeef0005"); + String name2 = "eunice"; + + // Player 3 + UUID id3 = UUID.fromString("deadbeef-ea75-dead-cafe-deadbeef0006"); + String name3 = "frank"; + + // Player 4 + UUID id4 = UUID.fromString("deadbeef-ea75-dead-cafe-deadbeef0007"); + String name4 = "gloria"; + + // Arena slugs + String slug1 = "jungle"; + String slug2 = "caverns"; + + // Session IDs + UUID sessionId1 = UUID.fromString("cafebabe-ea75-dead-beef-deadbabe0003"); + UUID sessionId2 = UUID.fromString("cafebabe-ea75-dead-beef-deadbabe0004"); + UUID sessionId3 = UUID.fromString("cafebabe-ea75-dead-beef-deadbabe0005"); + + // Create and save the sessions + { + Session session = new Session(sessionId1, slug1); + set(session, 730, 11, SessionConclusion.DEFEAT); + set(session, id1, name1, "chemist", -10, 0, 100, null, 1, 3, PlayerConclusion.RETREAT); + set(session, id2, name2, "oddjob", -50, -5, null, 730, 11, 11, PlayerConclusion.DEFEAT); + subject.save(session); + } + { + Session session = new Session(sessionId2, slug1); + set(session, 400, 20, SessionConclusion.VICTORY); + set(session, id1, name1, "tank", -120, -60, null, 310, 5, 5, PlayerConclusion.DEFEAT); + set(session, id3, name3, "chemist", -110, -50, null, null, 10, 20, PlayerConclusion.VICTORY); + subject.save(session); + } + { + Session session = new Session(sessionId3, slug2); + set(session, 610, 25, SessionConclusion.DEFEAT); + set(session, id1, name1, "tank", -197, -45, null, 610, 2, 25, PlayerConclusion.DEFEAT); + set(session, id2, name2, "archer", -99, 0, null, 550, 150, 15, PlayerConclusion.DEFEAT); + set(session, id3, name3, "knight", -99, -10, null, 500, 10, 10, PlayerConclusion.DEFEAT); + set(session, id4, name4, "oddjob", -10, -5, 200, null, 1, 3, PlayerConclusion.RETREAT); + subject.save(session); + } + + try { + // Global stats: + // - Total sessions: 3 + // - Total duration: (730 + 400 + 610) = 1740 secs + // - Total kills: (11 + 1) + (5 + 10) + (2 + 150 + 10 + 1) = 190 + // - Total waves: (11 + 20 + 25) = 56 + { + GlobalStats stats = subject.getGlobalStats(); + test(stats, 3, 1740, 190, 56); + } + + // First arena stats: + // - Highest wave: 20 (second session) + // - Longest duration: 730 secs (first session) + // - Highest kills: 11 (first session) + // - Total sessions: 2 + // - Total duration: 730 + 400 = 1130 secs + // - Total kills: (11 + 1) + (5 + 10) = 27 + // - Total waves: 11 + 20 = 31 + { + ArenaStats stats = subject.getArenaStats(slug1); + test(stats, 20, 730, 11, 2, 1130, 27, 31); + } + + // Second arena stats: + // - Highest wave: 25 + // - Longest duration: 610 + // - Highest kills: 150 + // - Total sessions: 1 + // - Total duration: 610 + // - Total kills: (2 + 150 + 10 + 1) = 163 + // - Total waves: 25 + { + ArenaStats stats = subject.getArenaStats(slug2); + test(stats, 25, 610, 150, 1, 610, 163, 25); + } + + // Player 1 stats: + // - Total sessions: 3 + // - Total duration: (100 + 310 + 610) = 1020 + // - Total kills: (1 + 5 + 2) = 8 + // - Total waves: (3 + 5 + 25) = 33 + { + PlayerStats stats = subject.getPlayerStats(name1); + test(stats, 3, 1020, 8, 33); + } + + // Player 2 stats: + // - Total sessions: 2 + // - Total duration: (730 + 550) = 1280 + // - Total kills: (11 + 150) = 161 + // - Total waves: (11 + 15) = 26 + { + PlayerStats stats = subject.getPlayerStats(name2); + test(stats, 2, 1280, 161, 26); + } + + // Player 3 stats: + // - Total sessions: 2 + // - Total duration: (400 + 500) = 900 + // - Total kills: (10 + 10) = 20 + // - Total waves: (20 + 10) = 30 + { + PlayerStats stats = subject.getPlayerStats(name3); + test(stats, 2, 900, 20, 30); + } + + // Player 4 stats: + // - Total sessions: 1 + // - Total duration: 200 + // - Total kills: 1 + // - Total waves: 3 + { + PlayerStats stats = subject.getPlayerStats(name4); + test(stats, 1, 200, 1, 3); + } + } finally { + subject.delete(sessionId1); + subject.delete(sessionId2); + subject.delete(sessionId3); + } + } + + static final Instant epoch = Instant.parse("2021-06-28T10:00:00Z"); + + private static void set( + Session session, + int endOffset, + int lastWave, + SessionConclusion conclusion + ) { + SessionStats stats = session.getSessionStats(); + stats.startTime = epoch; + stats.endTime = epoch.plusSeconds(endOffset); + stats.lastWave = lastWave; + stats.conclusion = conclusion; + } + + private static void set( + Session session, + UUID playerId, + String playerName, + String className, + int joinOffset, + int readyOffset, + Integer leaveOffset, + Integer deathOffset, + int kills, + int lastWave, + PlayerConclusion conclusion + ) { + PlayerSessionStats stats = new PlayerSessionStats(session.getSessionId(), playerId, playerName); + stats.className = className; + stats.joinTime = epoch.plusSeconds(joinOffset); + stats.readyTime = epoch.plusSeconds(readyOffset); + stats.leaveTime = (leaveOffset != null) ? epoch.plusSeconds(leaveOffset) : null; + stats.deathTime = (deathOffset != null) ? epoch.plusSeconds(deathOffset) : null; + stats.kills = kills; + stats.lastWave = lastWave; + stats.conclusion = conclusion; + session.setPlayerStats(stats.playerId, stats); + } + + private static void test( + GlobalStats stats, + int totalSessions, + long totalSeconds, + long totalKills, + long totalWaves + ) { + assertThat(stats.totalSessions, equalTo(totalSessions)); + assertThat(stats.totalSeconds, equalTo(totalSeconds)); + assertThat(stats.totalKills, equalTo(totalKills)); + assertThat(stats.totalWaves, equalTo(totalWaves)); + } + + private static void test( + ArenaStats stats, + int highestWave, + int highestSeconds, + int highestKills, + int totalSessions, + long totalSeconds, + long totalKills, + long totalWaves + ) { + assertThat(stats.highestWave, equalTo(highestWave)); + assertThat(stats.highestSeconds, equalTo(highestSeconds)); + assertThat(stats.highestKills, equalTo(highestKills)); + assertThat(stats.totalSessions, equalTo(totalSessions)); + assertThat(stats.totalSeconds, equalTo(totalSeconds)); + assertThat(stats.totalKills, equalTo(totalKills)); + assertThat(stats.totalWaves, equalTo(totalWaves)); + } + + private static void test( + PlayerStats stats, + int totalSessions, + long totalSeconds, + long totalKills, + long totalWaves + ) { + assertThat(stats.totalSessions, equalTo(totalSessions)); + assertThat(stats.totalSeconds, equalTo(totalSeconds)); + assertThat(stats.totalKills, equalTo(totalKills)); + assertThat(stats.totalWaves, equalTo(totalWaves)); + } + + +} diff --git a/src/test/java/org/mobarena/stats/store/jdbc/StatementsTest.java b/src/test/java/org/mobarena/stats/store/jdbc/StatementsTest.java new file mode 100644 index 0000000..cee948d --- /dev/null +++ b/src/test/java/org/mobarena/stats/store/jdbc/StatementsTest.java @@ -0,0 +1,41 @@ +package org.mobarena.stats.store.jdbc; + +import org.junit.jupiter.api.Test; +import org.mobarena.stats.util.ResourceLoader; + +import static org.hamcrest.CoreMatchers.*; +import static org.hamcrest.MatcherAssert.*; + +/** + * This very simple test just ensures that all of the supported JDBC-based + * store types have all the necessary SQL statement files. The actual call to + * {@link Statements#create(ResourceLoader, String)} will throw an exception + * if a file is missing, but the unit test ensures that it has content. + *

+ * The tight coupling with {@link org.mobarena.stats.util.ResourceLoader} is + * not as daunting as it may seem, since {@link Statements} itself is a hard + * bootstrap-only utility class, and its usage is carefully wrapped in other + * bootstrap components. + */ +class StatementsTest { + + @Test + void sqlite() throws Exception { + test("sqlite"); + } + + @Test + void mysql() throws Exception { + test("mysql"); + } + + private void test(String type) throws Exception { + ResourceLoader loader = ResourceLoader.create(Statements.class.getClassLoader()); + Statements statements = Statements.create(loader, type); + for (Statement statement : Statement.values()) { + String sql = statements.get(statement); + assertThat(sql, notNullValue()); + } + } + +} diff --git a/src/test/java/org/mobarena/stats/store/mariadb/MariadbStatsStoreIT.java b/src/test/java/org/mobarena/stats/store/mariadb/MariadbStatsStoreIT.java new file mode 100644 index 0000000..229d5c5 --- /dev/null +++ b/src/test/java/org/mobarena/stats/store/mariadb/MariadbStatsStoreIT.java @@ -0,0 +1,50 @@ +package org.mobarena.stats.store.mariadb; + +import org.bukkit.configuration.ConfigurationSection; +import org.bukkit.configuration.MemoryConfiguration; +import org.junit.jupiter.api.BeforeAll; +import org.mobarena.stats.MobArenaStats; +import org.mobarena.stats.store.StatsStore; +import org.mobarena.stats.store.StatsStoreIT; +import org.mobarena.stats.store.jdbc.JdbcStatsStore; +import org.testcontainers.containers.MariaDBContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +import java.util.logging.Logger; + +import static org.mockito.Mockito.*; + +@SuppressWarnings("rawtypes") +@Testcontainers +public class MariadbStatsStoreIT extends StatsStoreIT { + + @Container + static final MariaDBContainer mariadb = new MariaDBContainer("mariadb:10.4"); + + static StatsStore subject; + + @BeforeAll + static void setup() throws Exception { + // Set up fake configuration + ConfigurationSection config = new MemoryConfiguration(); + config.set("type", "mysql"); + config.set("url", mariadb.getJdbcUrl()); + config.set("username", mariadb.getUsername()); + config.set("password", mariadb.getPassword()); + + // Set up fake plugin + Logger log = mock(Logger.class); + MobArenaStats plugin = mock(MobArenaStats.class); + when(plugin.getLogger()).thenReturn(log); + + // Create a real store test subject + subject = JdbcStatsStore.create(config, plugin); + } + + @Override + public StatsStore getStore() { + return subject; + } + +} diff --git a/src/test/java/org/mobarena/stats/store/mariadb/MariadbStatsStoreTest.java b/src/test/java/org/mobarena/stats/store/mariadb/MariadbStatsStoreTest.java new file mode 100644 index 0000000..f9570d0 --- /dev/null +++ b/src/test/java/org/mobarena/stats/store/mariadb/MariadbStatsStoreTest.java @@ -0,0 +1,37 @@ +package org.mobarena.stats.store.mariadb; + +import org.bukkit.configuration.ConfigurationSection; +import org.bukkit.configuration.file.YamlConfiguration; +import org.junit.jupiter.api.Test; +import org.mobarena.stats.store.mysql.MysqlStatsStore; + +import static org.hamcrest.CoreMatchers.equalTo; +import static org.hamcrest.MatcherAssert.assertThat; + +class MariadbStatsStoreTest { + + @Test + void getUrlDefaultValues() { + ConfigurationSection config = new YamlConfiguration(); + + String result = MariadbStatsStore.getUrl(config); + + String expected = "jdbc:mariadb://localhost:3306/mobarena_stats?useSSL=false"; + assertThat(result, equalTo(expected)); + } + + @Test + void getUrlConstructsJdbcUrl() { + ConfigurationSection config = new YamlConfiguration(); + config.set("host", "stats.example.com"); + config.set("port", 1337); + config.set("database", "mastats"); + config.set("ssl", true); + + String result = MariadbStatsStore.getUrl(config); + + String expected = "jdbc:mariadb://stats.example.com:1337/mastats?useSSL=true"; + assertThat(result, equalTo(expected)); + } + +} diff --git a/src/test/java/org/mobarena/stats/store/mysql/MysqlStatsStoreIT.java b/src/test/java/org/mobarena/stats/store/mysql/MysqlStatsStoreIT.java new file mode 100644 index 0000000..4901da7 --- /dev/null +++ b/src/test/java/org/mobarena/stats/store/mysql/MysqlStatsStoreIT.java @@ -0,0 +1,50 @@ +package org.mobarena.stats.store.mysql; + +import org.bukkit.configuration.ConfigurationSection; +import org.bukkit.configuration.MemoryConfiguration; +import org.junit.jupiter.api.BeforeAll; +import org.mobarena.stats.MobArenaStats; +import org.mobarena.stats.store.StatsStore; +import org.mobarena.stats.store.StatsStoreIT; +import org.mobarena.stats.store.jdbc.JdbcStatsStore; +import org.testcontainers.containers.MySQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +import java.util.logging.Logger; + +import static org.mockito.Mockito.*; + +@SuppressWarnings("rawtypes") +@Testcontainers +public class MysqlStatsStoreIT extends StatsStoreIT { + + @Container + static final MySQLContainer mysql = new MySQLContainer("mysql:5.7"); + + static StatsStore subject; + + @BeforeAll + static void setup() throws Exception { + // Set up fake configuration + ConfigurationSection config = new MemoryConfiguration(); + config.set("type", "mysql"); + config.set("url", mysql.getJdbcUrl()); + config.set("username", mysql.getUsername()); + config.set("password", mysql.getPassword()); + + // Set up fake plugin + Logger log = mock(Logger.class); + MobArenaStats plugin = mock(MobArenaStats.class); + when(plugin.getLogger()).thenReturn(log); + + // Create a real store test subject + subject = JdbcStatsStore.create(config, plugin); + } + + @Override + public StatsStore getStore() { + return subject; + } + +} diff --git a/src/test/java/org/mobarena/stats/store/mysql/MysqlStatsStoreTest.java b/src/test/java/org/mobarena/stats/store/mysql/MysqlStatsStoreTest.java new file mode 100644 index 0000000..c727a1c --- /dev/null +++ b/src/test/java/org/mobarena/stats/store/mysql/MysqlStatsStoreTest.java @@ -0,0 +1,37 @@ +package org.mobarena.stats.store.mysql; + +import org.bukkit.configuration.ConfigurationSection; +import org.bukkit.configuration.file.YamlConfiguration; +import org.junit.jupiter.api.Test; +import org.mobarena.stats.store.mysql.MysqlStatsStore; + +import static org.hamcrest.CoreMatchers.*; +import static org.hamcrest.MatcherAssert.*; + +class MysqlStatsStoreTest { + + @Test + void getUrlDefaultValues() { + ConfigurationSection config = new YamlConfiguration(); + + String result = MysqlStatsStore.getUrl(config); + + String expected = "jdbc:mysql://localhost:3306/mobarena_stats?useSSL=false"; + assertThat(result, equalTo(expected)); + } + + @Test + void getUrlConstructsJdbcUrl() { + ConfigurationSection config = new YamlConfiguration(); + config.set("host", "stats.example.com"); + config.set("port", 1337); + config.set("database", "mastats"); + config.set("ssl", true); + + String result = MysqlStatsStore.getUrl(config); + + String expected = "jdbc:mysql://stats.example.com:1337/mastats?useSSL=true"; + assertThat(result, equalTo(expected)); + } + +} diff --git a/src/test/java/org/mobarena/stats/store/sqlite/SqliteStatsStoreIT.java b/src/test/java/org/mobarena/stats/store/sqlite/SqliteStatsStoreIT.java new file mode 100644 index 0000000..472da1b --- /dev/null +++ b/src/test/java/org/mobarena/stats/store/sqlite/SqliteStatsStoreIT.java @@ -0,0 +1,44 @@ +package org.mobarena.stats.store.sqlite; + +import org.bukkit.configuration.ConfigurationSection; +import org.bukkit.configuration.MemoryConfiguration; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.io.TempDir; +import org.mobarena.stats.MobArenaStats; +import org.mobarena.stats.store.StatsStore; +import org.mobarena.stats.store.StatsStoreIT; + +import java.io.File; +import java.util.logging.Logger; + +import static org.mockito.Mockito.*; + +public class SqliteStatsStoreIT extends StatsStoreIT { + + @TempDir + static File data; + + static StatsStore subject; + + @BeforeAll + static void setup() throws Exception { + // Set up fake configuration + ConfigurationSection config = new MemoryConfiguration(); + config.set("type", "sqlite"); + + // Set up fake plugin + Logger log = mock(Logger.class); + MobArenaStats plugin = mock(MobArenaStats.class); + when(plugin.getLogger()).thenReturn(log); + when(plugin.getDataFolder()).thenReturn(data); + + // Create a real store test subject + subject = SqliteStatsStore.create(config, plugin); + } + + @Override + public StatsStore getStore() { + return subject; + } + +} diff --git a/src/test/java/org/mobarena/stats/store/sqlite/SqliteStatsStoreTest.java b/src/test/java/org/mobarena/stats/store/sqlite/SqliteStatsStoreTest.java new file mode 100644 index 0000000..9527561 --- /dev/null +++ b/src/test/java/org/mobarena/stats/store/sqlite/SqliteStatsStoreTest.java @@ -0,0 +1,38 @@ +package org.mobarena.stats.store.sqlite; + +import org.bukkit.configuration.ConfigurationSection; +import org.bukkit.configuration.file.YamlConfiguration; +import org.junit.jupiter.api.Test; +import org.mobarena.stats.store.sqlite.SqliteStatsStore; + +import java.io.File; + +import static org.hamcrest.CoreMatchers.*; +import static org.hamcrest.MatcherAssert.*; + +class SqliteStatsStoreTest { + + @Test + void getUrlDefaultValues() { + ConfigurationSection config = new YamlConfiguration(); + File data = new File("data"); + + String result = SqliteStatsStore.getUrl(config, data); + + String expected = "jdbc:sqlite:" + data.getPath() + "/stats.db"; + assertThat(result, equalTo(expected)); + } + + @Test + void getUrlConstructsJdbcUrl() { + ConfigurationSection config = new YamlConfiguration(); + config.set("filename", "HECK-YES.db"); + File data = new File("data"); + + String result = SqliteStatsStore.getUrl(config, data); + + String expected = "jdbc:sqlite:" + data.getPath() + "/HECK-YES.db"; + assertThat(result, equalTo(expected)); + } + +} diff --git a/src/test/java/org/mobarena/stats/util/ResourceLoaderTest.java b/src/test/java/org/mobarena/stats/util/ResourceLoaderTest.java new file mode 100644 index 0000000..69d7bb2 --- /dev/null +++ b/src/test/java/org/mobarena/stats/util/ResourceLoaderTest.java @@ -0,0 +1,213 @@ +package org.mobarena.stats.util; + +import org.junit.jupiter.api.Test; + +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.InputStream; +import java.net.URL; +import java.net.URLClassLoader; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayDeque; +import java.util.Arrays; +import java.util.Deque; +import java.util.List; +import java.util.jar.JarEntry; +import java.util.jar.JarOutputStream; + +import static org.hamcrest.CoreMatchers.*; +import static org.hamcrest.MatcherAssert.*; + +/** + * Resource loading is no joke. In IDEs and build tools, resources + * typically sit in a file system folder like src/main/resources, + * which means the URI scheme is "file:". When a plugin is deployed + * to a Minecraft server, however, the URI scheme changes to "jar:", + * which means any operation that is scheme-dependent will have to + * support both schemes for a good developer experience... + *

+ * Loading a specific resource is scheme-independent, but iterating + * resources isn't. The resource loader provides a "list" method to + * list all the resources under a given prefix path, which means it + * has to iterate (part of) the classpath, so it has to know which + * scheme it's working under. + *

+ * Thus, to properly unit test the resource loader's jar-specific + * code path, we need to somehow provide a class loader that will + * resolve resources with the jar URI scheme. + *

+ * As it turns out, here be dragons... + */ +class ResourceLoaderTest { + + @Test + void listResourcesInDirectory() throws Exception { + // The normal class loader from the test class will properly + // resolve the resources in src/test/resources because this + // folder is part of the class path during test runs, so we + // don't have to do anything special here. + ClassLoader loader = getClass().getClassLoader(); + ResourceLoader subject = new ResourceLoader(loader); + + List result = subject.list("dummy/migration"); + + List expected = Arrays.asList( + "V1__baseline.sql", + "V2__new_stuff.sql", + "V3__changed_stuff.sql" + ); + assertThat(result, equalTo(expected)); + } + + @Test + void loadResourceInDirectory() throws Exception { + ClassLoader loader = getClass().getClassLoader(); + ResourceLoader subject = new ResourceLoader(loader); + String name = "dummy/migration/V1__baseline.sql"; + + String result = subject.loadString(name); + + String expected = String.join( + "\n", + "-- Some database baseline", + "CREATE TABLE IF NOT EXISTS bob(id INTEGER PRIMARY KEY AUTOINCREMENT);", + "" + ); + assertThat(result, equalTo(expected)); + } + + @Test + void listResourcesInJarFile() throws Exception { + // For the jar case, we wrap the test resources in a real, + // and temporary, jar-file. This is extremely complex stuff + // for a unit test, but it does mean we get a test case that + // hits that specific code path for a boost of confidence. + Path jar = createJarWithTestResources(); + try { + // We also need a special class loader that can access + // the contents of the jar-file with the correct scheme, + // and while this isn't as complex, the URL/URI stuff is + // pretty intricate. + ClassLoader loader = createJarClassLoader(jar); + ResourceLoader subject = new ResourceLoader(loader); + + List result = subject.list("dummy/migration"); + + List expected = Arrays.asList( + "V1__baseline.sql", + "V2__new_stuff.sql", + "V3__changed_stuff.sql" + ); + assertThat(result, equalTo(expected)); + } finally { + Files.deleteIfExists(jar); + } + } + + @Test + void loadResourceInJarFile() throws Exception { + Path jar = createJarWithTestResources(); + try { + ClassLoader loader = createJarClassLoader(jar); + ResourceLoader subject = new ResourceLoader(loader); + String name = "dummy/migration/V1__baseline.sql"; + + String result = subject.loadString(name); + + String expected = String.join( + "\n", + "-- Some database baseline", + "CREATE TABLE IF NOT EXISTS bob(id INTEGER PRIMARY KEY AUTOINCREMENT);", + "" + ); + assertThat(result, equalTo(expected)); + } finally { + Files.deleteIfExists(jar); + } + } + + private static Path createJarWithTestResources() throws Exception { + // To create a jar file, we write some bytes to a jar output + // stream, along with some jar-specific convenience functions + // related to the concept of "entries". The implementation is + // an iterative version of this solution from StackOverflow: + // + // https://stackoverflow.com/a/59351837/2221849 + // + // Each file (and folder) in the test resources folder needs + // to be written to the jar file as an "entry". Directories + // are just empty entries that end in a forward slash (/), + // while files are names and some actual bytes. + // + // We have to "relativize" the paths before writing the names + // down, because the "root" starts in src/test/resources, and + // we want to strip that part out of the names in the actual + // jar file. + Path jar = Files.createTempFile("mobarena-stats_", ".jar"); + try { + JarOutputStream target = new JarOutputStream(new FileOutputStream(jar.toFile())); + File root = Paths.get("src", "test", "resources").toFile(); + Deque queue = new ArrayDeque<>(); + queue.push(root); + while (!queue.isEmpty()) { + File file = queue.pop(); + File relative = root.toPath().relativize(file.toPath()).toFile(); + String name = relative.getPath().replace("\\", "/"); + if (file.isDirectory()) { + if (!name.isEmpty()) { + JarEntry entry = new JarEntry(name + "/"); + entry.setTime(file.lastModified()); + target.putNextEntry(entry); + target.closeEntry(); + } + File[] children = file.listFiles(); + if (children != null) { + for (File child : children) { + queue.push(child); + } + } + } else { + JarEntry entry = new JarEntry(name); + entry.setTime(file.lastModified()); + target.putNextEntry(entry); + try (InputStream is = new FileInputStream(file)) { + byte[] buffer = new byte[1024]; + int length; + while ((length = is.read(buffer)) != -1) { + target.write(buffer, 0, length); + } + target.closeEntry(); + } + } + } + target.close(); + } catch (Exception up) { + Files.deleteIfExists(jar); + throw up; + } + + return jar; + } + + private static ClassLoader createJarClassLoader(Path jar) throws Exception { + // I'll be honest and admit that I have no clue about the + // structure of these URLs, but it turns out the the "!/" + // suffix is of utmost importance. + // + // My guess is that it's necessary because the jar-scheme + // "wraps" the file-scheme, and so it needs a dedicated + // separator to get the following format: + // + // jar:file:!/ + // + // That is, the "!/" is there to indicate the end of the + // file system path and the start of the jar-file path. + String file = jar.toUri().toURL() + "!/"; + URL url = new URL("jar", "", file); + return new URLClassLoader(new URL[]{url}, null); + } + +} diff --git a/src/test/resources/dummy/migration/V1__baseline.sql b/src/test/resources/dummy/migration/V1__baseline.sql new file mode 100644 index 0000000..d84850a --- /dev/null +++ b/src/test/resources/dummy/migration/V1__baseline.sql @@ -0,0 +1,2 @@ +-- Some database baseline +CREATE TABLE IF NOT EXISTS bob(id INTEGER PRIMARY KEY AUTOINCREMENT); diff --git a/src/test/resources/dummy/migration/V2__new_stuff.sql b/src/test/resources/dummy/migration/V2__new_stuff.sql new file mode 100644 index 0000000..eb3ff25 --- /dev/null +++ b/src/test/resources/dummy/migration/V2__new_stuff.sql @@ -0,0 +1,2 @@ +-- Some new stuff +ALTER TABLE bob ADD age INTEGER; diff --git a/src/test/resources/dummy/migration/V3__changed_stuff.sql b/src/test/resources/dummy/migration/V3__changed_stuff.sql new file mode 100644 index 0000000..0090203 --- /dev/null +++ b/src/test/resources/dummy/migration/V3__changed_stuff.sql @@ -0,0 +1,2 @@ +-- Some more changes +ALTER TABLE bob ADD nickname TEXT; diff --git a/src/test/resources/dummy/query.sql b/src/test/resources/dummy/query.sql new file mode 100644 index 0000000..aaf1d7f --- /dev/null +++ b/src/test/resources/dummy/query.sql @@ -0,0 +1,2 @@ +-- A query +SELECT 1; diff --git a/src/test/resources/plugin.yml b/src/test/resources/plugin.yml new file mode 100644 index 0000000..c286170 --- /dev/null +++ b/src/test/resources/plugin.yml @@ -0,0 +1,4 @@ +name: MobArenaStats +author: garbagemule +main: org.mobarena.stats.MobArenaStatsPlugin +version: '1.0'