1.0 release progress

feat/data-edit-commands
William 3 years ago
parent f842afac1e
commit f7f1dc50eb

@ -1,20 +1,89 @@
[![HuskSync Banner](images/banner-graphic.png)](https://github.com/WiIIiam278/HuskSync)
# HuskSync
**HuskSync** is a robust solution for synchronising player data (inventories, health, hunger & status effects) between servers. It was designed as a much faster alternative to MySQLPlayerDataBridge,
[![Discord](https://img.shields.io/discord/818135932103557162?color=7289da&logo=discord)](https://discord.gg/tVYhJfyDWG)
## Installation
Install HuskSync 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.
**HuskSync** is a modern, cross-server player data synchronisation system that allows player data (inventories, health, hunger & status effects) to be synchronised across servers through the use of **Redis**.
Navigate to the generated config.yml files on your Spigot server and Proxy (located in `/plugins/HuskSync/`) 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.
## Disclaimer
This source code is provided as reference to licensed individuals that have purchased the HuskSync plugin once from any of the official sources it is provided. The availability of this code does not grant you the rights to re-distribute, compile or share this source code outside this intended purpose.
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.
Are you a developer? [Read below for information about code bounty licensing](#Contributing).
## Setup
### Requirements
* A BungeeCord-based proxy server
* A Spigot-based game server
* A Redis server
### Installation
1. Install HuskSync in the `/plugins/` folder of both your Spigot and Proxy servers.
2. Start your servers, then stop them again to allow the configuration files to generate.
3. Navigate to the generated `config.yml` files on your Spigot server and Proxy (located in `/plugins/HuskSync/`) and fill in the credentials of your redis server.
1. 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.
2. 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.
3. By default, everything except player locations are synchronised. If you would like to change what gets synchronised, you can do this by editing the `config.yml` files of each Spigot server.
4. Once you have finished setting everything up, make sure to restart all of your servers and proxy server. Then, log in and data should be synchronised!
### Migration from MySQLPlayerDataBridge
HuskSync supports the migration of player data from [MySQLPlayerDataBridge](https://www.spigotmc.org/resources/mysql-player-data-bridge.8117/). Please note that HuskSync is not compatible with MySQLPlayerInventoryBridge, as that has a different system for data handling.
To migrate from MySQLPLayerDataBridge, you need a Proxy server with HuskSync installed and one Spigot server with both HuskSync and MySQLPlayerDataBridge installed. To migrate:
1. Make sure HuskSync is set up correctly on the Proxy and Spigot server, making sure that the two are able to communicate with Redis (it will display a handshake confirmation message in both consoles when communications have been established)
2. Make sure your database is configured correctly on your Proxy server. For example, if you would like to change from SQLite to MySQL, you should do this now because the data from MySQLPlayerDataBridge will be moved into it.
3. Make sure no players are online, then in the Proxy server's console run `husksync migrate`
4. Follow the steps in the Migration wizard to ensure the connection credentials and details of the database containing your MySQLPlayerDataBridge are correct, changing settings with `husksync migrate setting <setting> <new value>` as necessary.
5. Run `husksync migrate start` in the Proxy server's console to start the migration. This could take some time, depending on the amount of data that needs migrating and the speed of your database/server. When the migration is complete, it will display a "Migration complete" message.
## How it works
![Flow chart showing different processes of how the plugin works](images/flow-chart.png)
HuskSync synchronises player data between servers using Redis to transfer cached data, loaded from a central database as necessary.
HuskSync saves a player's data when they log out to a cache on your proxy server, and redistributes that data to players when they join another HuskSync-enabled server. Player data in the cache is then saved to a database (be it SQLite or MySQL) and this is loaded from when a player joins your network.
To facilitate the transfer of data between servers, HuskSync serializes player data and then makes use of Redis to communicate between the Proxy and Spigot servers.
### What is synchronised
Everything except player locations are synchronised by default. You can enable or disable what data is loaded on a server by modifying these values in the `/plugins/HuskSync/config.yml` file on each Spigot server.
* Player inventory
* Player armour and off-hand
* Player currently selected hotbar slot
* Player ender chest
* Player experience points & levels
* Player health
* Player max health
* Player health scale
* Player hunger
* Player saturation
* Player exhaustion
* Player game mode
* Player advancements
* Player statistics (ESC → Statistics menu)
* Player location
* Player flight status
### Commands
Commands are handled by the proxy server, rather than each spigot server. Some will only work on Spigot servers with HuskSync installed. Please remember that you will need a Proxy permission plugin (e.g. LuckPermsBungee) to set permissions for proxy commands.
Command | Description | Permission
------- | ----------- | ----------
`/husksync about` | View plugin information | N/A
`/husksync status` | View system status information | `husksync.command.admin`
`/husksync reload` | Reload config & message files | `husksync.command.admin`
`/husksync invsee` | View an offline player's inventory | `husksync.command.inventory`
`/husksync echest` | View an offline player's ender chest | `husksync.command.ender_chest`
## Developers
### API
Coming soon!
### Contributing
A code bounty program is in place for HuskSync, where developers making significant code contributions to HuskSync may be entitled to a discretionary license to use HuskSync in commercial contexts without having to purchase the resource, so please feel free to submit pull requests with improvements, fixes and features!
### Building
To build HuskSync you will first need to download MySqlPlayerDataBridge and `mvn install:install-file` the jar file to your local maven repository.
```
mvn install:install-file -Dfile=MysqlPlayerDataBridge-v3.36.3.jar -DgroupId=net.craftersland.data -DartifactId=bridge -Dversion=3.36.3 -Dpackaging=jar
```
## Building
To build HuskSync, run the following in the root of the repository:
Then, to build the plugin, run the following in the root of the repository:
```
./gradlew clean build
```

@ -11,7 +11,7 @@ plugins {
allprojects {
group 'me.William278'
version '0.1'
version '1.0-dev'
compileJava { options.encoding = 'UTF-8' }
tasks.withType(JavaCompile) { options.encoding = 'UTF-8' }

@ -6,6 +6,7 @@ dependencies {
implementation 'redis.clients:jedis:3.7.0'
implementation 'de.themoep:minedown:1.7.1-SNAPSHOT'
compileOnly 'net.craftersland.data:bridge:3.36.3'
compileOnly 'org.spigotmc:spigot-api:1.16.5-R0.1-SNAPSHOT'
}

@ -4,17 +4,76 @@ import me.william278.husksync.bukkit.config.ConfigLoader;
import me.william278.husksync.bukkit.data.BukkitDataCache;
import me.william278.husksync.bukkit.listener.BukkitRedisListener;
import me.william278.husksync.bukkit.listener.EventListener;
import me.william278.husksync.bukkit.migrator.MPDBDeserializer;
import me.william278.husksync.redis.RedisMessage;
import org.bukkit.Bukkit;
import org.bukkit.plugin.Plugin;
import org.bukkit.plugin.java.JavaPlugin;
import org.bukkit.scheduler.BukkitTask;
import java.io.IOException;
import java.util.UUID;
import java.util.logging.Level;
public final class HuskSyncBukkit extends JavaPlugin {
private static HuskSyncBukkit instance;
public static HuskSyncBukkit getInstance() {
return instance;
}
public static BukkitDataCache bukkitCache;
// Used for establishing a handshake with redis
public static UUID serverUUID;
// Has a handshake been established with the Bungee?
public static boolean handshakeCompleted = false;
// THe handshake task to execute
private static BukkitTask handshakeTask;
// Whether MySqlPlayerDataBridge is installed
public static boolean isMySqlPlayerDataBridgeInstalled;
// Establish the handshake with the proxy
public static void establishRedisHandshake() {
serverUUID = UUID.randomUUID();
getInstance().getLogger().log(Level.INFO, "Executing handshake with Proxy server...");
final int[] attempts = {0}; // How many attempts to establish communication have been made
handshakeTask = Bukkit.getScheduler().runTaskTimerAsynchronously(getInstance(), () -> {
if (handshakeCompleted) {
handshakeTask.cancel();
return;
}
try {
new RedisMessage(RedisMessage.MessageType.CONNECTION_HANDSHAKE,
new RedisMessage.MessageTarget(Settings.ServerType.BUNGEECORD, null),
serverUUID.toString(),
Boolean.toString(isMySqlPlayerDataBridgeInstalled),
Bukkit.getName()).send();
attempts[0]++;
if (attempts[0] == 10) {
getInstance().getLogger().log(Level.WARNING, "Failed to complete handshake with the Proxy server; Please make sure your Proxy server is online and has HuskSync installed in its' /plugins/ folder. HuskSync will continue to try and establish a connection.");
}
} catch (IOException e) {
getInstance().getLogger().log(Level.SEVERE, "Failed to serialize Redis message for handshake establishment", e);
}
}, 0, 60);
}
private void closeRedisHandshake() {
try {
new RedisMessage(RedisMessage.MessageType.TERMINATE_HANDSHAKE,
new RedisMessage.MessageTarget(Settings.ServerType.BUNGEECORD, null),
serverUUID.toString(),
Bukkit.getName()).send();
} catch (IOException e) {
getInstance().getLogger().log(Level.SEVERE, "Failed to serialize Redis message for handshake termination", e);
}
}
@Override
public void onLoad() {
instance = this;
@ -31,6 +90,14 @@ public final class HuskSyncBukkit extends JavaPlugin {
reloadConfig();
ConfigLoader.loadSettings(getConfig());
// Check if MySqlPlayerDataBridge is installed
Plugin mySqlPlayerDataBridge = Bukkit.getPluginManager().getPlugin("MySqlPlayerDataBridge");
if (mySqlPlayerDataBridge != null) {
isMySqlPlayerDataBridgeInstalled = mySqlPlayerDataBridge.isEnabled();
MPDBDeserializer.setMySqlPlayerDataBridge();
getLogger().info("MySQLPlayerDataBridge detected! Disabled data synchronisation to prevent data loss. To perform a migration, run \"husksync migrate\" in your Proxy (Bungeecord, Waterfall, etc) server console.");
}
// Initialize last data update UUID cache
bukkitCache = new BukkitDataCache();
@ -38,7 +105,14 @@ public final class HuskSyncBukkit extends JavaPlugin {
getServer().getPluginManager().registerEvents(new EventListener(), this);
// Initialize the redis listener
new BukkitRedisListener();
if (!new BukkitRedisListener().isActiveAndEnabled) {
getPluginLoader().disablePlugin(this);
getLogger().severe("Failed to initialize Redis; disabling HuskSync (" + getServer().getName() + ") v" + getDescription().getVersion());
return;
}
// Ensure redis is connected; establish a handshake
establishRedisHandshake();
// Log to console
getLogger().info("Enabled HuskSync (" + getServer().getName() + ") v" + getDescription().getVersion());
@ -46,6 +120,9 @@ public final class HuskSyncBukkit extends JavaPlugin {
@Override
public void onDisable() {
// Send termination handshake to proxy
closeRedisHandshake();
// Plugin shutdown logic
getLogger().info("Disabled HuskSync (" + getServer().getName() + ") v" + getDescription().getVersion());
}

@ -1,17 +1,17 @@
package me.william278.husksync.bukkit;
import de.themoep.minedown.MineDown;
import me.william278.husksync.HuskSyncBukkit;
import me.william278.husksync.MessageStrings;
import me.william278.husksync.PlayerData;
import me.william278.husksync.Settings;
import net.md_5.bungee.api.ChatMessageType;
import me.william278.husksync.bukkit.data.DataSerializer;
import me.william278.husksync.redis.RedisMessage;
import org.bukkit.*;
import org.bukkit.advancement.Advancement;
import org.bukkit.advancement.AdvancementProgress;
import org.bukkit.attribute.Attribute;
import org.bukkit.entity.EntityType;
import org.bukkit.entity.Player;
import org.bukkit.inventory.Inventory;
import org.bukkit.inventory.ItemStack;
import org.bukkit.potion.PotionEffect;
@ -19,12 +19,19 @@ import java.io.IOException;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.Objects;
import java.util.UUID;
import java.util.logging.Level;
public class PlayerSetter {
private static final HuskSyncBukkit plugin = HuskSyncBukkit.getInstance();
public static void requestPlayerData(UUID playerUUID) throws IOException {
new RedisMessage(RedisMessage.MessageType.PLAYER_DATA_REQUEST,
new RedisMessage.MessageTarget(Settings.ServerType.BUNGEECORD, null),
playerUUID.toString()).send();
}
/**
* Set a player from their PlayerData, based on settings
*
@ -34,15 +41,23 @@ public class PlayerSetter {
public static void setPlayerFrom(Player player, PlayerData data) {
// If the data is flagged as being default data, skip setting
if (data.isUseDefaultData()) {
HuskSyncBukkit.bukkitCache.removeAwaitingDataFetch(player.getUniqueId());
return;
}
// Clear player
player.getInventory().clear();
player.getEnderChest().clear();
player.setExp(0);
player.setLevel(0);
HuskSyncBukkit.bukkitCache.removeAwaitingDataFetch(player.getUniqueId());
// Set the player's data from the PlayerData
Bukkit.getScheduler().runTask(plugin, () -> {
try {
if (Settings.syncAdvancements) {
// Sync advancements first so that any rewards will be overridden
setPlayerAdvancements(player, DataSerializer.deserializeAdvancementData(data.getSerializedAdvancements()));
setPlayerAdvancements(player, DataSerializer.deserializeAdvancementData(data.getSerializedAdvancements()), data);
}
if (Settings.syncInventories) {
setPlayerInventory(player, DataSerializer.itemStackArrayFromBase64(data.getSerializedInventory()));
@ -52,6 +67,7 @@ public class PlayerSetter {
setPlayerEnderChest(player, DataSerializer.itemStackArrayFromBase64(data.getSerializedEnderChest()));
}
if (Settings.syncHealth) {
player.setHealthScale(data.getHealthScale() > 0 ? data.getHealthScale() : 0D);
Objects.requireNonNull(player.getAttribute(Attribute.GENERIC_MAX_HEALTH)).setBaseValue(data.getMaxHealth());
player.setHealth(data.getHealth());
}
@ -61,9 +77,8 @@ public class PlayerSetter {
player.setExhaustion(data.getSaturationExhaustion());
}
if (Settings.syncExperience) {
player.setTotalExperience(data.getTotalExperience());
player.setLevel(data.getExpLevel());
player.setExp(data.getExpProgress());
// This is also handled when syncing advancements to ensure its correct
setPlayerExperience(player, data);
}
if (Settings.syncPotionEffects) {
setPlayerPotionEffects(player, DataSerializer.potionEffectArrayFromBase64(data.getSerializedEffectData()));
@ -78,9 +93,6 @@ public class PlayerSetter {
player.setFlying(player.getAllowFlight() && data.isFlying());
setPlayerLocation(player, DataSerializer.deserializePlayerLocationData(data.getSerializedLocation()));
}
// Send action bar synchronisation message
player.spigot().sendMessage(ChatMessageType.ACTION_BAR, new MineDown(MessageStrings.SYNCHRONISATION_COMPLETE).toComponent());
} catch (IOException e) {
plugin.getLogger().log(Level.SEVERE, "Failed to deserialize PlayerData", e);
}
@ -94,14 +106,7 @@ public class PlayerSetter {
* @param items The array of {@link ItemStack}s to set
*/
private static void setPlayerEnderChest(Player player, ItemStack[] items) {
player.getEnderChest().clear();
int index = 0;
for (ItemStack item : items) {
if (item != null) {
player.getEnderChest().setItem(index, item);
}
index++;
}
setInventory(player.getEnderChest(), items);
}
/**
@ -111,11 +116,21 @@ public class PlayerSetter {
* @param items The array of {@link ItemStack}s to set
*/
private static void setPlayerInventory(Player player, ItemStack[] items) {
player.getInventory().clear();
setInventory(player.getInventory(), items);
}
/**
* Sets an inventory's contents from an array of {@link ItemStack}s
*
* @param inventory The inventory to set
* @param items The {@link ItemStack}s to fill it with
*/
public static void setInventory(Inventory inventory, ItemStack[] items) {
inventory.clear();
int index = 0;
for (ItemStack item : items) {
if (item != null) {
player.getInventory().setItem(index, item);
inventory.setItem(index, item);
}
index++;
}
@ -142,7 +157,7 @@ public class PlayerSetter {
* @param player The player to set the advancements of
* @param advancementData The ArrayList of {@link DataSerializer.AdvancementRecord}s to set
*/
private static void setPlayerAdvancements(Player player, ArrayList<DataSerializer.AdvancementRecord> advancementData) {
private static void setPlayerAdvancements(Player player, ArrayList<DataSerializer.AdvancementRecord> advancementData, PlayerData data) {
// Temporarily disable advancement announcing if needed
boolean announceAdvancementUpdate = false;
if (Boolean.TRUE.equals(player.getWorld().getGameRuleValue(GameRule.ANNOUNCE_ADVANCEMENTS))) {
@ -153,36 +168,39 @@ public class PlayerSetter {
// Run async because advancement loading is very slow
Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> {
// Apply the advancements to the player
Iterator<Advancement> serverAdvancements = Bukkit.getServer().advancementIterator();
while (serverAdvancements.hasNext()) {
while (serverAdvancements.hasNext()) { // Iterate through all advancements
boolean correctExperienceCheck = false; // Determines whether the experience might have changed warranting an update
Advancement advancement = serverAdvancements.next();
AdvancementProgress playerProgress = player.getAdvancementProgress(advancement);
boolean hasAdvancement = false;
for (DataSerializer.AdvancementRecord record : advancementData) {
// If the advancement is one on the data
if (record.advancementKey().equals(advancement.getKey().getNamespace() + ":" + advancement.getKey().getKey())) {
hasAdvancement = true;
// Save the experience before granting the advancement
final int expLevel = player.getLevel();
final float expProgress = player.getExp();
// Grant advancement criteria if the player does not have it
// Award all criteria that the player does not have that they do on the cache
ArrayList<String> currentlyAwardedCriteria = new ArrayList<>(playerProgress.getAwardedCriteria());
for (String awardCriteria : record.awardedAdvancementCriteria()) {
if (!playerProgress.getAwardedCriteria().contains(awardCriteria)) {
Bukkit.getScheduler().runTask(plugin, () -> player.getAdvancementProgress(advancement).awardCriteria(awardCriteria));
correctExperienceCheck = true;
}
currentlyAwardedCriteria.remove(awardCriteria);
}
// Set experience back to before granting advancement; nullify exp gained from it
player.setLevel(expLevel);
player.setExp(expProgress);
// Revoke all criteria that the player does have but should not
for (String awardCriteria : currentlyAwardedCriteria) {
Bukkit.getScheduler().runTask(plugin, () -> player.getAdvancementProgress(advancement).revokeCriteria(awardCriteria));
}
break;
}
}
if (!hasAdvancement) {
for (String awardCriteria : playerProgress.getAwardedCriteria()) {
Bukkit.getScheduler().runTask(plugin, () -> player.getAdvancementProgress(advancement).revokeCriteria(awardCriteria));
// Update the player's experience in case the advancement changed that
if (correctExperienceCheck) {
if (Settings.syncExperience) {
setPlayerExperience(player, data);
}
}
}
@ -230,6 +248,18 @@ public class PlayerSetter {
}
}
/**
* Set a player's exp level, exp points & score
*
* @param player The {@link Player} to set
* @param data The {@link PlayerData} to set them
*/
private static void setPlayerExperience(Player player, PlayerData data) {
player.setTotalExperience(data.getTotalExperience());
player.setLevel(data.getExpLevel());
player.setExp(data.getExpProgress());
}
/**
* Set a player's location from {@link DataSerializer.PlayerLocation} data
*

@ -1,5 +1,6 @@
package me.william278.husksync.bukkit.data;
import java.util.HashMap;
import java.util.HashSet;
import java.util.UUID;
@ -10,10 +11,6 @@ public class BukkitDataCache {
*/
private static HashSet<UUID> requestOnJoin;
public BukkitDataCache() {
requestOnJoin = new HashSet<>();
}
public boolean isPlayerRequestingOnJoin(UUID uuid) {
return requestOnJoin.contains(uuid);
}
@ -25,4 +22,53 @@ public class BukkitDataCache {
public void removeRequestOnJoin(UUID uuid) {
requestOnJoin.remove(uuid);
}
}
/**
* Map of Player UUIDs whose data has not been set yet
*/
private static HashSet<UUID> awaitingDataFetch;
public boolean isAwaitingDataFetch(UUID uuid) {
return awaitingDataFetch.contains(uuid);
}
public void setAwaitingDataFetch(UUID uuid) {
awaitingDataFetch.add(uuid);
}
public void removeAwaitingDataFetch(UUID uuid) {
awaitingDataFetch.remove(uuid);
}
public HashSet<UUID> getAwaitingDataFetch() {
return awaitingDataFetch;
}
/**
* Map of data being viewed by players
*/
private static HashMap<UUID, DataViewer.DataView> viewingPlayerData;
public void setViewing(UUID uuid, DataViewer.DataView dataView) {
viewingPlayerData.put(uuid, dataView);
}
public void removeViewing(UUID uuid) {
viewingPlayerData.remove(uuid);
}
public boolean isViewing(UUID uuid) {
return viewingPlayerData.containsKey(uuid);
}
public DataViewer.DataView getViewing(UUID uuid) {
return viewingPlayerData.get(uuid);
}
// Cache object
public BukkitDataCache() {
requestOnJoin = new HashSet<>();
viewingPlayerData = new HashMap<>();
awaitingDataFetch = new HashSet<>();
}
}

@ -1,4 +1,4 @@
package me.william278.husksync.bukkit;
package me.william278.husksync.bukkit.data;
import me.william278.husksync.redis.RedisMessage;
import org.bukkit.*;
@ -6,6 +6,7 @@ import org.bukkit.advancement.Advancement;
import org.bukkit.advancement.AdvancementProgress;
import org.bukkit.entity.EntityType;
import org.bukkit.entity.Player;
import org.bukkit.inventory.Inventory;
import org.bukkit.inventory.ItemStack;
import org.bukkit.potion.PotionEffect;
import org.bukkit.util.io.BukkitObjectInputStream;
@ -33,19 +34,19 @@ public final class DataSerializer {
/**
* Converts the player inventory to a Base64 encoded string.
*
* @param player whose inventory will be turned into an array of strings.
* @param inventory the inventory to convert to Base64.
* @return string with serialized Inventory
* @throws IllegalStateException in the event the item stacks cannot be saved
*/
public static String getSerializedInventoryContents(Player player) throws IllegalStateException {
public static String getSerializedInventoryContents(Inventory inventory) throws IllegalStateException {
// This contains contents, armor and offhand (contents are indexes 0 - 35, armor 36 - 39, offhand - 40)
return itemStackArrayToBase64(player.getInventory().getContents());
return itemStackArrayToBase64(inventory.getContents());
}
/**
* Converts the player inventory to a Base64 encoded string.
*
* @param player whose Ender Chest will be turned into an array of strings.
* @param player whose Ender Chest will be turned into Base64.
* @return string with serialized Ender Chest
* @throws IllegalStateException in the event the item stacks cannot be saved
*/

@ -0,0 +1,115 @@
package me.william278.husksync.bukkit.data;
import me.william278.husksync.HuskSyncBukkit;
import me.william278.husksync.PlayerData;
import me.william278.husksync.Settings;
import me.william278.husksync.bukkit.PlayerSetter;
import me.william278.husksync.redis.RedisMessage;
import org.bukkit.Bukkit;
import org.bukkit.entity.Player;
import org.bukkit.inventory.Inventory;
import org.bukkit.inventory.ItemStack;
import java.io.IOException;
/**
* Class used for managing viewing inventories using inventory-see command
*/
public class DataViewer {
/**
* Show a viewer's data to a viewer
*
* @param viewer The viewing {@link Player} who will see the data
* @param data The {@link DataView} to show the viewer
* @throws IOException If an exception occurred deserializing item data
*/
public static void showData(Player viewer, DataView data) throws IOException {
// Show an inventory with the viewer's inventory and equipment
viewer.closeInventory();
viewer.openInventory(createInventory(viewer, data));
// Set the viewer as viewing
HuskSyncBukkit.bukkitCache.setViewing(viewer.getUniqueId(), data);
}
/**
* Handles what happens after a data viewer finishes viewing data
*
* @param viewer The viewing {@link Player} who was looking at data
* @param inventory The {@link Inventory} that was being viewed
* @throws IOException If an exception occurred serializing item data
*/
public static void stopShowing(Player viewer, Inventory inventory) throws IOException {
// Get the DataView the player was looking at
DataView dataView = HuskSyncBukkit.bukkitCache.getViewing(viewer.getUniqueId());
// Set the player as no longer viewing an inventory
HuskSyncBukkit.bukkitCache.removeViewing(viewer.getUniqueId());
// Get and update the PlayerData with the new item data
PlayerData playerData = dataView.playerData();
String serializedItemData = DataSerializer.itemStackArrayToBase64(inventory.getContents());
switch (dataView.inventoryType()) {
case INVENTORY -> playerData.setSerializedInventory(serializedItemData);
case ENDER_CHEST -> playerData.setSerializedEnderChest(serializedItemData);
}
// Send a redis message with the updated data after the viewing
new RedisMessage(RedisMessage.MessageType.PLAYER_DATA_UPDATE,
new RedisMessage.MessageTarget(Settings.ServerType.BUNGEECORD, null),
RedisMessage.serialize(playerData))
.send();
}
/**
* Creates the inventory object that the viewer will see
*
* @param viewer The {@link Player} who will view the data
* @param data The {@link DataView} data to view
* @return The {@link Inventory} that the viewer will see
* @throws IOException If an exception occurred deserializing item data
*/
private static Inventory createInventory(Player viewer, DataView data) throws IOException {
Inventory inventory = switch (data.inventoryType) {
case INVENTORY -> Bukkit.createInventory(viewer, 45, data.ownerName + "'s Inventory");
case ENDER_CHEST -> Bukkit.createInventory(viewer, 27, data.ownerName + "'s Ender Chest");
};
PlayerSetter.setInventory(inventory, data.getDeserializedData());
return inventory;
}
/**
* Represents Player Data being viewed by a {@link Player}
*/
public record DataView(PlayerData playerData, String ownerName, InventoryType inventoryType) {
/**
* What kind of item data is being viewed
*/
public enum InventoryType {
/**
* A player's inventory
*/
INVENTORY,
/**
* A player's ender chest
*/
ENDER_CHEST
}
/**
* Gets the deserialized data currently being viewed
*
* @return The deserialized item data, as an {@link ItemStack[]} array
* @throws IOException If an exception occurred deserializing item data
*/
public ItemStack[] getDeserializedData() throws IOException {
return switch (inventoryType) {
case INVENTORY -> DataSerializer.itemStackArrayFromBase64(playerData.getSerializedInventory());
case ENDER_CHEST -> DataSerializer.itemStackArrayFromBase64(playerData.getSerializedEnderChest());
};
}
}
}

@ -1,12 +1,16 @@
package me.william278.husksync.bukkit.listener;
import de.themoep.minedown.MineDown;
import me.william278.husksync.PlayerData;
import me.william278.husksync.HuskSyncBukkit;
import me.william278.husksync.MessageManager;
import me.william278.husksync.PlayerData;
import me.william278.husksync.Settings;
import me.william278.husksync.bukkit.config.ConfigLoader;
import me.william278.husksync.bukkit.data.DataViewer;
import me.william278.husksync.bukkit.PlayerSetter;
import me.william278.husksync.bukkit.migrator.MPDBDeserializer;
import me.william278.husksync.migrator.MPDBPlayerData;
import me.william278.husksync.redis.RedisListener;
import me.william278.husksync.MessageStrings;
import me.william278.husksync.Settings;
import me.william278.husksync.redis.RedisMessage;
import org.bukkit.Bukkit;
import org.bukkit.entity.Player;
@ -25,25 +29,74 @@ public class BukkitRedisListener extends RedisListener {
}
/**
* Handle an incoming {@link me.william278.husksync.redis.RedisMessage}
* Handle an incoming {@link RedisMessage}
*
* @param message The {@link me.william278.husksync.redis.RedisMessage} to handle
* @param message The {@link RedisMessage} to handle
*/
@Override
public void handleMessage(me.william278.husksync.redis.RedisMessage message) {
public void handleMessage(RedisMessage message) {
// Ignore messages for proxy servers
if (!message.getMessageTarget().targetServerType().equals(Settings.ServerType.BUKKIT)) {
return;
}
// Handle the message for the player
if (message.getMessageTarget().targetPlayerUUID() == null) {
if (message.getMessageType() == me.william278.husksync.redis.RedisMessage.MessageType.REQUEST_DATA_ON_JOIN) {
UUID playerUUID = UUID.fromString(message.getMessageDataElements()[1]);
switch (me.william278.husksync.redis.RedisMessage.RequestOnJoinUpdateType.valueOf(message.getMessageDataElements()[0])) {
case ADD_REQUESTER -> HuskSyncBukkit.bukkitCache.setRequestOnJoin(playerUUID);
case REMOVE_REQUESTER -> HuskSyncBukkit.bukkitCache.removeRequestOnJoin(playerUUID);
switch (message.getMessageType()) {
case REQUEST_DATA_ON_JOIN -> {
UUID playerUUID = UUID.fromString(message.getMessageDataElements()[1]);
switch (me.william278.husksync.redis.RedisMessage.RequestOnJoinUpdateType.valueOf(message.getMessageDataElements()[0])) {
case ADD_REQUESTER -> HuskSyncBukkit.bukkitCache.setRequestOnJoin(playerUUID);
case REMOVE_REQUESTER -> HuskSyncBukkit.bukkitCache.removeRequestOnJoin(playerUUID);
}
}
case CONNECTION_HANDSHAKE -> {
UUID serverUUID = UUID.fromString(message.getMessageDataElements()[0]);
String proxyBrand = message.getMessageDataElements()[1];
if (serverUUID.equals(HuskSyncBukkit.serverUUID)) {
HuskSyncBukkit.handshakeCompleted = true;
log(Level.INFO, "Completed handshake with " + proxyBrand + " proxy (" + serverUUID + ")");
// If there are any players awaiting a data update, request it
for (UUID uuid : HuskSyncBukkit.bukkitCache.getAwaitingDataFetch()) {
try {
PlayerSetter.requestPlayerData(uuid);
} catch (IOException e) {
log(Level.SEVERE, "Failed to serialize handshake message data");
}
}
}
}
case TERMINATE_HANDSHAKE -> {
UUID serverUUID = UUID.fromString(message.getMessageDataElements()[0]);
String proxyBrand = message.getMessageDataElements()[1];
if (serverUUID.equals(HuskSyncBukkit.serverUUID)) {
HuskSyncBukkit.handshakeCompleted = false;
log(Level.WARNING, proxyBrand + " proxy has terminated communications; attempting to re-establish (" + serverUUID + ")");
// Attempt to re-establish communications via another handshake
Bukkit.getScheduler().runTaskLaterAsynchronously(plugin, HuskSyncBukkit::establishRedisHandshake, 20);
}
}
case DECODE_MPDB_DATA -> {
UUID serverUUID = UUID.fromString(message.getMessageDataElements()[0]);
String encodedData = message.getMessageDataElements()[1];
if (serverUUID.equals(HuskSyncBukkit.serverUUID)) {
try {
MPDBPlayerData data = (MPDBPlayerData) RedisMessage.deserialize(encodedData);
new RedisMessage(RedisMessage.MessageType.DECODED_MPDB_DATA_SET,
new RedisMessage.MessageTarget(Settings.ServerType.BUNGEECORD, null),
RedisMessage.serialize(MPDBDeserializer.convertMPDBData(data)),
data.playerName)
.send();
} catch (IOException | ClassNotFoundException e) {
log(Level.SEVERE, "Failed to serialize encoded MPDB data");
}
}
}
case RELOAD_CONFIG -> {
plugin.reloadConfig();
ConfigLoader.loadSettings(plugin.getConfig());
}
}
} else {
@ -51,6 +104,7 @@ public class BukkitRedisListener extends RedisListener {
if (player.getUniqueId().equals(message.getMessageTarget().targetPlayerUUID())) {
switch (message.getMessageType()) {
case PLAYER_DATA_SET -> {
if (HuskSyncBukkit.isMySqlPlayerDataBridgeInstalled) return;
try {
// Deserialize the received PlayerData
PlayerData data = (PlayerData) RedisMessage.deserialize(message.getMessageData());
@ -58,7 +112,7 @@ public class BukkitRedisListener extends RedisListener {
// Set the player's data
PlayerSetter.setPlayerFrom(player, data);
} catch (IOException | ClassNotFoundException e) {
log(Level.SEVERE, "Failed to deserialize PlayerData when handling a reply from the proxy with PlayerData");
log(Level.SEVERE, "Failed to deserialize PlayerData when handling data from the proxy");
e.printStackTrace();
}
}
@ -66,7 +120,7 @@ public class BukkitRedisListener extends RedisListener {
String proxyBrand = message.getMessageDataElements()[0];
String proxyVersion = message.getMessageDataElements()[1];
assert plugin.getDescription().getDescription() != null;
player.spigot().sendMessage(new MineDown(MessageStrings.PLUGIN_INFORMATION.toString()
player.spigot().sendMessage(new MineDown(MessageManager.PLUGIN_INFORMATION.toString()
.replaceAll("%plugin_description%", plugin.getDescription().getDescription())
.replaceAll("%proxy_brand%", proxyBrand)
.replaceAll("%proxy_version%", proxyVersion)
@ -74,6 +128,42 @@ public class BukkitRedisListener extends RedisListener {
.replaceAll("%bukkit_version%", plugin.getDescription().getVersion()))
.toComponent());
}
case OPEN_INVENTORY -> {
// Get the name of the inventory owner
String inventoryOwnerName = message.getMessageDataElements()[0];
// Synchronously do inventory setting, etc
Bukkit.getScheduler().runTask(plugin, () -> {
try {
// Get that player's data
PlayerData data = (PlayerData) RedisMessage.deserialize(message.getMessageDataElements()[1]);
// Show the data to the player
DataViewer.showData(player, new DataViewer.DataView(data, inventoryOwnerName, DataViewer.DataView.InventoryType.INVENTORY));
} catch (IOException | ClassNotFoundException e) {
log(Level.SEVERE, "Failed to deserialize PlayerData when handling inventory-see data from the proxy");
e.printStackTrace();
}
});
}
case OPEN_ENDER_CHEST -> {
// Get the name of the inventory owner
String enderChestOwnerName = message.getMessageDataElements()[0];
// Synchronously do inventory setting, etc
Bukkit.getScheduler().runTask(plugin, () -> {
try {
// Get that player's data
PlayerData data = (PlayerData) RedisMessage.deserialize(message.getMessageDataElements()[1]);
// Show the data to the player
DataViewer.showData(player, new DataViewer.DataView(data, enderChestOwnerName, DataViewer.DataView.InventoryType.ENDER_CHEST));
} catch (IOException | ClassNotFoundException e) {
log(Level.SEVERE, "Failed to deserialize PlayerData when handling ender chest-see data from the proxy");
e.printStackTrace();
}
});
}
}
return;
}

@ -3,14 +3,22 @@ package me.william278.husksync.bukkit.listener;
import me.william278.husksync.HuskSyncBukkit;
import me.william278.husksync.PlayerData;
import me.william278.husksync.Settings;
import me.william278.husksync.bukkit.DataSerializer;
import me.william278.husksync.bukkit.data.DataSerializer;
import me.william278.husksync.bukkit.data.DataViewer;
import me.william278.husksync.bukkit.PlayerSetter;
import me.william278.husksync.redis.RedisMessage;
import org.bukkit.Bukkit;
import org.bukkit.attribute.Attribute;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.player.PlayerJoinEvent;
import org.bukkit.event.player.PlayerQuitEvent;
import org.bukkit.event.block.BlockBreakEvent;
import org.bukkit.event.block.BlockPlaceEvent;
import org.bukkit.event.entity.EntityPickupItemEvent;
import org.bukkit.event.inventory.InventoryCloseEvent;
import org.bukkit.event.inventory.InventoryOpenEvent;
import org.bukkit.event.player.*;
import java.io.IOException;
import java.util.Objects;
@ -28,11 +36,12 @@ public class EventListener implements Listener {
* @throws IOException If the serialization fails
*/
private static String getNewSerializedPlayerData(Player player) throws IOException {
return me.william278.husksync.redis.RedisMessage.serialize(new PlayerData(player.getUniqueId(),
DataSerializer.getSerializedInventoryContents(player),
return RedisMessage.serialize(new PlayerData(player.getUniqueId(),
DataSerializer.getSerializedInventoryContents(player.getInventory()),
DataSerializer.getSerializedEnderChestContents(player),
player.getHealth(),
Objects.requireNonNull(player.getAttribute(Attribute.GENERIC_MAX_HEALTH)).getBaseValue(),
player.getHealthScale(),
player.getFoodLevel(),
player.getSaturation(),
player.getExhaustion(),
@ -53,11 +62,19 @@ public class EventListener implements Listener {
// When a player leaves a Bukkit server
final Player player = event.getPlayer();
// If the player was awaiting data fetch, remove them and prevent data from being overwritten
if (HuskSyncBukkit.bukkitCache.isAwaitingDataFetch(player.getUniqueId())) {
HuskSyncBukkit.bukkitCache.removeAwaitingDataFetch(player.getUniqueId());
return;
}
if (!plugin.isEnabled() || !HuskSyncBukkit.handshakeCompleted || HuskSyncBukkit.isMySqlPlayerDataBridgeInstalled) return; // If the plugin has not been initialized correctly
// Send a redis message with the player's last updated PlayerData version UUID and their new PlayerData
try {
final String serializedPlayerData = getNewSerializedPlayerData(player);
new me.william278.husksync.redis.RedisMessage(me.william278.husksync.redis.RedisMessage.MessageType.PLAYER_DATA_UPDATE,
new me.william278.husksync.redis.RedisMessage.MessageTarget(Settings.ServerType.BUNGEECORD, null),
new RedisMessage(RedisMessage.MessageType.PLAYER_DATA_UPDATE,
new RedisMessage.MessageTarget(Settings.ServerType.BUNGEECORD, null),
serializedPlayerData).send();
} catch (IOException e) {
plugin.getLogger().log(Level.SEVERE, "Failed to send a PlayerData update to the proxy", e);
@ -70,22 +87,101 @@ public class EventListener implements Listener {
@EventHandler
public void onPlayerJoin(PlayerJoinEvent event) {
if (!plugin.isEnabled()) return; // If the plugin has not been initialized correctly
// When a player joins a Bukkit server
final Player player = event.getPlayer();
// Clear player inventory and ender chest
player.getInventory().clear();
player.getEnderChest().clear();
// Mark the player as awaiting data fetch
HuskSyncBukkit.bukkitCache.setAwaitingDataFetch(player.getUniqueId());
if (!HuskSyncBukkit.handshakeCompleted || HuskSyncBukkit.isMySqlPlayerDataBridgeInstalled) return; // If the data handshake has not been completed yet (or MySqlPlayerDataBridge is installed)
// Send a redis message requesting the player data (if they need to)
if (HuskSyncBukkit.bukkitCache.isPlayerRequestingOnJoin(player.getUniqueId())) {
try {
// Send a redis message requesting the player data
new me.william278.husksync.redis.RedisMessage(me.william278.husksync.redis.RedisMessage.MessageType.PLAYER_DATA_REQUEST,
new RedisMessage.MessageTarget(Settings.ServerType.BUNGEECORD, null),
player.getUniqueId().toString()).send();
PlayerSetter.requestPlayerData(player.getUniqueId());
} catch (IOException e) {
plugin.getLogger().log(Level.SEVERE, "Failed to send a PlayerData fetch request", e);
}
} else {
// If the player's data wasn't set after 10 ticks, ensure it will be
Bukkit.getScheduler().scheduleSyncDelayedTask(plugin, () -> {
if (player.isOnline()) {
try {
if (HuskSyncBukkit.bukkitCache.isAwaitingDataFetch(player.getUniqueId())) {
PlayerSetter.requestPlayerData(player.getUniqueId());
}
} catch (IOException e) {
plugin.getLogger().log(Level.SEVERE, "Failed to send a PlayerData fetch request", e);
}
}
}, 5);
}
}
@EventHandler
public void onInventoryClose(InventoryCloseEvent event) {
if (!plugin.isEnabled() || !HuskSyncBukkit.handshakeCompleted || HuskSyncBukkit.bukkitCache.isAwaitingDataFetch(event.getPlayer().getUniqueId())) return; // If the plugin has not been initialized correctly
// When a player closes an Inventory
final Player player = (Player) event.getPlayer();
// Handle a player who has finished viewing a player's item data
if (HuskSyncBukkit.bukkitCache.isViewing(player.getUniqueId())) {
try {
DataViewer.stopShowing(player, event.getInventory());
} catch (IOException e) {
plugin.getLogger().log(Level.SEVERE, "Failed to serialize updated item data", e);
}
}
}
/*
* Events to cancel if the player has not been set yet
*/
@EventHandler(priority = EventPriority.MONITOR)
public void onDropItem(PlayerDropItemEvent event) {
if (!plugin.isEnabled() || !HuskSyncBukkit.handshakeCompleted || HuskSyncBukkit.bukkitCache.isAwaitingDataFetch(event.getPlayer().getUniqueId())) {
event.setCancelled(true); // If the plugin / player has not been set
}
}
@EventHandler(priority = EventPriority.MONITOR)
public void onPickupItem(EntityPickupItemEvent event) {
if (event.getEntity() instanceof Player player) {
if (!plugin.isEnabled() || !HuskSyncBukkit.handshakeCompleted || HuskSyncBukkit.bukkitCache.isAwaitingDataFetch(player.getUniqueId())) {
event.setCancelled(true); // If the plugin / player has not been set
}
}
}
@EventHandler(priority = EventPriority.MONITOR)
public void onPlayerInteract(PlayerInteractEvent event) {
if (!plugin.isEnabled() || !HuskSyncBukkit.handshakeCompleted || HuskSyncBukkit.bukkitCache.isAwaitingDataFetch(event.getPlayer().getUniqueId())) {
event.setCancelled(true); // If the plugin / player has not been set
}
}
@EventHandler(priority = EventPriority.MONITOR)
public void onBlockPlace(BlockPlaceEvent event) {
if (!plugin.isEnabled() || !HuskSyncBukkit.handshakeCompleted || HuskSyncBukkit.bukkitCache.isAwaitingDataFetch(event.getPlayer().getUniqueId())) {
event.setCancelled(true); // If the plugin / player has not been set
}
}
@EventHandler(priority = EventPriority.MONITOR)
public void onBlockBreak(BlockBreakEvent event) {
if (!plugin.isEnabled() || !HuskSyncBukkit.handshakeCompleted || HuskSyncBukkit.bukkitCache.isAwaitingDataFetch(event.getPlayer().getUniqueId())) {
event.setCancelled(true); // If the plugin / player has not been set
}
}
@EventHandler(priority = EventPriority.MONITOR)
public void onInventoryOpen(InventoryOpenEvent event) {
if (!plugin.isEnabled() || !HuskSyncBukkit.handshakeCompleted || HuskSyncBukkit.bukkitCache.isAwaitingDataFetch(event.getPlayer().getUniqueId())) {
event.setCancelled(true); // If the plugin / player has not been set
}
}
}

@ -0,0 +1,81 @@
package me.william278.husksync.bukkit.migrator;
import me.william278.husksync.HuskSyncBukkit;
import me.william278.husksync.PlayerData;
import me.william278.husksync.bukkit.PlayerSetter;
import me.william278.husksync.bukkit.data.DataSerializer;
import me.william278.husksync.migrator.MPDBPlayerData;
import net.craftersland.data.bridge.PD;
import org.bukkit.Bukkit;
import org.bukkit.event.inventory.InventoryType;
import org.bukkit.inventory.Inventory;
import org.bukkit.inventory.ItemStack;
import java.io.IOException;
import java.lang.reflect.InvocationTargetException;
import java.util.logging.Level;
public class MPDBDeserializer {
private static final HuskSyncBukkit plugin = HuskSyncBukkit.getInstance();
// Instance of MySqlPlayerDataBridge
private static PD mySqlPlayerDataBridge;
public static void setMySqlPlayerDataBridge() {
mySqlPlayerDataBridge = (PD) Bukkit.getPluginManager().getPlugin("MySqlPlayerDataBridge");
}
/**
* Convert MySqlPlayerDataBridge ({@link MPDBPlayerData}) data to HuskSync's {@link PlayerData}
*
* @param mpdbPlayerData The {@link MPDBPlayerData} to convert
* @return The converted {@link PlayerData}
*/
public static PlayerData convertMPDBData(MPDBPlayerData mpdbPlayerData) {
PlayerData playerData = PlayerData.DEFAULT_PLAYER_DATA(mpdbPlayerData.playerUUID);
playerData.useDefaultData = false;
if (!HuskSyncBukkit.isMySqlPlayerDataBridgeInstalled) {
plugin.getLogger().log(Level.SEVERE, "MySqlPlayerDataBridge is not installed, failed to serialize data!");
return null;
}
// Convert the data
try {
// Set inventory
Inventory inventory = Bukkit.createInventory(null, InventoryType.PLAYER);
PlayerSetter.setInventory(inventory, getItemStackArrayFromMPDBBase64String(mpdbPlayerData.inventoryData));
playerData.setSerializedInventory(DataSerializer.getSerializedInventoryContents(inventory));
inventory.clear();
// Set ender chest
playerData.setSerializedEnderChest(DataSerializer.itemStackArrayToBase64(
getItemStackArrayFromMPDBBase64String(mpdbPlayerData.enderChestData)));
// Set experience
playerData.setExpLevel(mpdbPlayerData.expLevel);
playerData.setExpProgress(mpdbPlayerData.expProgress);
playerData.setTotalExperience(mpdbPlayerData.totalExperience);
} catch (IOException | InvocationTargetException | IllegalAccessException e) {
plugin.getLogger().log(Level.WARNING, "Failed to convert MPDB data to HuskSync's format!");
e.printStackTrace();
}
return playerData;
}
/**
* Returns an ItemStack array from a decoded base 64 string in MySQLPlayerDataBridge's format
*
* @param data The encoded ItemStack[] string from MySQLPlayerDataBridge
* @return The {@link ItemStack[]} array
* @throws IOException If an error occurs during decoding
* @throws InvocationTargetException If an error occurs during decoding
* @throws IllegalAccessException If an error occurs during decoding
*/
public static ItemStack[] getItemStackArrayFromMPDBBase64String(String data) throws IOException, InvocationTargetException, IllegalAccessException {
if (data.isEmpty()) {
return new ItemStack[0];
}
return mySqlPlayerDataBridge.getItemStackSerializer().fromBase64(data);
}
}

@ -9,11 +9,18 @@ import me.william278.husksync.bungeecord.data.sql.MySQL;
import me.william278.husksync.bungeecord.data.sql.SQLite;
import me.william278.husksync.bungeecord.listener.BungeeEventListener;
import me.william278.husksync.bungeecord.listener.BungeeRedisListener;
import me.william278.husksync.bungeecord.migrator.MPDBMigrator;
import me.william278.husksync.redis.RedisMessage;
import net.md_5.bungee.api.ProxyServer;
import net.md_5.bungee.api.plugin.Plugin;
import java.io.IOException;
import java.sql.Connection;
import java.sql.SQLException;
import java.util.HashSet;
import java.util.Objects;
import java.util.UUID;
import java.util.logging.Level;
public final class HuskSyncBungeeCord extends Plugin {
@ -22,11 +29,18 @@ public final class HuskSyncBungeeCord extends Plugin {
return instance;
}
/**
Set of all the {@link Server}s that have completed the synchronisation handshake with HuskSync on the proxy
*/
public static HashSet<Server> synchronisedServers;
private static Database database;
public static Connection getConnection() throws SQLException {
return database.getConnection();
}
public static MPDBMigrator mpdbMigrator;
@Override
public void onLoad() {
instance = this;
@ -35,6 +49,7 @@ public final class HuskSyncBungeeCord extends Plugin {
@Override
public void onEnable() {
// Plugin startup logic
synchronisedServers = new HashSet<>();
// Load config
ConfigManager.loadConfig();
@ -42,24 +57,43 @@ public final class HuskSyncBungeeCord extends Plugin {
// Load settings from config
ConfigLoader.loadSettings(Objects.requireNonNull(ConfigManager.getConfig()));
// Load messages
ConfigManager.loadMessages(Settings.language);
// Load locales from messages
ConfigLoader.loadMessages(Objects.requireNonNull(ConfigManager.getMessages(Settings.language)));
// Initialize the database
database = switch (Settings.dataStorageType) {
case SQLITE -> new SQLite(this);
case MYSQL -> new MySQL(this);
};
database.load();
database.createTables();
// Abort loading if the database failed to initialize
if (database.isInactive()) {
getLogger().severe("Failed to initialize the database; HuskSync will now abort loading itself (" + getProxy().getName() + ") v" + getDescription().getVersion());
return;
}
// Setup player data cache
DataManager.playerDataCache = new DataManager.PlayerDataCache();
// Initialize the redis listener
if (!new BungeeRedisListener().isActiveAndEnabled) {
getLogger().severe("Failed to initialize Redis; HuskSync will now abort loading itself (" + getProxy().getName() + ") v" + getDescription().getVersion());
return;
}
// Register listener
getProxy().getPluginManager().registerListener(this, new BungeeEventListener());
// Register command
getProxy().getPluginManager().registerCommand(this, new HuskSyncCommand());
// Initialize the redis listener
new BungeeRedisListener();
// Prepare the migrator for use if needed
mpdbMigrator = new MPDBMigrator();
// Log to console
getLogger().info("Enabled HuskSync (" + getProxy().getName() + ") v" + getDescription().getVersion());
@ -69,10 +103,27 @@ public final class HuskSyncBungeeCord extends Plugin {
public void onDisable() {
// Plugin shutdown logic
// Send terminating handshake message
for (Server server: synchronisedServers) {
try {
new RedisMessage(RedisMessage.MessageType.TERMINATE_HANDSHAKE,
new RedisMessage.MessageTarget(Settings.ServerType.BUKKIT, null),
server.serverUUID().toString(),
ProxyServer.getInstance().getName()).send();
} catch (IOException e) {
getInstance().getLogger().log(Level.SEVERE, "Failed to serialize Redis message for handshake termination", e);
}
}
// Close the database
database.close();
// Log to console
getLogger().info("Disabled HuskSync (" + getProxy().getName() + ") v" + getDescription().getVersion());
}
/**
* A record representing a server synchronised on the network and whether it has MySqlPlayerDataBridge installed
*/
public record Server(UUID serverUUID, boolean hasMySqlPlayerDataBridge) { }
}

@ -2,52 +2,304 @@ package me.william278.husksync.bungeecord.command;
import de.themoep.minedown.MineDown;
import me.william278.husksync.HuskSyncBungeeCord;
import me.william278.husksync.MessageStrings;
import me.william278.husksync.MessageManager;
import me.william278.husksync.PlayerData;
import me.william278.husksync.Settings;
import me.william278.husksync.bungeecord.config.ConfigLoader;
import me.william278.husksync.bungeecord.config.ConfigManager;
import me.william278.husksync.bungeecord.data.DataManager;
import me.william278.husksync.bungeecord.migrator.MPDBMigrator;
import me.william278.husksync.redis.RedisMessage;
import net.md_5.bungee.api.CommandSender;
import net.md_5.bungee.api.ProxyServer;
import net.md_5.bungee.api.connection.ProxiedPlayer;
import net.md_5.bungee.api.plugin.Command;
import net.md_5.bungee.api.plugin.TabExecutor;
import java.io.IOException;
import java.util.Arrays;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Locale;
import java.util.Objects;
import java.util.logging.Level;
import java.util.stream.Collectors;
public class HuskSyncCommand extends Command implements TabExecutor {
private final static HuskSyncBungeeCord plugin = HuskSyncBungeeCord.getInstance();
private final static String[] COMMAND_TAB_ARGUMENTS = {"about", "reload"};
private final static String PERMISSION = "husksync.command.csc";
private final static SubCommand[] SUB_COMMANDS = {new SubCommand("about", null),
new SubCommand("status", "husksync.command.admin"),
new SubCommand("reload", "husksync.command.admin"),
new SubCommand("invsee", "husksync.command.inventory"),
new SubCommand("echest", "husksync.command.ender_chest")};
//public HuskSyncCommand() { super("husksync", PERMISSION, "hs"); }
public HuskSyncCommand() { super("husksync"); }
public HuskSyncCommand() {
super("husksync", null, "hs");
}
@Override
public void execute(CommandSender sender, String[] args) {
if (sender instanceof ProxiedPlayer player) {
if (args.length == 1) {
if (HuskSyncBungeeCord.synchronisedServers.size() == 0) {
player.sendMessage(new MineDown(MessageManager.getMessage("error_no_servers_proxied")).toComponent());
return;
}
if (args.length >= 1) {
switch (args[0].toLowerCase(Locale.ROOT)) {
case "about", "info" -> sendAboutInformation(player);
case "invsee", "openinv", "inventory" -> {
if (!player.hasPermission("husksync.command.inventory")) {
sender.sendMessage(new MineDown(MessageManager.getMessage("error_no_permission")).toComponent());
return;
}
if (args.length == 2) {
String playerName = args[1];
openInventory(player, playerName);
} else {
sender.sendMessage(new MineDown(MessageManager.getMessage("error_invalid_syntax").replaceAll("%1%",
"/husksync invsee <player>")).toComponent());
}
}
case "echest", "enderchest" -> {
if (!player.hasPermission("husksync.command.ender_chest")) {
sender.sendMessage(new MineDown(MessageManager.getMessage("error_no_permission")).toComponent());
return;
}
if (args.length == 2) {
String playerName = args[1];
openEnderChest(player, playerName);
} else {
sender.sendMessage(new MineDown(MessageManager.getMessage("error_invalid_syntax")
.replaceAll("%1%", "/husksync echest <player>")).toComponent());
}
}
case "migrate" -> {
if (!player.hasPermission("husksync.command.admin")) {
sender.sendMessage(new MineDown(MessageManager.getMessage("error_no_permission")).toComponent());
return;
}
sender.sendMessage(new MineDown(MessageManager.getMessage("error_console_command_only")
.replaceAll("%1%", ProxyServer.getInstance().getName())).toComponent());
}
case "status" -> {
if (!player.hasPermission("husksync.command.admin")) {
sender.sendMessage(new MineDown(MessageManager.getMessage("error_no_permission")).toComponent());
return;
}
sender.sendMessage(new MineDown(MessageManager.PLUGIN_STATUS.toString()
.replaceAll("%1%", String.valueOf(HuskSyncBungeeCord.synchronisedServers.size()))
.replaceAll("%2%", String.valueOf(DataManager.playerDataCache.playerData.size()))).toComponent());
}
case "reload" -> {
if (!player.hasPermission("husksync.command.admin")) {
sender.sendMessage(new MineDown(MessageManager.getMessage("error_no_permission")).toComponent());
return;
}
ConfigManager.loadConfig();
ConfigLoader.loadSettings(Objects.requireNonNull(ConfigManager.getConfig()));
ConfigManager.loadMessages(Settings.language);
ConfigLoader.loadMessages(Objects.requireNonNull(ConfigManager.getMessages(Settings.language)));
// Send reload request to all bukkit servers
try {
new RedisMessage(RedisMessage.MessageType.RELOAD_CONFIG,
new RedisMessage.MessageTarget(Settings.ServerType.BUKKIT, null),
"reload")
.send();
} catch (IOException e) {
plugin.getLogger().log(Level.WARNING, "Failed to serialize reload notification message data");
}
default -> sender.sendMessage(new MineDown(MessageStrings.ERROR_INVALID_SYNTAX.replaceAll("%1%", "/csc <about>")).toComponent());
sender.sendMessage(new MineDown(MessageManager.getMessage("reload_complete")).toComponent());
}
default -> sender.sendMessage(new MineDown(MessageManager.getMessage("error_invalid_syntax").replaceAll("%1%",
"/husksync <about/status/invsee/echest>")).toComponent());
}
} else {
sendAboutInformation(player);
}
} else {
// Database migration wizard
if (args.length >= 1) {
if (args[0].equalsIgnoreCase("migrate")) {
if (args.length == 1) {
sender.sendMessage(new MineDown(
"""
=== MySQLPlayerDataBridge Migration Wizard ==========
This will migrate data from the MySQLPlayerDataBridge
plugin to HuskSync.
Data that will be migrated:
- Inventories
- Ender Chests
- Experience points
Other non-vital data, such as current health, hunger
& potion effects will not be migrated to ensure that
migration does not take an excessive amount of time.
To do this, you need to have MySqlPlayerDataBridge
and HuskSync installed on one Spigot server as well
as HuskSync installed on the proxy (which you have)
>To proceed, type: husksync migrate setup""").toComponent());
} else {
switch (args[1].toLowerCase()) {
case "setup" -> sender.sendMessage(new MineDown(
"""
=== MySQLPlayerDataBridge Migration Wizard ==========
The following database settings will be used.
Please make sure they match the correct settings to
access your MySQLPlayerDataBridge Data
sourceHost: %1%
sourcePort: %2%
sourceDatabase: %3%
sourceUsername: %4%
sourcePassword: %5%
sourceInventoryTableName: %6%
sourceEnderChestTableName: %7%
sourceExperienceTableName: %8%
To change a setting, type:
husksync migrate setting <settingName> <value>
Please ensure no players are logged in to the network
and that at least one Spigot server is online with
both HuskSync AND MySqlPlayerDataBridge installed AND
that the server has been configured with the correct
Redis credentials.
Warning: Data will be saved to your configured data
source, which is currently a %9% database.
Please make sure you are happy with this, or stop
the proxy server and edit this in config.yml
Warning: Migration will overwrite any current data
saved by HuskSync. It will not, however, delete any
data from the source MySQLPlayerDataBridge database.
>When done, type: husksync migrate start"""
.replaceAll("%1%", MPDBMigrator.migrationSettings.sourceHost)
.replaceAll("%2%", String.valueOf(MPDBMigrator.migrationSettings.sourcePort))
.replaceAll("%3%", MPDBMigrator.migrationSettings.sourceDatabase)
.replaceAll("%4%", MPDBMigrator.migrationSettings.sourceUsername)
.replaceAll("%5%", MPDBMigrator.migrationSettings.sourcePassword)
.replaceAll("%6%", MPDBMigrator.migrationSettings.inventoryDataTable)
.replaceAll("%7%", MPDBMigrator.migrationSettings.enderChestDataTable)
.replaceAll("%8%", MPDBMigrator.migrationSettings.expDataTable)
.replaceAll("%9%", Settings.dataStorageType.toString())
).toComponent());
case "setting" -> {
if (args.length == 4) {
String value = args[3];
switch (args[2]) {
case "sourceHost", "host" -> MPDBMigrator.migrationSettings.sourceHost = value;
case "sourcePort", "port" -> {
try {
MPDBMigrator.migrationSettings.sourcePort = Integer.parseInt(value);
} catch (NumberFormatException e) {
sender.sendMessage(new MineDown("Error: Invalid value; port must be a number").toComponent());
return;
}
}
case "sourceDatabase", "database" -> MPDBMigrator.migrationSettings.sourceDatabase = value;
case "sourceUsername", "username" -> MPDBMigrator.migrationSettings.sourceUsername = value;
case "sourcePassword", "password" -> MPDBMigrator.migrationSettings.sourcePassword = value;
case "sourceInventoryTableName", "inventoryTableName", "inventoryTable" -> MPDBMigrator.migrationSettings.inventoryDataTable = value;
case "sourceEnderChestTableName", "enderChestTableName", "enderChestTable" -> MPDBMigrator.migrationSettings.enderChestDataTable = value;
case "sourceExperienceTableName", "experienceTableName", "experienceTable" -> MPDBMigrator.migrationSettings.expDataTable = value;
default -> {
sender.sendMessage(new MineDown("Error: Invalid setting; please use \"husksync migrate setup\" to view a list").toComponent());
return;
}
}
sender.sendMessage(new MineDown("Successfully updated setting: \"" + args[2] + "\" --> \"" + value + "\"").toComponent());
} else {
sender.sendMessage(new MineDown("Error: Invalid usage. Syntax: husksync migrate setting <settingName> <value>").toComponent());
}
}
case "start" -> {
sender.sendMessage(new MineDown("Starting MySQLPlayerDataBridge migration!...").toComponent());
HuskSyncBungeeCord.mpdbMigrator.start();
}
default -> sender.sendMessage(new MineDown("Error: Invalid argument for migration. Use \"husksync migrate\" to start the process").toComponent());
}
}
return;
}
}
sender.sendMessage(new MineDown("Error: Invalid syntax. Usage: husksync migrate <args>").toComponent());
}
}
// View the inventory of a player specified by their name
private void openInventory(ProxiedPlayer viewer, String targetPlayerName) {
if (viewer.getName().equalsIgnoreCase(targetPlayerName)) {
viewer.sendMessage(new MineDown(MessageManager.getMessage("error_cannot_view_own_ender_chest")).toComponent());
return;
}
if (ProxyServer.getInstance().getPlayer(targetPlayerName) != null) {
viewer.sendMessage(new MineDown(MessageManager.getMessage("error_cannot_view_inventory_online")).toComponent());
return;
}
ProxyServer.getInstance().getScheduler().runAsync(plugin, () -> {
PlayerData playerData = DataManager.getPlayerDataByName(targetPlayerName);
if (playerData == null) {
viewer.sendMessage(new MineDown(MessageManager.getMessage("error_invalid_player")).toComponent());
return;
}
try {
new RedisMessage(RedisMessage.MessageType.OPEN_INVENTORY,
new RedisMessage.MessageTarget(Settings.ServerType.BUKKIT, viewer.getUniqueId()),
targetPlayerName, RedisMessage.serialize(playerData))
.send();
viewer.sendMessage(new MineDown(MessageManager.getMessage("viewing_inventory_of").replaceAll("%1%",
targetPlayerName)).toComponent());
} catch (IOException e) {
plugin.getLogger().log(Level.WARNING, "Failed to serialize inventory-see player data", e);
}
});
}
// View the ender chest of a player specified by their name
private void openEnderChest(ProxiedPlayer viewer, String targetPlayerName) {
if (viewer.getName().equalsIgnoreCase(targetPlayerName)) {
viewer.sendMessage(new MineDown(MessageManager.getMessage("error_cannot_view_own_ender_chest")).toComponent());
return;
}
if (ProxyServer.getInstance().getPlayer(targetPlayerName) != null) {
viewer.sendMessage(new MineDown(MessageManager.getMessage("error_cannot_view_ender_chest_online")).toComponent());
return;
}
ProxyServer.getInstance().getScheduler().runAsync(plugin, () -> {
PlayerData playerData = DataManager.getPlayerDataByName(targetPlayerName);
if (playerData == null) {
viewer.sendMessage(new MineDown(MessageManager.getMessage("error_invalid_player")).toComponent());
return;
}
try {
new RedisMessage(RedisMessage.MessageType.OPEN_ENDER_CHEST,
new RedisMessage.MessageTarget(Settings.ServerType.BUKKIT, viewer.getUniqueId()),
targetPlayerName, RedisMessage.serialize(playerData))
.send();
viewer.sendMessage(new MineDown(MessageManager.getMessage("viewing_ender_chest_of").replaceAll("%1%",
targetPlayerName)).toComponent());
} catch (IOException e) {
plugin.getLogger().log(Level.WARNING, "Failed to serialize inventory-see player data", e);
}
});
}
/**
* Send information about the plugin
*
* @param player The player to send it to
*/
private void sendAboutInformation(ProxiedPlayer player) {
try {
new me.william278.husksync.redis.RedisMessage(me.william278.husksync.redis.RedisMessage.MessageType.SEND_PLUGIN_INFORMATION,
new RedisMessage(RedisMessage.MessageType.SEND_PLUGIN_INFORMATION,
new RedisMessage.MessageTarget(Settings.ServerType.BUKKIT, player.getUniqueId()),
plugin.getProxy().getName(), plugin.getDescription().getVersion()).send();
} catch (IOException e) {
@ -55,14 +307,19 @@ public class HuskSyncCommand extends Command implements TabExecutor {
}
}
// Tab completion
@Override
public Iterable<String> onTabComplete(CommandSender sender, String[] args) {
if (sender instanceof ProxiedPlayer player) {
if (!player.hasPermission(PERMISSION)) {
return Collections.emptyList();
}
if (args.length == 1) {
return Arrays.stream(COMMAND_TAB_ARGUMENTS).filter(val -> val.startsWith(args[0]))
final ArrayList<String> subCommands = new ArrayList<>();
for (SubCommand subCommand : SUB_COMMANDS) {
if (subCommand.doesPlayerHavePermission(player)) {
subCommands.add(subCommand.command());
}
}
// Automatically filter the sub commands' order in tab completion by what the player has typed
return subCommands.stream().filter(val -> val.startsWith(args[0]))
.sorted().collect(Collectors.toList());
} else {
return Collections.emptyList();
@ -71,4 +328,19 @@ public class HuskSyncCommand extends Command implements TabExecutor {
return Collections.emptyList();
}
/**
* A sub command, that may require a permission
*/
public record SubCommand(String command, String permission) {
/**
* Returns if the player can use the sub command
*
* @param player The {@link ProxiedPlayer} to check
* @return {@code true} if the player can use the sub command; {@code false} otherwise
*/
public boolean doesPlayerHavePermission(ProxiedPlayer player) {
return permission == null || player.hasPermission(permission);
}
}
}

@ -1,8 +1,11 @@
package me.william278.husksync.bungeecord.config;
import me.william278.husksync.MessageManager;
import me.william278.husksync.Settings;
import net.md_5.bungee.config.Configuration;
import java.util.HashMap;
public class ConfigLoader {
public static void loadSettings(Configuration config) throws IllegalArgumentException {
@ -14,7 +17,7 @@ public class ConfigLoader {
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.mySQLPort = config.getInt("data_storage_settings.mysql_settings.port", 3306);
Settings.mySQLDatabase = config.getString("data_storage_settings.mysql_settings.database", "HuskSync");
Settings.mySQLUsername = config.getString("data_storage_settings.mysql_settings.username", "root");
Settings.mySQLPassword = config.getString("data_storage_settings.mysql_settings.password", "pa55w0rd");
@ -28,4 +31,12 @@ public class ConfigLoader {
Settings.hikariConnectionTimeOut = config.getLong("data_storage_settings.hikari_pool_settings.connection_timeout", 5000);
}
public static void loadMessages(Configuration config) {
final HashMap<String,String> messages = new HashMap<>();
for (String messageId : config.getKeys()) {
messages.put(messageId, config.getString(messageId));
}
MessageManager.setMessages(messages);
}
}

@ -24,13 +24,30 @@ public class ConfigManager {
File configFile = new File(plugin.getDataFolder(), "config.yml");
if (!configFile.exists()) {
Files.copy(plugin.getResourceAsStream("bungee-config.yml"), configFile.toPath());
plugin.getLogger().info("Created HuskSync bungee-config.yml file");
plugin.getLogger().info("Created HuskSync config file");
}
} catch (Exception e) {
plugin.getLogger().log(Level.CONFIG, "An exception occurred loading the configuration file", e);
}
}
public static void loadMessages(String language) {
try {
if (!plugin.getDataFolder().exists()) {
if (plugin.getDataFolder().mkdir()) {
plugin.getLogger().info("Created HuskSync data folder");
}
}
File messagesFile = new File(plugin.getDataFolder(), "messages_ " + language + ".yml");
if (!messagesFile.exists()) {
Files.copy(plugin.getResourceAsStream("languages" + File.separator + language + ".yml"), messagesFile.toPath());
plugin.getLogger().info("Created HuskSync messages file");
}
} catch (Exception e) {
plugin.getLogger().log(Level.CONFIG, "An exception occurred loading the messages file", e);
}
}
public static Configuration getConfig() {
try {
File configFile = new File(plugin.getDataFolder(), "config.yml");
@ -41,5 +58,15 @@ public class ConfigManager {
}
}
public static Configuration getMessages(String language) {
try {
File configFile = new File(plugin.getDataFolder(), "messages-" + language + ".yml");
return ConfigurationProvider.getProvider(YamlConfiguration.class).load(configFile);
} catch (IOException e) {
plugin.getLogger().log(Level.CONFIG, "An IOException occurred fetching the messages file", e);
return null;
}
}
}

@ -16,13 +16,17 @@ public class DataManager {
public static PlayerDataCache playerDataCache;
/**
* Checks if the player is registered on the database; register them if not.
* Checks if the player is registered on the database.
* If not, register them to the database
* If they are, ensure that their player name is up-to-date on the database
*
* @param playerUUID The UUID of the player to register
*/
public static void ensurePlayerExists(UUID playerUUID) {
public static void ensurePlayerExists(UUID playerUUID, String playerName) {
if (!playerExists(playerUUID)) {
createPlayerEntry(playerUUID);
createPlayerEntry(playerUUID, playerName);
} else {
updatePlayerName(playerUUID, playerName);
}
}
@ -46,11 +50,12 @@ public class DataManager {
}
}
private static void createPlayerEntry(UUID playerUUID) {
private static void createPlayerEntry(UUID playerUUID, String playerName) {
try (Connection connection = HuskSyncBungeeCord.getConnection()) {
try (PreparedStatement statement = connection.prepareStatement(
"INSERT INTO " + Database.PLAYER_TABLE_NAME + " (`uuid`) VALUES(?);")) {
"INSERT INTO " + Database.PLAYER_TABLE_NAME + " (`uuid`,`username`) VALUES(?,?);")) {
statement.setString(1, playerUUID.toString());
statement.setString(2, playerName);
statement.executeUpdate();
}
} catch (SQLException e) {
@ -58,6 +63,47 @@ public class DataManager {
}
}
public static void updatePlayerName(UUID playerUUID, String playerName) {
try (Connection connection = HuskSyncBungeeCord.getConnection()) {
try (PreparedStatement statement = connection.prepareStatement(
"UPDATE " + Database.PLAYER_TABLE_NAME + " SET `username`=? WHERE `uuid`=?;")) {
statement.setString(1, playerName);
statement.setString(2, playerUUID.toString());
statement.executeUpdate();
}
} catch (SQLException e) {
plugin.getLogger().log(Level.SEVERE, "An SQL exception occurred", e);
}
}
/**
* Returns a player's PlayerData by their username
* @param playerName The PlayerName of the data to get
* @return Their {@link PlayerData}; or {@code null} if the player does not exist
*/
public static PlayerData getPlayerDataByName(String playerName) {
PlayerData playerData = null;
try (Connection connection = HuskSyncBungeeCord.getConnection()) {
try (PreparedStatement statement = connection.prepareStatement(
"SELECT * FROM " + Database.PLAYER_TABLE_NAME + " WHERE `username`=? LIMIT 1;")) {
statement.setString(1, playerName);
ResultSet resultSet = statement.executeQuery();
if (resultSet.next()) {
final UUID uuid = UUID.fromString(resultSet.getString("uuid"));
// Get the player data from the cache if it's there, otherwise pull from SQL
playerData = playerDataCache.getPlayer(uuid);
if (playerData == null) {
playerData = getPlayerData(uuid);
}
}
}
} catch (SQLException e) {
plugin.getLogger().log(Level.SEVERE, "An SQL exception occurred", e);
}
return playerData;
}
public static PlayerData getPlayerData(UUID playerUUID) {
try (Connection connection = HuskSyncBungeeCord.getConnection()) {
try (PreparedStatement statement = connection.prepareStatement(
@ -66,11 +112,12 @@ public class DataManager {
ResultSet resultSet = statement.executeQuery();
if (resultSet.next()) {
final UUID dataVersionUUID = UUID.fromString(resultSet.getString("version_uuid"));
final Timestamp dataSaveTimestamp = resultSet.getTimestamp("timestamp");
//final Timestamp dataSaveTimestamp = resultSet.getTimestamp("timestamp");
final String serializedInventory = resultSet.getString("inventory");
final String serializedEnderChest = resultSet.getString("ender_chest");
final double health = resultSet.getDouble("health");
final double maxHealth = resultSet.getDouble("max_health");
final double healthScale = resultSet.getDouble("health_scale");
final int hunger = resultSet.getInt("hunger");
final float saturation = resultSet.getFloat("saturation");
final float saturationExhaustion = resultSet.getFloat("saturation_exhaustion");
@ -87,7 +134,7 @@ public class DataManager {
final String serializedStatisticData = resultSet.getString("statistics");
return new PlayerData(playerUUID, dataVersionUUID, serializedInventory, serializedEnderChest,
health, maxHealth, hunger, saturation, saturationExhaustion, selectedSlot, serializedStatusEffects,
health, maxHealth, healthScale, hunger, saturation, saturationExhaustion, selectedSlot, serializedStatusEffects,
totalExperience, expLevel, expProgress, gameMode, serializedStatisticData, isFlying,
serializedAdvancementData, serializedLocationData);
} else {
@ -117,28 +164,29 @@ public class DataManager {
private static void updatePlayerSQLData(PlayerData playerData) {
try (Connection connection = HuskSyncBungeeCord.getConnection()) {
try (PreparedStatement statement = connection.prepareStatement(
"UPDATE " + Database.DATA_TABLE_NAME + " SET `version_uuid`=?, `timestamp`=?, `inventory`=?, `ender_chest`=?, `health`=?, `max_health`=?, `hunger`=?, `saturation`=?, `saturation_exhaustion`=?, `selected_slot`=?, `status_effects`=?, `total_experience`=?, `exp_level`=?, `exp_progress`=?, `game_mode`=?, `statistics`=?, `is_flying`=?, `advancements`=?, `location`=? WHERE `player_id`=(SELECT `id` FROM " + Database.PLAYER_TABLE_NAME + " WHERE `uuid`=?);")) {
"UPDATE " + Database.DATA_TABLE_NAME + " SET `version_uuid`=?, `timestamp`=?, `inventory`=?, `ender_chest`=?, `health`=?, `max_health`=?, `health_scale`=?, `hunger`=?, `saturation`=?, `saturation_exhaustion`=?, `selected_slot`=?, `status_effects`=?, `total_experience`=?, `exp_level`=?, `exp_progress`=?, `game_mode`=?, `statistics`=?, `is_flying`=?, `advancements`=?, `location`=? WHERE `player_id`=(SELECT `id` FROM " + Database.PLAYER_TABLE_NAME + " WHERE `uuid`=?);")) {
statement.setString(1, playerData.getDataVersionUUID().toString());
statement.setTimestamp(2, new Timestamp(Instant.now().getEpochSecond()));
statement.setString(3, playerData.getSerializedInventory());
statement.setString(4, playerData.getSerializedEnderChest());
statement.setDouble(5, playerData.getHealth()); // Health
statement.setDouble(6, playerData.getMaxHealth()); // Max health
statement.setInt(7, playerData.getHunger()); // Hunger
statement.setFloat(8, playerData.getSaturation()); // Saturation
statement.setFloat(9, playerData.getSaturationExhaustion()); // Saturation exhaustion
statement.setInt(10, playerData.getSelectedSlot()); // Current selected slot
statement.setString(11, playerData.getSerializedEffectData()); // Status effects
statement.setInt(12, playerData.getTotalExperience()); // Total Experience
statement.setInt(13, playerData.getExpLevel()); // Exp level
statement.setFloat(14, playerData.getExpProgress()); // Exp progress
statement.setString(15, playerData.getGameMode()); // GameMode
statement.setString(16, playerData.getSerializedStatistics()); // Statistics
statement.setBoolean(17, playerData.isFlying()); // Is flying
statement.setString(18, playerData.getSerializedAdvancements()); // Advancements
statement.setString(19, playerData.getSerializedLocation()); // Location
statement.setString(20, playerData.getPlayerUUID().toString());
statement.setDouble(7, playerData.getHealthScale()); // Health scale
statement.setInt(8, playerData.getHunger()); // Hunger
statement.setFloat(9, playerData.getSaturation()); // Saturation
statement.setFloat(10, playerData.getSaturationExhaustion()); // Saturation exhaustion
statement.setInt(11, playerData.getSelectedSlot()); // Current selected slot
statement.setString(12, playerData.getSerializedEffectData()); // Status effects
statement.setInt(13, playerData.getTotalExperience()); // Total Experience
statement.setInt(14, playerData.getExpLevel()); // Exp level
statement.setFloat(15, playerData.getExpProgress()); // Exp progress
statement.setString(16, playerData.getGameMode()); // GameMode
statement.setString(17, playerData.getSerializedStatistics()); // Statistics
statement.setBoolean(18, playerData.isFlying()); // Is flying
statement.setString(19, playerData.getSerializedAdvancements()); // Advancements
statement.setString(20, playerData.getSerializedLocation()); // Location
statement.setString(21, playerData.getPlayerUUID().toString());
statement.executeUpdate();
}
} catch (SQLException e) {
@ -149,7 +197,7 @@ public class DataManager {
private static void insertPlayerData(PlayerData playerData) {
try (Connection connection = HuskSyncBungeeCord.getConnection()) {
try (PreparedStatement statement = connection.prepareStatement(
"INSERT INTO " + Database.DATA_TABLE_NAME + " (`player_id`,`version_uuid`,`timestamp`,`inventory`,`ender_chest`,`health`,`max_health`,`hunger`,`saturation`,`saturation_exhaustion`,`selected_slot`,`status_effects`,`total_experience`,`exp_level`,`exp_progress`,`game_mode`,`statistics`,`is_flying`,`advancements`,`location`) VALUES((SELECT `id` FROM " + Database.PLAYER_TABLE_NAME + " WHERE `uuid`=?),?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?);")) {
"INSERT INTO " + Database.DATA_TABLE_NAME + " (`player_id`,`version_uuid`,`timestamp`,`inventory`,`ender_chest`,`health`,`max_health`,`health_scale`,`hunger`,`saturation`,`saturation_exhaustion`,`selected_slot`,`status_effects`,`total_experience`,`exp_level`,`exp_progress`,`game_mode`,`statistics`,`is_flying`,`advancements`,`location`) VALUES((SELECT `id` FROM " + Database.PLAYER_TABLE_NAME + " WHERE `uuid`=?),?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?);")) {
statement.setString(1, playerData.getPlayerUUID().toString());
statement.setString(2, playerData.getDataVersionUUID().toString());
statement.setTimestamp(3, new Timestamp(Instant.now().getEpochSecond()));
@ -157,19 +205,20 @@ public class DataManager {
statement.setString(5, playerData.getSerializedEnderChest());
statement.setDouble(6, playerData.getHealth()); // Health
statement.setDouble(7, playerData.getMaxHealth()); // Max health
statement.setInt(8, playerData.getHunger()); // Hunger
statement.setFloat(9, playerData.getSaturation()); // Saturation
statement.setFloat(10, playerData.getSaturationExhaustion()); // Saturation exhaustion
statement.setInt(11, playerData.getSelectedSlot()); // Current selected slot
statement.setString(12, playerData.getSerializedEffectData()); // Status effects
statement.setInt(13, playerData.getTotalExperience()); // Total Experience
statement.setInt(14, playerData.getExpLevel()); // Exp level
statement.setFloat(15, playerData.getExpProgress()); // Exp progress
statement.setString(16, playerData.getGameMode()); // GameMode
statement.setString(17, playerData.getSerializedStatistics()); // Statistics
statement.setBoolean(18, playerData.isFlying()); // Is flying
statement.setString(19, playerData.getSerializedAdvancements()); // Advancements
statement.setString(20, playerData.getSerializedLocation()); // Location
statement.setDouble(8, playerData.getHealthScale()); // Health scale
statement.setInt(9, playerData.getHunger()); // Hunger
statement.setFloat(10, playerData.getSaturation()); // Saturation
statement.setFloat(11, playerData.getSaturationExhaustion()); // Saturation exhaustion
statement.setInt(12, playerData.getSelectedSlot()); // Current selected slot
statement.setString(13, playerData.getSerializedEffectData()); // Status effects
statement.setInt(14, playerData.getTotalExperience()); // Total Experience
statement.setInt(15, playerData.getExpLevel()); // Exp level
statement.setFloat(16, playerData.getExpProgress()); // Exp progress
statement.setString(17, playerData.getGameMode()); // GameMode
statement.setString(18, playerData.getSerializedStatistics()); // Statistics
statement.setBoolean(19, playerData.isFlying()); // Is flying
statement.setString(20, playerData.getSerializedAdvancements()); // Advancements
statement.setString(21, playerData.getSerializedLocation()); // Location
statement.executeUpdate();
}

@ -19,9 +19,17 @@ public abstract class Database {
public abstract Connection getConnection() throws SQLException;
public boolean isInactive() {
try {
return getConnection() == null;
} catch (SQLException e) {
return true;
}
}
public abstract void load();
public abstract void backup();
public abstract void createTables();
public abstract void close();

@ -15,6 +15,7 @@ public class MySQL extends Database {
"CREATE TABLE IF NOT EXISTS " + PLAYER_TABLE_NAME + " (" +
"`id` integer NOT NULL AUTO_INCREMENT," +
"`uuid` char(36) NOT NULL UNIQUE," +
"`username` varchar(16) NOT NULL," +
"PRIMARY KEY (`id`)" +
");",
@ -27,6 +28,7 @@ public class MySQL extends Database {
"`ender_chest` longtext NOT NULL," +
"`health` double NOT NULL," +
"`max_health` double NOT NULL," +
"`health_scale` double NOT NULL," +
"`hunger` integer NOT NULL," +
"`saturation` float NOT NULL," +
"`saturation_exhaustion` float NOT NULL," +
@ -47,12 +49,13 @@ public class MySQL extends Database {
};
final String host = me.william278.husksync.Settings.mySQLHost;
final int port = me.william278.husksync.Settings.mySQLPort;
final String database = me.william278.husksync.Settings.mySQLDatabase;
final String username = me.william278.husksync.Settings.mySQLUsername;
final String password = me.william278.husksync.Settings.mySQLPassword;
final String params = Settings.mySQLParams;
public String host = Settings.mySQLHost;
public int port = Settings.mySQLPort;
public String database = Settings.mySQLDatabase;
public String username = Settings.mySQLUsername;
public String password = Settings.mySQLPassword;
public String params = Settings.mySQLParams;
public String dataPoolName = DATA_POOL_NAME;
private HikariDataSource dataSource;
@ -81,8 +84,11 @@ public class MySQL extends Database {
dataSource.setMaxLifetime(hikariMaximumLifetime);
dataSource.setKeepaliveTime(hikariKeepAliveTime);
dataSource.setConnectionTimeout(hikariConnectionTimeOut);
dataSource.setPoolName(DATA_POOL_NAME);
dataSource.setPoolName(dataPoolName);
}
@Override
public void createTables() {
// Create tables
try (Connection connection = dataSource.getConnection()) {
try (Statement statement = connection.createStatement()) {
@ -102,8 +108,4 @@ public class MySQL extends Database {
}
}
@Override
public void backup() {
plugin.getLogger().info("Remember to make backups of your HuskHomes Database before updating the plugin!");
}
}

@ -5,15 +5,9 @@ import me.william278.husksync.HuskSyncBungeeCord;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.StandardCopyOption;
import java.sql.Connection;
import java.sql.SQLException;
import java.sql.Statement;
import java.time.Instant;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.Locale;
import java.util.logging.Level;
public class SQLite extends Database {
@ -24,7 +18,8 @@ public class SQLite extends Database {
"CREATE TABLE IF NOT EXISTS " + PLAYER_TABLE_NAME + " (" +
"`id` integer PRIMARY KEY," +
"`uuid` char(36) NOT NULL UNIQUE" +
"`uuid` char(36) NOT NULL UNIQUE," +
"`username` varchar(16) NOT NULL" +
");",
"CREATE TABLE IF NOT EXISTS " + DATA_TABLE_NAME + " (" +
@ -35,6 +30,7 @@ public class SQLite extends Database {
"`ender_chest` longtext NOT NULL," +
"`health` double NOT NULL," +
"`max_health` double NOT NULL," +
"`health_scale` double NOT NULL," +
"`hunger` integer NOT NULL," +
"`saturation` float NOT NULL," +
"`saturation_exhaustion` float NOT NULL," +
@ -98,7 +94,10 @@ public class SQLite extends Database {
dataSource.setKeepaliveTime(hikariKeepAliveTime);
dataSource.setConnectionTimeout(hikariConnectionTimeOut);
dataSource.setPoolName(DATA_POOL_NAME);
}
@Override
public void createTables() {
// Create tables
try (Connection connection = dataSource.getConnection()) {
try (Statement statement = connection.createStatement()) {
@ -118,23 +117,4 @@ public class SQLite extends Database {
}
}
@Override
public void backup() {
final String BACKUPS_FOLDER_NAME = "database-backups";
final String backupFileName = DATABASE_NAME + "Backup_" + DateTimeFormatter.ofPattern("yyyy-MM-dd_HH-mm-ss-SS")
.withLocale(Locale.getDefault())
.withZone(ZoneId.systemDefault())
.format(Instant.now()).replaceAll(" ", "-") + ".db";
final File databaseFile = new File(plugin.getDataFolder(), DATABASE_NAME + ".db");
if (new File(plugin.getDataFolder(), BACKUPS_FOLDER_NAME).mkdirs()) {
plugin.getLogger().info("Created backups directory in HuskSync plugin data folder.");
}
final File backUpFile = new File(plugin.getDataFolder(), BACKUPS_FOLDER_NAME + File.separator + backupFileName);
try {
Files.copy(databaseFile.toPath(), backUpFile.toPath(), StandardCopyOption.REPLACE_EXISTING);
plugin.getLogger().info("Created a backup of your database.");
} catch (IOException iox) {
plugin.getLogger().log(Level.WARNING, "An error occurred making a database backup", iox);
}
}
}

@ -22,8 +22,8 @@ public class BungeeEventListener implements Listener {
public void onPostLogin(PostLoginEvent event) {
final ProxiedPlayer player = event.getPlayer();
ProxyServer.getInstance().getScheduler().runAsync(plugin, () -> {
// Ensure the player has data on SQL
DataManager.ensurePlayerExists(player.getUniqueId());
// Ensure the player has data on SQL and that it is up-to-date
DataManager.ensurePlayerExists(player.getUniqueId(), player.getName());
// Get the player's data from SQL
final PlayerData data = DataManager.getPlayerData(player.getUniqueId());

@ -1,10 +1,13 @@
package me.william278.husksync.bungeecord.listener;
import de.themoep.minedown.MineDown;
import me.william278.husksync.HuskSyncBungeeCord;
import me.william278.husksync.MessageManager;
import me.william278.husksync.PlayerData;
import me.william278.husksync.Settings;
import me.william278.husksync.bungeecord.data.DataManager;
import me.william278.husksync.bungeecord.migrator.MPDBMigrator;
import me.william278.husksync.redis.RedisListener;
import me.william278.husksync.Settings;
import me.william278.husksync.redis.RedisMessage;
import net.md_5.bungee.api.ProxyServer;
import net.md_5.bungee.api.connection.ProxiedPlayer;
@ -29,23 +32,20 @@ public class BungeeRedisListener extends RedisListener {
return cachedData;
}
// If the cache does not contain player data:
DataManager.ensurePlayerExists(uuid); // Make sure the player is registered on MySQL
final PlayerData data = DataManager.getPlayerData(uuid); // Get their player data from MySQL
DataManager.playerDataCache.updatePlayer(data); // Update the cache
return data; // Return the data
}
/**
* Handle an incoming {@link me.william278.husksync.redis.RedisMessage}
* Handle an incoming {@link RedisMessage}
*
* @param message The {@link me.william278.husksync.redis.RedisMessage} to handle
* @param message The {@link RedisMessage} to handle
*/
@Override
public void handleMessage(me.william278.husksync.redis.RedisMessage message) {
public void handleMessage(RedisMessage message) {
// Ignore messages destined for Bukkit servers
if (message.getMessageTarget().targetServerType() != me.william278.husksync.Settings.ServerType.BUNGEECORD) {
if (message.getMessageTarget().targetServerType() != Settings.ServerType.BUNGEECORD) {
return;
}
@ -56,16 +56,22 @@ public class BungeeRedisListener extends RedisListener {
ProxyServer.getInstance().getScheduler().runAsync(plugin, () -> {
try {
// Send the reply, serializing the message data
new me.william278.husksync.redis.RedisMessage(me.william278.husksync.redis.RedisMessage.MessageType.PLAYER_DATA_SET,
new me.william278.husksync.redis.RedisMessage.MessageTarget(me.william278.husksync.Settings.ServerType.BUKKIT, requestingPlayerUUID),
me.william278.husksync.redis.RedisMessage.serialize(getPlayerCachedData(requestingPlayerUUID)))
new RedisMessage(RedisMessage.MessageType.PLAYER_DATA_SET,
new RedisMessage.MessageTarget(Settings.ServerType.BUKKIT, requestingPlayerUUID),
RedisMessage.serialize(getPlayerCachedData(requestingPlayerUUID)))
.send();
// Send an update to all bukkit servers removing the player from the requester cache
new me.william278.husksync.redis.RedisMessage(me.william278.husksync.redis.RedisMessage.MessageType.REQUEST_DATA_ON_JOIN,
new me.william278.husksync.redis.RedisMessage.MessageTarget(me.william278.husksync.Settings.ServerType.BUKKIT, null),
me.william278.husksync.redis.RedisMessage.RequestOnJoinUpdateType.REMOVE_REQUESTER.toString(), requestingPlayerUUID.toString())
new RedisMessage(RedisMessage.MessageType.REQUEST_DATA_ON_JOIN,
new RedisMessage.MessageTarget(Settings.ServerType.BUKKIT, null),
RedisMessage.RequestOnJoinUpdateType.REMOVE_REQUESTER.toString(), requestingPlayerUUID.toString())
.send();
// Send synchronisation complete message
ProxiedPlayer player = ProxyServer.getInstance().getPlayer(requestingPlayerUUID);
if (player.isConnected()) {
player.sendMessage(new MineDown(MessageManager.getMessage("synchronisation_complete")).toComponent());
}
} catch (IOException e) {
log(Level.SEVERE, "Failed to serialize data when replying to a data request");
e.printStackTrace();
@ -77,7 +83,7 @@ public class BungeeRedisListener extends RedisListener {
PlayerData playerData;
final String serializedPlayerData = message.getMessageData();
try {
playerData = (PlayerData) me.william278.husksync.redis.RedisMessage.deserialize(serializedPlayerData);
playerData = (PlayerData) RedisMessage.deserialize(serializedPlayerData);
} catch (IOException | ClassNotFoundException e) {
log(Level.SEVERE, "Failed to deserialize PlayerData when handling a player update request");
e.printStackTrace();
@ -92,10 +98,13 @@ public class BungeeRedisListener extends RedisListener {
ProxiedPlayer player = ProxyServer.getInstance().getPlayer(playerData.getPlayerUUID());
if (player != null) {
if (player.isConnected()) {
new me.william278.husksync.redis.RedisMessage(me.william278.husksync.redis.RedisMessage.MessageType.PLAYER_DATA_SET,
new me.william278.husksync.redis.RedisMessage.MessageTarget(Settings.ServerType.BUKKIT, playerData.getPlayerUUID()),
new RedisMessage(RedisMessage.MessageType.PLAYER_DATA_SET,
new RedisMessage.MessageTarget(Settings.ServerType.BUKKIT, playerData.getPlayerUUID()),
RedisMessage.serialize(playerData))
.send();
// Send synchronisation complete message
player.sendMessage(new MineDown(MessageManager.getMessage("synchronisation_complete")).toComponent());
}
}
} catch (IOException e) {
@ -103,6 +112,64 @@ public class BungeeRedisListener extends RedisListener {
e.printStackTrace();
}
}
case CONNECTION_HANDSHAKE -> {
// Reply to a Bukkit server's connection handshake to complete the process
final UUID serverUUID = UUID.fromString(message.getMessageDataElements()[0]);
final boolean hasMySqlPlayerDataBridge = Boolean.parseBoolean(message.getMessageDataElements()[1]);
final String bukkitBrand = message.getMessageDataElements()[2];
try {
new RedisMessage(RedisMessage.MessageType.CONNECTION_HANDSHAKE,
new RedisMessage.MessageTarget(Settings.ServerType.BUKKIT, null),
serverUUID.toString(), plugin.getProxy().getName())
.send();
HuskSyncBungeeCord.synchronisedServers.add(new HuskSyncBungeeCord.Server(serverUUID, hasMySqlPlayerDataBridge));
log(Level.INFO, "Completed handshake with " + bukkitBrand + " server (" + serverUUID + ")");
} catch (IOException e) {
log(Level.SEVERE, "Failed to serialize handshake message data");
e.printStackTrace();
}
}
case TERMINATE_HANDSHAKE -> {
// Terminate the handshake with a Bukkit server
final UUID serverUUID = UUID.fromString(message.getMessageDataElements()[0]);
final String bukkitBrand = message.getMessageDataElements()[1];
// Remove a server from the synchronised server list
HuskSyncBungeeCord.Server serverToRemove = null;
for (HuskSyncBungeeCord.Server server : HuskSyncBungeeCord.synchronisedServers) {
if (server.serverUUID().equals(serverUUID)) {
serverToRemove = server;
break;
}
}
HuskSyncBungeeCord.synchronisedServers.remove(serverToRemove);
log(Level.INFO, "Terminated the handshake with " + bukkitBrand + " server (" + serverUUID + ")");
}
case DECODED_MPDB_DATA_SET -> {
// Deserialize the PlayerData received
PlayerData playerData;
final String serializedPlayerData = message.getMessageDataElements()[0];
final String playerName = message.getMessageDataElements()[1];
try {
playerData = (PlayerData) RedisMessage.deserialize(serializedPlayerData);
} catch (IOException | ClassNotFoundException e) {
log(Level.SEVERE, "Failed to deserialize PlayerData when handling incoming decoded MPDB data");
e.printStackTrace();
return;
}
// Add the incoming data to the data to be saved
MPDBMigrator.incomingPlayerData.put(playerData, playerName);
// Increment players migrated
MPDBMigrator.playersMigrated++;
plugin.getLogger().log(Level.INFO, "Migrated " + MPDBMigrator.playersMigrated + "/" + MPDBMigrator.migratedDataSent + " players.");
// When all the data has been received, save it
if (MPDBMigrator.migratedDataSent == MPDBMigrator.playersMigrated) {
MPDBMigrator.loadIncomingData();
}
}
}
}

@ -0,0 +1,278 @@
package me.william278.husksync.bungeecord.migrator;
import me.william278.husksync.HuskSyncBungeeCord;
import me.william278.husksync.PlayerData;
import me.william278.husksync.Settings;
import me.william278.husksync.bungeecord.data.DataManager;
import me.william278.husksync.bungeecord.data.sql.Database;
import me.william278.husksync.bungeecord.data.sql.MySQL;
import me.william278.husksync.migrator.MPDBPlayerData;
import me.william278.husksync.redis.RedisMessage;
import net.md_5.bungee.api.ProxyServer;
import java.io.IOException;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.HashMap;
import java.util.HashSet;
import java.util.UUID;
import java.util.logging.Level;
/**
* Class to handle migration of data from MySQLPlayerDataBridge
* <p>
* The migrator accesses and decodes MPDB's format directly.
* It does this by establishing a connection
*/
public class MPDBMigrator {
public static int migratedDataSent = 0;
public static int playersMigrated = 0;
private static final HuskSyncBungeeCord plugin = HuskSyncBungeeCord.getInstance();
public static HashMap<PlayerData,String> incomingPlayerData;
public static MigrationSettings migrationSettings = new MigrationSettings();
private static Database sourceDatabase;
private static HashSet<MPDBPlayerData> mpdbPlayerData;
public void start() {
if (ProxyServer.getInstance().getPlayers().size() > 0) {
plugin.getLogger().log(Level.WARNING, "Failed to start migration because there are players online. " +
"Your network has to be empty to migrate data for safety reasons.");
return;
}
int synchronisedServersWithMpdb = 0;
for (HuskSyncBungeeCord.Server server : HuskSyncBungeeCord.synchronisedServers) {
if (server.hasMySqlPlayerDataBridge()) {
synchronisedServersWithMpdb++;
}
}
if (synchronisedServersWithMpdb < 1) {
plugin.getLogger().log(Level.WARNING, "Failed to start migration because at least one Spigot server must be online and have both HuskSync and MySqlPlayerDataBridge installed. " +
"Please start one Spigot server with HuskSync installed to begin migration.");
}
migratedDataSent = 0;
playersMigrated = 0;
mpdbPlayerData = new HashSet<>();
incomingPlayerData = new HashMap<>();
final MigrationSettings settings = migrationSettings;
// Get connection to source database
sourceDatabase = new MigratorMySQL(plugin, settings.sourceHost, settings.sourcePort,
settings.sourceDatabase, settings.sourceUsername, settings.sourcePassword);
sourceDatabase.load();
if (sourceDatabase.isInactive()) {
plugin.getLogger().log(Level.WARNING, "Failed to establish connection to the origin MySQL database. " +
"Please check you have input the correct connection details and try again.");
return;
}
ProxyServer.getInstance().getScheduler().runAsync(plugin, () -> {
prepareTargetDatabase();
getInventoryData();
getEnderChestData();
getExperienceData();
sendEncodedData();
});
}
// Clear the new database out of current data
private void prepareTargetDatabase() {
plugin.getLogger().log(Level.INFO, "Preparing target database...");
try (Connection connection = HuskSyncBungeeCord.getConnection()) {
try (PreparedStatement statement = connection.prepareStatement("DELETE FROM " + Database.PLAYER_TABLE_NAME + ";")) {
statement.executeUpdate();
}
try (PreparedStatement statement = connection.prepareStatement("DELETE FROM " + Database.DATA_TABLE_NAME + ";")) {
statement.executeUpdate();
}
} catch (SQLException e) {
plugin.getLogger().log(Level.SEVERE, "An exception occurred preparing the target database", e);
} finally {
plugin.getLogger().log(Level.INFO, "Finished preparing target database!");
}
}
private void getInventoryData() {
plugin.getLogger().log(Level.INFO, "Getting inventory data from MySQLPlayerDataBridge...");
try (Connection connection = sourceDatabase.getConnection()) {
try (PreparedStatement statement = connection.prepareStatement("SELECT * FROM " + migrationSettings.inventoryDataTable + ";")) {
ResultSet resultSet = statement.executeQuery();
while (resultSet.next()) {
final UUID playerUUID = UUID.fromString(resultSet.getString("player_uuid"));
final String playerName = resultSet.getString("player_name");
MPDBPlayerData data = new MPDBPlayerData(playerUUID, playerName);
data.inventoryData = resultSet.getString("inventory");
data.armorData = resultSet.getString("armor");
mpdbPlayerData.add(data);
}
}
} catch (SQLException e) {
plugin.getLogger().log(Level.SEVERE, "An exception occurred getting inventory data", e);
} finally {
plugin.getLogger().log(Level.INFO, "Finished getting inventory data from MySQLPlayerDataBridge");
}
}
private void getEnderChestData() {
plugin.getLogger().log(Level.INFO, "Getting ender chest data from MySQLPlayerDataBridge...");
try (Connection connection = sourceDatabase.getConnection()) {
try (PreparedStatement statement = connection.prepareStatement("SELECT * FROM " + migrationSettings.enderChestDataTable + ";")) {
ResultSet resultSet = statement.executeQuery();
while (resultSet.next()) {
final UUID playerUUID = UUID.fromString(resultSet.getString("player_uuid"));
for (MPDBPlayerData data : mpdbPlayerData) {
if (data.playerUUID.equals(playerUUID)) {
data.enderChestData = resultSet.getString("enderchest");
break;
}
}
}
}
} catch (SQLException e) {
plugin.getLogger().log(Level.SEVERE, "An exception occurred getting ender chest", e);
} finally {
plugin.getLogger().log(Level.INFO, "Finished getting ender chest data from MySQLPlayerDataBridge");
}
}
private void getExperienceData() {
plugin.getLogger().log(Level.INFO, "Getting experience data from MySQLPlayerDataBridge...");
try (Connection connection = sourceDatabase.getConnection()) {
try (PreparedStatement statement = connection.prepareStatement("SELECT * FROM " + migrationSettings.expDataTable + ";")) {
ResultSet resultSet = statement.executeQuery();
while (resultSet.next()) {
final UUID playerUUID = UUID.fromString(resultSet.getString("player_uuid"));
for (MPDBPlayerData data : mpdbPlayerData) {
if (data.playerUUID.equals(playerUUID)) {
data.expLevel = resultSet.getInt("exp_lvl");
data.expProgress = resultSet.getInt("exp");
data.totalExperience = resultSet.getInt("total_exp");
break;
}
}
}
}
} catch (SQLException e) {
plugin.getLogger().log(Level.SEVERE, "An exception occurred getting ender chest", e);
} finally {
plugin.getLogger().log(Level.INFO, "Finished getting experience data from MySQLPlayerDataBridge");
}
}
private void sendEncodedData() {
for (HuskSyncBungeeCord.Server processingServer : HuskSyncBungeeCord.synchronisedServers) {
if (processingServer.hasMySqlPlayerDataBridge()) {
for (MPDBPlayerData data : mpdbPlayerData) {
try {
new RedisMessage(RedisMessage.MessageType.DECODE_MPDB_DATA,
new RedisMessage.MessageTarget(Settings.ServerType.BUKKIT, null),
processingServer.serverUUID().toString(),
RedisMessage.serialize(data))
.send();
migratedDataSent++;
} catch (IOException e) {
plugin.getLogger().log(Level.SEVERE, "Failed to serialize encoded MPDB data", e);
}
}
plugin.getLogger().log(Level.INFO, "Finished dispatching encoded data for " + migratedDataSent + " players; please wait for conversion to finish");
}
return;
}
}
/**
* Load all incoming decoded MPDB data to cache / SQL
*/
public static void loadIncomingData() {
ProxyServer.getInstance().getScheduler().runAsync(plugin, () -> {
int playersSaved = 0;
plugin.getLogger().log(Level.INFO, "Saving data for " + playersMigrated + " players...");
for (PlayerData playerData : incomingPlayerData.keySet()) {
String playerName = incomingPlayerData.get(playerData);
// Add the player to the MySQL table
DataManager.ensurePlayerExists(playerData.getPlayerUUID(), playerName);
// Update the data in the cache and SQL
DataManager.updatePlayerData(playerData);
playersSaved++;
plugin.getLogger().log(Level.INFO, "Saved data for " + playersSaved + "/" + playersMigrated + " players");
}
// Mark as done when done
plugin.getLogger().log(Level.INFO, """
=== MySQLPlayerDataBridge Migration Wizard ==========
Migration complete!
Successfully migrated data for %1%/%2% players.
You should now uninstall MySQLPlayerDataBridge from
the rest of the Spigot servers, then restart them.
""".replaceAll("%1%", Integer.toString(MPDBMigrator.playersMigrated))
.replaceAll("%2%", Integer.toString(MPDBMigrator.migratedDataSent)));
});
}
/**
* Class used to hold settings for the MPDB migration
*/
public static class MigrationSettings {
public String sourceHost;
public int sourcePort;
public String sourceDatabase;
public String sourceUsername;
public String sourcePassword;
public String inventoryDataTable;
public String enderChestDataTable;
public String expDataTable;
public MigrationSettings() {
sourceHost = "localhost";
sourcePort = 3306;
sourceDatabase = "mpdb";
sourceUsername = "root";
sourcePassword = "pa55w0rd";
inventoryDataTable = "mpdb_inventory";
enderChestDataTable = "mpdb_enderchest";
expDataTable = "mpdb_experience";
}
}
/**
* MySQL class used for importing data from MPDB
*/
public static class MigratorMySQL extends MySQL {
public MigratorMySQL(HuskSyncBungeeCord instance, String host, int port, String database, String username, String password) {
super(instance);
super.host = host;
super.port = port;
super.database = database;
super.username = username;
super.password = password;
super.params = "?useSSL=false";
super.dataPoolName = DATA_POOL_NAME + "Migrator";
}
}
}

@ -6,7 +6,7 @@ data_storage_settings:
database_type: 'sqlite'
mysql_settings:
host: 'localhost'
port: 8123
port: 3306
database: 'HuskSync'
username: 'root'
password: 'pa55w0rd'

@ -0,0 +1,28 @@
package me.william278.husksync;
import java.util.HashMap;
public class MessageManager {
private static HashMap<String, String> messages = new HashMap<>();
public static void setMessages(HashMap<String, String> newMessages) {
messages = new HashMap<>(newMessages);
}
public static String getMessage(String messageId) {
return messages.get(messageId);
}
public static StringBuilder PLUGIN_INFORMATION = new StringBuilder().append("[HuskSync](#00fb9a bold) [| %proxy_brand% Version %proxy_version% (%bukkit_brand% v%bukkit_version%)](#00fb9a)\n")
.append("[%plugin_description%](gray)\n")
.append("[• Author:](white) [William278](gray show_text=&7Click to pay a visit open_url=https://youtube.com/William27528)\n")
.append("[• Help Wiki:](white) [[Link]](#00fb9a show_text=&7Click to open link open_url=https://github.com/WiIIiam278/HuskSync/wiki/)\n")
.append("[• Report Issues:](white) [[Link]](#00fb9a show_text=&7Click to open link open_url=https://github.com/WiIIiam278/HuskSync/issues)\n")
.append("[• Support Discord:](white) [[Link]](#00fb9a show_text=&7Click to join open_url=https://discord.gg/tVYhJfyDWG)");
public static StringBuilder PLUGIN_STATUS = new StringBuilder().append("[HuskSync](#00fb9a bold) [| Current system status:](#00fb9a)\n")
.append("[• Connected servers:](white) [%1%](#00fb9a)")
.append("[• Cached player data:](white) [%2%](#00fb9a)");
}

@ -1,16 +0,0 @@
package me.william278.husksync;
public class MessageStrings {
public static final StringBuilder PLUGIN_INFORMATION = new StringBuilder().append("[HuskSync](#00fb9a bold) [| %proxy_brand% Version %proxy_version% (%bukkit_brand% v%bukkit_version%)](#00fb9a)\n")
.append("[%plugin_description%](gray)\n")
.append("[• Author:](white) [William278](gray show_text=&7Click to pay a visit open_url=https://youtube.com/William27528)\n")
.append("[• Help Wiki:](white) [[Link]](#00fb9a show_text=&7Click to open link open_url=https://github.com/WiIIiam278/HuskSync/wiki/)\n")
.append("[• Report Issues:](white) [[Link]](#00fb9a show_text=&7Click to open link open_url=https://github.com/WiIIiam278/HuskSync/issues)\n")
.append("[• Support Discord:](white) [[Link]](#00fb9a show_text=&7Click to join open_url=https://discord.gg/tVYhJfyDWG)");
public static final String ERROR_INVALID_SYNTAX = "[Error:](#ff3300) [Incorrect syntax. Usage: %1%](#ff7e5e)";
public static final String SYNCHRONISATION_COMPLETE = "[Data synchronised!](#00fb9a)";
}

@ -16,26 +16,27 @@ public class PlayerData implements Serializable {
private final UUID dataVersionUUID;
// Flag to indicate if the Bukkit server should use default data
private boolean useDefaultData = false;
public boolean useDefaultData = false;
// Player data
private final String serializedInventory;
private final String serializedEnderChest;
private final double health;
private final double maxHealth;
private final int hunger;
private final float saturation;
private final float saturationExhaustion;
private final int selectedSlot;
private final String serializedEffectData;
private final int totalExperience;
private final int expLevel;
private final float expProgress;
private final String gameMode;
private final String serializedStatistics;
private final boolean isFlying;
private final String serializedAdvancements;
private final String serializedLocation;
private String serializedInventory;
private String serializedEnderChest;
private double health;
private double maxHealth;
private double healthScale;
private int hunger;
private float saturation;
private float saturationExhaustion;
private int selectedSlot;
private String serializedEffectData;
private int totalExperience;
private int expLevel;
private float expProgress;
private String gameMode;
private String serializedStatistics;
private boolean isFlying;
private String serializedAdvancements;
private String serializedLocation;
/**
* Constructor to create new PlayerData from a bukkit {@code Player}'s data
@ -45,6 +46,7 @@ public class PlayerData implements Serializable {
* @param serializedEnderChest Their serialized ender chest
* @param health Their health
* @param maxHealth Their max health
* @param healthScale Their health scale
* @param hunger Their hunger
* @param saturation Their saturation
* @param saturationExhaustion Their saturation exhaustion
@ -56,8 +58,8 @@ public class PlayerData implements Serializable {
* @param gameMode Their game mode ({@code SURVIVAL}, {@code CREATIVE}, etc)
* @param serializedStatistics Their serialized statistics data (Displayed in Statistics menu in ESC menu)
*/
public PlayerData(UUID playerUUID, String serializedInventory, String serializedEnderChest, double health,
double maxHealth, int hunger, float saturation, float saturationExhaustion, int selectedSlot,
public PlayerData(UUID playerUUID, String serializedInventory, String serializedEnderChest, double health, double maxHealth,
double healthScale, int hunger, float saturation, float saturationExhaustion, int selectedSlot,
String serializedStatusEffects, int totalExperience, int expLevel, float expProgress, String gameMode,
String serializedStatistics, boolean isFlying, String serializedAdvancements, String serializedLocation) {
this.dataVersionUUID = UUID.randomUUID();
@ -66,6 +68,7 @@ public class PlayerData implements Serializable {
this.serializedEnderChest = serializedEnderChest;
this.health = health;
this.maxHealth = maxHealth;
this.healthScale = healthScale;
this.hunger = hunger;
this.saturation = saturation;
this.saturationExhaustion = saturationExhaustion;
@ -90,6 +93,7 @@ public class PlayerData implements Serializable {
* @param serializedEnderChest Their serialized ender chest
* @param health Their health
* @param maxHealth Their max health
* @param healthScale Their health scale
* @param hunger Their hunger
* @param saturation Their saturation
* @param saturationExhaustion Their saturation exhaustion
@ -102,7 +106,7 @@ public class PlayerData implements Serializable {
* @param serializedStatistics Their serialized statistics data (Displayed in Statistics menu in ESC menu)
*/
public PlayerData(UUID playerUUID, UUID dataVersionUUID, String serializedInventory, String serializedEnderChest,
double health, double maxHealth, int hunger, float saturation, float saturationExhaustion,
double health, double maxHealth, double healthScale, int hunger, float saturation, float saturationExhaustion,
int selectedSlot, String serializedStatusEffects, int totalExperience, int expLevel, float expProgress,
String gameMode, String serializedStatistics, boolean isFlying, String serializedAdvancements,
String serializedLocation) {
@ -112,6 +116,7 @@ public class PlayerData implements Serializable {
this.serializedEnderChest = serializedEnderChest;
this.health = health;
this.maxHealth = maxHealth;
this.healthScale = healthScale;
this.hunger = hunger;
this.saturation = saturation;
this.saturationExhaustion = saturationExhaustion;
@ -135,7 +140,7 @@ public class PlayerData implements Serializable {
*/
public static PlayerData DEFAULT_PLAYER_DATA(UUID playerUUID) {
PlayerData data = new PlayerData(playerUUID, "", "", 20,
20, 20, 10, 1, 0,
20, 0, 20, 10, 1, 0,
"", 0, 0, 0, "SURVIVAL",
"", false, "", "");
data.useDefaultData = true;
@ -166,6 +171,8 @@ public class PlayerData implements Serializable {
return maxHealth;
}
public double getHealthScale() { return healthScale; }
public int getHunger() {
return hunger;
}
@ -222,4 +229,75 @@ public class PlayerData implements Serializable {
return useDefaultData;
}
public void setSerializedInventory(String serializedInventory) {
this.serializedInventory = serializedInventory;
}
public void setSerializedEnderChest(String serializedEnderChest) {
this.serializedEnderChest = serializedEnderChest;
}
public void setHealth(double health) {
this.health = health;
}
public void setMaxHealth(double maxHealth) {
this.maxHealth = maxHealth;
}
public void setHealthScale(double healthScale) {
this.healthScale = healthScale;
}
public void setHunger(int hunger) {
this.hunger = hunger;
}
public void setSaturation(float saturation) {
this.saturation = saturation;
}
public void setSaturationExhaustion(float saturationExhaustion) {
this.saturationExhaustion = saturationExhaustion;
}
public void setSelectedSlot(int selectedSlot) {
this.selectedSlot = selectedSlot;
}
public void setSerializedEffectData(String serializedEffectData) {
this.serializedEffectData = serializedEffectData;
}
public void setTotalExperience(int totalExperience) {
this.totalExperience = totalExperience;
}
public void setExpLevel(int expLevel) {
this.expLevel = expLevel;
}
public void setExpProgress(float expProgress) {
this.expProgress = expProgress;
}
public void setGameMode(String gameMode) {
this.gameMode = gameMode;
}
public void setSerializedStatistics(String serializedStatistics) {
this.serializedStatistics = serializedStatistics;
}
public void setFlying(boolean flying) {
isFlying = flying;
}
public void setSerializedAdvancements(String serializedAdvancements) {
this.serializedAdvancements = serializedAdvancements;
}
public void setSerializedLocation(String serializedLocation) {
this.serializedLocation = serializedLocation;
}
}

@ -9,6 +9,9 @@ public class Settings {
* General settings
*/
// Messages language
public static String language;
// The type of THIS server (Bungee or Bukkit)
public static ServerType serverType;

@ -0,0 +1,35 @@
package me.william278.husksync.migrator;
import java.io.Serializable;
import java.util.UUID;
/**
* A class that stores player data taken from MPDB's database, that can then be converted into HuskSync's format
*/
public class MPDBPlayerData implements Serializable {
/*
* Player information
*/
public final UUID playerUUID;
public final String playerName;
/*
* Inventory, ender chest and armor data
*/
public String inventoryData;
public String armorData;
public String enderChestData;
/*
* Experience data
*/
public int expLevel;
public float expProgress;
public int totalExperience;
public MPDBPlayerData(UUID playerUUID, String playerName) {
this.playerUUID = playerUUID;
this.playerName = playerName;
}
}

@ -9,6 +9,11 @@ import java.util.logging.Level;
public abstract class RedisListener {
/**
* Determines if the RedisListener is working properly
*/
public boolean isActiveAndEnabled;
/**
* Handle an incoming {@link RedisMessage}
* @param message The {@link RedisMessage} to handle
@ -33,6 +38,7 @@ public abstract class RedisListener {
}
jedis.connect();
if (jedis.isConnected()) {
isActiveAndEnabled = true;
log(Level.INFO,"Enabled Redis listener successfully!");
new Thread(() -> jedis.subscribe(new JedisPubSub() {
@Override
@ -51,6 +57,7 @@ public abstract class RedisListener {
}
}, RedisMessage.REDIS_CHANNEL), "Redis Subscriber").start();
} else {
isActiveAndEnabled = false;
log(Level.SEVERE, "Failed to initialize the redis listener!");
}
}

@ -112,7 +112,42 @@ public class RedisMessage {
/**
* Sent by the proxy to ask the Bukkit server to send the full plugin information, contains information about the proxy brand and version
*/
SEND_PLUGIN_INFORMATION
SEND_PLUGIN_INFORMATION,
/**
* Sent by the proxy to show a player the contents of another player's inventory, contains their username and {@link PlayerData}
*/
OPEN_INVENTORY,
/**
* Sent by the proxy to show a player the contents of another player's ender chest, contains their username and {@link PlayerData}
*/
OPEN_ENDER_CHEST,
/**
* Sent by both the proxy and bukkit servers to confirm cross-server communication has been established
*/
CONNECTION_HANDSHAKE,
/**
* Sent by both the proxy and bukkit servers to terminate communications (if a bukkit / the proxy goes offline)
*/
TERMINATE_HANDSHAKE,
/**
* Sent by a proxy to a bukkit server to decode MPDB data
*/
DECODE_MPDB_DATA,
/**
* Sent by a bukkit server back to the proxy with the correctly decoded MPDB data
*/
DECODED_MPDB_DATA_SET,
/**
* Sent by the proxy to a bukkit server to initiate a reload
*/
RELOAD_CONFIG
}
public enum RequestOnJoinUpdateType {

@ -2,7 +2,7 @@ name: HuskSync
version: @version@
main: me.william278.husksync.HuskSyncBungeeCord
author: William278
description: 'Synchronize data cross-server'
description: 'A modern, cross-server player data synchronisation system'
libraries:
- mysql:mysql-connector-java:8.0.25
- org.xerial:sqlite-jdbc:3.36.0.3

@ -0,0 +1,13 @@
synchronisation_complete: '[Data synchronised!](#00fb9a)'
viewing_inventory_of: '[Viewing the inventory of](#00fb9a) [%1%](#00fb9a bold)'
viewing_ender_chest_of: '[Viewing the ender chest of](#00fb9a) [%1%](#00fb9a bold)'
reload_complete: '[HuskSync](#00fb9a bold) [| Reloaded config and message files.](#00fb9a)'
error_invalid_syntax: '[Error:](#ff3300) [Incorrect syntax. Usage: %1%](#ff7e5e)'
error_invalid_player: '[Error:](#ff3300) [Could not find that player](#ff7e5e)'
error_no_permission: '[Error:](#ff3300) [You do not have permission to execute this command](#ff7e5e)'
error_cannot_view_inventory_online: '[Error:](#ff3300) [You can''t access the inventory of an online player through HuskSync](#ff7e5e)'
error_cannot_view_ender_chest_online: '[Error:](#ff3300) [You can''t access the ender chest of an online player through HuskSync](#ff7e5e)'
error_cannot_view_own_inventory: '[Error:](#ff3300) [You can''t access your own inventory!](#ff7e5e)'
error_cannot_view_own_ender_chest: '[Error:](#ff3300) [You can''t access your own ender chest!](#ff7e5e)'
error_console_command_only: '[Error:](#ff3300) [That command can only be run through the %1% console](#ff7e5e)'
error_no_servers_proxied: '[Error:](#ff3300) [Failed to process operation; no servers are online that have HuskSync installed.\nPlease ensure HuskSync is installed on both the Proxy server and all servers you wish to synchronise data between](#ff7e5e)'

@ -1,6 +1,7 @@
name: CrossServerSync
name: HuskSync
version: @version@
main: me.william278.husksync.HuskSyncBukkit
api-version: 1.16
author: William278
description: 'Synchronize data cross-server'
description: 'A modern, cross-server player data synchronisation system'
softdepend: [MysqlPlayerDataBridge]

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 182 KiB

After

Width:  |  Height:  |  Size: 159 KiB

Loading…
Cancel
Save