diff --git a/.gitignore b/.gitignore index 22b29d22..d7b144fa 100644 --- a/.gitignore +++ b/.gitignore @@ -106,7 +106,7 @@ build/ # Ignore Gradle GUI config gradle-app.setting -# Cache of project +# me.william278.crossserversync.bungeecord.PlayerDataCache of project .gradletasknamecache **/build/ @@ -117,3 +117,4 @@ run/ # Avoid ignoring Gradle wrapper jar file (.jar files are usually ignored) !gradle-wrapper.jar /build-output-final/ +/target/ diff --git a/README.md b/README.md new file mode 100644 index 00000000..cc26c3fc --- /dev/null +++ b/README.md @@ -0,0 +1,20 @@ +# CrossServerSync +**CrossServerSync** is a robust solution for synchronising player data (inventories, health, hunger & status effects) between servers. It was designed as a lightweight alternative to MySQLPlayerDataBridge, + +### Installation +Install CrossServerSync in the `/plugins/` folder of your Spigot (and derivatives) servers and Proxy (BungeeCord and derivatives) server. +Start your servers, then stop them again to allow the configuration files to generate. + +Navigate to the generated config.yml files on your Spigot server and Proxy (located in `/plugins/CrossServerSync/`) and fill in the credentials of your redis server. On the Proxy server, you can additionally configure a MySQL database to save player data in, as by default the plugin will create a SQLite database for this. + +If you have multiple proxy servers (i.e. via RedisBungee), you need to install the plugin on all of them and make use of the MySQL option and ensure the proxies are using the same database. + +### How it works +![Flow chart showing different processes of how the plugin works](images/flow-chart.png) +CrossServerSync synchronises player data between servers using Redis to transfer cached data, loaded from a central database as necessary. + +### Building +To build CrossServerSync, run the following in the root of the repository: +``` +./gradlew clean build +``` \ No newline at end of file diff --git a/build.gradle b/build.gradle index fa5925b9..3211c6f7 100644 --- a/build.gradle +++ b/build.gradle @@ -1,28 +1,40 @@ -defaultTasks ':bukkit:createFinalJar' +buildscript { + repositories { + mavenCentral() + } +} -subprojects { - apply plugin: 'java-library' - apply plugin: 'maven-publish' +plugins { + id 'com.github.johnrengelman.shadow' version '7.1.0' apply false + id 'java' +} - group = 'me.William278' - project.version = '0.1' - project.properties.put('description', 'Synchronize data cross-server') +allprojects { + group 'me.William278' + version '0.1' - sourceCompatibility = 16 - targetCompatibility = 16 + compileJava { options.encoding = 'UTF-8' } + tasks.withType(JavaCompile) { options.encoding = 'UTF-8' } + javadoc { options.encoding = 'UTF-8' } +} - tasks.withType(JavaCompile) { - options.encoding = 'UTF-8' - } - tasks.withType(Javadoc) { - options.encoding = 'UTF-8' +logger.lifecycle('Building CrossServerSync v' + version.toString()) + +subprojects { + apply plugin: 'com.github.johnrengelman.shadow' + apply plugin: 'java' + apply plugin: 'maven-publish' + + compileJava { + options.release = 16 } repositories { mavenLocal() - } - - tasks.withType(Copy).all { - duplicatesStrategy 'include' + mavenCentral() + maven { url 'https://hub.spigotmc.org/nexus/content/repositories/snapshots/' } + maven { url 'https://repo.minebench.de/' } + maven { url 'https://repo.codemc.org/repository/maven-public' } + maven { url 'https://jitpack.io' } } } \ No newline at end of file diff --git a/bukkit/build.gradle b/bukkit/build.gradle index 316ff92f..d9716746 100644 --- a/bukkit/build.gradle +++ b/bukkit/build.gradle @@ -1,54 +1,18 @@ -plugins { - id 'java' - id 'com.github.johnrengelman.shadow' version '7.1.0' -} - -repositories { - mavenCentral() - maven { url 'https://hub.spigotmc.org/nexus/content/repositories/snapshots/' } - maven { url 'https://repo.minebench.de/' } - maven { url 'https://repo.codemc.org/repository/maven-public' } - maven { url 'https://jitpack.io' } -} - dependencies { - api project(':common') - implementation 'de.themoep:minedown:1.7.1-SNAPSHOT' + compileOnly project(':common') + implementation project(path: ':common', configuration: 'shadow') + implementation 'org.bstats:bstats-bukkit:2.2.1' - implementation 'com.zaxxer:HikariCP:5.0.0' - compileOnly 'org.spigotmc:spigot-api:1.16.5-R0.1-SNAPSHOT' -} + implementation 'redis.clients:jedis:3.7.0' -processResources { - def props = [version: project.version] - inputs.properties props - expand props - filteringCharset 'UTF-8' - filesMatching('bungee.yml') { - expand props - } + compileOnly 'org.spigotmc:spigot-api:1.16.5-R0.1-SNAPSHOT' } shadowJar { - relocate 'org.bstats', 'me.william278.shaded.org.bukkit.bstats' - relocate 'de.themoep.minedown', 'me.william278.shaded.de.themeop.minedown' - dependencies { - include(dependency(':common')) - } -} - -artifacts { - archives shadowJar -} - -tasks.build.dependsOn tasks.shadowJar - -task createFinalJar(type: Copy) { - dependsOn(tasks.build) - from file("build/libs/bukkit-${project.version}-all.jar") - into file("../build-output-final") - rename 'bukkit-', 'CrossServerSync-' - rename '-all', '' + relocate 'redis.clients', 'me.William278.crossserversync.libraries.jedis' + relocate 'org.bstats', 'me.William278.crossserversync.libraries.plan' + relocate 'org.apache.commons', 'me.William278.crossserversync.libraries.apache-commons' + relocate 'org.slf4j', 'me.William278.crossserversync.libraries.slf4j' } -task prepareKotlinBuildScriptModel {} \ No newline at end of file +tasks.register('prepareKotlinBuildScriptModel'){} \ No newline at end of file diff --git a/bukkit/src/main/java/me/william278/crossserversync/CrossServerSyncSpigot.java b/bukkit/src/main/java/me/william278/crossserversync/CrossServerSyncSpigot.java deleted file mode 100644 index c39603be..00000000 --- a/bukkit/src/main/java/me/william278/crossserversync/CrossServerSyncSpigot.java +++ /dev/null @@ -1,17 +0,0 @@ -package me.william278.crossserversync; - -import org.bukkit.plugin.java.JavaPlugin; - -public final class CrossServerSyncSpigot extends JavaPlugin { - - @Override - public void onEnable() { - // Plugin startup logic - - } - - @Override - public void onDisable() { - // Plugin shutdown logic - } -} diff --git a/bukkit/src/main/java/me/william278/crossserversync/bukkit/BukkitRedisListener.java b/bukkit/src/main/java/me/william278/crossserversync/bukkit/BukkitRedisListener.java new file mode 100644 index 00000000..a0315e04 --- /dev/null +++ b/bukkit/src/main/java/me/william278/crossserversync/bukkit/BukkitRedisListener.java @@ -0,0 +1,50 @@ +package me.william278.crossserversync.bukkit; + +import me.william278.crossserversync.Settings; +import me.william278.crossserversync.redis.RedisListener; +import me.william278.crossserversync.redis.RedisMessage; +import org.bukkit.Bukkit; +import org.bukkit.entity.Player; + +import java.util.logging.Level; + +public class BukkitRedisListener extends RedisListener { + + private static final CrossServerSyncBukkit plugin = CrossServerSyncBukkit.getInstance(); + + // Initialize the listener on the bukkit server + public BukkitRedisListener() { + listen(); + } + + /** + * Handle an incoming {@link RedisMessage} + * + * @param message The {@link RedisMessage} to handle + */ + @Override + public void handleMessage(RedisMessage message) { + // Ignore messages for proxy servers + if (message.getMessageTarget().targetServerType() != Settings.ServerType.BUKKIT) { + return; + } + // Handle the message for the player + for (Player player : Bukkit.getOnlinePlayers()) { + if (player.getUniqueId() == message.getMessageTarget().targetPlayerName()) { + + return; + } + } + } + + /** + * Log to console + * + * @param level The {@link Level} to log + * @param message Message to log + */ + @Override + public void log(Level level, String message) { + plugin.getLogger().log(level, message); + } +} diff --git a/bukkit/src/main/java/me/william278/crossserversync/bukkit/CrossServerSyncBukkit.java b/bukkit/src/main/java/me/william278/crossserversync/bukkit/CrossServerSyncBukkit.java new file mode 100644 index 00000000..a4cd3f88 --- /dev/null +++ b/bukkit/src/main/java/me/william278/crossserversync/bukkit/CrossServerSyncBukkit.java @@ -0,0 +1,37 @@ +package me.william278.crossserversync.bukkit; + +import me.william278.crossserversync.bukkit.config.ConfigLoader; +import org.bukkit.plugin.java.JavaPlugin; + +public final class CrossServerSyncBukkit extends JavaPlugin { + + private static CrossServerSyncBukkit instance; + public static CrossServerSyncBukkit getInstance() { + return instance; + } + + @Override + public void onLoad() { + instance = this; + } + + @Override + public void onEnable() { + // Plugin startup logic + + // Load the config file + getConfig().options().copyDefaults(true); + saveDefaultConfig(); + saveConfig(); + reloadConfig(); + ConfigLoader.loadSettings(getConfig()); + + // Initialize the redis listener + new BukkitRedisListener(); + } + + @Override + public void onDisable() { + // Plugin shutdown logic + } +} diff --git a/bukkit/src/main/java/me/william278/crossserversync/bukkit/InventorySerializer.java b/bukkit/src/main/java/me/william278/crossserversync/bukkit/InventorySerializer.java new file mode 100644 index 00000000..ae00c954 --- /dev/null +++ b/bukkit/src/main/java/me/william278/crossserversync/bukkit/InventorySerializer.java @@ -0,0 +1,137 @@ +package me.william278.crossserversync.bukkit; + +import org.bukkit.entity.Player; +import org.bukkit.inventory.Inventory; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.PlayerInventory; +import org.bukkit.util.io.BukkitObjectInputStream; +import org.bukkit.util.io.BukkitObjectOutputStream; +import org.yaml.snakeyaml.external.biz.base64Coder.Base64Coder; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.util.Map; + +/** + * Class for serializing and deserializing player inventories and Ender Chests contents ({@link ItemStack[]}) as base64 strings. + * Based on https://gist.github.com/graywolf336/8153678 by graywolf336 + * Modified for 1.16 via https://gist.github.com/graywolf336/8153678#gistcomment-3551376 by efindus + * + * @author efindus + * @author graywolf336 + */ +public final class InventorySerializer { + + /** + * Converts the player inventory to a Base64 encoded string. + * + * @param player whose inventory will be turned into an array of strings. + * @return string with serialized Inventory + * @throws IllegalStateException in the event the item stacks cannot be saved + */ + public static String getSerializedInventoryContents(Player player) throws IllegalStateException { + // This contains contents, armor and offhand (contents are indexes 0 - 35, armor 36 - 39, offhand - 40) + return itemStackArrayToBase64(player.getInventory().getContents()); + } + + /** + * Converts the player inventory to a Base64 encoded string. + * + * @param player whose Ender Chest will be turned into an array of strings. + * @return string with serialized Ender Chest + * @throws IllegalStateException in the event the item stacks cannot be saved + */ + public static String getSerializedEnderChestContents(Player player) throws IllegalStateException { + // This contains all slots (0-27) in the player's Ender Chest + return itemStackArrayToBase64(player.getEnderChest().getContents()); + } + + /** + * Sets a player's inventory from a set of {@link ItemStack}s + * + * @param player The player to set the inventory of + * @param items The array of {@link ItemStack}s to set + */ + public static void setPlayerItems(Player player, ItemStack[] items) { + setInventoryItems(player.getInventory(), items); + } + + /** + * Sets a player's ender chest from a set of {@link ItemStack}s + * + * @param player The player to set the inventory of + * @param items The array of {@link ItemStack}s to set + */ + public static void setPlayerEnderChest(Player player, ItemStack[] items) { + setInventoryItems(player.getEnderChest(), items); + } + + // Clears, then fills an inventory's items correctly. + private static void setInventoryItems(Inventory inventory, ItemStack[] items) { + inventory.clear(); + int index = 0; + for (ItemStack item : items) { + if (item != null) { + inventory.setItem(index, item); + } + index++; + } + } + + /** + * A method to serialize an {@link ItemStack} array to Base64 String. + * + * @param items to turn into a Base64 String. + * @return Base64 string of the items. + * @throws IllegalStateException in the event the item stacks cannot be saved + */ + public static String itemStackArrayToBase64(ItemStack[] items) throws IllegalStateException { + try { + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + try (BukkitObjectOutputStream dataOutput = new BukkitObjectOutputStream(outputStream)) { + dataOutput.writeInt(items.length); + + for (ItemStack item : items) { + if (item != null) { + dataOutput.writeObject(item.serialize()); + } else { + dataOutput.writeObject(null); + } + } + } + return Base64Coder.encodeLines(outputStream.toByteArray()); + } catch (Exception e) { + throw new IllegalStateException("Unable to save item stacks.", e); + } + } + + /** + * Gets an array of ItemStacks from Base64 string. + * + * @param data Base64 string to convert to ItemStack array. + * @return ItemStack array created from the Base64 string. + * @throws IOException in the event the class type cannot be decoded + */ + public static ItemStack[] itemStackArrayFromBase64(String data) throws IOException { + try (ByteArrayInputStream inputStream = new ByteArrayInputStream(Base64Coder.decodeLines(data))) { + BukkitObjectInputStream dataInput = new BukkitObjectInputStream(inputStream); + ItemStack[] items = new ItemStack[dataInput.readInt()]; + + for (int Index = 0; Index < items.length; Index++) { + @SuppressWarnings("unchecked") // Ignore the unchecked cast here + Map stack = (Map) dataInput.readObject(); + + if (stack != null) { + items[Index] = ItemStack.deserialize(stack); + } else { + items[Index] = null; + } + } + + return items; + } catch (ClassNotFoundException e) { + throw new IOException("Unable to decode class type.", e); + } + } +} \ No newline at end of file diff --git a/bukkit/src/main/java/me/william278/crossserversync/bukkit/config/ConfigLoader.java b/bukkit/src/main/java/me/william278/crossserversync/bukkit/config/ConfigLoader.java new file mode 100644 index 00000000..8652352e --- /dev/null +++ b/bukkit/src/main/java/me/william278/crossserversync/bukkit/config/ConfigLoader.java @@ -0,0 +1,15 @@ +package me.william278.crossserversync.bukkit.config; + +import me.william278.crossserversync.Settings; +import org.bukkit.configuration.file.FileConfiguration; + +public class ConfigLoader { + + public static void loadSettings(FileConfiguration config) throws IllegalArgumentException { + Settings.serverType = Settings.ServerType.BUKKIT; + Settings.redisHost = config.getString("redis_settings.host", "localhost"); + Settings.redisPort = config.getInt("redis_settings.port", 6379); + Settings.redisPassword = config.getString("redis_settings.password", ""); + } + +} diff --git a/bukkit/src/main/resources/config.yml b/bukkit/src/main/resources/config.yml new file mode 100644 index 00000000..bface11b --- /dev/null +++ b/bukkit/src/main/resources/config.yml @@ -0,0 +1,4 @@ +redis_settings: + host: 'localhost' + port: 6379 + password: '' \ No newline at end of file diff --git a/bukkit/src/main/resources/plugin.yml b/bukkit/src/main/resources/plugin.yml deleted file mode 100644 index af6bc739..00000000 --- a/bukkit/src/main/resources/plugin.yml +++ /dev/null @@ -1,6 +0,0 @@ -name: CrossServerSync -version: '${version}' -main: me.william278.crossserversync.CrossServerSyncSpigot -api-version: 1.16 -author: William278 -description: '${properties.description}' \ No newline at end of file diff --git a/bungeecord/build.gradle b/bungeecord/build.gradle index d52ea285..93b05103 100644 --- a/bungeecord/build.gradle +++ b/bungeecord/build.gradle @@ -1,39 +1,17 @@ -plugins { - id 'java' - id 'com.github.johnrengelman.shadow' version '7.1.0' -} - -repositories { - mavenCentral() - maven { url = 'https://oss.sonatype.org/content/groups/public/' } -} - - dependencies { - api project(':common') - compileOnly 'net.md-5:bungeecord-api:1.16-R0.4' -} + compileOnly project(':common') + implementation project(path: ':common', configuration: 'shadow') -shadowJar { - relocate 'org.bstats', 'me.william278.shaded.org.bukkit.bstats' - relocate 'de.themoep.minedown', 'me.william278.shaded.de.themeop.minedown' - dependencies { - include(dependency(':common')) - } -} + implementation 'redis.clients:jedis:3.7.0' -artifacts { - archives shadowJar + compileOnly 'net.md-5:bungeecord-api:1.16-R0.5-SNAPSHOT' } -processResources { - def props = [version: project.version] - inputs.properties props - expand props - filteringCharset 'UTF-8' - filesMatching('bungee.yml') { - expand props - } +shadowJar { + relocate 'redis.clients', 'me.William278.crossserversync.libraries.jedis' + relocate 'org.bstats', 'me.William278.crossserversync.libraries.plan' + relocate 'org.apache.commons', 'me.William278.crossserversync.libraries.apache-commons' + relocate 'org.slf4j', 'me.William278.crossserversync.libraries.slf4j' } -tasks.build.dependsOn tasks.shadowJar +tasks.register('prepareKotlinBuildScriptModel'){} \ No newline at end of file diff --git a/bungeecord/src/main/java/me/william278/crossserversync/CrossServerSyncBungeeCord.java b/bungeecord/src/main/java/me/william278/crossserversync/CrossServerSyncBungeeCord.java deleted file mode 100644 index 77a31c7f..00000000 --- a/bungeecord/src/main/java/me/william278/crossserversync/CrossServerSyncBungeeCord.java +++ /dev/null @@ -1,16 +0,0 @@ -package me.william278.crossserversync; - -import net.md_5.bungee.api.plugin.Plugin; - -public final class CrossServerSyncBungeeCord extends Plugin { - - @Override - public void onEnable() { - // Plugin startup logic - } - - @Override - public void onDisable() { - // Plugin shutdown logic - } -} diff --git a/bungeecord/src/main/java/me/william278/crossserversync/bungeecord/BungeeRedisListener.java b/bungeecord/src/main/java/me/william278/crossserversync/bungeecord/BungeeRedisListener.java new file mode 100644 index 00000000..6edf42b5 --- /dev/null +++ b/bungeecord/src/main/java/me/william278/crossserversync/bungeecord/BungeeRedisListener.java @@ -0,0 +1,41 @@ +package me.william278.crossserversync.bungeecord; + +import me.william278.crossserversync.Settings; +import me.william278.crossserversync.redis.RedisListener; +import me.william278.crossserversync.redis.RedisMessage; + +import java.util.logging.Level; + +public class BungeeRedisListener extends RedisListener { + + private static final CrossServerSyncBungeeCord plugin = CrossServerSyncBungeeCord.getInstance(); + + // Initialize the listener on the bungee + public BungeeRedisListener() { + listen(); + } + + /** + * Handle an incoming {@link RedisMessage} + * + * @param message The {@link RedisMessage} to handle + */ + @Override + public void handleMessage(RedisMessage message) { + // Ignore messages destined for Bukkit servers + if (message.getMessageTarget().targetServerType() != Settings.ServerType.BUNGEECORD) { + return; + } + } + + /** + * Log to console + * + * @param level The {@link Level} to log + * @param message Message to log + */ + @Override + public void log(Level level, String message) { + plugin.getLogger().log(level, message); + } +} diff --git a/bungeecord/src/main/java/me/william278/crossserversync/bungeecord/CrossServerSyncBungeeCord.java b/bungeecord/src/main/java/me/william278/crossserversync/bungeecord/CrossServerSyncBungeeCord.java new file mode 100644 index 00000000..93ed42e5 --- /dev/null +++ b/bungeecord/src/main/java/me/william278/crossserversync/bungeecord/CrossServerSyncBungeeCord.java @@ -0,0 +1,44 @@ +package me.william278.crossserversync.bungeecord; + +import me.william278.crossserversync.bungeecord.config.ConfigLoader; +import me.william278.crossserversync.bungeecord.config.ConfigManager; +import net.md_5.bungee.api.plugin.Plugin; + +import java.util.Objects; + +public final class CrossServerSyncBungeeCord extends Plugin { + + private static CrossServerSyncBungeeCord instance; + public static CrossServerSyncBungeeCord getInstance() { + return instance; + } + + public PlayerDataCache cache; + + @Override + public void onLoad() { + instance = this; + } + + @Override + public void onEnable() { + // Plugin startup logic + + // Load config + ConfigManager.loadConfig(); + + // Load settings from config + ConfigLoader.loadSettings(Objects.requireNonNull(ConfigManager.getConfig())); + + // Setup player data cache + cache = new PlayerDataCache(); + + // Initialize the redis listener + new BungeeRedisListener(); + } + + @Override + public void onDisable() { + // Plugin shutdown logic + } +} diff --git a/bungeecord/src/main/java/me/william278/crossserversync/bungeecord/PlayerDataCache.java b/bungeecord/src/main/java/me/william278/crossserversync/bungeecord/PlayerDataCache.java new file mode 100644 index 00000000..27a5bf8a --- /dev/null +++ b/bungeecord/src/main/java/me/william278/crossserversync/bungeecord/PlayerDataCache.java @@ -0,0 +1,51 @@ +package me.william278.crossserversync.bungeecord; + +import me.william278.crossserversync.PlayerData; + +import java.util.HashSet; +import java.util.UUID; + +public class PlayerDataCache { + + // The cached player data + public HashSet playerData; + + public PlayerDataCache() { + playerData = new HashSet<>(); + } + + /** + * Update ar add data for a player to the cache + * @param newData The player's new/updated {@link PlayerData} + */ + public void updatePlayer(PlayerData newData) { + // Remove the old data if it exists + PlayerData oldData = null; + for (PlayerData data : playerData) { + if (data.getPlayerUUID() == newData.getPlayerUUID()) { + oldData = data; + } + } + if (oldData != null) { + playerData.remove(oldData); + } + + // Add the new data + playerData.add(newData); + } + + /** + * Get a player's {@link PlayerData} by their {@link UUID} + * @param playerUUID The {@link UUID} of the player to check + * @return The player's {@link PlayerData} + */ + public PlayerData getPlayer(UUID playerUUID) { + for (PlayerData data : playerData) { + if (data.getPlayerUUID() == playerUUID) { + return data; + } + } + return null; + } + +} diff --git a/bungeecord/src/main/java/me/william278/crossserversync/bungeecord/config/ConfigLoader.java b/bungeecord/src/main/java/me/william278/crossserversync/bungeecord/config/ConfigLoader.java new file mode 100644 index 00000000..ef64ea06 --- /dev/null +++ b/bungeecord/src/main/java/me/william278/crossserversync/bungeecord/config/ConfigLoader.java @@ -0,0 +1,32 @@ +package me.william278.crossserversync.bungeecord.config; + +import me.william278.crossserversync.Settings; +import net.md_5.bungee.config.Configuration; + +public class ConfigLoader { + + public static void loadSettings(Configuration config) throws IllegalArgumentException { + Settings.serverType = Settings.ServerType.BUNGEECORD; + Settings.redisHost = config.getString("redis_settings.host", "localhost"); + Settings.redisPort = config.getInt("redis_settings.port", 6379); + Settings.redisPassword = config.getString("redis_settings.password", ""); + + Settings.dataStorageType = Settings.DataStorageType.valueOf(config.getString("data_storage_settings.database_type", "sqlite").toUpperCase()); + if (Settings.dataStorageType == Settings.DataStorageType.MYSQL) { + Settings.mySQLHost = config.getString("data_storage_settings.mysql_settings.host", "localhost"); + Settings.mySQLPort = config.getInt("data_storage_settings.mysql_settings.port", 8123); + Settings.mySQLDatabase = config.getString("data_storage_settings.mysql_settings.database", "CrossServerSync"); + Settings.mySQLUsername = config.getString("data_storage_settings.mysql_settings.username", "CrossServerSync"); + Settings.mySQLPassword = config.getString("data_storage_settings.mysql_settings.password", "CrossServerSync"); + Settings.mySQLParams = config.getString("data_storage_settings.mysql_settings.params", "CrossServerSync"); + } + + Settings.hikariMaximumPoolSize = config.getInt("data_storage_settings.hikari_pool_settings.maximum_pool_size", 10); + Settings.hikariMinimumIdle = config.getInt("data_storage_settings.hikari_pool_settings.minimum_idle", 10); + Settings.hikariMaximumLifetime = config.getLong("data_storage_settings.hikari_pool_settings.maximum_lifetime", 1800000); + Settings.hikariKeepAliveTime = config.getLong("data_storage_settings.hikari_pool_settings.keepalive_time", 10); + Settings.hikariConnectionTimeOut = config.getLong("data_storage_settings.hikari_pool_settings.connection_timeout", 5000); + + } + +} diff --git a/bungeecord/src/main/java/me/william278/crossserversync/bungeecord/config/ConfigManager.java b/bungeecord/src/main/java/me/william278/crossserversync/bungeecord/config/ConfigManager.java new file mode 100644 index 00000000..2adebe41 --- /dev/null +++ b/bungeecord/src/main/java/me/william278/crossserversync/bungeecord/config/ConfigManager.java @@ -0,0 +1,44 @@ +package me.william278.crossserversync.bungeecord.config; + +import me.william278.crossserversync.bungeecord.CrossServerSyncBungeeCord; +import net.md_5.bungee.config.Configuration; +import net.md_5.bungee.config.ConfigurationProvider; +import net.md_5.bungee.config.YamlConfiguration; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.util.logging.Level; + +public class ConfigManager { + + private static final CrossServerSyncBungeeCord plugin = CrossServerSyncBungeeCord.getInstance(); + + public static void loadConfig() { + try { + if (!plugin.getDataFolder().exists()) { + if (plugin.getDataFolder().mkdir()) { + plugin.getLogger().info("Created CrossServerSync data folder"); + } + } + File configFile = new File(plugin.getDataFolder(), "config.yml"); + if (!configFile.exists()) { + Files.copy(plugin.getResourceAsStream("bungee_config.yml"), configFile.toPath()); + } + } catch (Exception e) { + plugin.getLogger().log(Level.CONFIG, "An exception occurred loading the configuration file", e); + } + } + + public static Configuration getConfig() { + try { + File configFile = new File(plugin.getDataFolder(), "config.yml"); + return ConfigurationProvider.getProvider(YamlConfiguration.class).load(configFile); + } catch (IOException e) { + plugin.getLogger().log(Level.CONFIG, "An IOException occurred fetching the configuration file", e); + return null; + } + } + +} + diff --git a/bungeecord/src/main/resources/bungee-config.yml b/bungeecord/src/main/resources/bungee-config.yml new file mode 100644 index 00000000..7914c84c --- /dev/null +++ b/bungeecord/src/main/resources/bungee-config.yml @@ -0,0 +1,19 @@ +redis_settings: + host: 'localhost' + port: 6379 + password: '' +data_storage_settings: + database_type: 'sqlite' + mysql_settings: + host: 'localhost' + port: 8123 + database: 'CrossServerSync' + username: 'root' + password: 'pa55w0rd' + params: '' + hikari_pool_settings: + maximum_pool_size: 10 + minimum_idle: 10 + maximum_lifetime: 1800000 + keepalive_time: 0 + connection_timeout: 5000 \ No newline at end of file diff --git a/bungeecord/src/main/resources/bungee.yml b/bungeecord/src/main/resources/bungee.yml deleted file mode 100644 index 6398d846..00000000 --- a/bungeecord/src/main/resources/bungee.yml +++ /dev/null @@ -1,4 +0,0 @@ -name: CrossServerSync -version: '${version}' -main: me.william278.crossserversync.CrossServerSyncBungeeCord -description: '${properties.description}' diff --git a/common/build.gradle b/common/build.gradle index a5bd9280..e0a5e3f3 100644 --- a/common/build.gradle +++ b/common/build.gradle @@ -1,16 +1,32 @@ -plugins { - id 'java' +dependencies { + implementation 'redis.clients:jedis:3.7.0' } -dependencies { - compileOnly 'org.spigotmc:spigot-api:1.16.5-R0.1-SNAPSHOT' +import org.apache.tools.ant.filters.ReplaceTokens +task updateVersion(type: Copy) { + from('src/main/resources') { + include 'plugin.yml' + include 'bungee.yml' + } + into 'build/sources/resources/' + filter(ReplaceTokens, tokens: [version: '' + project.version]) } -repositories { - mavenCentral() - maven { url 'https://hub.spigotmc.org/nexus/content/repositories/snapshots/' } +processResources { + duplicatesStrategy = DuplicatesStrategy.INCLUDE + dependsOn updateVersion + from 'build/sources/resources' } -java { - withJavadocJar() +shadowJar { + dependsOn processResources + + // Exclude some unnecessary files + exclude "**/module-info.class" + exclude "module-info.class" + + // Relocations + relocate 'redis.clients', 'me.William278.crossserversync.libraries.jedis' + relocate 'org.apache.commons', 'me.William278.crossserversync.libraries.apache-commons' + relocate 'org.slf4j', 'me.William278.crossserversync.libraries.slf4j' } \ No newline at end of file diff --git a/common/src/main/java/me/william278/crossserversync/PlayerData.java b/common/src/main/java/me/william278/crossserversync/PlayerData.java new file mode 100644 index 00000000..fccba6b4 --- /dev/null +++ b/common/src/main/java/me/william278/crossserversync/PlayerData.java @@ -0,0 +1,57 @@ +package me.william278.crossserversync; + +import java.io.Serializable; +import java.util.UUID; + +public class PlayerData implements Serializable { + + /** + * The UUID of the player who this data belongs to + */ + private final UUID playerUUID; + + /** + * The unique version UUID of this data + */ + private final UUID dataVersionUUID; + + /** + * Serialized inventory data + */ + private final String serializedInventory; + + /** + * Serialized ender chest data + */ + private final String serializedEnderChest; + + //todo add more stuff, like ender chest, player health, max health, hunger and status effects, et cetera + + /** + * Create a new PlayerData object; a random data version UUID will be selected. + * @param playerUUID The UUID of the player + * @param serializedInventory The player's serialized inventory data + */ + public PlayerData(UUID playerUUID, String serializedInventory, String serializedEnderChest) { + this.dataVersionUUID = UUID.randomUUID(); + this.playerUUID = playerUUID; + this.serializedInventory = serializedInventory; + this.serializedEnderChest = serializedEnderChest; + } + + public UUID getPlayerUUID() { + return playerUUID; + } + + public UUID getDataVersionUUID() { + return dataVersionUUID; + } + + public String getSerializedInventory() { + return serializedInventory; + } + + public String getSerializedEnderChest() { + return serializedEnderChest; + } +} diff --git a/common/src/main/java/me/william278/crossserversync/Settings.java b/common/src/main/java/me/william278/crossserversync/Settings.java new file mode 100644 index 00000000..f1ca139b --- /dev/null +++ b/common/src/main/java/me/william278/crossserversync/Settings.java @@ -0,0 +1,52 @@ +package me.william278.crossserversync; + +public class Settings { + + /* + * General settings + */ + + // The type of THIS server (Bungee or Bukkit) + public static ServerType serverType; + + // Redis settings + public static String redisHost; + public static int redisPort; + public static String redisPassword; + + /* + * Bungee / Proxy server-only settings + */ + + // SQL settings + public static DataStorageType dataStorageType; + + // MySQL specific settings + public static String mySQLHost; + public static String mySQLDatabase; + public static String mySQLUsername; + public static String mySQLPassword; + public static int mySQLPort; + public static String mySQLParams; + + // Hikari connection pooling settings + public static int hikariMaximumPoolSize; + public static int hikariMinimumIdle; + public static long hikariMaximumLifetime; + public static long hikariKeepAliveTime; + public static long hikariConnectionTimeOut; + + /* + * Enum definitions + */ + + public enum ServerType { + BUKKIT, + BUNGEECORD + } + + public enum DataStorageType { + MYSQL, + SQLITE + } +} diff --git a/common/src/main/java/me/william278/crossserversync/redis/RedisListener.java b/common/src/main/java/me/william278/crossserversync/redis/RedisListener.java new file mode 100644 index 00000000..077e08b1 --- /dev/null +++ b/common/src/main/java/me/william278/crossserversync/redis/RedisListener.java @@ -0,0 +1,52 @@ +package me.william278.crossserversync.redis; + +import me.william278.crossserversync.Settings; +import redis.clients.jedis.Jedis; +import redis.clients.jedis.JedisPubSub; + +import java.util.logging.Level; + +public abstract class RedisListener { + + /** + * Handle an incoming {@link RedisMessage} + * @param message The {@link RedisMessage} to handle + */ + public abstract void handleMessage(RedisMessage message); + + /** + * Log to console + * @param level The {@link Level} to log + * @param message Message to log + */ + public abstract void log(Level level, String message); + + /** + * Start the Redis listener + */ + public final void listen() { + Jedis jedis = new Jedis(Settings.redisHost, Settings.redisPort); + final String jedisPassword = Settings.redisPassword; + if (!jedisPassword.equals("")) { + jedis.auth(jedisPassword); + } + jedis.connect(); + if (jedis.isConnected()) { + log(Level.INFO,"Enabled Redis listener successfully!"); + new Thread(() -> jedis.subscribe(new JedisPubSub() { + @Override + public void onMessage(String channel, String message) { + // Only accept messages to the CrossServerSync channel + if (!channel.equals(RedisMessage.REDIS_CHANNEL)) { + return; + } + + // Handle the message + handleMessage(new RedisMessage(message)); + } + }, RedisMessage.REDIS_CHANNEL), "Redis Subscriber").start(); + } else { + log(Level.SEVERE, "Failed to initialize the redis listener!"); + } + } +} diff --git a/common/src/main/java/me/william278/crossserversync/redis/RedisMessage.java b/common/src/main/java/me/william278/crossserversync/redis/RedisMessage.java new file mode 100644 index 00000000..60896796 --- /dev/null +++ b/common/src/main/java/me/william278/crossserversync/redis/RedisMessage.java @@ -0,0 +1,117 @@ +package me.william278.crossserversync.redis; + +import me.william278.crossserversync.Settings; +import redis.clients.jedis.Jedis; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.ObjectInputStream; +import java.io.Serializable; +import java.util.StringJoiner; +import java.util.UUID; + +public class RedisMessage { + + public static String REDIS_CHANNEL = "CrossServerSync"; + + public static String MESSAGE_META_SEPARATOR = "♦"; + public static String MESSAGE_DATA_SEPARATOR = "♣"; + + private final String messageData; + private final MessageType messageType; + private MessageTarget messageTarget; + + /** + * Create a new RedisMessage + * @param type The type of the message + * @param target Who will receive this message + * @param messageData The message data elements + */ + public RedisMessage(MessageType type, MessageTarget target, String... messageData) { + final StringJoiner messageDataJoiner = new StringJoiner(MESSAGE_DATA_SEPARATOR); + for (String dataElement : messageData) { + messageDataJoiner.add(dataElement); + } + this.messageData = messageDataJoiner.toString(); + this.messageType = type; + this.messageTarget = target; + } + + /** + * Get a new RedisMessage from an incoming message string + * @param messageString The message string to parse + */ + public RedisMessage(String messageString) { + String[] messageMetaElements = messageString.split(MESSAGE_META_SEPARATOR); + messageType = MessageType.valueOf(messageMetaElements[0]); + messageData = messageMetaElements[2]; + + try (ObjectInputStream stream = new ObjectInputStream(new ByteArrayInputStream(messageMetaElements[1].getBytes()))) { + messageTarget = (MessageTarget) stream.readObject(); + } catch (IOException | ClassNotFoundException e) { + e.printStackTrace(); + } + } + + /** + * Returns the full, formatted message string with type, target & data + * @return The fully formatted message + */ + private String getFullMessage() { + return new StringJoiner(MESSAGE_META_SEPARATOR) + .add(messageType.toString()).add(messageTarget.toString()).add(messageData) + .toString(); + } + + /** + * Send the redis message + */ + public void send() { + try (Jedis publisher = new Jedis(Settings.redisHost, Settings.redisPort)) { + final String jedisPassword = Settings.redisPassword; + if (!jedisPassword.equals("")) { + publisher.auth(jedisPassword); + } + publisher.connect(); + publisher.publish(REDIS_CHANNEL, getFullMessage()); + } + } + + public String getMessageData() { + return messageData; + } + + public MessageType getMessageType() { + return messageType; + } + + public MessageTarget getMessageTarget() { + return messageTarget; + } + + /** + * Defines the type of the message + */ + public enum MessageType { + /** + * Sent by Bukkit servers to proxy when a player disconnects with a player's updated data, alongside the UUID of the last loaded {@link me.william278.crossserversync.PlayerData} for the user + */ + PLAYER_DATA_UPDATE, + + /** + * Sent by Bukkit servers to proxy to request {@link me.william278.crossserversync.PlayerData} from the proxy. + */ + PLAYER_DATA_REQUEST, + + /** + * Sent by the Proxy to reply to a {@code MessageType.PLAYER_DATA_REQUEST}, contains the latest {@link me.william278.crossserversync.PlayerData} for the requester. + */ + PLAYER_DATA_REPLY + } + + /** + * A record that defines the target of a plugin message; a spigot server or the proxy server(s). + * For Bukkit servers, the name of the server must also be specified + */ + public record MessageTarget(Settings.ServerType targetServerType, UUID targetPlayerName) implements Serializable { } +} \ No newline at end of file diff --git a/common/src/main/resources/bungee.yml b/common/src/main/resources/bungee.yml new file mode 100644 index 00000000..3ad7238a --- /dev/null +++ b/common/src/main/resources/bungee.yml @@ -0,0 +1,5 @@ +name: CrossServerSync +version: @version@ +main: me.william278.crossserversync.bungeecord.CrossServerSyncBungeeCord +author: William278 +description: 'Synchronize data cross-server' \ No newline at end of file diff --git a/common/src/main/resources/plugin.yml b/common/src/main/resources/plugin.yml new file mode 100644 index 00000000..42f7dfea --- /dev/null +++ b/common/src/main/resources/plugin.yml @@ -0,0 +1,6 @@ +name: CrossServerSync +version: @version@ +main: me.william278.crossserversync.bukkit.CrossServerSyncBukkit +api-version: 1.16 +author: William278 +description: 'Synchronize data cross-server' \ No newline at end of file diff --git a/images/flow-chart.png b/images/flow-chart.png new file mode 100644 index 00000000..818d4315 Binary files /dev/null and b/images/flow-chart.png differ diff --git a/plugin/build.gradle b/plugin/build.gradle new file mode 100644 index 00000000..bbe15b4c --- /dev/null +++ b/plugin/build.gradle @@ -0,0 +1,27 @@ +dependencies { + implementation project(path: ":common", configuration: 'shadow') + implementation project(path: ":bukkit", configuration: 'shadow') + implementation project(path: ":bungeecord", configuration: 'shadow') +} + +shadowJar { + destinationDirectory.set(file("$rootDir/target/")) + archiveBaseName.set('CrossServerSync') + archiveClassifier.set('') + + build { + dependsOn tasks.named("shadowJar") + } +} + +publishing { + publications { + mavenJava(MavenPublication) { + groupId = 'me.William278' + artifactId = 'CrossServerSync-plugin' + version = "$project.version" + + artifact shadowJar + } + } +} \ No newline at end of file diff --git a/settings.gradle b/settings.gradle index 2f9aafe3..b0b434fd 100644 --- a/settings.gradle +++ b/settings.gradle @@ -1,2 +1,12 @@ +pluginManagement { + repositories { + gradlePluginPortal() + } +} + rootProject.name = 'CrossServerSync' -include 'common', 'bukkit', 'bungeecord' \ No newline at end of file + +include 'common' +include 'bukkit' +include 'bungeecord' +include 'plugin' \ No newline at end of file