9
0
mirror of https://github.com/WiIIiam278/HuskSync.git synced 2025-12-29 11:39:14 +00:00

Refactor package to net.william278; update dependencies & stop shading internal modules

This commit is contained in:
William
2022-06-07 20:44:53 +01:00
parent 2f3b0f37e8
commit a76aecdd23
61 changed files with 255 additions and 253 deletions

View File

@@ -0,0 +1,533 @@
package net.william278.husksync;
import java.io.*;
import java.time.Instant;
import java.util.UUID;
/**
* Cross-platform class used to represent a player's data. Data from this can be deserialized using the DataSerializer class on Bukkit platforms.
*/
public class PlayerData implements Serializable {
/**
* The UUID of the player who this data belongs to
*/
private final UUID playerUUID;
/**
* The unique version UUID of this data
*/
private final UUID dataVersionUUID;
/**
* Epoch time identifying when the data was last updated or created
*/
private long timestamp;
/**
* A special flag that will be {@code true} if the player is new to the network and should not have their data set when joining the Bukkit
*/
public boolean useDefaultData = false;
/*
* Player data records
*/
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
*
* @param playerUUID The Player's UUID
* @param serializedInventory Their serialized inventory
* @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
* @param selectedSlot Their selected hot bar slot
* @param serializedStatusEffects Their serialized status effects
* @param totalExperience Their total experience points ("Score")
* @param expLevel Their exp level
* @param expProgress Their exp progress to the next level
* @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,
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();
this.timestamp = Instant.now().getEpochSecond();
this.playerUUID = playerUUID;
this.serializedInventory = serializedInventory;
this.serializedEnderChest = serializedEnderChest;
this.health = health;
this.maxHealth = maxHealth;
this.healthScale = healthScale;
this.hunger = hunger;
this.saturation = saturation;
this.saturationExhaustion = saturationExhaustion;
this.selectedSlot = selectedSlot;
this.serializedEffectData = serializedStatusEffects;
this.totalExperience = totalExperience;
this.expLevel = expLevel;
this.expProgress = expProgress;
this.gameMode = gameMode;
this.serializedStatistics = serializedStatistics;
this.isFlying = isFlying;
this.serializedAdvancements = serializedAdvancements;
this.serializedLocation = serializedLocation;
}
/**
* Constructor for a PlayerData object from an existing object that was stored in SQL
*
* @param playerUUID The player whose data this is' UUID
* @param dataVersionUUID The PlayerData version UUID
* @param serializedInventory Their serialized inventory
* @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
* @param selectedSlot Their selected hot bar slot
* @param serializedStatusEffects Their serialized status effects
* @param totalExperience Their total experience points ("Score")
* @param expLevel Their exp level
* @param expProgress Their exp progress to the next level
* @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, UUID dataVersionUUID, long timestamp, 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.playerUUID = playerUUID;
this.dataVersionUUID = dataVersionUUID;
this.timestamp = timestamp;
this.serializedInventory = serializedInventory;
this.serializedEnderChest = serializedEnderChest;
this.health = health;
this.maxHealth = maxHealth;
this.healthScale = healthScale;
this.hunger = hunger;
this.saturation = saturation;
this.saturationExhaustion = saturationExhaustion;
this.selectedSlot = selectedSlot;
this.serializedEffectData = serializedStatusEffects;
this.totalExperience = totalExperience;
this.expLevel = expLevel;
this.expProgress = expProgress;
this.gameMode = gameMode;
this.serializedStatistics = serializedStatistics;
this.isFlying = isFlying;
this.serializedAdvancements = serializedAdvancements;
this.serializedLocation = serializedLocation;
}
/**
* Get default PlayerData for a new user
*
* @param playerUUID The bukkit Player's UUID
* @return Default {@link PlayerData}
*/
public static PlayerData DEFAULT_PLAYER_DATA(UUID playerUUID) {
PlayerData data = new PlayerData(playerUUID, "", "", 20,
20, 20, 20, 10, 1, 0,
"", 0, 0, 0, "SURVIVAL",
"", false, "", "");
data.useDefaultData = true;
return data;
}
/**
* Get the {@link UUID} of the player whose data this is
*
* @return the player's {@link UUID}
*/
public UUID getPlayerUUID() {
return playerUUID;
}
/**
* Get the unique version {@link UUID} of the PlayerData
*
* @return The unique data version
*/
public UUID getDataVersionUUID() {
return dataVersionUUID;
}
/**
* Get the timestamp when this data was created or last updated
*
* @return time since epoch of last data update or creation
*/
public long getDataTimestamp() {
return timestamp;
}
/**
* Returns the serialized player {@code ItemStack[]} inventory
*
* @return The player's serialized inventory
*/
public String getSerializedInventory() {
return serializedInventory;
}
/**
* Returns the serialized player {@code ItemStack[]} ender chest
*
* @return The player's serialized ender chest
*/
public String getSerializedEnderChest() {
return serializedEnderChest;
}
/**
* Returns the player's health value
*
* @return the player's health
*/
public double getHealth() {
return health;
}
/**
* Returns the player's max health value
*
* @return the player's max health
*/
public double getMaxHealth() {
return maxHealth;
}
/**
* Returns the player's health scale value {@see https://hub.spigotmc.org/javadocs/bukkit/org/bukkit/entity/Player.html#getHealthScale()}
*
* @return the player's health scaling value
*/
public double getHealthScale() {
return healthScale;
}
/**
* Returns the player's hunger points
*
* @return the player's hunger level
*/
public int getHunger() {
return hunger;
}
/**
* Returns the player's saturation points
*
* @return the player's saturation level
*/
public float getSaturation() {
return saturation;
}
/**
* Returns the player's saturation exhaustion value {@see https://hub.spigotmc.org/javadocs/bukkit/org/bukkit/entity/HumanEntity.html#getExhaustion()}
*
* @return the player's saturation exhaustion
*/
public float getSaturationExhaustion() {
return saturationExhaustion;
}
/**
* Returns the number of the player's currently selected hotbar slot
*
* @return the player's selected hotbar slot
*/
public int getSelectedSlot() {
return selectedSlot;
}
/**
* Returns a serialized {@link String} of the player's current status effects
*
* @return the player's serialized status effect data
*/
public String getSerializedEffectData() {
return serializedEffectData;
}
/**
* Returns the player's total experience score (used for presenting the death screen score value)
*
* @return the player's total experience score
*/
public int getTotalExperience() {
return totalExperience;
}
/**
* Returns a serialized {@link String} of the player's statistics
*
* @return the player's serialized statistic records
*/
public String getSerializedStatistics() {
return serializedStatistics;
}
/**
* Returns the player's current experience level
*
* @return the player's exp level
*/
public int getExpLevel() {
return expLevel;
}
/**
* Returns the player's progress to the next experience level
*
* @return the player's exp progress
*/
public float getExpProgress() {
return expProgress;
}
/**
* Returns the player's current game mode as a string ({@code SURVIVAL}, {@code CREATIVE}, etc.)
*
* @return the player's game mode
*/
public String getGameMode() {
return gameMode;
}
/**
* Returns if the player is currently flying
*
* @return {@code true} if the player is in flight; {@code false} otherwise
*/
public boolean isFlying() {
return isFlying;
}
/**
* Returns a serialized {@link String} of the player's advancements
*
* @return the player's serialized advancement data
*/
public String getSerializedAdvancements() {
return serializedAdvancements;
}
/**
* Returns a serialized {@link String} of the player's current location
*
* @return the player's serialized location
*/
public String getSerializedLocation() {
return serializedLocation;
}
/**
* Update the player's inventory data
*
* @param serializedInventory A serialized {@code String}; new inventory data
*/
public void setSerializedInventory(String serializedInventory) {
this.serializedInventory = serializedInventory;
this.timestamp = Instant.now().getEpochSecond();
}
/**
* Update the player's ender chest data
*
* @param serializedEnderChest A serialized {@code String}; new ender chest inventory data
*/
public void setSerializedEnderChest(String serializedEnderChest) {
this.serializedEnderChest = serializedEnderChest;
this.timestamp = Instant.now().getEpochSecond();
}
/**
* Update the player's health
*
* @param health new health value
*/
public void setHealth(double health) {
this.health = health;
this.timestamp = Instant.now().getEpochSecond();
}
/**
* Update the player's max health
*
* @param maxHealth new maximum health value
*/
public void setMaxHealth(double maxHealth) {
this.maxHealth = maxHealth;
this.timestamp = Instant.now().getEpochSecond();
}
/**
* Update the player's health scale
*
* @param healthScale new health scaling value
*/
public void setHealthScale(double healthScale) {
this.healthScale = healthScale;
this.timestamp = Instant.now().getEpochSecond();
}
/**
* Update the player's hunger meter
*
* @param hunger new hunger value
*/
public void setHunger(int hunger) {
this.hunger = hunger;
this.timestamp = Instant.now().getEpochSecond();
}
/**
* Update the player's saturation level
*
* @param saturation new saturation value
*/
public void setSaturation(float saturation) {
this.saturation = saturation;
this.timestamp = Instant.now().getEpochSecond();
}
/**
* Update the player's saturation exhaustion value
*
* @param saturationExhaustion new exhaustion value
*/
public void setSaturationExhaustion(float saturationExhaustion) {
this.saturationExhaustion = saturationExhaustion;
this.timestamp = Instant.now().getEpochSecond();
}
/**
* Update the player's selected hotbar slot
*
* @param selectedSlot new hotbar slot number (0-9)
*/
public void setSelectedSlot(int selectedSlot) {
this.selectedSlot = selectedSlot;
this.timestamp = Instant.now().getEpochSecond();
}
/**
* Update the player's status effect data
*
* @param serializedEffectData A serialized {@code String} of the player's new status effect data
*/
public void setSerializedEffectData(String serializedEffectData) {
this.serializedEffectData = serializedEffectData;
this.timestamp = Instant.now().getEpochSecond();
}
/**
* Set the player's total experience points (used to display score on death screen)
*
* @param totalExperience the player's new total experience score
*/
public void setTotalExperience(int totalExperience) {
this.totalExperience = totalExperience;
this.timestamp = Instant.now().getEpochSecond();
}
/**
* Set the player's exp level
*
* @param expLevel the player's new exp level
*/
public void setExpLevel(int expLevel) {
this.expLevel = expLevel;
this.timestamp = Instant.now().getEpochSecond();
}
/**
* Set the player's progress to their next exp level
*
* @param expProgress the player's new experience progress
*/
public void setExpProgress(float expProgress) {
this.expProgress = expProgress;
this.timestamp = Instant.now().getEpochSecond();
}
/**
* Set the player's game mode
*
* @param gameMode the player's new game mode ({@code SURVIVAL}, {@code CREATIVE}, etc.)
*/
public void setGameMode(String gameMode) {
this.gameMode = gameMode;
this.timestamp = Instant.now().getEpochSecond();
}
/**
* Update the player's statistics data
*
* @param serializedStatistics A serialized {@code String}; new statistic data
*/
public void setSerializedStatistics(String serializedStatistics) {
this.serializedStatistics = serializedStatistics;
this.timestamp = Instant.now().getEpochSecond();
}
/**
* Set if the player is flying
*
* @param flying whether the player is flying
*/
public void setFlying(boolean flying) {
isFlying = flying;
this.timestamp = Instant.now().getEpochSecond();
}
/**
* Update the player's advancement data
*
* @param serializedAdvancements A serialized {@code String}; new advancement data
*/
public void setSerializedAdvancements(String serializedAdvancements) {
this.serializedAdvancements = serializedAdvancements;
this.timestamp = Instant.now().getEpochSecond();
}
/**
* Update the player's location data
*
* @param serializedLocation A serialized {@code String}; new location data
*/
public void setSerializedLocation(String serializedLocation) {
this.serializedLocation = serializedLocation;
this.timestamp = Instant.now().getEpochSecond();
}
}

View File

@@ -0,0 +1,10 @@
package net.william278.husksync;
import java.util.UUID;
/**
* A record representing a server synchronised on the network and whether it has MySqlPlayerDataBridge installed
*/
public record Server(UUID serverUUID, boolean hasMySqlPlayerDataBridge, String huskSyncVersion, String serverBrand,
String clusterId) {
}

View File

@@ -0,0 +1,99 @@
package net.william278.husksync;
import java.util.ArrayList;
/**
* Settings class, holds values loaded from the plugin config (either Bukkit or Bungee)
*/
public class Settings {
/*
* General settings
*/
// Whether to do automatic update checks on startup
public static boolean automaticUpdateChecks;
// The type of THIS server (Bungee or Bukkit)
public static ServerType serverType;
// Redis settings
public static String redisHost;
public static int redisPort;
public static String redisPassword;
public static boolean redisSSL;
/*
* Bungee / Proxy server-only settings
*/
// Messages language
public static String language;
// Cluster IDs
public static ArrayList<SynchronisationCluster> clusters = new ArrayList<>();
// SQL settings
public static DataStorageType dataStorageType;
// Bounce-back synchronisation (default)
public static boolean bounceBackSynchronisation;
// MySQL specific settings
public static String mySQLHost;
public static String mySQLDatabase;
public static String mySQLUsername;
public static String mySQLPassword;
public static int mySQLPort;
public static String mySQLParams;
// Hikari connection pooling settings
public static int hikariMaximumPoolSize;
public static int hikariMinimumIdle;
public static long hikariMaximumLifetime;
public static long hikariKeepAliveTime;
public static long hikariConnectionTimeOut;
/*
* Bukkit server-only settings
*/
// Synchronisation options
public static boolean syncInventories;
public static boolean syncEnderChests;
public static boolean syncHealth;
public static boolean syncHunger;
public static boolean syncExperience;
public static boolean syncPotionEffects;
public static boolean syncStatistics;
public static boolean syncGameMode;
public static boolean syncAdvancements;
public static boolean syncLocation;
public static boolean syncFlight;
public static long synchronizationTimeoutRetryDelay;
public static boolean useNativeImplementation;
// This Cluster ID
public static String cluster;
/*
* Enum definitions
*/
public enum ServerType {
BUKKIT,
PROXY,
}
public enum DataStorageType {
MYSQL,
SQLITE
}
/**
* Defines information for a synchronisation cluster as listed on the proxy
*/
public record SynchronisationCluster(String clusterId, String databaseName, String playerTableName, String dataTableName) {
}
}

View File

@@ -0,0 +1,312 @@
package net.william278.husksync.migrator;
import net.william278.husksync.PlayerData;
import net.william278.husksync.Server;
import net.william278.husksync.Settings;
import net.william278.husksync.proxy.data.DataManager;
import net.william278.husksync.proxy.data.sql.Database;
import net.william278.husksync.proxy.data.sql.MySQL;
import net.william278.husksync.redis.RedisListener;
import net.william278.husksync.redis.RedisMessage;
import net.william278.husksync.util.Logger;
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,
* by communicating with a Spigot server
*/
public class MPDBMigrator {
public int migratedDataSent = 0;
public int playersMigrated = 0;
public HashMap<PlayerData, String> incomingPlayerData;
public MigrationSettings migrationSettings = new MigrationSettings();
private Settings.SynchronisationCluster targetCluster;
private Database sourceDatabase;
private HashSet<MPDBPlayerData> mpdbPlayerData;
private final Logger logger;
public MPDBMigrator(Logger logger) {
this.logger = logger;
}
public boolean readyToMigrate(int networkPlayerCount, HashSet<Server> synchronisedServers) {
if (networkPlayerCount > 0) {
logger.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 false;
}
int synchronisedServersWithMpdb = 0;
for (Server server : synchronisedServers) {
if (server.hasMySqlPlayerDataBridge()) {
synchronisedServersWithMpdb++;
}
}
if (synchronisedServersWithMpdb < 1) {
logger.log(Level.WARNING, "Failed to start migration because at least one Spigot server with both HuskSync and MySqlPlayerDataBridge installed is not online. " +
"Please start one Spigot server with HuskSync installed to begin migration.");
return false;
}
for (Settings.SynchronisationCluster cluster : Settings.clusters) {
if (migrationSettings.targetCluster.equals(cluster.clusterId())) {
targetCluster = cluster;
break;
}
}
if (targetCluster == null) {
logger.log(Level.WARNING, "Failed to start migration because the target cluster could not be found. " +
"Please ensure the target cluster is correct, configured in the proxy config file, then try again");
return false;
}
migratedDataSent = 0;
playersMigrated = 0;
mpdbPlayerData = new HashSet<>();
incomingPlayerData = new HashMap<>();
final MigrationSettings settings = migrationSettings;
// Get connection to source database
sourceDatabase = new MigratorMySQL(logger, settings.sourceHost, settings.sourcePort,
settings.sourceDatabase, settings.sourceUsername, settings.sourcePassword, targetCluster);
sourceDatabase.load();
if (sourceDatabase.isInactive()) {
logger.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 false;
}
return true;
}
// Carry out the migration
public void executeMigrationOperations(DataManager dataManager, HashSet<Server> synchronisedServers, RedisListener redisListener) {
// Prepare the target database for insertion
prepareTargetDatabase(dataManager);
// Fetch inventory data from MPDB
getInventoryData();
// Fetch ender chest data from MPDB
getEnderChestData();
// Fetch experience data from MPDB
getExperienceData();
// Send the encoded data to the Bukkit servers for conversion
sendEncodedData(synchronisedServers, redisListener);
}
// Clear the new database out of current data
private void prepareTargetDatabase(DataManager dataManager) {
logger.log(Level.INFO, "Preparing target database...");
try (Connection connection = dataManager.getConnection(targetCluster.clusterId())) {
try (PreparedStatement statement = connection.prepareStatement("DELETE FROM " + targetCluster.playerTableName() + ";")) {
statement.executeUpdate();
}
try (PreparedStatement statement = connection.prepareStatement("DELETE FROM " + targetCluster.dataTableName() + ";")) {
statement.executeUpdate();
}
} catch (SQLException e) {
logger.log(Level.SEVERE, "An exception occurred preparing the target database", e);
} finally {
logger.log(Level.INFO, "Finished preparing target database!");
}
}
private void getInventoryData() {
logger.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) {
logger.log(Level.SEVERE, "An exception occurred getting inventory data", e);
} finally {
logger.log(Level.INFO, "Finished getting inventory data from MySQLPlayerDataBridge");
}
}
private void getEnderChestData() {
logger.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) {
logger.log(Level.SEVERE, "An exception occurred getting ender chest data", e);
} finally {
logger.log(Level.INFO, "Finished getting ender chest data from MySQLPlayerDataBridge");
}
}
private void getExperienceData() {
logger.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.getFloat("exp");
data.totalExperience = resultSet.getInt("total_exp");
break;
}
}
}
}
} catch (SQLException e) {
logger.log(Level.SEVERE, "An exception occurred getting experience data", e);
} finally {
logger.log(Level.INFO, "Finished getting experience data from MySQLPlayerDataBridge");
}
}
private void sendEncodedData(HashSet<Server> synchronisedServers, RedisListener redisListener) {
for (Server processingServer : synchronisedServers) {
if (processingServer.hasMySqlPlayerDataBridge()) {
for (MPDBPlayerData data : mpdbPlayerData) {
try {
new RedisMessage(RedisMessage.MessageType.DECODE_MPDB_DATA,
new RedisMessage.MessageTarget(Settings.ServerType.BUKKIT, null, null),
processingServer.serverUUID().toString(),
RedisMessage.serialize(data))
.send();
migratedDataSent++;
} catch (IOException e) {
logger.log(Level.SEVERE, "Failed to serialize encoded MPDB data", e);
}
}
logger.log(Level.INFO, "Finished dispatching encoded data for " + migratedDataSent + " players; please wait for conversion to finish");
}
return;
}
}
/**
* Loads all incoming decoded MPDB data to the cache and database
*
* @param dataToLoad HashMap of the {@link PlayerData} to player Usernames that will be loaded
*/
public void loadIncomingData(HashMap<PlayerData, String> dataToLoad, DataManager dataManager) {
int playersSaved = 0;
logger.log(Level.INFO, "Saving data for " + playersMigrated + " players...");
for (PlayerData playerData : dataToLoad.keySet()) {
String playerName = dataToLoad.get(playerData);
// Add the player to the MySQL table
dataManager.ensurePlayerExists(playerData.getPlayerUUID(), playerName);
// Update the data in the cache and SQL
for (Settings.SynchronisationCluster cluster : Settings.clusters) {
dataManager.updatePlayerData(playerData, cluster);
break;
}
playersSaved++;
logger.log(Level.INFO, "Saved data for " + playersSaved + "/" + playersMigrated + " players");
}
// Mark as done when done
logger.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(playersMigrated))
.replaceAll("%2%", Integer.toString(migratedDataSent)));
sourceDatabase.close(); // Close source database
}
/**
* 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 String targetCluster;
public MigrationSettings() {
sourceHost = "localhost";
sourcePort = 3306;
sourceDatabase = "mpdb";
sourceUsername = "root";
sourcePassword = "pa55w0rd";
targetCluster = "main";
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(Logger logger, String host, int port, String database, String username, String password, Settings.SynchronisationCluster cluster) {
super(cluster, logger);
super.host = host;
super.port = port;
super.database = database;
super.username = username;
super.password = password;
super.params = "?useSSL=false";
super.dataPoolName = super.dataPoolName + "Migrator";
}
}
}

View File

@@ -0,0 +1,35 @@
package net.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;
}
}

View File

@@ -0,0 +1,17 @@
package net.william278.husksync.proxy.command;
public interface HuskSyncCommand {
SubCommand[] SUB_COMMANDS = {new SubCommand("about", null),
new SubCommand("status", "husksync.command.admin"),
new SubCommand("reload", "husksync.command.admin"),
new SubCommand("update", "husksync.command.admin"),
new SubCommand("invsee", "husksync.command.inventory"),
new SubCommand("echest", "husksync.command.ender_chest")};
/**
* A sub command, that may require a permission
*/
record SubCommand(String command, String permission) { }
}

View File

@@ -0,0 +1,372 @@
package net.william278.husksync.proxy.data;
import net.william278.husksync.PlayerData;
import net.william278.husksync.Settings;
import net.william278.husksync.proxy.data.sql.Database;
import net.william278.husksync.proxy.data.sql.MySQL;
import net.william278.husksync.proxy.data.sql.SQLite;
import net.william278.husksync.util.Logger;
import java.io.File;
import java.sql.*;
import java.util.*;
import java.util.logging.Level;
public class DataManager {
/**
* The player data cache for each cluster ID
*/
public HashMap<Settings.SynchronisationCluster, PlayerDataCache> playerDataCache = new HashMap<>();
/**
* Map of the database assigned for each cluster
*/
private final HashMap<String, Database> clusterDatabases;
// Retrieve database connection for a cluster
public Connection getConnection(String clusterId) throws SQLException {
return clusterDatabases.get(clusterId).getConnection();
}
// Console logger for errors
private final Logger logger;
// Plugin data folder
private final File dataFolder;
// Flag variable identifying if the data manager failed to initialize
public boolean hasFailedInitialization = false;
public DataManager(Logger logger, File dataFolder) {
this.logger = logger;
this.dataFolder = dataFolder;
clusterDatabases = new HashMap<>();
initializeDatabases();
}
private void initializeDatabases() {
for (Settings.SynchronisationCluster cluster : Settings.clusters) {
Database clusterDatabase = switch (Settings.dataStorageType) {
case SQLITE -> new SQLite(cluster, dataFolder, logger);
case MYSQL -> new MySQL(cluster, logger);
};
clusterDatabase.load();
clusterDatabase.createTables();
clusterDatabases.put(cluster.clusterId(), clusterDatabase);
}
// Abort loading if the database failed to initialize
for (Database database : clusterDatabases.values()) {
if (database.isInactive()) {
hasFailedInitialization = true;
return;
}
}
}
/**
* Close the database connections
*/
public void closeDatabases() {
for (Database database : clusterDatabases.values()) {
database.close();
}
}
/**
* 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 void ensurePlayerExists(UUID playerUUID, String playerName) {
for (Settings.SynchronisationCluster cluster : Settings.clusters) {
if (!playerExists(playerUUID, cluster)) {
createPlayerEntry(playerUUID, playerName, cluster);
} else {
updatePlayerName(playerUUID, playerName, cluster);
}
}
}
/**
* Returns whether the player is registered in SQL (an entry in the PLAYER_TABLE)
*
* @param playerUUID The UUID of the player
* @return {@code true} if the player is on the player table
*/
private boolean playerExists(UUID playerUUID, Settings.SynchronisationCluster cluster) {
try (Connection connection = getConnection(cluster.clusterId())) {
try (PreparedStatement statement = connection.prepareStatement(
"SELECT * FROM " + cluster.playerTableName() + " WHERE `uuid`=?;")) {
statement.setString(1, playerUUID.toString());
ResultSet resultSet = statement.executeQuery();
return resultSet.next();
}
} catch (SQLException e) {
logger.log(Level.SEVERE, "An SQL exception occurred", e);
return false;
}
}
private void createPlayerEntry(UUID playerUUID, String playerName, Settings.SynchronisationCluster cluster) {
try (Connection connection = getConnection(cluster.clusterId())) {
try (PreparedStatement statement = connection.prepareStatement(
"INSERT INTO " + cluster.playerTableName() + " (`uuid`,`username`) VALUES(?,?);")) {
statement.setString(1, playerUUID.toString());
statement.setString(2, playerName);
statement.executeUpdate();
}
} catch (SQLException e) {
logger.log(Level.SEVERE, "An SQL exception occurred", e);
}
}
public void updatePlayerName(UUID playerUUID, String playerName, Settings.SynchronisationCluster cluster) {
try (Connection connection = getConnection(cluster.clusterId())) {
try (PreparedStatement statement = connection.prepareStatement(
"UPDATE " + cluster.playerTableName() + " SET `username`=? WHERE `uuid`=?;")) {
statement.setString(1, playerName);
statement.setString(2, playerUUID.toString());
statement.executeUpdate();
}
} catch (SQLException e) {
logger.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 PlayerData getPlayerDataByName(String playerName, String clusterId) {
PlayerData playerData = null;
for (Settings.SynchronisationCluster cluster : Settings.clusters) {
if (cluster.clusterId().equals(clusterId)) {
try (Connection connection = getConnection(clusterId)) {
try (PreparedStatement statement = connection.prepareStatement(
"SELECT * FROM " + cluster.playerTableName() + " 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.get(cluster).getPlayer(uuid);
if (playerData == null) {
playerData = Objects.requireNonNull(getPlayerData(uuid)).get(cluster);
}
break;
}
}
} catch (SQLException e) {
logger.log(Level.SEVERE, "An SQL exception occurred", e);
}
break;
}
}
return playerData;
}
public Map<Settings.SynchronisationCluster, PlayerData> getPlayerData(UUID playerUUID) {
HashMap<Settings.SynchronisationCluster, PlayerData> data = new HashMap<>();
for (Settings.SynchronisationCluster cluster : Settings.clusters) {
try (Connection connection = getConnection(cluster.clusterId())) {
try (PreparedStatement statement = connection.prepareStatement(
"SELECT * FROM " + cluster.dataTableName() + " WHERE `player_id`=(SELECT `id` FROM " + cluster.playerTableName() + " WHERE `uuid`=?);")) {
statement.setString(1, playerUUID.toString());
ResultSet resultSet = statement.executeQuery();
if (resultSet.next()) {
final UUID dataVersionUUID = UUID.fromString(resultSet.getString("version_uuid"));
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");
final int selectedSlot = resultSet.getInt("selected_slot");
final String serializedStatusEffects = resultSet.getString("status_effects");
final int totalExperience = resultSet.getInt("total_experience");
final int expLevel = resultSet.getInt("exp_level");
final float expProgress = resultSet.getFloat("exp_progress");
final String gameMode = resultSet.getString("game_mode");
final boolean isFlying = resultSet.getBoolean("is_flying");
final String serializedAdvancementData = resultSet.getString("advancements");
final String serializedLocationData = resultSet.getString("location");
final String serializedStatisticData = resultSet.getString("statistics");
data.put(cluster, new PlayerData(playerUUID, dataVersionUUID, dataSaveTimestamp.toInstant().getEpochSecond(),
serializedInventory, serializedEnderChest, health, maxHealth, healthScale, hunger, saturation,
saturationExhaustion, selectedSlot, serializedStatusEffects, totalExperience, expLevel, expProgress,
gameMode, serializedStatisticData, isFlying, serializedAdvancementData, serializedLocationData));
} else {
data.put(cluster, PlayerData.DEFAULT_PLAYER_DATA(playerUUID));
}
}
} catch (SQLException e) {
logger.log(Level.SEVERE, "An SQL exception occurred", e);
return null;
}
}
return data;
}
public void updatePlayerData(PlayerData playerData, Settings.SynchronisationCluster cluster) {
// Ignore if the Spigot server didn't properly sync the previous data
// Add the new player data to the cache
playerDataCache.get(cluster).updatePlayer(playerData);
// SQL: If the player has cached data, update it, otherwise insert new data.
if (playerHasCachedData(playerData.getPlayerUUID(), cluster)) {
updatePlayerSQLData(playerData, cluster);
} else {
insertPlayerData(playerData, cluster);
}
}
private void updatePlayerSQLData(PlayerData playerData, Settings.SynchronisationCluster cluster) {
try (Connection connection = getConnection(cluster.clusterId())) {
try (PreparedStatement statement = connection.prepareStatement(
"UPDATE " + cluster.dataTableName() + " 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 " + cluster.playerTableName() + " WHERE `uuid`=?);")) {
statement.setString(1, playerData.getDataVersionUUID().toString());
statement.setTimestamp(2, new Timestamp(System.currentTimeMillis()));
statement.setString(3, playerData.getSerializedInventory());
statement.setString(4, playerData.getSerializedEnderChest());
statement.setDouble(5, playerData.getHealth()); // Health
statement.setDouble(6, playerData.getMaxHealth()); // Max health
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) {
logger.log(Level.SEVERE, "An SQL exception occurred", e);
}
}
private void insertPlayerData(PlayerData playerData, Settings.SynchronisationCluster cluster) {
try (Connection connection = getConnection(cluster.clusterId())) {
try (PreparedStatement statement = connection.prepareStatement(
"INSERT INTO " + cluster.dataTableName() + " (`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 " + cluster.playerTableName() + " WHERE `uuid`=?),?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?);")) {
statement.setString(1, playerData.getPlayerUUID().toString());
statement.setString(2, playerData.getDataVersionUUID().toString());
statement.setTimestamp(3, new Timestamp(System.currentTimeMillis()));
statement.setString(4, playerData.getSerializedInventory());
statement.setString(5, playerData.getSerializedEnderChest());
statement.setDouble(6, playerData.getHealth()); // Health
statement.setDouble(7, playerData.getMaxHealth()); // Max health
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();
}
} catch (SQLException e) {
logger.log(Level.SEVERE, "An SQL exception occurred", e);
}
}
/**
* Returns whether the player has cached data saved in SQL (an entry in the DATA_TABLE)
*
* @param playerUUID The UUID of the player
* @return {@code true} if the player has an entry in the data table
*/
private boolean playerHasCachedData(UUID playerUUID, Settings.SynchronisationCluster cluster) {
try (Connection connection = getConnection(cluster.clusterId())) {
try (PreparedStatement statement = connection.prepareStatement(
"SELECT * FROM " + cluster.dataTableName() + " WHERE `player_id`=(SELECT `id` FROM " + cluster.playerTableName() + " WHERE `uuid`=?);")) {
statement.setString(1, playerUUID.toString());
ResultSet resultSet = statement.executeQuery();
return resultSet.next();
}
} catch (SQLException e) {
logger.log(Level.SEVERE, "An SQL exception occurred", e);
return false;
}
}
/**
* A cache of PlayerData
*/
public static class PlayerDataCache {
// The cached player data
public HashSet<PlayerData> playerData;
public PlayerDataCache() {
playerData = new HashSet<>();
}
/**
* Update ar add data for a player to the cache
*
* @param newData The player's new/updated {@link PlayerData}
*/
public void updatePlayer(PlayerData newData) {
// Remove the old data if it exists
PlayerData oldData = null;
for (PlayerData data : playerData) {
if (data.getPlayerUUID().equals(newData.getPlayerUUID())) {
oldData = data;
break;
}
}
if (oldData != null) {
playerData.remove(oldData);
}
// Add the new data
playerData.add(newData);
}
/**
* Get a player's {@link PlayerData} by their {@link UUID}
*
* @param playerUUID The {@link UUID} of the player to check
* @return The player's {@link PlayerData}
*/
public PlayerData getPlayer(UUID playerUUID) {
for (PlayerData data : playerData) {
if (data.getPlayerUUID().equals(playerUUID)) {
return data;
}
}
return null;
}
}
}

View File

@@ -0,0 +1,42 @@
package net.william278.husksync.proxy.data.sql;
import net.william278.husksync.Settings;
import net.william278.husksync.util.Logger;
import java.sql.Connection;
import java.sql.SQLException;
public abstract class Database {
public String dataPoolName;
public Settings.SynchronisationCluster cluster;
public final Logger logger;
public Database(Settings.SynchronisationCluster cluster, Logger logger) {
this.cluster = cluster;
this.dataPoolName = cluster != null ? "HuskSyncHikariPool-" + cluster.clusterId() : "HuskSyncMigratorPool";
this.logger = logger;
}
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 createTables();
public abstract void close();
public final int hikariMaximumPoolSize = Settings.hikariMaximumPoolSize;
public final int hikariMinimumIdle = Settings.hikariMinimumIdle;
public final long hikariMaximumLifetime = Settings.hikariMaximumLifetime;
public final long hikariKeepAliveTime = Settings.hikariKeepAliveTime;
public final long hikariConnectionTimeOut = Settings.hikariConnectionTimeOut;
}

View File

@@ -0,0 +1,113 @@
package net.william278.husksync.proxy.data.sql;
import com.zaxxer.hikari.HikariDataSource;
import net.william278.husksync.Settings;
import net.william278.husksync.util.Logger;
import java.sql.Connection;
import java.sql.SQLException;
import java.sql.Statement;
import java.util.logging.Level;
public class MySQL extends Database {
final String[] SQL_SETUP_STATEMENTS = {
"CREATE TABLE IF NOT EXISTS " + cluster.playerTableName() + " (" +
"`id` integer NOT NULL AUTO_INCREMENT," +
"`uuid` char(36) NOT NULL UNIQUE," +
"`username` varchar(16) NOT NULL," +
"PRIMARY KEY (`id`)" +
");",
"CREATE TABLE IF NOT EXISTS " + cluster.dataTableName() + " (" +
"`player_id` integer NOT NULL," +
"`version_uuid` char(36) NOT NULL UNIQUE," +
"`timestamp` datetime NOT NULL," +
"`inventory` longtext NOT NULL," +
"`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," +
"`selected_slot` integer NOT NULL," +
"`status_effects` longtext NOT NULL," +
"`total_experience` integer NOT NULL," +
"`exp_level` integer NOT NULL," +
"`exp_progress` float NOT NULL," +
"`game_mode` tinytext NOT NULL," +
"`statistics` longtext NOT NULL," +
"`is_flying` boolean NOT NULL," +
"`advancements` longtext NOT NULL," +
"`location` text NOT NULL," +
"PRIMARY KEY (`player_id`,`version_uuid`)," +
"FOREIGN KEY (`player_id`) REFERENCES " + cluster.playerTableName() + " (`id`)" +
");"
};
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;
private HikariDataSource dataSource;
public MySQL(Settings.SynchronisationCluster cluster, Logger logger) {
super(cluster, logger);
}
@Override
public Connection getConnection() throws SQLException {
return dataSource.getConnection();
}
@Override
public void load() {
// Create new HikariCP data source
final String jdbcUrl = "jdbc:mysql://" + host + ":" + port + "/" + database + params;
dataSource = new HikariDataSource();
dataSource.setJdbcUrl(jdbcUrl);
dataSource.setUsername(username);
dataSource.setPassword(password);
// Set data source driver path
dataSource.setDriverClassName("com.mysql.cj.jdbc.Driver");
// Set various additional parameters
dataSource.setMaximumPoolSize(hikariMaximumPoolSize);
dataSource.setMinimumIdle(hikariMinimumIdle);
dataSource.setMaxLifetime(hikariMaximumLifetime);
dataSource.setKeepaliveTime(hikariKeepAliveTime);
dataSource.setConnectionTimeout(hikariConnectionTimeOut);
dataSource.setPoolName(dataPoolName);
}
@Override
public void createTables() {
// Create tables
try (Connection connection = dataSource.getConnection()) {
try (Statement statement = connection.createStatement()) {
for (String tableCreationStatement : SQL_SETUP_STATEMENTS) {
statement.execute(tableCreationStatement);
}
}
} catch (SQLException e) {
logger.log(Level.SEVERE, "An error occurred creating tables on the MySQL database: ", e);
}
}
@Override
public void close() {
if (dataSource != null) {
dataSource.close();
}
}
}

View File

@@ -0,0 +1,126 @@
package net.william278.husksync.proxy.data.sql;
import com.zaxxer.hikari.HikariDataSource;
import net.william278.husksync.Settings;
import net.william278.husksync.util.Logger;
import java.io.File;
import java.io.IOException;
import java.sql.Connection;
import java.sql.SQLException;
import java.sql.Statement;
import java.util.logging.Level;
public class SQLite extends Database {
final String[] SQL_SETUP_STATEMENTS = {
"PRAGMA foreign_keys = ON;",
"PRAGMA encoding = 'UTF-8';",
"CREATE TABLE IF NOT EXISTS " + cluster.playerTableName() + " (" +
"`id` integer PRIMARY KEY," +
"`uuid` char(36) NOT NULL UNIQUE," +
"`username` varchar(16) NOT NULL" +
");",
"CREATE TABLE IF NOT EXISTS " + cluster.dataTableName() + " (" +
"`player_id` integer NOT NULL REFERENCES " + cluster.playerTableName() + "(`id`)," +
"`version_uuid` char(36) NOT NULL UNIQUE," +
"`timestamp` datetime NOT NULL," +
"`inventory` longtext NOT NULL," +
"`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," +
"`selected_slot` integer NOT NULL," +
"`status_effects` longtext NOT NULL," +
"`total_experience` integer NOT NULL," +
"`exp_level` integer NOT NULL," +
"`exp_progress` float NOT NULL," +
"`game_mode` tinytext NOT NULL," +
"`statistics` longtext NOT NULL," +
"`is_flying` boolean NOT NULL," +
"`advancements` longtext NOT NULL," +
"`location` text NOT NULL," +
"PRIMARY KEY (`player_id`,`version_uuid`)" +
");"
};
private String getDatabaseName() {
return cluster.databaseName() + "Data";
}
private final File dataFolder;
private HikariDataSource dataSource;
public SQLite(Settings.SynchronisationCluster cluster, File dataFolder, Logger logger) {
super(cluster, logger);
this.dataFolder = dataFolder;
}
// Create the database file if it does not exist yet
private void createDatabaseFileIfNotExist() {
File databaseFile = new File(dataFolder, getDatabaseName() + ".db");
if (!databaseFile.exists()) {
try {
if (!databaseFile.createNewFile()) {
logger.log(Level.SEVERE, "Failed to write new file: " + getDatabaseName() + ".db (file already exists)");
}
} catch (IOException e) {
logger.log(Level.SEVERE, "An error occurred writing a file: " + getDatabaseName() + ".db (" + e.getCause() + ")", e);
}
}
}
@Override
public Connection getConnection() throws SQLException {
return dataSource.getConnection();
}
@Override
public void load() {
// Make SQLite database file
createDatabaseFileIfNotExist();
// Create new HikariCP data source
final String jdbcUrl = "jdbc:sqlite:" + dataFolder.getAbsolutePath() + File.separator + getDatabaseName() + ".db";
dataSource = new HikariDataSource();
dataSource.setDataSourceClassName("org.sqlite.SQLiteDataSource");
dataSource.addDataSourceProperty("url", jdbcUrl);
// Set various additional parameters
dataSource.setMaximumPoolSize(hikariMaximumPoolSize);
dataSource.setMinimumIdle(hikariMinimumIdle);
dataSource.setMaxLifetime(hikariMaximumLifetime);
dataSource.setKeepaliveTime(hikariKeepAliveTime);
dataSource.setConnectionTimeout(hikariConnectionTimeOut);
dataSource.setPoolName(dataPoolName);
}
@Override
public void createTables() {
// Create tables
try (Connection connection = dataSource.getConnection()) {
try (Statement statement = connection.createStatement()) {
for (String tableCreationStatement : SQL_SETUP_STATEMENTS) {
statement.execute(tableCreationStatement);
}
}
} catch (SQLException e) {
logger.log(Level.SEVERE, "An error occurred creating tables on the SQLite database", e);
}
}
@Override
public void close() {
if (dataSource != null) {
dataSource.close();
}
}
}

View File

@@ -0,0 +1,126 @@
package net.william278.husksync.redis;
import net.william278.husksync.Settings;
import redis.clients.jedis.*;
import redis.clients.jedis.exceptions.JedisConnectionException;
import redis.clients.jedis.exceptions.JedisException;
import java.io.IOException;
import java.util.logging.Level;
public abstract class RedisListener {
/**
* Determines if the RedisListener is working properly
*/
public boolean isActiveAndEnabled;
/**
* Pool of connections to the Redis server
*/
private static JedisPool jedisPool;
/**
* Creates a new RedisListener and initialises the Redis connection
*/
public RedisListener() {
JedisPoolConfig config = new JedisPoolConfig();
config.setMaxIdle(0);
config.setTestOnBorrow(true);
config.setTestOnReturn(true);
if (Settings.redisPassword.isEmpty()) {
jedisPool = new JedisPool(config,
Settings.redisHost,
Settings.redisPort,
0,
Settings.redisSSL);
} else {
jedisPool = new JedisPool(config,
Settings.redisHost,
Settings.redisPort,
0,
Settings.redisPassword,
Settings.redisSSL);
}
}
/**
* Handle an incoming {@link RedisMessage}
*
* @param message The {@link RedisMessage} to handle
*/
public abstract void handleMessage(RedisMessage message);
/**
* Log to console
*
* @param level The {@link Level} to log
* @param message Message to log
*/
public abstract void log(Level level, String message);
/**
* Fetch a connection to the Redis server from the JedisPool
*
* @return Jedis instance from the pool
*/
public static Jedis getJedisConnection() {
return jedisPool.getResource();
}
/**
* Start the Redis listener
*/
public final void listen() {
new Thread(() -> {
isActiveAndEnabled = true;
while (isActiveAndEnabled) {
Jedis subscriber;
if (Settings.redisPassword.isEmpty()) {
subscriber = new Jedis(Settings.redisHost,
Settings.redisPort,
0);
} else {
final JedisClientConfig config = DefaultJedisClientConfig.builder()
.password(Settings.redisPassword)
.timeoutMillis(0).build();
subscriber = new Jedis(Settings.redisHost,
Settings.redisPort,
config);
}
subscriber.connect();
log(Level.INFO, "Enabled Redis listener successfully!");
try {
subscriber.subscribe(new JedisPubSub() {
@Override
public void onMessage(String channel, String message) {
// Only accept messages to the HuskSync channel
if (!channel.equals(RedisMessage.REDIS_CHANNEL)) {
return;
}
// Handle the message
try {
handleMessage(new RedisMessage(message));
} catch (IOException | ClassNotFoundException e) {
log(Level.SEVERE, "Failed to deserialize message target");
}
}
}, RedisMessage.REDIS_CHANNEL);
} catch (JedisConnectionException connectionException) {
log(Level.SEVERE, "A connection exception occurred with the Jedis listener");
connectionException.printStackTrace();
} catch (JedisException jedisException) {
isActiveAndEnabled = false;
log(Level.SEVERE, "An exception occurred with the Jedis listener");
jedisException.printStackTrace();
} finally {
subscriber.close();
}
}
}, "Redis Subscriber").start();
}
}

View File

@@ -0,0 +1,200 @@
package net.william278.husksync.redis;
import net.william278.husksync.PlayerData;
import net.william278.husksync.Settings;
import redis.clients.jedis.Jedis;
import java.io.*;
import java.util.Base64;
import java.util.StringJoiner;
import java.util.UUID;
public class RedisMessage {
public static String REDIS_CHANNEL = "HuskSync";
public static String MESSAGE_META_SEPARATOR = "";
public static String MESSAGE_DATA_SEPARATOR = "";
private final String messageData;
private final MessageType messageType;
private final MessageTarget messageTarget;
/**
* Create a new RedisMessage
*
* @param type The type of the message
* @param target Who will receive this message
* @param messageData The message data elements
*/
public RedisMessage(MessageType type, MessageTarget target, String... messageData) {
final StringJoiner messageDataJoiner = new StringJoiner(MESSAGE_DATA_SEPARATOR);
for (String dataElement : messageData) {
messageDataJoiner.add(dataElement);
}
this.messageData = messageDataJoiner.toString();
this.messageType = type;
this.messageTarget = target;
}
/**
* Get a new RedisMessage from an incoming message string
*
* @param messageString The message string to parse
*/
public RedisMessage(String messageString) throws IOException, ClassNotFoundException {
String[] messageMetaElements = messageString.split(MESSAGE_META_SEPARATOR);
messageType = MessageType.valueOf(messageMetaElements[0]);
messageTarget = (MessageTarget) RedisMessage.deserialize(messageMetaElements[1]);
messageData = messageMetaElements[2];
}
/**
* Returns the full, formatted message string with type, target & data
*
* @return The fully formatted message
*/
private String getFullMessage() throws IOException {
return new StringJoiner(MESSAGE_META_SEPARATOR)
.add(messageType.toString()).add(RedisMessage.serialize(messageTarget)).add(messageData)
.toString();
}
/**
* Send the redis message
*/
public void send() throws IOException {
try (Jedis publisher = RedisListener.getJedisConnection()) {
publisher.publish(REDIS_CHANNEL, getFullMessage());
}
}
public String getMessageData() {
return messageData;
}
public String[] getMessageDataElements() {
return messageData.split(MESSAGE_DATA_SEPARATOR);
}
public MessageType getMessageType() {
return messageType;
}
public MessageTarget getMessageTarget() {
return messageTarget;
}
/**
* Defines the type of the message
*/
public enum MessageType implements Serializable {
/**
* Sent by Bukkit servers to proxy when a user disconnects with that player's updated {@link PlayerData}.
*/
PLAYER_DATA_UPDATE,
/**
* Sent by Bukkit servers to proxy to request {@link PlayerData} from the proxy if they are set as needing to request data on join.
*/
PLAYER_DATA_REQUEST,
/**
* Sent by the Proxy to reply to a {@code MessageType.PLAYER_DATA_REQUEST}, contains the latest {@link PlayerData} for the requester.
*/
PLAYER_DATA_SET,
/**
* Sent by Bukkit servers to proxy to request {@link PlayerData} from the proxy via the API.
*/
API_DATA_REQUEST,
/**
* Sent by the Proxy to fulfill an {@code MessageType.API_DATA_REQUEST}, containing the latest {@link PlayerData} for the requested UUID.
*/
API_DATA_RETURN,
/**
* Sent by the Proxy to cancel an {@code MessageType.API_DATA_REQUEST} if no data can be returned.
*/
API_DATA_CANCEL,
/**
* Sent by the proxy to a Bukkit server to have them request data on join; contains no data otherwise.
*/
REQUEST_DATA_ON_JOIN,
/**
* 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,
/**
* 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 {
ADD_REQUESTER,
REMOVE_REQUESTER
}
/**
* A record that defines the target of a plugin message; a spigot server or the proxy server(s). For Bukkit servers, the name of the server must also be specified
*/
public record MessageTarget(Settings.ServerType targetServerType, UUID targetPlayerUUID,
String targetClusterId) implements Serializable {
}
/**
* Deserialize an object from a Base64 string
*/
public static Object deserialize(String s) throws IOException, ClassNotFoundException {
byte[] data = Base64.getDecoder().decode(s);
try (ObjectInputStream objectInputStream = new ObjectInputStream(new ByteArrayInputStream(data))) {
return objectInputStream.readObject();
}
}
/**
* Serialize an object to a Base64 string
*/
public static String serialize(Serializable o) throws IOException {
ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
try (ObjectOutputStream objectOutputStream = new ObjectOutputStream(byteArrayOutputStream)) {
objectOutputStream.writeObject(o);
}
return Base64.getEncoder().encodeToString(byteArrayOutputStream.toByteArray());
}
}

View File

@@ -0,0 +1,19 @@
package net.william278.husksync.util;
import java.util.logging.Level;
/**
* Logger interface to allow for implementation of different logger platforms used by Bungee and Velocity
*/
public interface Logger {
void log(Level level, String message, Exception e);
void log(Level level, String message);
void info(String message);
void severe(String message);
void config(String message);
}

View File

@@ -0,0 +1,30 @@
package net.william278.husksync.util;
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 visit website open_url=https://william278.net)\n")
.append("[• Contributors:](white) [HarvelsX](gray show_text=&7Code)\n")
.append("[• Translators:](white) [Namiu/うにたろう](gray show_text=&7Japanese, ja-jp), [anchelthe](gray show_text=&7Spanish, es-es), [Ceddix](gray show_text=&7German, de-de), [小蔡](gray show_text=&7Traditional Chinese, zh-tw), [Ghost-chu](gray show_text=&7Simplified Chinese, zh-cn), [Thourgard](gray show_text=&7Ukrainian, uk-ua)\n")
.append("[• Plugin Info:](white) [[Link]](#00fb9a show_text=&7Click to open link open_url=https://github.com/WiIIiam278/HuskSync/)\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)\n")
.append("[• Cached player data:](white) [%2%](#00fb9a)");
}

View File

@@ -0,0 +1,13 @@
package net.william278.husksync.util;
public interface ThrowSupplier<T> {
T get() throws Exception;
static <A> A get(ThrowSupplier<A> supplier) {
try {
return supplier.get();
} catch (Exception e) {
throw new RuntimeException(e.getMessage(), e);
}
}
}

View File

@@ -0,0 +1,53 @@
package net.william278.husksync.util;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.net.URL;
import java.net.URLConnection;
import java.util.logging.Level;
public abstract class UpdateChecker {
private final static int SPIGOT_PROJECT_ID = 97144;
private final VersionUtils.Version currentVersion;
private VersionUtils.Version latestVersion;
public UpdateChecker(String currentVersion) {
this.currentVersion = VersionUtils.Version.of(currentVersion);
try {
final URL url = new URL("https://api.spigotmc.org/legacy/update.php?resource=" + SPIGOT_PROJECT_ID);
URLConnection urlConnection = url.openConnection();
this.latestVersion = VersionUtils.Version.of(new BufferedReader(new InputStreamReader(urlConnection.getInputStream())).readLine());
} catch (IOException e) {
log(Level.WARNING, "Failed to check for updates: An IOException occurred.");
this.latestVersion = new VersionUtils.Version();
} catch (Exception e) {
log(Level.WARNING, "Failed to check for updates: An exception occurred.");
this.latestVersion = new VersionUtils.Version();
}
}
public boolean isUpToDate() {
return this.currentVersion.compareTo(latestVersion) >= 0;
}
public String getLatestVersion() {
return latestVersion.toString();
}
public String getCurrentVersion() {
return currentVersion.toString();
}
public abstract void log(Level level, String message);
public void logToConsole() {
if (!isUpToDate()) {
log(Level.WARNING, "A new version of HuskSync is available: Version "
+ latestVersion + " (Currently running: " + currentVersion + ")");
}
}
}

View File

@@ -0,0 +1,61 @@
package net.william278.husksync.util;
import java.util.Arrays;
public class VersionUtils {
private final static char META_SEPARATOR = '+';
private final static String VERSION_SEPARATOR = "\\.";
public static class Version implements Comparable<Version> {
public int[] versions = new int[]{};
public String metadata = "";
public Version() {
}
public Version(String version) {
this.parse(version);
}
public static Version of(String version) {
return new Version(version);
}
private void parse(String version) {
int metaIndex = version.indexOf(META_SEPARATOR);
if (metaIndex > 0) {
this.metadata = version.substring(metaIndex + 1);
version = version.substring(0, metaIndex);
}
String[] versions = version.split(VERSION_SEPARATOR);
this.versions = Arrays.stream(versions).mapToInt(Integer::parseInt).toArray();
}
@Override
public int compareTo(Version version) {
int length = Math.max(this.versions.length, version.versions.length);
for (int i = 0; i < length; i++) {
int a = i < this.versions.length ? this.versions[i] : 0;
int b = i < version.versions.length ? version.versions[i] : 0;
if (a < b) return -1;
if (a > b) return 1;
}
return 0;
}
@Override
public String toString() {
StringBuilder stringBuffer = new StringBuilder();
for (int version : this.versions) {
stringBuffer.append(version).append('.');
}
stringBuffer.deleteCharAt(stringBuffer.length() - 1);
return stringBuffer.append('+').append(this.metadata).toString();
}
}
}