Start 2.0 rewrite

Use redis key caching, remove need for proxy plugin
Make platform independent to allow porting to other platforms
feat/data-edit-commands
William 2 years ago
parent 633847a254
commit 9471e0cbff

@ -1,81 +0,0 @@
package net.william278.husksync.bukkit.api;
import net.william278.husksync.PlayerData;
import net.william278.husksync.Settings;
import net.william278.husksync.bukkit.listener.BukkitRedisListener;
import net.william278.husksync.redis.RedisMessage;
import java.io.IOException;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
/**
* HuskSync's API. To access methods, use the {@link #getInstance()} entrypoint.
*
* @author William
*/
public class HuskSyncAPI {
private HuskSyncAPI() {
}
private static HuskSyncAPI instance;
/**
* The API entry point. Returns an instance of the {@link HuskSyncAPI}
*
* @return instance of the {@link HuskSyncAPI}
*/
public static HuskSyncAPI getInstance() {
if (instance == null) {
instance = new HuskSyncAPI();
}
return instance;
}
/**
* Returns a {@link CompletableFuture} that will fetch the {@link PlayerData} for a user given their {@link UUID},
* which contains serialized synchronised data.
* <p>
* This can then be deserialized into ItemStacks and other usable values using the {@code DataSerializer} class.
* <p>
* If no data could be returned, such as if an invalid UUID is specified, the CompletableFuture will be cancelled.
*
* @param playerUUID The {@link UUID} of the player to get data for
* @return a {@link CompletableFuture} with the user's {@link PlayerData} accessible on completion
* @throws IOException If an exception occurs with serializing during processing of the request
* @apiNote This only returns the latest saved and cached data of the user. This is <b>not</b> necessarily the current state of their inventory if they are online.
*/
public CompletableFuture<PlayerData> getPlayerData(UUID playerUUID) throws IOException {
// Create the request to be completed
final UUID requestUUID = UUID.randomUUID();
BukkitRedisListener.apiRequests.put(requestUUID, new CompletableFuture<>());
// Remove the request from the map on completion
BukkitRedisListener.apiRequests.get(requestUUID).whenComplete((playerData, throwable) -> BukkitRedisListener.apiRequests.remove(requestUUID));
// Request the data via the proxy
new RedisMessage(RedisMessage.MessageType.API_DATA_REQUEST,
new RedisMessage.MessageTarget(Settings.ServerType.PROXY, null, Settings.cluster),
playerUUID.toString(), requestUUID.toString()).send();
return BukkitRedisListener.apiRequests.get(requestUUID);
}
/**
* Updates a player's {@link PlayerData} to the proxy cache and database.
* <p>
* If the player is online on the Proxy network, they will be updated and overwritten with this data.
*
* @param playerData The {@link PlayerData} (which contains the {@link UUID}) of the player data to update to the central cache and database
* @throws IOException If an exception occurs with serializing during processing of the update
*/
public void updatePlayerData(PlayerData playerData) throws IOException {
// Serialize and send the updated player data
final String serializedPlayerData = RedisMessage.serialize(playerData);
new RedisMessage(RedisMessage.MessageType.PLAYER_DATA_UPDATE,
new RedisMessage.MessageTarget(Settings.ServerType.PROXY, null, Settings.cluster),
serializedPlayerData, Boolean.toString(true)).send();
}
}

@ -9,6 +9,8 @@ version "$ext.plugin_version+${versionMetadata()}"
ext {
set 'version', version.toString()
set 'jedis_version', jedis_version.toString()
set 'sqlite_driver_version', sqlite_driver_version.toString()
}
import org.apache.tools.ant.filters.ReplaceTokens
@ -27,9 +29,7 @@ allprojects {
mavenLocal()
mavenCentral()
maven { url 'https://hub.spigotmc.org/nexus/content/repositories/snapshots/' }
maven { url 'https://repo.velocitypowered.com/snapshots/' }
maven { url 'https://repo.minebench.de/' }
maven { url 'https://repo.codemc.org/repository/maven-public' }
maven { url 'https://repo.alessiodp.com/releases/' }
maven { url 'https://jitpack.io' }
}
@ -51,7 +51,7 @@ subprojects {
version rootProject.version
archivesBaseName = "${rootProject.name}-${project.name.capitalize()}"
if (['bukkit', 'api', 'bungeecord', 'velocity', 'plugin'].contains(project.name)) {
if (['bukkit', 'api', 'plugin'].contains(project.name)) {
shadowJar {
destinationDirectory.set(file("$rootDir/target"))
archiveClassifier.set('')

@ -2,7 +2,6 @@ dependencies {
implementation project(path: ':common')
implementation 'org.bstats:bstats-bukkit:3.0.0'
implementation 'de.themoep:minedown:1.7.1-SNAPSHOT'
implementation 'net.william278:mpdbdataconverter:1.0'
compileOnly 'org.spigotmc:spigot-api:1.16.5-R0.1-SNAPSHOT'
@ -10,9 +9,13 @@ dependencies {
}
shadowJar {
relocate 'de.themoep', 'net.william278.husksync.libraries'
relocate 'org.bstats', 'net.william278.husksync.libraries.bstats'
relocate 'redis.clients', 'net.william278.husksync.libraries'
relocate 'org.apache', 'net.william278.husksync.libraries'
relocate 'net.william278.mpdbconverter', 'net.william278.husksync.libraries.mpdbconverter'
relocate 'org.apache', 'net.william278.huskhomes.libraries'
relocate 'dev.dejvokep', 'net.william278.huskhomes.libraries'
relocate 'de.themoep', 'net.william278.huskhomes.libraries'
relocate 'org.jetbrains', 'net.william278.huskhomes.libraries'
relocate 'org.intellij', 'net.william278.huskhomes.libraries'
relocate 'com.zaxxer', 'net.william278.huskhomes.libraries'
relocate 'org.slf4j', 'net.william278.huskhomes.libraries.slf4j'
relocate 'com.google', 'net.william278.huskhomes.libraries'
}

@ -1,57 +0,0 @@
package me.william278.husksync.bukkit.data;
import org.bukkit.Material;
import org.bukkit.Statistic;
import org.bukkit.World;
import org.bukkit.entity.EntityType;
import java.io.Serializable;
import java.time.Instant;
import java.util.*;
/**
* Holds legacy data store methods for data storage
*/
@Deprecated
@SuppressWarnings("DeprecatedIsStillUsed")
public class DataSerializer {
/**
* A record used to store data for advancement synchronisation
*
* @deprecated Old format - Use {@link AdvancementRecordDate} instead
*/
@Deprecated
@SuppressWarnings("DeprecatedIsStillUsed")
// Suppress deprecation warnings here (still used for backwards compatibility)
public record AdvancementRecord(String advancementKey,
ArrayList<String> awardedAdvancementCriteria) implements Serializable {
}
/**
* A record used to store data for a player's statistics
*/
public record StatisticData(HashMap<Statistic, Integer> untypedStatisticValues,
HashMap<Statistic, HashMap<Material, Integer>> blockStatisticValues,
HashMap<Statistic, HashMap<Material, Integer>> itemStatisticValues,
HashMap<Statistic, HashMap<EntityType, Integer>> entityStatisticValues) implements Serializable {
}
/**
* A record used to store data for native advancement synchronisation, tracking advancement date progress
*/
public record AdvancementRecordDate(String key, Map<String, Date> criteriaMap) implements Serializable {
public AdvancementRecordDate(String key, List<String> criteriaList) {
this(key, new HashMap<>() {{
criteriaList.forEach(s -> put(s, Date.from(Instant.EPOCH)));
}});
}
}
/**
* A record used to store data for a player's location
*/
public record PlayerLocation(double x, double y, double z, float yaw, float pitch,
String worldName, World.Environment environment) implements Serializable {
}
}

@ -1,163 +0,0 @@
package net.william278.husksync;
import net.william278.husksync.Settings;
import net.william278.husksync.bukkit.util.BukkitUpdateChecker;
import net.william278.husksync.bukkit.util.PlayerSetter;
import net.william278.husksync.bukkit.config.ConfigLoader;
import net.william278.husksync.bukkit.data.BukkitDataCache;
import net.william278.husksync.bukkit.listener.BukkitRedisListener;
import net.william278.husksync.bukkit.listener.BukkitEventListener;
import net.william278.husksync.bukkit.migrator.MPDBDeserializer;
import net.william278.husksync.redis.RedisMessage;
import org.bstats.bukkit.Metrics;
import org.bukkit.Bukkit;
import org.bukkit.entity.Player;
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 {
// Bukkit bStats ID (Different to BungeeCord)
private static final int METRICS_ID = 13140;
private static HuskSyncBukkit instance;
public static HuskSyncBukkit getInstance() {
return instance;
}
public static BukkitDataCache bukkitCache;
public static BukkitRedisListener redisListener;
// 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.PROXY, null, Settings.cluster),
serverUUID.toString(),
Boolean.toString(isMySqlPlayerDataBridgeInstalled),
Bukkit.getName(),
getInstance().getDescription().getVersion())
.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() {
if (!handshakeCompleted) return;
try {
new RedisMessage(RedisMessage.MessageType.TERMINATE_HANDSHAKE,
new RedisMessage.MessageTarget(Settings.ServerType.PROXY, null, Settings.cluster),
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;
}
@Override
public void onEnable() {
// Plugin startup logic
// Load the config file
getConfig().options().copyDefaults(true);
saveDefaultConfig();
saveConfig();
reloadConfig();
ConfigLoader.loadSettings(getConfig());
// Do update checker
if (Settings.automaticUpdateChecks) {
new BukkitUpdateChecker().logToConsole();
}
// 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();
// Initialize event listener
getServer().getPluginManager().registerEvents(new BukkitEventListener(), this);
// Initialize the redis listener
redisListener = new BukkitRedisListener();
// Ensure redis is connected; establish a handshake
establishRedisHandshake();
// Initialize bStats metrics
try {
new Metrics(this, METRICS_ID);
} catch (Exception e) {
getLogger().info("Skipped metrics initialization");
}
// Log to console
getLogger().info("Enabled HuskSync (" + getServer().getName() + ") v" + getDescription().getVersion());
}
@Override
public void onDisable() {
// Update player data for disconnecting players
if (HuskSyncBukkit.handshakeCompleted && !HuskSyncBukkit.isMySqlPlayerDataBridgeInstalled && Bukkit.getOnlinePlayers().size() > 0) {
getLogger().info("Saving data for remaining online players...");
for (Player player : Bukkit.getOnlinePlayers()) {
PlayerSetter.updatePlayerData(player, false);
// Clear player inventory and ender chest
player.getInventory().clear();
player.getEnderChest().clear();
}
getLogger().info("Data save complete!");
}
// Send termination handshake to proxy
closeRedisHandshake();
// Plugin shutdown logic
getLogger().info("Disabled HuskSync (" + getServer().getName() + ") v" + getDescription().getVersion());
}
}

@ -1,34 +0,0 @@
package net.william278.husksync.bukkit.config;
import net.william278.husksync.Settings;
import org.bukkit.configuration.file.FileConfiguration;
public class ConfigLoader {
public static void loadSettings(FileConfiguration config) throws IllegalArgumentException {
Settings.serverType = Settings.ServerType.BUKKIT;
Settings.automaticUpdateChecks = config.getBoolean("check_for_updates", true);
Settings.cluster = config.getString("cluster_id", "main");
Settings.redisHost = config.getString("redis_settings.host", "localhost");
Settings.redisPort = config.getInt("redis_settings.port", 6379);
Settings.redisPassword = config.getString("redis_settings.password", "");
Settings.redisSSL = config.getBoolean("redis_settings.use_ssl", false);
Settings.syncInventories = config.getBoolean("synchronisation_settings.inventories", true);
Settings.syncEnderChests = config.getBoolean("synchronisation_settings.ender_chests", true);
Settings.syncHealth = config.getBoolean("synchronisation_settings.health", true);
Settings.syncHunger = config.getBoolean("synchronisation_settings.hunger", true);
Settings.syncExperience = config.getBoolean("synchronisation_settings.experience", true);
Settings.syncPotionEffects = config.getBoolean("synchronisation_settings.potion_effects", true);
Settings.syncStatistics = config.getBoolean("synchronisation_settings.statistics", true);
Settings.syncGameMode = config.getBoolean("synchronisation_settings.game_mode", true);
Settings.syncAdvancements = config.getBoolean("synchronisation_settings.advancements", true);
Settings.syncLocation = config.getBoolean("synchronisation_settings.location", false);
Settings.syncFlight = config.getBoolean("synchronisation_settings.flight", false);
Settings.useNativeImplementation = config.getBoolean("native_advancement_synchronization", false);
Settings.saveOnWorldSave = config.getBoolean("save_on_world_save", true);
Settings.synchronizationTimeoutRetryDelay = config.getLong("synchronization_timeout_retry_delay", 15L);
}
}

@ -1,74 +0,0 @@
package net.william278.husksync.bukkit.data;
import java.util.HashMap;
import java.util.HashSet;
import java.util.UUID;
public class BukkitDataCache {
/**
* Map of Player UUIDs to request on join
*/
private static HashSet<UUID> requestOnJoin;
public boolean isPlayerRequestingOnJoin(UUID uuid) {
return requestOnJoin.contains(uuid);
}
public void setRequestOnJoin(UUID uuid) {
requestOnJoin.add(uuid);
}
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,327 +0,0 @@
package net.william278.husksync.bukkit.data;
import net.william278.husksync.redis.RedisMessage;
import org.bukkit.*;
import org.bukkit.advancement.Advancement;
import org.bukkit.advancement.AdvancementProgress;
import org.bukkit.entity.EntityType;
import org.bukkit.entity.Player;
import org.bukkit.inventory.ItemStack;
import org.bukkit.potion.PotionEffect;
import org.bukkit.util.io.BukkitObjectInputStream;
import org.bukkit.util.io.BukkitObjectOutputStream;
import org.yaml.snakeyaml.external.biz.base64Coder.Base64Coder;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.util.*;
/**
* Class that contains static methods for serializing and deserializing data from {@link net.william278.husksync.PlayerData}
*/
public class DataSerializer {
/**
* Returns a serialized array of {@link ItemStack}s
*
* @param inventoryContents The contents of the inventory
* @return The serialized inventory contents
*/
public static String serializeInventory(ItemStack[] inventoryContents) {
// Return an empty string if there is no inventory item data to serialize
if (inventoryContents.length == 0) {
return "";
}
// Create an output stream that will be encoded into base 64
ByteArrayOutputStream byteOutputStream = new ByteArrayOutputStream();
try (BukkitObjectOutputStream bukkitOutputStream = new BukkitObjectOutputStream(byteOutputStream)) {
// Define the length of the inventory array to serialize
bukkitOutputStream.writeInt(inventoryContents.length);
// Write each serialize each ItemStack to the output stream
for (ItemStack inventoryItem : inventoryContents) {
bukkitOutputStream.writeObject(serializeItemStack(inventoryItem));
}
// Return encoded data, using the encoder from SnakeYaml to get a ByteArray conversion
return Base64Coder.encodeLines(byteOutputStream.toByteArray());
} catch (IOException e) {
throw new IllegalArgumentException("Failed to serialize item stack data");
}
}
/**
* Returns an array of ItemStacks from serialized inventory data. Note: empty slots will be represented by {@code null}
*
* @param inventoryData The serialized {@link ItemStack[]} array
* @return The inventory contents as an array of {@link ItemStack}s
* @throws IOException If the deserialization fails reading data from the InputStream
* @throws ClassNotFoundException If the deserialization class cannot be found
*/
public static ItemStack[] deserializeInventory(String inventoryData) throws IOException, ClassNotFoundException {
// Return empty array if there is no inventory data (set the player as having an empty inventory)
if (inventoryData.isEmpty()) {
return new ItemStack[0];
}
// Create a byte input stream to read the serialized data
try (ByteArrayInputStream byteInputStream = new ByteArrayInputStream(Base64Coder.decodeLines(inventoryData))) {
try (BukkitObjectInputStream bukkitInputStream = new BukkitObjectInputStream(byteInputStream)) {
// Read the length of the Bukkit input stream and set the length of the array to this value
ItemStack[] inventoryContents = new ItemStack[bukkitInputStream.readInt()];
// Set the ItemStacks in the array from deserialized ItemStack data
int slotIndex = 0;
for (ItemStack ignored : inventoryContents) {
inventoryContents[slotIndex] = deserializeItemStack(bukkitInputStream.readObject());
slotIndex++;
}
// Return the finished, serialized inventory contents
return inventoryContents;
}
}
}
/**
* Returns the serialized version of an {@link ItemStack} as a string to object Map
*
* @param item The {@link ItemStack} to serialize
* @return The serialized {@link ItemStack}
*/
private static Map<String, Object> serializeItemStack(ItemStack item) {
return item != null ? item.serialize() : null;
}
/**
* Returns the deserialized {@link ItemStack} from the Object read from the {@link BukkitObjectInputStream}
*
* @param serializedItemStack The serialized item stack; a String-Object map
* @return The deserialized {@link ItemStack}
*/
@SuppressWarnings("unchecked") // Ignore the "Unchecked cast" warning
private static ItemStack deserializeItemStack(Object serializedItemStack) {
return serializedItemStack != null ? ItemStack.deserialize((Map<String, Object>) serializedItemStack) : null;
}
/**
* Returns a serialized array of {@link PotionEffect}s
*
* @param potionEffects The potion effect array
* @return The serialized potion effects
*/
public static String serializePotionEffects(PotionEffect[] potionEffects) {
// Return an empty string if there are no effects to serialize
if (potionEffects.length == 0) {
return "";
}
// Create an output stream that will be encoded into base 64
ByteArrayOutputStream byteOutputStream = new ByteArrayOutputStream();
try (BukkitObjectOutputStream bukkitOutputStream = new BukkitObjectOutputStream(byteOutputStream)) {
// Define the length of the potion effect array to serialize
bukkitOutputStream.writeInt(potionEffects.length);
// Write each serialize each PotionEffect to the output stream
for (PotionEffect potionEffect : potionEffects) {
bukkitOutputStream.writeObject(serializePotionEffect(potionEffect));
}
// Return encoded data, using the encoder from SnakeYaml to get a ByteArray conversion
return Base64Coder.encodeLines(byteOutputStream.toByteArray());
} catch (IOException e) {
throw new IllegalArgumentException("Failed to serialize potion effect data");
}
}
/**
* Returns an array of ItemStacks from serialized potion effect data
*
* @param potionEffectData The serialized {@link PotionEffect[]} array
* @return The {@link PotionEffect}s
* @throws IOException If the deserialization fails reading data from the InputStream
* @throws ClassNotFoundException If the deserialization class cannot be found
*/
public static PotionEffect[] deserializePotionEffects(String potionEffectData) throws IOException, ClassNotFoundException {
// Return empty array if there is no potion effect data (don't apply any effects to the player)
if (potionEffectData.isEmpty()) {
return new PotionEffect[0];
}
// Create a byte input stream to read the serialized data
try (ByteArrayInputStream byteInputStream = new ByteArrayInputStream(Base64Coder.decodeLines(potionEffectData))) {
try (BukkitObjectInputStream bukkitInputStream = new BukkitObjectInputStream(byteInputStream)) {
// Read the length of the Bukkit input stream and set the length of the array to this value
PotionEffect[] potionEffects = new PotionEffect[bukkitInputStream.readInt()];
// Set the potion effects in the array from deserialized PotionEffect data
int potionIndex = 0;
for (PotionEffect ignored : potionEffects) {
potionEffects[potionIndex] = deserializePotionEffect(bukkitInputStream.readObject());
potionIndex++;
}
// Return the finished, serialized potion effect array
return potionEffects;
}
}
}
/**
* Returns the serialized version of an {@link ItemStack} as a string to object Map
*
* @param potionEffect The {@link ItemStack} to serialize
* @return The serialized {@link ItemStack}
*/
private static Map<String, Object> serializePotionEffect(PotionEffect potionEffect) {
return potionEffect != null ? potionEffect.serialize() : null;
}
/**
* Returns the deserialized {@link PotionEffect} from the Object read from the {@link BukkitObjectInputStream}
*
* @param serializedPotionEffect The serialized potion effect; a String-Object map
* @return The deserialized {@link PotionEffect}
*/
@SuppressWarnings("unchecked") // Ignore the "Unchecked cast" warning
private static PotionEffect deserializePotionEffect(Object serializedPotionEffect) {
return serializedPotionEffect != null ? new PotionEffect((Map<String, Object>) serializedPotionEffect) : null;
}
public static me.william278.husksync.bukkit.data.DataSerializer.PlayerLocation deserializePlayerLocationData(String serializedLocationData) throws IOException {
if (serializedLocationData.isEmpty()) {
return null;
}
try {
return (me.william278.husksync.bukkit.data.DataSerializer.PlayerLocation) RedisMessage.deserialize(serializedLocationData);
} catch (ClassNotFoundException e) {
throw new IOException("Unable to decode class type.", e);
}
}
public static String getSerializedLocation(Player player) throws IOException {
final Location playerLocation = player.getLocation();
return RedisMessage.serialize(new me.william278.husksync.bukkit.data.DataSerializer.PlayerLocation(playerLocation.getX(), playerLocation.getY(), playerLocation.getZ(),
playerLocation.getYaw(), playerLocation.getPitch(), player.getWorld().getName(), player.getWorld().getEnvironment()));
}
/**
* Deserializes a player's advancement data as serialized with {@link #getSerializedAdvancements(Player)} into {@link me.william278.husksync.bukkit.data.DataSerializer.AdvancementRecordDate} data.
*
* @param serializedAdvancementData The serialized advancement data {@link String}
* @return The deserialized {@link me.william278.husksync.bukkit.data.DataSerializer.AdvancementRecordDate} for the player
* @throws IOException If the deserialization fails
*/
@SuppressWarnings("unchecked") // Ignore the unchecked cast here
public static List<me.william278.husksync.bukkit.data.DataSerializer.AdvancementRecordDate> deserializeAdvancementData(String serializedAdvancementData) throws IOException {
if (serializedAdvancementData.isEmpty()) {
return new ArrayList<>();
}
try {
List<?> deserialize = (List<?>) RedisMessage.deserialize(serializedAdvancementData);
// Migrate old AdvancementRecord into date format
if (!deserialize.isEmpty() && deserialize.get(0) instanceof me.william278.husksync.bukkit.data.DataSerializer.AdvancementRecord) {
deserialize = ((List<me.william278.husksync.bukkit.data.DataSerializer.AdvancementRecord>) deserialize).stream()
.map(o -> new me.william278.husksync.bukkit.data.DataSerializer.AdvancementRecordDate(
o.advancementKey(),
o.awardedAdvancementCriteria()
)).toList();
}
return (List<me.william278.husksync.bukkit.data.DataSerializer.AdvancementRecordDate>) deserialize;
} catch (ClassNotFoundException e) {
throw new IOException("Unable to decode class type.", e);
}
}
/**
* Returns a serialized {@link String} of a player's advancements that can be deserialized with {@link #deserializeStatisticData(String)}
*
* @param player {@link Player} to serialize advancement data of
* @return The serialized advancement data as a {@link String}
* @throws IOException If the serialization fails
*/
public static String getSerializedAdvancements(Player player) throws IOException {
Iterator<Advancement> serverAdvancements = Bukkit.getServer().advancementIterator();
ArrayList<me.william278.husksync.bukkit.data.DataSerializer.AdvancementRecordDate> advancementData = new ArrayList<>();
while (serverAdvancements.hasNext()) {
final AdvancementProgress progress = player.getAdvancementProgress(serverAdvancements.next());
final NamespacedKey advancementKey = progress.getAdvancement().getKey();
final Map<String, Date> awardedCriteria = new HashMap<>();
progress.getAwardedCriteria().forEach(s -> awardedCriteria.put(s, progress.getDateAwarded(s)));
advancementData.add(new me.william278.husksync.bukkit.data.DataSerializer.AdvancementRecordDate(advancementKey.getNamespace() + ":" + advancementKey.getKey(), awardedCriteria));
}
return RedisMessage.serialize(advancementData);
}
/**
* Deserializes a player's statistic data as serialized with {@link #getSerializedStatisticData(Player)} into {@link me.william278.husksync.bukkit.data.DataSerializer.StatisticData}.
*
* @param serializedStatisticData The serialized statistic data {@link String}
* @return The deserialized {@link me.william278.husksync.bukkit.data.DataSerializer.StatisticData} for the player
* @throws IOException If the deserialization fails
*/
public static me.william278.husksync.bukkit.data.DataSerializer.StatisticData deserializeStatisticData(String serializedStatisticData) throws IOException {
if (serializedStatisticData.isEmpty()) {
return new me.william278.husksync.bukkit.data.DataSerializer.StatisticData(new HashMap<>(), new HashMap<>(), new HashMap<>(), new HashMap<>());
}
try {
return (me.william278.husksync.bukkit.data.DataSerializer.StatisticData) RedisMessage.deserialize(serializedStatisticData);
} catch (ClassNotFoundException e) {
throw new IOException("Unable to decode class type.", e);
}
}
/**
* Returns a serialized {@link String} of a player's statistic data that can be deserialized with {@link #deserializeStatisticData(String)}
*
* @param player {@link Player} to serialize statistic data of
* @return The serialized statistic data as a {@link String}
* @throws IOException If the serialization fails
*/
public static String getSerializedStatisticData(Player player) throws IOException {
HashMap<Statistic, Integer> untypedStatisticValues = new HashMap<>();
HashMap<Statistic, HashMap<Material, Integer>> blockStatisticValues = new HashMap<>();
HashMap<Statistic, HashMap<Material, Integer>> itemStatisticValues = new HashMap<>();
HashMap<Statistic, HashMap<EntityType, Integer>> entityStatisticValues = new HashMap<>();
for (Statistic statistic : Statistic.values()) {
switch (statistic.getType()) {
case ITEM -> {
HashMap<Material, Integer> itemValues = new HashMap<>();
for (Material itemMaterial : Arrays.stream(Material.values()).filter(Material::isItem).toList()) {
itemValues.put(itemMaterial, player.getStatistic(statistic, itemMaterial));
}
itemStatisticValues.put(statistic, itemValues);
}
case BLOCK -> {
HashMap<Material, Integer> blockValues = new HashMap<>();
for (Material blockMaterial : Arrays.stream(Material.values()).filter(Material::isBlock).toList()) {
blockValues.put(blockMaterial, player.getStatistic(statistic, blockMaterial));
}
blockStatisticValues.put(statistic, blockValues);
}
case ENTITY -> {
HashMap<EntityType, Integer> entityValues = new HashMap<>();
for (EntityType type : Arrays.stream(EntityType.values()).filter(EntityType::isAlive).toList()) {
entityValues.put(type, player.getStatistic(statistic, type));
}
entityStatisticValues.put(statistic, entityValues);
}
case UNTYPED -> untypedStatisticValues.put(statistic, player.getStatistic(statistic));
}
}
me.william278.husksync.bukkit.data.DataSerializer.StatisticData statisticData = new me.william278.husksync.bukkit.data.DataSerializer.StatisticData(untypedStatisticValues, blockStatisticValues, itemStatisticValues, entityStatisticValues);
return RedisMessage.serialize(statisticData);
}
}

@ -1,115 +0,0 @@
package net.william278.husksync.bukkit.data;
import net.william278.husksync.HuskSyncBukkit;
import net.william278.husksync.PlayerData;
import net.william278.husksync.Settings;
import net.william278.husksync.bukkit.util.PlayerSetter;
import net.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, ClassNotFoundException {
// 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.serializeInventory(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.PROXY, null, Settings.cluster),
RedisMessage.serialize(playerData), Boolean.toString(true))
.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, ClassNotFoundException {
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, ClassNotFoundException {
return switch (inventoryType) {
case INVENTORY -> DataSerializer.deserializeInventory(playerData.getSerializedInventory());
case ENDER_CHEST -> DataSerializer.deserializeInventory(playerData.getSerializedEnderChest());
};
}
}
}

@ -1,38 +0,0 @@
package net.william278.husksync.bukkit.events;
import net.william278.husksync.PlayerData;
import org.bukkit.entity.Player;
import org.bukkit.event.HandlerList;
import org.bukkit.event.player.PlayerEvent;
import org.jetbrains.annotations.NotNull;
/**
* Represents an event that will be fired when a {@link Player} has finished being synchronised with the correct {@link PlayerData}.
*/
public class SyncCompleteEvent extends PlayerEvent {
private static final HandlerList HANDLER_LIST = new HandlerList();
private final PlayerData data;
public SyncCompleteEvent(Player player, PlayerData data) {
super(player);
this.data = data;
}
/**
* Returns the {@link PlayerData} which has just been set on the {@link Player}
* @return The {@link PlayerData} that has been set
*/
public PlayerData getData() {
return data;
}
@Override
public @NotNull HandlerList getHandlers() {
return HANDLER_LIST;
}
public static HandlerList getHandlerList() {
return HANDLER_LIST;
}
}

@ -1,70 +0,0 @@
package net.william278.husksync.bukkit.events;
import net.william278.husksync.PlayerData;
import org.bukkit.entity.Player;
import org.bukkit.event.Cancellable;
import org.bukkit.event.HandlerList;
import org.bukkit.event.player.PlayerEvent;
import org.jetbrains.annotations.NotNull;
/**
* Represents an event that will be fired before a {@link Player} is about to be synchronised with their {@link PlayerData}.
*/
public class SyncEvent extends PlayerEvent implements Cancellable {
private boolean cancelled;
private static final HandlerList HANDLER_LIST = new HandlerList();
private PlayerData data;
public SyncEvent(Player player, PlayerData data) {
super(player);
this.data = data;
}
/**
* Returns the {@link PlayerData} which has just been set on the {@link Player}
*
* @return The {@link PlayerData} that has been set
*/
public PlayerData getData() {
return data;
}
/**
* Sets the {@link PlayerData} to be synchronised to this player
*
* @param data The {@link PlayerData} to set to the player
*/
public void setData(PlayerData data) {
this.data = data;
}
@Override
public @NotNull HandlerList getHandlers() {
return HANDLER_LIST;
}
public static HandlerList getHandlerList() {
return HANDLER_LIST;
}
/**
* Gets the cancellation state of this event. A cancelled event will not be executed in the server, but will still pass to other plugins
*
* @return true if this event is cancelled
*/
@Override
public boolean isCancelled() {
return cancelled;
}
/**
* Sets the cancellation state of this event. A cancelled event will not be executed in the server, but will still pass to other plugins.
*
* @param cancel true if you wish to cancel this event
*/
@Override
public void setCancelled(boolean cancel) {
this.cancelled = cancel;
}
}

@ -1,166 +0,0 @@
package net.william278.husksync.bukkit.listener;
import net.william278.husksync.HuskSyncBukkit;
import net.william278.husksync.Settings;
import net.william278.husksync.bukkit.data.DataViewer;
import net.william278.husksync.bukkit.util.PlayerSetter;
import org.bukkit.Bukkit;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
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 org.bukkit.event.world.WorldSaveEvent;
import java.io.IOException;
import java.util.logging.Level;
public class BukkitEventListener implements Listener {
private static final HuskSyncBukkit plugin = HuskSyncBukkit.getInstance();
@EventHandler(priority = EventPriority.LOWEST)
public void onPlayerQuit(PlayerQuitEvent event) {
// 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
// Update the player's data
Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> {
// Update data to proxy
PlayerSetter.updatePlayerData(player, true);
// Clear player inventory and ender chest
player.getInventory().clear();
player.getEnderChest().clear();
});
}
@EventHandler(priority = EventPriority.LOWEST)
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();
// 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())) {
Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> {
try {
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 the synchronization timeout retry delay ticks, ensure it will be
Bukkit.getScheduler().runTaskLaterAsynchronously(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);
}
}
}, Settings.synchronizationTimeoutRetryDelay);
}
}
@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.HIGHEST)
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.HIGHEST)
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.HIGHEST)
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.HIGHEST)
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.HIGHEST)
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.HIGHEST)
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
}
}
@EventHandler(priority = EventPriority.NORMAL)
public void onWorldSave(WorldSaveEvent event) {
if (!plugin.isEnabled() || !HuskSyncBukkit.handshakeCompleted) {
return;
}
for (Player playerInWorld : event.getWorld().getPlayers()) {
PlayerSetter.updatePlayerData(playerInWorld, false);
}
}
}

@ -1,221 +0,0 @@
package net.william278.husksync.bukkit.listener;
import de.themoep.minedown.MineDown;
import net.william278.husksync.HuskSyncBukkit;
import net.william278.husksync.PlayerData;
import net.william278.husksync.Settings;
import net.william278.husksync.bukkit.config.ConfigLoader;
import net.william278.husksync.bukkit.data.DataViewer;
import net.william278.husksync.bukkit.migrator.MPDBDeserializer;
import net.william278.husksync.bukkit.util.PlayerSetter;
import net.william278.husksync.migrator.MPDBPlayerData;
import net.william278.husksync.redis.RedisListener;
import net.william278.husksync.redis.RedisMessage;
import net.william278.husksync.util.MessageManager;
import org.bukkit.Bukkit;
import org.bukkit.entity.Player;
import java.io.IOException;
import java.util.HashMap;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.logging.Level;
public class BukkitRedisListener extends RedisListener {
private static final HuskSyncBukkit plugin = HuskSyncBukkit.getInstance();
public static HashMap<UUID, CompletableFuture<PlayerData>> apiRequests = new HashMap<>();
// Initialize the listener on the bukkit server
public BukkitRedisListener() {
super();
listen();
}
/**
* Handle an incoming {@link RedisMessage}
*
* @param message The {@link RedisMessage} to handle
*/
@Override
public void handleMessage(RedisMessage message) {
// Ignore messages for proxy servers
if (!message.getMessageTarget().targetServerType().equals(Settings.ServerType.BUKKIT)) {
return;
}
// Ignore messages if the plugin is disabled
if (!plugin.isEnabled()) {
return;
}
// Ignore messages for other clusters if applicable
final String targetClusterId = message.getMessageTarget().targetClusterId();
if (targetClusterId != null) {
if (!targetClusterId.equalsIgnoreCase(Settings.cluster)) {
return;
}
}
// Handle the incoming redis message; either for a specific player or the system
if (message.getMessageTarget().targetPlayerUUID() == null) {
switch (message.getMessageType()) {
case REQUEST_DATA_ON_JOIN -> {
UUID playerUUID = UUID.fromString(message.getMessageDataElements()[1]);
switch (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];
Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> {
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.PROXY, null, Settings.cluster),
RedisMessage.serialize(MPDBDeserializer.convertMPDBData(data)),
data.playerName)
.send();
} catch (IOException | ClassNotFoundException e) {
log(Level.SEVERE, "Failed to serialize encoded MPDB data");
}
}
});
}
case API_DATA_RETURN -> {
final UUID requestUUID = UUID.fromString(message.getMessageDataElements()[0]);
if (apiRequests.containsKey(requestUUID)) {
try {
final PlayerData data = (PlayerData) RedisMessage.deserialize(message.getMessageDataElements()[1]);
apiRequests.get(requestUUID).complete(data);
} catch (IOException | ClassNotFoundException e) {
log(Level.SEVERE, "Failed to serialize returned API-requested player data");
}
}
}
case API_DATA_CANCEL -> {
final UUID requestUUID = UUID.fromString(message.getMessageDataElements()[0]);
// Cancel requests if no data could be found on the proxy
if (apiRequests.containsKey(requestUUID)) {
apiRequests.get(requestUUID).cancel(true);
}
}
case RELOAD_CONFIG -> {
plugin.reloadConfig();
ConfigLoader.loadSettings(plugin.getConfig());
}
}
} else {
for (Player player : Bukkit.getOnlinePlayers()) {
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());
// Set the player's data
PlayerSetter.setPlayerFrom(player, data);
} catch (IOException | ClassNotFoundException e) {
log(Level.SEVERE, "Failed to deserialize PlayerData when handling data from the proxy");
e.printStackTrace();
}
}
case SEND_PLUGIN_INFORMATION -> {
String proxyBrand = message.getMessageDataElements()[0];
String proxyVersion = message.getMessageDataElements()[1];
assert plugin.getDescription().getDescription() != null;
player.spigot().sendMessage(new MineDown(MessageManager.PLUGIN_INFORMATION.toString()
.replaceAll("%plugin_description%", plugin.getDescription().getDescription())
.replaceAll("%proxy_brand%", proxyBrand)
.replaceAll("%proxy_version%", proxyVersion)
.replaceAll("%bukkit_brand%", Bukkit.getName())
.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;
}
}
}
}
/**
* Log to console
*
* @param level The {@link Level} to log
* @param message Message to log
*/
@Override
public void log(Level level, String message) {
plugin.getLogger().log(level, message);
}
}

@ -1,87 +0,0 @@
package net.william278.husksync.bukkit.migrator;
import net.william278.husksync.HuskSyncBukkit;
import net.william278.husksync.PlayerData;
import net.william278.husksync.bukkit.data.DataSerializer;
import net.william278.husksync.bukkit.util.PlayerSetter;
import net.william278.husksync.migrator.MPDBPlayerData;
import net.william278.mpdbconverter.MPDBConverter;
import org.bukkit.Bukkit;
import org.bukkit.event.inventory.InventoryType;
import org.bukkit.inventory.Inventory;
import org.bukkit.inventory.ItemStack;
import org.bukkit.plugin.Plugin;
import java.util.logging.Level;
public class MPDBDeserializer {
private static final HuskSyncBukkit plugin = HuskSyncBukkit.getInstance();
// Instance of MySqlPlayerDataBridge
private static MPDBConverter mpdbConverter;
public static void setMySqlPlayerDataBridge() {
Plugin mpdbPlugin = Bukkit.getPluginManager().getPlugin("MySqlPlayerDataBridge");
assert mpdbPlugin != null;
mpdbConverter = MPDBConverter.getInstance(mpdbPlugin);
}
/**
* 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 contents
Inventory inventory = Bukkit.createInventory(null, InventoryType.PLAYER);
if (!mpdbPlayerData.inventoryData.isEmpty() && !mpdbPlayerData.inventoryData.equalsIgnoreCase("none")) {
PlayerSetter.setInventory(inventory, mpdbConverter.getItemStackFromSerializedData(mpdbPlayerData.inventoryData));
}
// Set armor (if there is data; MPDB stores empty data with literally the word "none". Obviously.)
int armorSlot = 36;
if (!mpdbPlayerData.armorData.isEmpty() && !mpdbPlayerData.armorData.equalsIgnoreCase("none")) {
ItemStack[] armorItems = mpdbConverter.getItemStackFromSerializedData(mpdbPlayerData.armorData);
for (ItemStack armorPiece : armorItems) {
if (armorPiece != null) {
inventory.setItem(armorSlot, armorPiece);
}
armorSlot++;
}
}
// Now apply the contents and clear the temporary inventory variable
playerData.setSerializedInventory(DataSerializer.serializeInventory(inventory.getContents()));
// Set ender chest (again, if there is data)
ItemStack[] enderChestData;
if (!mpdbPlayerData.enderChestData.isEmpty() && !mpdbPlayerData.enderChestData.equalsIgnoreCase("none")) {
enderChestData = mpdbConverter.getItemStackFromSerializedData(mpdbPlayerData.enderChestData);
} else {
enderChestData = new ItemStack[0];
}
playerData.setSerializedEnderChest(DataSerializer.serializeInventory(enderChestData));
// Set experience
playerData.setExpLevel(mpdbPlayerData.expLevel);
playerData.setExpProgress(mpdbPlayerData.expProgress);
playerData.setTotalExperience(mpdbPlayerData.totalExperience);
} catch (Exception e) {
plugin.getLogger().log(Level.WARNING, "Failed to convert MPDB data to HuskSync's format!");
e.printStackTrace();
}
return playerData;
}
}

@ -1,20 +0,0 @@
package net.william278.husksync.bukkit.util;
import net.william278.husksync.HuskSyncBukkit;
import net.william278.husksync.util.UpdateChecker;
import java.util.logging.Level;
public class BukkitUpdateChecker extends UpdateChecker {
private static final HuskSyncBukkit plugin = HuskSyncBukkit.getInstance();
public BukkitUpdateChecker() {
super(plugin.getDescription().getVersion());
}
@Override
public void log(Level level, String message) {
plugin.getLogger().log(level, message);
}
}

@ -1,479 +0,0 @@
package net.william278.husksync.bukkit.util;
import net.william278.husksync.HuskSyncBukkit;
import net.william278.husksync.PlayerData;
import net.william278.husksync.Settings;
import net.william278.husksync.bukkit.events.SyncCompleteEvent;
import net.william278.husksync.bukkit.events.SyncEvent;
import net.william278.husksync.bukkit.data.DataSerializer;
import net.william278.husksync.bukkit.util.nms.AdvancementUtils;
import net.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;
import org.bukkit.potion.PotionEffectType;
import java.io.IOException;
import java.util.*;
import java.util.logging.Level;
public class PlayerSetter {
private static final HuskSyncBukkit plugin = HuskSyncBukkit.getInstance();
/**
* Returns the new serialized PlayerData for a player.
*
* @param player The {@link Player} to get the new serialized PlayerData for
* @return The {@link PlayerData}, serialized as a {@link String}
* @throws IOException If the serialization fails
*/
private static String getNewSerializedPlayerData(Player player) throws IOException {
final double maxHealth = getMaxHealth(player); // Get the player's max health (used to determine health as well)
return RedisMessage.serialize(new PlayerData(player.getUniqueId(),
DataSerializer.serializeInventory(player.getInventory().getContents()),
DataSerializer.serializeInventory(player.getEnderChest().getContents()),
Math.min(player.getHealth(), maxHealth),
maxHealth,
player.isHealthScaled() ? player.getHealthScale() : 0D,
player.getFoodLevel(),
player.getSaturation(),
player.getExhaustion(),
player.getInventory().getHeldItemSlot(),
DataSerializer.serializePotionEffects(getPlayerPotionEffects(player)),
player.getTotalExperience(),
player.getLevel(),
player.getExp(),
player.getGameMode().toString(),
DataSerializer.getSerializedStatisticData(player),
player.isFlying(),
DataSerializer.getSerializedAdvancements(player),
DataSerializer.getSerializedLocation(player)));
}
/**
* Returns a {@link Player}'s maximum health, minus any health boost effects
*
* @param player The {@link Player} to get the maximum health of
* @return The {@link Player}'s max health
*/
private static double getMaxHealth(Player player) {
double maxHealth = Objects.requireNonNull(player.getAttribute(Attribute.GENERIC_MAX_HEALTH)).getBaseValue();
// If the player has additional health bonuses from synchronised potion effects, subtract these from this number as they are synchronised separately
if (player.hasPotionEffect(PotionEffectType.HEALTH_BOOST) && maxHealth > 20D) {
PotionEffect healthBoostEffect = player.getPotionEffect(PotionEffectType.HEALTH_BOOST);
assert healthBoostEffect != null;
double healthBoostBonus = 4 * (healthBoostEffect.getAmplifier() + 1);
maxHealth -= healthBoostBonus;
}
return maxHealth;
}
/**
* Returns a {@link Player}'s active potion effects in a {@link PotionEffect} array
*
* @param player The {@link Player} to get the effects of
* @return The {@link PotionEffect} array
*/
private static PotionEffect[] getPlayerPotionEffects(Player player) {
PotionEffect[] potionEffects = new PotionEffect[player.getActivePotionEffects().size()];
int arrayIndex = 0;
for (PotionEffect effect : player.getActivePotionEffects()) {
potionEffects[arrayIndex] = effect;
arrayIndex++;
}
return potionEffects;
}
/**
* Update a {@link Player}'s data, sending it to the proxy
*
* @param player {@link Player} to send data to proxy
* @param bounceBack whether the plugin should bounce-back the updated data to the player (used for server switching)
*/
public static void updatePlayerData(Player player, boolean bounceBack) {
// Send a redis message with the player's last updated PlayerData version UUID and their new PlayerData
try {
final String serializedPlayerData = getNewSerializedPlayerData(player);
new RedisMessage(RedisMessage.MessageType.PLAYER_DATA_UPDATE,
new RedisMessage.MessageTarget(Settings.ServerType.PROXY, null, Settings.cluster),
serializedPlayerData, Boolean.toString(bounceBack)).send();
} catch (IOException e) {
plugin.getLogger().log(Level.SEVERE, "Failed to send a PlayerData update to the proxy", e);
}
}
/**
* Request a {@link Player}'s data from the proxy
*
* @param playerUUID The {@link UUID} of the {@link Player} to fetch PlayerData from
* @throws IOException If the request Redis message data fails to serialize
*/
public static void requestPlayerData(UUID playerUUID) throws IOException {
new RedisMessage(RedisMessage.MessageType.PLAYER_DATA_REQUEST,
new RedisMessage.MessageTarget(Settings.ServerType.PROXY, null, Settings.cluster),
playerUUID.toString()).send();
}
/**
* Set a player from their PlayerData, based on settings
*
* @param player The {@link Player} to set
* @param dataToSet The {@link PlayerData} to assign to the player
*/
public static void setPlayerFrom(Player player, PlayerData dataToSet) {
Bukkit.getScheduler().runTask(plugin, () -> {
// Handle the SyncEvent
SyncEvent syncEvent = new SyncEvent(player, dataToSet);
Bukkit.getPluginManager().callEvent(syncEvent);
final PlayerData data = syncEvent.getData();
if (syncEvent.isCancelled()) {
return;
}
// If the data is flagged as being default data, skip setting
if (data.useDefaultData) {
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
try {
if (Settings.syncAdvancements) {
List<me.william278.husksync.bukkit.data.DataSerializer.AdvancementRecordDate> advancementRecords
= DataSerializer.deserializeAdvancementData(data.getSerializedAdvancements());
if (Settings.useNativeImplementation) {
try {
nativeSyncPlayerAdvancements(player, advancementRecords);
} catch (Exception e) {
plugin.getLogger().log(Level.WARNING,
"Your server does not support a native implementation of achievements synchronization");
plugin.getLogger().log(Level.WARNING,
"Your server version is {0}. Please disable using native implementation!", Bukkit.getVersion());
Settings.useNativeImplementation = false;
setPlayerAdvancements(player, advancementRecords, data);
plugin.getLogger().log(Level.SEVERE, e.getMessage(), e);
}
} else {
setPlayerAdvancements(player, advancementRecords, data);
}
}
if (Settings.syncInventories) {
setPlayerInventory(player, DataSerializer.deserializeInventory(data.getSerializedInventory()));
player.getInventory().setHeldItemSlot(data.getSelectedSlot());
}
if (Settings.syncEnderChests) {
setPlayerEnderChest(player, DataSerializer.deserializeInventory(data.getSerializedEnderChest()));
}
if (Settings.syncHealth) {
setPlayerHealth(player, data.getHealth(), data.getMaxHealth(), data.getHealthScale());
}
if (Settings.syncHunger) {
player.setFoodLevel(data.getHunger());
player.setSaturation(data.getSaturation());
player.setExhaustion(data.getSaturationExhaustion());
}
if (Settings.syncExperience) {
// This is also handled when syncing advancements to ensure its correct
setPlayerExperience(player, data);
}
if (Settings.syncPotionEffects) {
setPlayerPotionEffects(player, DataSerializer.deserializePotionEffects(data.getSerializedEffectData()));
}
if (Settings.syncStatistics) {
setPlayerStatistics(player, DataSerializer.deserializeStatisticData(data.getSerializedStatistics()));
}
if (Settings.syncGameMode) {
player.setGameMode(GameMode.valueOf(data.getGameMode()));
}
if (Settings.syncLocation) {
setPlayerLocation(player, DataSerializer.deserializePlayerLocationData(data.getSerializedLocation()));
}
if (Settings.syncFlight) {
if (data.isFlying()) {
player.setAllowFlight(true);
}
player.setFlying(player.getAllowFlight() && data.isFlying());
}
// Handle the SyncCompleteEvent
Bukkit.getPluginManager().callEvent(new SyncCompleteEvent(player, data));
} catch (IOException | ClassNotFoundException e) {
plugin.getLogger().log(Level.SEVERE, "Failed to deserialize PlayerData", e);
}
});
}
/**
* Sets a player's ender chest from a set of {@link ItemStack}s
*
* @param player The player to set the inventory of
* @param items The array of {@link ItemStack}s to set
*/
private static void setPlayerEnderChest(Player player, ItemStack[] items) {
setInventory(player.getEnderChest(), items);
}
/**
* Sets a player's inventory from a set of {@link ItemStack}s
*
* @param player The player to set the inventory of
* @param items The array of {@link ItemStack}s to set
*/
private static void setPlayerInventory(Player player, ItemStack[] items) {
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) {
inventory.setItem(index, item);
}
index++;
}
}
/**
* Set a player's current potion effects from a set of {@link PotionEffect[]}
*
* @param player The player to set the potion effects of
* @param effects The array of {@link PotionEffect}s to set
*/
private static void setPlayerPotionEffects(Player player, PotionEffect[] effects) {
for (PotionEffect effect : player.getActivePotionEffects()) {
player.removePotionEffect(effect.getType());
}
for (PotionEffect effect : effects) {
player.addPotionEffect(effect);
}
}
private static void nativeSyncPlayerAdvancements(final Player player, final List<me.william278.husksync.bukkit.data.DataSerializer.AdvancementRecordDate> advancementRecords) {
final Object playerAdvancements = AdvancementUtils.getPlayerAdvancements(player);
// Clear
AdvancementUtils.clearPlayerAdvancements(playerAdvancements);
AdvancementUtils.clearVisibleAdvancements(playerAdvancements);
advancementRecords.forEach(advancementRecord -> {
NamespacedKey namespacedKey = Objects.requireNonNull(
NamespacedKey.fromString(advancementRecord.key()),
"Invalid Namespaced key of " + advancementRecord.key()
);
Advancement bukkitAdvancement = Bukkit.getAdvancement(namespacedKey);
if (bukkitAdvancement == null) {
plugin.getLogger().log(Level.WARNING, "Ignored advancement '{0}' - it doesn't exist anymore?", namespacedKey);
return;
}
Object advancement = AdvancementUtils.getHandle(bukkitAdvancement);
Map<String, Date> criteriaList = advancementRecord.criteriaMap();
{
Map<String, Object> nativeCriteriaMap = new HashMap<>();
criteriaList.forEach((criteria, date) ->
nativeCriteriaMap.put(criteria, AdvancementUtils.newCriterionProgress(date))
);
Object nativeAdvancementProgress = AdvancementUtils.newAdvancementProgress(nativeCriteriaMap);
AdvancementUtils.startProgress(playerAdvancements, advancement, nativeAdvancementProgress);
}
});
AdvancementUtils.ensureAllVisible(playerAdvancements); // Set all completed advancement is visible
AdvancementUtils.markPlayerAdvancementsFirst(playerAdvancements); // Mark the sending of visible advancement as the first
}
/**
* Update a player's advancements and progress to match the advancementData
*
* @param player The player to set the advancements of
* @param advancementData The ArrayList of {@link me.william278.husksync.bukkit.data.DataSerializer.AdvancementRecordDate}s to set
*/
private static void setPlayerAdvancements(Player player, List<me.william278.husksync.bukkit.data.DataSerializer.AdvancementRecordDate> advancementData, PlayerData data) {
// Temporarily disable advancement announcing if needed
boolean announceAdvancementUpdate = false;
if (Boolean.TRUE.equals(player.getWorld().getGameRuleValue(GameRule.ANNOUNCE_ADVANCEMENTS))) {
player.getWorld().setGameRule(GameRule.ANNOUNCE_ADVANCEMENTS, false);
announceAdvancementUpdate = true;
}
final boolean finalAnnounceAdvancementUpdate = announceAdvancementUpdate;
// Run async because advancement loading is very slow
Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> {
// Apply the advancements to the player
final Iterator<Advancement> serverAdvancements = Bukkit.getServer().advancementIterator();
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);
for (me.william278.husksync.bukkit.data.DataSerializer.AdvancementRecordDate record : advancementData) {
// If the advancement is one on the data
if (record.key().equals(advancement.getKey().getNamespace() + ":" + advancement.getKey().getKey())) {
// 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.criteriaMap().keySet()) {
if (!playerProgress.getAwardedCriteria().contains(awardCriteria)) {
Bukkit.getScheduler().runTask(plugin, () -> player.getAdvancementProgress(advancement).awardCriteria(awardCriteria));
correctExperienceCheck = true;
}
currentlyAwardedCriteria.remove(awardCriteria);
}
// 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;
}
}
// Update the player's experience in case the advancement changed that
if (correctExperienceCheck) {
if (Settings.syncExperience) {
setPlayerExperience(player, data);
}
}
}
// Re-enable announcing advancements (back on main thread again)
Bukkit.getScheduler().runTask(plugin, () -> {
if (finalAnnounceAdvancementUpdate) {
player.getWorld().setGameRule(GameRule.ANNOUNCE_ADVANCEMENTS, true);
}
});
});
}
/**
* Set a player's statistics (in the Statistic menu)
*
* @param player The player to set the statistics of
* @param statisticData The {@link me.william278.husksync.bukkit.data.DataSerializer.StatisticData} to set
*/
private static void setPlayerStatistics(Player player, me.william278.husksync.bukkit.data.DataSerializer.StatisticData statisticData) {
// Set untyped statistics
for (Statistic statistic : statisticData.untypedStatisticValues().keySet()) {
player.setStatistic(statistic, statisticData.untypedStatisticValues().get(statistic));
}
// Set block statistics
for (Statistic statistic : statisticData.blockStatisticValues().keySet()) {
for (Material blockMaterial : statisticData.blockStatisticValues().get(statistic).keySet()) {
player.setStatistic(statistic, blockMaterial, statisticData.blockStatisticValues().get(statistic).get(blockMaterial));
}
}
// Set item statistics
for (Statistic statistic : statisticData.itemStatisticValues().keySet()) {
for (Material itemMaterial : statisticData.itemStatisticValues().get(statistic).keySet()) {
player.setStatistic(statistic, itemMaterial, statisticData.itemStatisticValues().get(statistic).get(itemMaterial));
}
}
// Set entity statistics
for (Statistic statistic : statisticData.entityStatisticValues().keySet()) {
for (EntityType entityType : statisticData.entityStatisticValues().get(statistic).keySet()) {
player.setStatistic(statistic, entityType, statisticData.entityStatisticValues().get(statistic).get(entityType));
}
}
}
/**
* 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 me.william278.husksync.bukkit.data.DataSerializer.PlayerLocation} data
*
* @param player The {@link Player} to teleport
* @param location The {@link me.william278.husksync.bukkit.data.DataSerializer.PlayerLocation}
*/
private static void setPlayerLocation(Player player, me.william278.husksync.bukkit.data.DataSerializer.PlayerLocation location) {
// Don't teleport if the location is invalid
if (location == null) {
return;
}
// Determine the world; if the names match, use that
World world = Bukkit.getWorld(location.worldName());
if (world == null) {
// If the names don't match, find the corresponding world with the same dimension environment
for (World worldOnServer : Bukkit.getWorlds()) {
if (worldOnServer.getEnvironment().equals(location.environment())) {
world = worldOnServer;
}
}
// If that still fails, return
if (world == null) {
return;
}
}
// Teleport the player
player.teleport(new Location(world, location.x(), location.y(), location.z(), location.yaw(), location.pitch()));
}
/**
* Correctly set a {@link Player}'s health data
*
* @param player The {@link Player} to set
* @param health Health to set to the player
* @param maxHealth Max health to set to the player
* @param healthScale Health scaling to apply to the player
*/
private static void setPlayerHealth(Player player, double health, double maxHealth, double healthScale) {
// Set max health
if (maxHealth != 0D) {
Objects.requireNonNull(player.getAttribute(Attribute.GENERIC_MAX_HEALTH)).setBaseValue(maxHealth);
}
// Set health
double currentHealth = player.getHealth();
if (health != currentHealth) player.setHealth(currentHealth > maxHealth ? maxHealth : health);
// Set health scaling if needed
if (healthScale != 0D) {
player.setHealthScale(healthScale);
} else {
player.setHealthScale(maxHealth);
}
player.setHealthScaled(healthScale != 0D);
}
}

@ -1,146 +0,0 @@
package net.william278.husksync.bukkit.util.nms;
import net.william278.husksync.util.ThrowSupplier;
import org.bukkit.advancement.Advancement;
import org.bukkit.entity.Player;
import java.lang.reflect.Field;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.util.Date;
import java.util.Map;
import java.util.Set;
public class AdvancementUtils {
public final static Class<?> PLAYER_ADVANCEMENT;
private final static Field PLAYER_ADVANCEMENTS_MAP;
private final static Field PLAYER_VISIBLE_SET;
private final static Field PLAYER_ADVANCEMENTS;
private final static Field CRITERIA_MAP;
private final static Field CRITERIA_DATE;
private final static Field IS_FIRST_PACKET;
private final static Method GET_HANDLE;
private final static Method START_PROGRESS;
private final static Method ENSURE_ALL_VISIBLE;
private final static Class<?> ADVANCEMENT_PROGRESS;
private final static Class<?> CRITERION_PROGRESS;
static {
Class<?> SERVER_PLAYER = MinecraftVersionUtils.getMinecraftClass("level.EntityPlayer");
PLAYER_ADVANCEMENTS = ThrowSupplier.get(() -> SERVER_PLAYER.getDeclaredField("cs"));
PLAYER_ADVANCEMENTS.setAccessible(true);
Class<?> CRAFT_ADVANCEMENT = MinecraftVersionUtils.getBukkitClass("advancement.CraftAdvancement");
GET_HANDLE = ThrowSupplier.get(() -> CRAFT_ADVANCEMENT.getDeclaredMethod("getHandle"));
ADVANCEMENT_PROGRESS = ThrowSupplier.get(() -> Class.forName("net.minecraft.advancements.AdvancementProgress"));
CRITERIA_MAP = ThrowSupplier.get(() -> ADVANCEMENT_PROGRESS.getDeclaredField("a"));
CRITERIA_MAP.setAccessible(true);
CRITERION_PROGRESS = ThrowSupplier.get(() -> Class.forName("net.minecraft.advancements.CriterionProgress"));
CRITERIA_DATE = ThrowSupplier.get(() -> CRITERION_PROGRESS.getDeclaredField("b"));
CRITERIA_DATE.setAccessible(true);
Class<?> ADVANCEMENT = ThrowSupplier.get(() -> Class.forName("net.minecraft.advancements.Advancement"));
PLAYER_ADVANCEMENT = MinecraftVersionUtils.getMinecraftClass("AdvancementDataPlayer");
PLAYER_ADVANCEMENTS_MAP = ThrowSupplier.get(() -> PLAYER_ADVANCEMENT.getDeclaredField("h"));
PLAYER_ADVANCEMENTS_MAP.setAccessible(true);
PLAYER_VISIBLE_SET = ThrowSupplier.get(() -> PLAYER_ADVANCEMENT.getDeclaredField("i"));
PLAYER_VISIBLE_SET.setAccessible(true);
START_PROGRESS = ThrowSupplier.get(() -> PLAYER_ADVANCEMENT.getDeclaredMethod("a", ADVANCEMENT, ADVANCEMENT_PROGRESS));
START_PROGRESS.setAccessible(true);
ENSURE_ALL_VISIBLE = ThrowSupplier.get(() -> PLAYER_ADVANCEMENT.getDeclaredMethod("c"));
ENSURE_ALL_VISIBLE.setAccessible(true);
IS_FIRST_PACKET = ThrowSupplier.get(() -> PLAYER_ADVANCEMENT.getDeclaredField("n"));
IS_FIRST_PACKET.setAccessible(true);
}
public static void markPlayerAdvancementsFirst(final Object playerAdvancements) {
try {
IS_FIRST_PACKET.set(playerAdvancements, true);
} catch (IllegalAccessException e) {
throw new RuntimeException(e.getMessage(), e);
}
}
public static Object getPlayerAdvancements(Player player) {
Object nativePlayer = EntityUtils.getHandle(player);
try {
return PLAYER_ADVANCEMENTS.get(nativePlayer);
} catch (IllegalAccessException e) {
throw new RuntimeException(e.getMessage(), e);
}
}
public static void clearPlayerAdvancements(final Object playerAdvancement) {
try {
((Map<?, ?>) PLAYER_ADVANCEMENTS_MAP.get(playerAdvancement))
.clear();
} catch (IllegalAccessException e) {
throw new RuntimeException(e.getMessage(), e);
}
}
public static Object getHandle(Advancement advancement) {
try {
return GET_HANDLE.invoke(advancement);
} catch (IllegalAccessException | InvocationTargetException e) {
throw new RuntimeException(e.getMessage(), e);
}
}
public static Object newCriterionProgress(final Date date) {
try {
Object nativeCriterionProgress = CRITERION_PROGRESS.getDeclaredConstructor().newInstance();
CRITERIA_DATE.set(nativeCriterionProgress, date);
return nativeCriterionProgress;
} catch (InstantiationException | IllegalAccessException | InvocationTargetException | NoSuchMethodException e) {
throw new RuntimeException(e.getMessage(), e);
}
}
@SuppressWarnings("unchecked") // Suppress unchecked cast warnings here
public static Object newAdvancementProgress(final Map<String, Object> criteria) {
try {
Object nativeAdvancementProgress = ADVANCEMENT_PROGRESS.getDeclaredConstructor().newInstance();
final Map<String, Object> criteriaMap = (Map<String, Object>) CRITERIA_MAP.get(nativeAdvancementProgress);
criteriaMap.putAll(criteria);
return nativeAdvancementProgress;
} catch (NoSuchMethodException | InvocationTargetException | InstantiationException | IllegalAccessException e) {
throw new RuntimeException(e.getMessage(), e);
}
}
public static void startProgress(final Object playerAdvancements, final Object advancement, final Object advancementProgress) {
try {
START_PROGRESS.invoke(playerAdvancements, advancement, advancementProgress);
} catch (IllegalAccessException | InvocationTargetException e) {
throw new RuntimeException(e.getMessage(), e);
}
}
public static void ensureAllVisible(final Object playerAdvancements) {
try {
ENSURE_ALL_VISIBLE.invoke(playerAdvancements);
} catch (IllegalAccessException | InvocationTargetException e) {
throw new RuntimeException(e.getMessage(), e);
}
}
public static void clearVisibleAdvancements(final Object playerAdvancements) {
try {
((Set<?>) PLAYER_VISIBLE_SET.get(playerAdvancements))
.clear();
} catch (IllegalAccessException e) {
throw new RuntimeException(e.getMessage(), e);
}
}
}

@ -1,26 +0,0 @@
package net.william278.husksync.bukkit.util.nms;
import net.william278.husksync.util.ThrowSupplier;
import org.bukkit.entity.LivingEntity;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
public class EntityUtils {
private final static Method GET_HANDLE;
static {
final Class<?> CRAFT_ENTITY = MinecraftVersionUtils.getBukkitClass("entity.CraftEntity");
GET_HANDLE = ThrowSupplier.get(() -> CRAFT_ENTITY.getDeclaredMethod("getHandle"));
}
public static Object getHandle(LivingEntity livingEntity) throws RuntimeException {
try {
return GET_HANDLE.invoke(livingEntity);
} catch (IllegalAccessException | InvocationTargetException e) {
throw new RuntimeException(e.getMessage(), e);
}
}
}

@ -1,25 +0,0 @@
package net.william278.husksync.bukkit.util.nms;
import net.william278.husksync.util.ThrowSupplier;
import net.william278.husksync.util.VersionUtils;
import org.bukkit.Bukkit;
public class MinecraftVersionUtils {
public final static String CRAFTBUKKIT_PACKAGE_PATH = Bukkit.getServer().getClass().getPackage().getName();
public final static String PACKAGE_VERSION = CRAFTBUKKIT_PACKAGE_PATH.split("\\.")[3];
public final static VersionUtils.Version SERVER_VERSION
= VersionUtils.Version.of(Bukkit.getBukkitVersion().split("-")[0]);
public final static String MINECRAFT_PACKAGE = SERVER_VERSION.compareTo(VersionUtils.Version.of("1.17")) < 0 ?
"net.minecraft.server.".concat(PACKAGE_VERSION) : "net.minecraft.server";
public static Class<?> getBukkitClass(String path) {
return ThrowSupplier.get(() -> Class.forName(CRAFTBUKKIT_PACKAGE_PATH.concat(".").concat(path)));
}
public static Class<?> getMinecraftClass(String path) {
return ThrowSupplier.get(() -> Class.forName(MINECRAFT_PACKAGE.concat(".").concat(path)));
}
}

@ -1,22 +0,0 @@
redis_settings:
host: 'localhost'
port: 6379
password: ''
use_ssl: false
synchronisation_settings:
inventories: true
ender_chests: true
health: true
hunger: true
experience: true
potion_effects: true
statistics: true
game_mode: true
advancements: true
location: false
flight: false
cluster_id: 'main'
check_for_updates: true
synchronization_timeout_retry_delay: 15
save_on_world_save: true
native_advancement_synchronization: false

@ -1,171 +0,0 @@
package net.william278.husksync;
import net.byteflux.libby.BungeeLibraryManager;
import net.byteflux.libby.Library;
import net.md_5.bungee.api.ProxyServer;
import net.md_5.bungee.api.plugin.Plugin;
import net.william278.husksync.bungeecord.command.BungeeCommand;
import net.william278.husksync.bungeecord.config.ConfigLoader;
import net.william278.husksync.bungeecord.config.ConfigManager;
import net.william278.husksync.bungeecord.listener.BungeeEventListener;
import net.william278.husksync.bungeecord.listener.BungeeRedisListener;
import net.william278.husksync.bungeecord.util.BungeeLogger;
import net.william278.husksync.bungeecord.util.BungeeUpdateChecker;
import net.william278.husksync.migrator.MPDBMigrator;
import net.william278.husksync.proxy.data.DataManager;
import net.william278.husksync.redis.RedisMessage;
import net.william278.husksync.util.Logger;
import org.bstats.bungeecord.Metrics;
import java.io.IOException;
import java.util.HashSet;
import java.util.Objects;
import java.util.logging.Level;
public final class HuskSyncBungeeCord extends Plugin {
// BungeeCord bStats ID (different to Bukkit)
private static final int METRICS_ID = 13141;
private static HuskSyncBungeeCord instance;
public static HuskSyncBungeeCord getInstance() {
return instance;
}
// Whether the plugin is ready to accept redis messages
public static boolean readyForRedis = false;
// Whether the plugin is in the process of disabling and should skip responding to handshake confirmations
public static boolean isDisabling = false;
/**
* Set of all the {@link Server}s that have completed the synchronisation handshake with HuskSync on the proxy
*/
public static HashSet<Server> synchronisedServers;
public static DataManager dataManager;
public static MPDBMigrator mpdbMigrator;
public static BungeeRedisListener redisListener;
private Logger logger;
public Logger getBungeeLogger() {
return logger;
}
@Override
public void onLoad() {
instance = this;
logger = new BungeeLogger(getLogger());
fetchDependencies();
}
@Override
public void onEnable() {
// Plugin startup logic
synchronisedServers = new HashSet<>();
// Load config
ConfigManager.loadConfig();
// Load settings from config
ConfigLoader.loadSettings(Objects.requireNonNull(ConfigManager.getConfig()));
// Load messages
ConfigManager.loadMessages();
// Load locales from messages
ConfigLoader.loadMessageStrings(Objects.requireNonNull(ConfigManager.getMessages()));
// Do update checker
if (Settings.automaticUpdateChecks) {
new BungeeUpdateChecker(getDescription().getVersion()).logToConsole();
}
// Setup data manager
dataManager = new DataManager(getBungeeLogger(), getDataFolder());
// Ensure the data manager initialized correctly
if (dataManager.hasFailedInitialization) {
getBungeeLogger().severe("Failed to initialize the HuskSync database(s).\n" +
"HuskSync will now abort loading itself (" + getProxy().getName() + ") v" + getDescription().getVersion());
}
// Setup player data cache
for (Settings.SynchronisationCluster cluster : Settings.clusters) {
dataManager.playerDataCache.put(cluster, new DataManager.PlayerDataCache());
}
// Initialize the redis listener
redisListener = new BungeeRedisListener();
// Register listener
getProxy().getPluginManager().registerListener(this, new BungeeEventListener());
// Register command
getProxy().getPluginManager().registerCommand(this, new BungeeCommand());
// Prepare the migrator for use if needed
mpdbMigrator = new MPDBMigrator(getBungeeLogger());
// Initialize bStats metrics
try {
new Metrics(this, METRICS_ID);
} catch (Exception e) {
getBungeeLogger().info("Skipped metrics initialization");
}
// Log to console
getBungeeLogger().info("Enabled HuskSync (" + getProxy().getName() + ") v" + getDescription().getVersion());
// Mark as ready for redis message processing
readyForRedis = true;
}
@Override
public void onDisable() {
// Plugin shutdown logic
isDisabling = true;
// Send terminating handshake message
for (Server server : synchronisedServers) {
try {
new RedisMessage(RedisMessage.MessageType.TERMINATE_HANDSHAKE,
new RedisMessage.MessageTarget(Settings.ServerType.BUKKIT, null, server.clusterId()),
server.serverUUID().toString(),
ProxyServer.getInstance().getName()).send();
} catch (IOException e) {
getBungeeLogger().log(Level.SEVERE, "Failed to serialize Redis message for handshake termination", e);
}
}
dataManager.closeDatabases();
// Log to console
getBungeeLogger().info("Disabled HuskSync (" + getProxy().getName() + ") v" + getDescription().getVersion());
}
// Load dependencies
private void fetchDependencies() {
BungeeLibraryManager manager = new BungeeLibraryManager(getInstance());
Library mySqlLib = Library.builder()
.groupId("mysql")
.artifactId("mysql-connector-java")
.version("8.0.29")
.build();
Library sqLiteLib = Library.builder()
.groupId("org.xerial")
.artifactId("sqlite-jdbc")
.version("3.36.0.3")
.build();
manager.addMavenCentral();
manager.loadLibrary(mySqlLib);
manager.loadLibrary(sqLiteLib);
}
}

@ -1,424 +0,0 @@
package net.william278.husksync.bungeecord.command;
import de.themoep.minedown.MineDown;
import net.william278.husksync.HuskSyncBungeeCord;
import net.william278.husksync.PlayerData;
import net.william278.husksync.Server;
import net.william278.husksync.Settings;
import net.william278.husksync.bungeecord.config.ConfigLoader;
import net.william278.husksync.bungeecord.config.ConfigManager;
import net.william278.husksync.bungeecord.util.BungeeUpdateChecker;
import net.william278.husksync.migrator.MPDBMigrator;
import net.william278.husksync.proxy.command.HuskSyncCommand;
import net.william278.husksync.redis.RedisMessage;
import net.william278.husksync.util.MessageManager;
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.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 BungeeCommand extends Command implements TabExecutor, HuskSyncCommand {
private final static HuskSyncBungeeCord plugin = HuskSyncBungeeCord.getInstance();
public BungeeCommand() {
super("husksync", null, "hs");
}
@Override
public void execute(CommandSender sender, String[] args) {
if (sender instanceof ProxiedPlayer player) {
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 "update" -> {
if (!player.hasPermission("husksync.command.inventory")) {
sender.sendMessage(new MineDown(MessageManager.getMessage("error_no_permission")).toComponent());
return;
}
sender.sendMessage(new MineDown("[Checking for HuskSync updates...](gray)").toComponent());
ProxyServer.getInstance().getScheduler().runAsync(plugin, () -> {
// Check Bukkit servers needing updates
int updatesNeeded = 0;
String bukkitBrand = "Spigot";
String bukkitVersion = "1.0";
for (Server server : HuskSyncBungeeCord.synchronisedServers) {
BungeeUpdateChecker updateChecker = new BungeeUpdateChecker(server.huskSyncVersion());
if (!updateChecker.isUpToDate()) {
updatesNeeded++;
bukkitBrand = server.serverBrand();
bukkitVersion = server.huskSyncVersion();
}
}
// Check Bungee servers needing updates and send message
BungeeUpdateChecker proxyUpdateChecker = new BungeeUpdateChecker(plugin.getDescription().getVersion());
if (proxyUpdateChecker.isUpToDate() && updatesNeeded == 0) {
sender.sendMessage(new MineDown("[HuskSync](#00fb9a bold) [| HuskSync is up-to-date, running Version " + proxyUpdateChecker.getLatestVersion() + "](#00fb9a)").toComponent());
} else {
sender.sendMessage(new MineDown("[HuskSync](#00fb9a bold) [| Your server(s) are not up-to-date:](#00fb9a)").toComponent());
if (!proxyUpdateChecker.isUpToDate()) {
sender.sendMessage(new MineDown("[•](white) [HuskSync on the " + ProxyServer.getInstance().getName() + " proxy is outdated (Latest: " + proxyUpdateChecker.getLatestVersion() + ", Running: " + proxyUpdateChecker.getCurrentVersion() + ")](#00fb9a)").toComponent());
}
if (updatesNeeded > 0) {
sender.sendMessage(new MineDown("[•](white) [HuskSync on " + updatesNeeded + " connected " + bukkitBrand + " server(s) are outdated (Latest: " + proxyUpdateChecker.getLatestVersion() + ", Running: " + bukkitVersion + ")](#00fb9a)").toComponent());
}
sender.sendMessage(new MineDown("[•](white) [Download links:](#00fb9a) [[⏩ Spigot]](gray open_url=https://www.spigotmc.org/resources/husktowns.92672/updates) [•](#262626) [[⏩ Polymart]](gray open_url=https://polymart.org/resource/husktowns.1056/updates)").toComponent());
}
});
}
case "invsee", "openinv", "inventory" -> {
if (!player.hasPermission("husksync.command.inventory")) {
sender.sendMessage(new MineDown(MessageManager.getMessage("error_no_permission")).toComponent());
return;
}
String clusterId;
if (Settings.clusters.size() > 1) {
if (args.length == 3) {
clusterId = args[2];
} else {
sender.sendMessage(new MineDown(MessageManager.getMessage("error_invalid_cluster")).toComponent());
return;
}
} else {
clusterId = "main";
for (Settings.SynchronisationCluster cluster : Settings.clusters) {
clusterId = cluster.clusterId();
break;
}
}
if (args.length == 2 || args.length == 3) {
String playerName = args[1];
openInventory(player, playerName, clusterId);
} 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;
}
String clusterId;
if (Settings.clusters.size() > 1) {
if (args.length == 3) {
clusterId = args[2];
} else {
sender.sendMessage(new MineDown(MessageManager.getMessage("error_invalid_cluster")).toComponent());
return;
}
} else {
clusterId = "main";
for (Settings.SynchronisationCluster cluster : Settings.clusters) {
clusterId = cluster.clusterId();
break;
}
}
if (args.length == 2 || args.length == 3) {
String playerName = args[1];
openEnderChest(player, playerName, clusterId);
} 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;
}
int playerDataSize = 0;
for (Settings.SynchronisationCluster cluster : Settings.clusters) {
playerDataSize += HuskSyncBungeeCord.dataManager.playerDataCache.get(cluster).playerData.size();
}
sender.sendMessage(new MineDown(MessageManager.PLUGIN_STATUS.toString()
.replaceAll("%1%", String.valueOf(HuskSyncBungeeCord.synchronisedServers.size()))
.replaceAll("%2%", String.valueOf(playerDataSize))).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();
ConfigLoader.loadMessageStrings(Objects.requireNonNull(ConfigManager.getMessages()));
// Send reload request to all bukkit servers
try {
new RedisMessage(RedisMessage.MessageType.RELOAD_CONFIG,
new RedisMessage.MessageTarget(Settings.ServerType.BUKKIT, null, null),
"reload")
.send();
} catch (IOException e) {
plugin.getBungeeLogger().log(Level.WARNING, "Failed to serialize reload notification message data");
}
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")) {
MPDBMigrator migrator = HuskSyncBungeeCord.mpdbMigrator;
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%
targetCluster: %9%
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 %10% 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%", migrator.migrationSettings.sourceHost)
.replaceAll("%2%", String.valueOf(migrator.migrationSettings.sourcePort))
.replaceAll("%3%", migrator.migrationSettings.sourceDatabase)
.replaceAll("%4%", migrator.migrationSettings.sourceUsername)
.replaceAll("%5%", migrator.migrationSettings.sourcePassword)
.replaceAll("%6%", migrator.migrationSettings.inventoryDataTable)
.replaceAll("%7%", migrator.migrationSettings.enderChestDataTable)
.replaceAll("%8%", migrator.migrationSettings.expDataTable)
.replaceAll("%9%", migrator.migrationSettings.targetCluster)
.replaceAll("%10%", Settings.dataStorageType.toString())
).toComponent());
case "setting" -> {
if (args.length == 4) {
String value = args[3];
switch (args[2]) {
case "sourceHost", "host" -> migrator.migrationSettings.sourceHost = value;
case "sourcePort", "port" -> {
try {
migrator.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" -> migrator.migrationSettings.sourceDatabase = value;
case "sourceUsername", "username" -> migrator.migrationSettings.sourceUsername = value;
case "sourcePassword", "password" -> migrator.migrationSettings.sourcePassword = value;
case "sourceInventoryTableName", "inventoryTableName", "inventoryTable" -> migrator.migrationSettings.inventoryDataTable = value;
case "sourceEnderChestTableName", "enderChestTableName", "enderChestTable" -> migrator.migrationSettings.enderChestDataTable = value;
case "sourceExperienceTableName", "experienceTableName", "experienceTable" -> migrator.migrationSettings.expDataTable = value;
case "targetCluster", "cluster" -> migrator.migrationSettings.targetCluster = 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());
// If the migrator is ready, execute the migration asynchronously
if (HuskSyncBungeeCord.mpdbMigrator.readyToMigrate(ProxyServer.getInstance().getOnlineCount(),
HuskSyncBungeeCord.synchronisedServers)) {
ProxyServer.getInstance().getScheduler().runAsync(plugin, () ->
HuskSyncBungeeCord.mpdbMigrator.executeMigrationOperations(HuskSyncBungeeCord.dataManager,
HuskSyncBungeeCord.synchronisedServers, HuskSyncBungeeCord.redisListener));
}
}
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, String clusterId) {
if (viewer.getName().equalsIgnoreCase(targetPlayerName)) {
viewer.sendMessage(new MineDown(MessageManager.getMessage("error_cannot_view_own_inventory")).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, () -> {
for (Settings.SynchronisationCluster cluster : Settings.clusters) {
if (!cluster.clusterId().equals(clusterId)) continue;
PlayerData playerData = HuskSyncBungeeCord.dataManager.getPlayerDataByName(targetPlayerName, cluster.clusterId());
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(), null),
targetPlayerName, RedisMessage.serialize(playerData))
.send();
viewer.sendMessage(new MineDown(MessageManager.getMessage("viewing_inventory_of").replaceAll("%1%",
targetPlayerName)).toComponent());
} catch (IOException e) {
plugin.getBungeeLogger().log(Level.WARNING, "Failed to serialize inventory-see player data", e);
}
return;
}
viewer.sendMessage(new MineDown(MessageManager.getMessage("error_invalid_cluster")).toComponent());
});
}
// View the ender chest of a player specified by their name
public void openEnderChest(ProxiedPlayer viewer, String targetPlayerName, String clusterId) {
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, () -> {
for (Settings.SynchronisationCluster cluster : Settings.clusters) {
if (!cluster.clusterId().equals(clusterId)) continue;
PlayerData playerData = HuskSyncBungeeCord.dataManager.getPlayerDataByName(targetPlayerName, cluster.clusterId());
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(), null),
targetPlayerName, RedisMessage.serialize(playerData))
.send();
viewer.sendMessage(new MineDown(MessageManager.getMessage("viewing_ender_chest_of").replaceAll("%1%",
targetPlayerName)).toComponent());
} catch (IOException e) {
plugin.getBungeeLogger().log(Level.WARNING, "Failed to serialize inventory-see player data", e);
}
return;
}
viewer.sendMessage(new MineDown(MessageManager.getMessage("error_invalid_cluster")).toComponent());
});
}
/**
* Send information about the plugin
*
* @param player The player to send it to
*/
private void sendAboutInformation(ProxiedPlayer player) {
try {
new RedisMessage(RedisMessage.MessageType.SEND_PLUGIN_INFORMATION,
new RedisMessage.MessageTarget(Settings.ServerType.BUKKIT, player.getUniqueId(), null),
plugin.getProxy().getName(), plugin.getDescription().getVersion()).send();
} catch (IOException e) {
plugin.getBungeeLogger().log(Level.WARNING, "Failed to serialize plugin information to send", e);
}
}
// Tab completion
@Override
public Iterable<String> onTabComplete(CommandSender sender, String[] args) {
if (sender instanceof ProxiedPlayer player) {
if (args.length == 1) {
final ArrayList<String> subCommands = new ArrayList<>();
for (SubCommand subCommand : SUB_COMMANDS) {
if (subCommand.permission() != null) {
if (!player.hasPermission(subCommand.permission())) {
continue;
}
}
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();
}
}
return Collections.emptyList();
}
}

@ -1,84 +0,0 @@
package net.william278.husksync.bungeecord.config;
import net.william278.husksync.HuskSyncBungeeCord;
import net.william278.husksync.Settings;
import net.william278.husksync.util.MessageManager;
import net.md_5.bungee.config.Configuration;
import java.util.HashMap;
public class ConfigLoader {
private static final HuskSyncBungeeCord plugin = HuskSyncBungeeCord.getInstance();
private static Configuration copyDefaults(Configuration config) {
// Get the config version and update if needed
String configVersion = config.getString("config_file_version", "1.0");
if (configVersion.contains("-dev")) {
configVersion = configVersion.replaceAll("-dev", "");
}
if (!configVersion.equals(plugin.getDescription().getVersion())) {
if (configVersion.equalsIgnoreCase("1.0")) {
config.set("check_for_updates", true);
}
if (configVersion.equalsIgnoreCase("1.0") || configVersion.equalsIgnoreCase("1.0.1") || configVersion.equalsIgnoreCase("1.0.2") || configVersion.equalsIgnoreCase("1.0.3")) {
config.set("clusters.main.player_table", "husksync_players");
config.set("clusters.main.data_table", "husksync_data");
}
config.set("config_file_version", plugin.getDescription().getVersion());
}
// Save the config back
ConfigManager.saveConfig(config);
return config;
}
public static void loadSettings(Configuration loadedConfig) throws IllegalArgumentException {
Configuration config = copyDefaults(loadedConfig);
Settings.language = config.getString("language", "en-gb");
Settings.serverType = Settings.ServerType.PROXY;
Settings.automaticUpdateChecks = config.getBoolean("check_for_updates", true);
Settings.redisHost = config.getString("redis_settings.host", "localhost");
Settings.redisPort = config.getInt("redis_settings.port", 6379);
Settings.redisPassword = config.getString("redis_settings.password", "");
Settings.redisSSL = config.getBoolean("redis_settings.use_ssl", false);
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", 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");
Settings.mySQLParams = config.getString("data_storage_settings.mysql_settings.params", "?autoReconnect=true&useSSL=false");
}
Settings.hikariMaximumPoolSize = config.getInt("data_storage_settings.hikari_pool_settings.maximum_pool_size", 10);
Settings.hikariMinimumIdle = config.getInt("data_storage_settings.hikari_pool_settings.minimum_idle", 10);
Settings.hikariMaximumLifetime = config.getLong("data_storage_settings.hikari_pool_settings.maximum_lifetime", 1800000);
Settings.hikariKeepAliveTime = config.getLong("data_storage_settings.hikari_pool_settings.keepalive_time", 0);
Settings.hikariConnectionTimeOut = config.getLong("data_storage_settings.hikari_pool_settings.connection_timeout", 5000);
Settings.bounceBackSynchronisation = config.getBoolean("bounce_back_synchronization", true);
// Read cluster data
Configuration section = config.getSection("clusters");
final String settingDatabaseName = Settings.mySQLDatabase != null ? Settings.mySQLDatabase : "HuskSync";
for (String clusterId : section.getKeys()) {
final String playerTableName = config.getString("clusters." + clusterId + ".player_table", "husksync_players");
final String dataTableName = config.getString("clusters." + clusterId + ".data_table", "husksync_data");
final String databaseName = config.getString("clusters." + clusterId + ".database", settingDatabaseName);
Settings.clusters.add(new Settings.SynchronisationCluster(clusterId, databaseName, playerTableName, dataTableName));
}
}
public static void loadMessageStrings(Configuration config) {
final HashMap<String,String> messages = new HashMap<>();
for (String messageId : config.getKeys()) {
messages.put(messageId, config.getString(messageId));
}
MessageManager.setMessages(messages);
}
}

@ -1,81 +0,0 @@
package net.william278.husksync.bungeecord.config;
import net.william278.husksync.HuskSyncBungeeCord;
import net.william278.husksync.Settings;
import net.md_5.bungee.config.Configuration;
import net.md_5.bungee.config.ConfigurationProvider;
import net.md_5.bungee.config.YamlConfiguration;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.util.logging.Level;
public class ConfigManager {
private static final HuskSyncBungeeCord plugin = HuskSyncBungeeCord.getInstance();
public static void loadConfig() {
try {
if (!plugin.getDataFolder().exists()) {
if (plugin.getDataFolder().mkdir()) {
plugin.getBungeeLogger().info("Created HuskSync data folder");
}
}
File configFile = new File(plugin.getDataFolder(), "config.yml");
if (!configFile.exists()) {
Files.copy(plugin.getResourceAsStream("proxy-config.yml"), configFile.toPath());
plugin.getBungeeLogger().info("Created HuskSync config file");
}
} catch (Exception e) {
plugin.getBungeeLogger().log(Level.CONFIG, "An exception occurred loading the configuration file", e);
}
}
public static void saveConfig(Configuration config) {
try {
ConfigurationProvider.getProvider(YamlConfiguration.class).save(config, new File(plugin.getDataFolder(), "config.yml"));
} catch (IOException e) {
plugin.getBungeeLogger().log(Level.CONFIG, "An exception occurred loading the configuration file", e);
}
}
public static void loadMessages() {
try {
if (!plugin.getDataFolder().exists()) {
if (plugin.getDataFolder().mkdir()) {
plugin.getBungeeLogger().info("Created HuskSync data folder");
}
}
File messagesFile = new File(plugin.getDataFolder(), "messages_" + Settings.language + ".yml");
if (!messagesFile.exists()) {
Files.copy(plugin.getResourceAsStream("languages/" + Settings.language + ".yml"), messagesFile.toPath());
plugin.getBungeeLogger().info("Created HuskSync messages file");
}
} catch (Exception e) {
plugin.getBungeeLogger().log(Level.CONFIG, "An exception occurred loading the messages file", e);
}
}
public static Configuration getConfig() {
try {
File configFile = new File(plugin.getDataFolder(), "config.yml");
return ConfigurationProvider.getProvider(YamlConfiguration.class).load(configFile);
} catch (IOException e) {
plugin.getBungeeLogger().log(Level.CONFIG, "An IOException occurred fetching the configuration file", e);
return null;
}
}
public static Configuration getMessages() {
try {
File configFile = new File(plugin.getDataFolder(), "messages_" + Settings.language + ".yml");
return ConfigurationProvider.getProvider(YamlConfiguration.class).load(configFile);
} catch (IOException e) {
plugin.getBungeeLogger().log(Level.CONFIG, "An IOException occurred fetching the messages file", e);
return null;
}
}
}

@ -1,50 +0,0 @@
package net.william278.husksync.bungeecord.listener;
import net.william278.husksync.HuskSyncBungeeCord;
import net.william278.husksync.PlayerData;
import net.william278.husksync.Settings;
import net.william278.husksync.redis.RedisMessage;
import net.md_5.bungee.api.ProxyServer;
import net.md_5.bungee.api.connection.ProxiedPlayer;
import net.md_5.bungee.api.event.PostLoginEvent;
import net.md_5.bungee.api.plugin.Listener;
import net.md_5.bungee.event.EventHandler;
import net.md_5.bungee.event.EventPriority;
import java.io.IOException;
import java.util.Map;
import java.util.logging.Level;
public class BungeeEventListener implements Listener {
private static final HuskSyncBungeeCord plugin = HuskSyncBungeeCord.getInstance();
@EventHandler(priority = EventPriority.LOWEST)
public void onPostLogin(PostLoginEvent event) {
final ProxiedPlayer player = event.getPlayer();
ProxyServer.getInstance().getScheduler().runAsync(plugin, () -> {
// Ensure the player has data on SQL and that it is up-to-date
HuskSyncBungeeCord.dataManager.ensurePlayerExists(player.getUniqueId(), player.getName());
// Get the player's data from SQL
final Map<Settings.SynchronisationCluster, PlayerData> data = HuskSyncBungeeCord.dataManager.getPlayerData(player.getUniqueId());
// Update the player's data from SQL onto the cache
assert data != null;
for (Settings.SynchronisationCluster cluster : data.keySet()) {
HuskSyncBungeeCord.dataManager.playerDataCache.get(cluster).updatePlayer(data.get(cluster));
}
// Send a message asking the bukkit to request data on join
try {
new RedisMessage(RedisMessage.MessageType.REQUEST_DATA_ON_JOIN,
new RedisMessage.MessageTarget(Settings.ServerType.BUKKIT, null, null),
RedisMessage.RequestOnJoinUpdateType.ADD_REQUESTER.toString(), player.getUniqueId().toString()).send();
} catch (IOException e) {
plugin.getBungeeLogger().log(Level.SEVERE, "Failed to serialize request data on join message data");
e.printStackTrace();
}
});
}
}

@ -1,234 +0,0 @@
package net.william278.husksync.bungeecord.listener;
import de.themoep.minedown.MineDown;
import net.william278.husksync.HuskSyncBungeeCord;
import net.william278.husksync.Server;
import net.william278.husksync.util.MessageManager;
import net.william278.husksync.PlayerData;
import net.william278.husksync.Settings;
import net.william278.husksync.migrator.MPDBMigrator;
import net.william278.husksync.redis.RedisListener;
import net.william278.husksync.redis.RedisMessage;
import net.md_5.bungee.api.ChatMessageType;
import net.md_5.bungee.api.ProxyServer;
import net.md_5.bungee.api.connection.ProxiedPlayer;
import java.io.IOException;
import java.util.Objects;
import java.util.UUID;
import java.util.logging.Level;
public class BungeeRedisListener extends RedisListener {
private static final HuskSyncBungeeCord plugin = HuskSyncBungeeCord.getInstance();
// Initialize the listener on the bungee
public BungeeRedisListener() {
super();
listen();
}
private PlayerData getPlayerCachedData(UUID uuid, String clusterId) {
PlayerData data = null;
for (Settings.SynchronisationCluster cluster : Settings.clusters) {
if (cluster.clusterId().equals(clusterId)) {
// Get the player data from the cache
PlayerData cachedData = HuskSyncBungeeCord.dataManager.playerDataCache.get(cluster).getPlayer(uuid);
if (cachedData != null) {
return cachedData;
}
data = Objects.requireNonNull(HuskSyncBungeeCord.dataManager.getPlayerData(uuid)).get(cluster); // Get their player data from MySQL
HuskSyncBungeeCord.dataManager.playerDataCache.get(cluster).updatePlayer(data); // Update the cache
break;
}
}
return data; // Return the data
}
/**
* Handle an incoming {@link RedisMessage}
*
* @param message The {@link RedisMessage} to handle
*/
@Override
public void handleMessage(RedisMessage message) {
// Ignore messages destined for Bukkit servers
if (message.getMessageTarget().targetServerType() != Settings.ServerType.PROXY) {
return;
}
// Only process redis messages when ready
if (!HuskSyncBungeeCord.readyForRedis) {
return;
}
switch (message.getMessageType()) {
case PLAYER_DATA_REQUEST -> ProxyServer.getInstance().getScheduler().runAsync(plugin, () -> {
// Get the UUID of the requesting player
final UUID requestingPlayerUUID = UUID.fromString(message.getMessageData());
try {
// Send the reply, serializing the message data
new RedisMessage(RedisMessage.MessageType.PLAYER_DATA_SET,
new RedisMessage.MessageTarget(Settings.ServerType.BUKKIT, requestingPlayerUUID, message.getMessageTarget().targetClusterId()),
RedisMessage.serialize(getPlayerCachedData(requestingPlayerUUID, message.getMessageTarget().targetClusterId())))
.send();
// Send an update to all bukkit servers removing the player from the requester cache
new RedisMessage(RedisMessage.MessageType.REQUEST_DATA_ON_JOIN,
new RedisMessage.MessageTarget(Settings.ServerType.BUKKIT, null, message.getMessageTarget().targetClusterId()),
RedisMessage.RequestOnJoinUpdateType.REMOVE_REQUESTER.toString(), requestingPlayerUUID.toString())
.send();
// Send synchronisation complete message
ProxiedPlayer player = ProxyServer.getInstance().getPlayer(requestingPlayerUUID);
if (player != null) {
player.sendMessage(ChatMessageType.ACTION_BAR, 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();
}
});
case PLAYER_DATA_UPDATE -> ProxyServer.getInstance().getScheduler().runAsync(plugin, () -> {
// Deserialize the PlayerData received
PlayerData playerData;
final String serializedPlayerData = message.getMessageDataElements()[0];
final boolean bounceBack = Boolean.parseBoolean(message.getMessageDataElements()[1]);
try {
playerData = (PlayerData) RedisMessage.deserialize(serializedPlayerData);
} catch (IOException | ClassNotFoundException e) {
log(Level.SEVERE, "Failed to deserialize PlayerData when handling a player update request");
e.printStackTrace();
return;
}
// Update the data in the cache and SQL
for (Settings.SynchronisationCluster cluster : Settings.clusters) {
if (cluster.clusterId().equals(message.getMessageTarget().targetClusterId())) {
HuskSyncBungeeCord.dataManager.updatePlayerData(playerData, cluster);
break;
}
}
// Reply with the player data if they are still online (switching server)
if (Settings.bounceBackSynchronisation && bounceBack) {
try {
ProxiedPlayer player = ProxyServer.getInstance().getPlayer(playerData.getPlayerUUID());
if (player != null) {
new RedisMessage(RedisMessage.MessageType.PLAYER_DATA_SET,
new RedisMessage.MessageTarget(Settings.ServerType.BUKKIT, playerData.getPlayerUUID(), message.getMessageTarget().targetClusterId()),
serializedPlayerData)
.send();
// Send synchronisation complete message
player.sendMessage(ChatMessageType.ACTION_BAR, new MineDown(MessageManager.getMessage("synchronisation_complete")).toComponent());
}
} catch (IOException e) {
log(Level.SEVERE, "Failed to re-serialize PlayerData when handling a player update request");
e.printStackTrace();
}
}
});
case CONNECTION_HANDSHAKE -> {
// Reply to a Bukkit server's connection handshake to complete the process
if (HuskSyncBungeeCord.isDisabling) return; // Return if the Proxy is disabling
final UUID serverUUID = UUID.fromString(message.getMessageDataElements()[0]);
final boolean hasMySqlPlayerDataBridge = Boolean.parseBoolean(message.getMessageDataElements()[1]);
final String bukkitBrand = message.getMessageDataElements()[2];
final String huskSyncVersion = message.getMessageDataElements()[3];
try {
new RedisMessage(RedisMessage.MessageType.CONNECTION_HANDSHAKE,
new RedisMessage.MessageTarget(Settings.ServerType.BUKKIT, null, message.getMessageTarget().targetClusterId()),
serverUUID.toString(), plugin.getProxy().getName())
.send();
HuskSyncBungeeCord.synchronisedServers.add(
new Server(serverUUID, hasMySqlPlayerDataBridge,
huskSyncVersion, bukkitBrand, message.getMessageTarget().targetClusterId()));
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
Server serverToRemove = null;
for (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;
}
// Get the migrator
MPDBMigrator migrator = HuskSyncBungeeCord.mpdbMigrator;
// Add the incoming data to the data to be saved
migrator.incomingPlayerData.put(playerData, playerName);
// Increment players migrated
migrator.playersMigrated++;
plugin.getBungeeLogger().log(Level.INFO, "Migrated " + migrator.playersMigrated + "/" + migrator.migratedDataSent + " players.");
// When all the data has been received, save it
if (migrator.migratedDataSent == migrator.playersMigrated) {
ProxyServer.getInstance().getScheduler().runAsync(plugin, () -> migrator.loadIncomingData(migrator.incomingPlayerData,
HuskSyncBungeeCord.dataManager));
}
}
case API_DATA_REQUEST -> ProxyServer.getInstance().getScheduler().runAsync(plugin, () -> {
final UUID playerUUID = UUID.fromString(message.getMessageDataElements()[0]);
final UUID requestUUID = UUID.fromString(message.getMessageDataElements()[1]);
try {
final PlayerData data = getPlayerCachedData(playerUUID, message.getMessageTarget().targetClusterId());
if (data == null) {
new RedisMessage(RedisMessage.MessageType.API_DATA_CANCEL,
new RedisMessage.MessageTarget(Settings.ServerType.BUKKIT, null, message.getMessageTarget().targetClusterId()),
requestUUID.toString())
.send();
} else {
// Send the reply alongside the request UUID, serializing the requested message data
new RedisMessage(RedisMessage.MessageType.API_DATA_RETURN,
new RedisMessage.MessageTarget(Settings.ServerType.BUKKIT, null, message.getMessageTarget().targetClusterId()),
requestUUID.toString(),
RedisMessage.serialize(data))
.send();
}
} catch (IOException e) {
plugin.getBungeeLogger().log(Level.SEVERE, "Failed to serialize PlayerData requested via the API");
}
});
}
}
/**
* Log to console
*
* @param level The {@link Level} to log
* @param message Message to log
*/
@Override
public void log(Level level, String message) {
plugin.getBungeeLogger().log(level, message);
}
}

@ -1,33 +0,0 @@
package net.william278.husksync.bungeecord.util;
import net.william278.husksync.util.Logger;
import java.util.logging.Level;
public record BungeeLogger(java.util.logging.Logger parent) implements Logger {
@Override
public void log(Level level, String message, Exception e) {
parent.log(level, message, e);
}
@Override
public void log(Level level, String message) {
parent.log(level, message);
}
@Override
public void info(String message) {
parent.info(message);
}
@Override
public void severe(String message) {
parent.severe(message);
}
@Override
public void config(String message) {
parent.config(message);
}
}

@ -1,20 +0,0 @@
package net.william278.husksync.bungeecord.util;
import net.william278.husksync.HuskSyncBungeeCord;
import net.william278.husksync.util.UpdateChecker;
import java.util.logging.Level;
public class BungeeUpdateChecker extends UpdateChecker {
private static final HuskSyncBungeeCord plugin = HuskSyncBungeeCord.getInstance();
public BungeeUpdateChecker(String versionToCheck) {
super(versionToCheck);
}
@Override
public void log(Level level, String message) {
plugin.getBungeeLogger().log(level, message);
}
}

@ -1,11 +1,23 @@
plugins {
id 'java'
}
dependencies {
compileOnly 'com.zaxxer:HikariCP:5.0.1'
implementation 'commons-io:commons-io:2.11.0'
implementation 'dev.dejvokep:boosted-yaml:1.2'
implementation 'de.themoep:minedown:1.7.1-SNAPSHOT'
implementation 'com.zaxxer:HikariCP:5.0.1'
implementation 'com.google.code.gson:gson:2.9.0'
compileOnly 'org.jetbrains:annotations:23.0.0'
compileOnly 'org.xerial:sqlite-jdbc:' + sqlite_driver_version
compileOnly 'redis.clients:jedis:' + jedis_version
}
shadowJar {
relocate 'com.zaxxer', 'net.william278.husksync.libraries'
relocate 'redis.clients', 'net.william278.husksync.libraries'
relocate 'org.apache', 'net.william278.huskhomes.libraries'
relocate 'dev.dejvokep', 'net.william278.huskhomes.libraries'
relocate 'de.themoep', 'net.william278.huskhomes.libraries'
relocate 'org.jetbrains', 'net.william278.huskhomes.libraries'
relocate 'org.intellij', 'net.william278.huskhomes.libraries'
relocate 'com.zaxxer', 'net.william278.huskhomes.libraries'
relocate 'org.slf4j', 'net.william278.huskhomes.libraries.slf4j'
relocate 'com.google', 'net.william278.huskhomes.libraries'
}

@ -0,0 +1,38 @@
package net.william278.husksync;
import net.william278.husksync.config.Locales;
import net.william278.husksync.config.Settings;
import net.william278.husksync.listener.EventListener;
import net.william278.husksync.player.OnlineUser;
import net.william278.husksync.redis.RedisManager;
import net.william278.husksync.database.Database;
import net.william278.husksync.util.Logger;
import org.jetbrains.annotations.NotNull;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
public interface HuskSync {
@NotNull Set<OnlineUser> getOnlineUsers();
@NotNull Optional<OnlineUser> getOnlineUser(@NotNull UUID uuid);
@NotNull EventListener getEventListener();
@NotNull Database getDatabase();
@NotNull RedisManager getRedisManager();
@NotNull Settings getSettings();
@NotNull Locales getLocales();
@NotNull Logger getLogger();
@NotNull String getVersion();
void reload();
}

@ -1,533 +0,0 @@
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();
}
}

@ -1,10 +0,0 @@
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) {
}

@ -1,99 +0,0 @@
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 saveOnWorldSave;
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) {
}
}

@ -0,0 +1,58 @@
package net.william278.husksync.command;
import net.william278.husksync.HuskSync;
import net.william278.husksync.player.OnlineUser;
import org.jetbrains.annotations.NotNull;
/**
* Represents an abstract cross-platform representation for a plugin command
*/
public abstract class CommandBase {
/**
* The input string to match for this command
*/
public final String command;
/**
* The permission node required to use this command
*/
public final String permission;
/**
* Alias input strings for this command
*/
public final String[] aliases;
/**
* Instance of the implementing plugin
*/
public final HuskSync plugin;
public CommandBase(@NotNull String command, @NotNull Permission permission, @NotNull HuskSync implementor, String... aliases) {
this.command = command;
this.permission = permission.node;
this.plugin = implementor;
this.aliases = aliases;
}
/**
* Fires when the command is executed
*
* @param player {@link OnlineUser} executing the command
* @param args Command arguments
*/
public abstract void onExecute(@NotNull OnlineUser player, @NotNull String[] args);
/**
* Returns the localised description string of this command
*
* @return the command description
*/
public String getDescription() {
return plugin.getLocales().getRawLocale(command + "_command_description")
.orElse("A HuskHomes command");
}
}

@ -0,0 +1,17 @@
package net.william278.husksync.command;
import org.jetbrains.annotations.NotNull;
/**
* Interface providing console execution of commands
*/
public interface ConsoleExecutable {
/**
* What to do when console executes a command
*
* @param args command argument strings
*/
void onConsoleExecute(@NotNull String[] args);
}

@ -0,0 +1,90 @@
package net.william278.husksync.command;
import de.themoep.minedown.MineDown;
import net.william278.husksync.HuskSync;
import net.william278.husksync.config.Locales;
import net.william278.husksync.player.OnlineUser;
import net.william278.husksync.util.UpdateChecker;
import org.jetbrains.annotations.NotNull;
import java.util.List;
import java.util.logging.Level;
public class HuskSyncCommand extends CommandBase implements TabCompletable, ConsoleExecutable {
public HuskSyncCommand(@NotNull HuskSync implementor) {
super("husksync", Permission.COMMAND_HUSKSYNC, implementor);
}
@Override
public void onExecute(@NotNull OnlineUser player, @NotNull String[] args) {
if (args.length < 1) {
displayPluginInformation(player);
return;
}
switch (args[0].toLowerCase()) {
case "update", "version" -> {
if (!player.hasPermission(Permission.COMMAND_HUSKSYNC_UPDATE.node)) {
plugin.getLocales().getLocale("error_no_permission").ifPresent(player::sendMessage);
return;
}
final UpdateChecker updateChecker = new UpdateChecker(plugin.getVersion(), plugin.getLogger());
updateChecker.fetchLatestVersion().thenAccept(latestVersion -> {
if (updateChecker.isUpdateAvailable(latestVersion)) {
player.sendMessage(new MineDown("[HuskSync](#00fb9a bold) [| A new update is available:](#00fb9a) [HuskSync " + updateChecker.fetchLatestVersion() + "](#00fb9a bold)" +
"[•](white) [Currently running:](#00fb9a) [Version " + updateChecker.getCurrentVersion() + "](gray)" +
"[•](white) [Download links:](#00fb9a) [[⏩ Spigot]](gray open_url=https://www.spigotmc.org/resources/husksync.97144/updates) [•](#262626) [[⏩ Polymart]](gray open_url=https://polymart.org/resource/husksync.1634/updates) [•](#262626) [[⏩ Songoda]](gray open_url=https://songoda.com/marketplace/product/husksync-a-modern-cross-server-player-data-synchronization-system.758)"));
} else {
player.sendMessage(new MineDown("[HuskSync](#00fb9a bold) [| HuskSync is up-to-date, running version " + latestVersion));
}
});
}
case "info", "about" -> displayPluginInformation(player);
case "reload" -> {
if (!player.hasPermission(Permission.COMMAND_HUSKSYNC_RELOAD.node)) {
plugin.getLocales().getLocale("error_no_permission").ifPresent(player::sendMessage);
return;
}
plugin.reload();
player.sendMessage(new MineDown("[HuskSync](#00fb9a bold) &#00fb9a&| Reloaded config & message files."));
}
default ->
plugin.getLocales().getLocale("error_invalid_syntax", "/husksync <update|info|reload>").ifPresent(player::sendMessage);
}
}
@Override
public void onConsoleExecute(@NotNull String[] args) {
if (args.length < 1) {
plugin.getLogger().log(Level.INFO, "Console usage: /husksync <update|info|reload|migrate>");
return;
}
switch (args[0].toLowerCase()) {
case "update", "version" -> new UpdateChecker(plugin.getVersion(), plugin.getLogger()).logToConsole();
case "info", "about" -> plugin.getLogger().log(Level.INFO, plugin.getLocales().stripMineDown(
Locales.PLUGIN_INFORMATION.replace("%version%", plugin.getVersion())));
case "reload" -> {
plugin.reload();
plugin.getLogger().log(Level.INFO, "Reloaded config & message files.");
}
case "migrate" -> {
//todo - MPDB migrator
}
default ->
plugin.getLogger().log(Level.INFO, "Invalid syntax. Console usage: /husksync <update|info|reload|migrate>");
}
}
@Override
public List<String> onTabComplete(@NotNull OnlineUser player, @NotNull String[] args) {
return null;
}
private void displayPluginInformation(@NotNull OnlineUser player) {
if (!player.hasPermission(Permission.COMMAND_HUSKSYNC_INFO.node)) {
plugin.getLocales().getLocale("error_no_permission").ifPresent(player::sendMessage);
return;
}
player.sendMessage(new MineDown(Locales.PLUGIN_INFORMATION.replace("%version%", plugin.getVersion())));
}
}

@ -0,0 +1,98 @@
package net.william278.husksync.command;
import org.jetbrains.annotations.NotNull;
/**
* Static plugin permission nodes required to execute commands
*/
public enum Permission {
/*
* /husksync command permissions
*/
/**
* Lets the user use the {@code /husksync} command (subcommand permissions required)
*/
COMMAND_HUSKSYNC("husksync.command.husksync", DefaultAccess.EVERYONE),
/**
* Lets the user view plugin info {@code /husksync info}
*/
COMMAND_HUSKSYNC_INFO("husksync.command.husksync.info", DefaultAccess.EVERYONE),
/**
* Lets the user reload the plugin {@code /husksync reload}
*/
COMMAND_HUSKSYNC_RELOAD("husksync.command.husksync.reload", DefaultAccess.OPERATORS),
/**
* Lets the user view the plugin version and check for updates {@code /husksync update}
*/
COMMAND_HUSKSYNC_UPDATE("husksync.command.husksync.update", DefaultAccess.OPERATORS),
/**
* Lets the user save a player's data {@code /husksync save (player)}
*/
COMMAND_HUSKSYNC_SAVE("husksync.command.husksync.save", DefaultAccess.OPERATORS),
/**
* Lets the user save all online player data {@code /husksync saveall}
*/
COMMAND_HUSKSYNC_SAVE_ALL("husksync.command.husksync.saveall", DefaultAccess.OPERATORS),
/**
* Lets the user view a player's backup data {@code /husksync backup (player)}
*/
COMMAND_HUSKSYNC_BACKUPS("husksync.command.husksync.backups", DefaultAccess.OPERATORS),
/**
* Lets the user restore a player's backup data {@code /husksync backup (player) restore (backup_uuid)}
*/
COMMAND_HUSKSYNC_BACKUPS_RESTORE("husksync.command.husksync.backups.restore", DefaultAccess.OPERATORS),
/*
* /invsee command permissions
*/
/**
* Lets the user use the {@code /invsee (player)} command and view offline players' inventories
*/
COMMAND_VIEW_INVENTORIES("husksync.command.invsee", DefaultAccess.OPERATORS),
/**
* Lets the user edit the contents of offline players' inventories
*/
COMMAND_EDIT_INVENTORIES("husksync.command.invsee.edit", DefaultAccess.OPERATORS),
/*
* /echest command permissions
*/
/**
* Lets the user use the {@code /echest (player)} command and view offline players' ender chests
*/
COMMAND_VIEW_ENDER_CHESTS("husksync.command.echest", DefaultAccess.OPERATORS),
/**
* Lets the user edit the contents of offline players' ender chests
*/
COMMAND_EDIT_ENDER_CHESTS("husksync.command.echest.edit", DefaultAccess.OPERATORS);
public final String node;
public final DefaultAccess defaultAccess;
Permission(@NotNull String node, @NotNull DefaultAccess defaultAccess) {
this.node = node;
this.defaultAccess = defaultAccess;
}
/**
* Identifies who gets what permissions by default
*/
public enum DefaultAccess {
/**
* Everyone gets this permission node by default
*/
EVERYONE,
/**
* Nobody gets this permission node by default
*/
NOBODY,
/**
* Server operators ({@code /op}) get this permission node by default
*/
OPERATORS
}
}

@ -0,0 +1,22 @@
package net.william278.husksync.command;
import net.william278.husksync.player.OnlineUser;
import org.jetbrains.annotations.NotNull;
import java.util.List;
/**
* Interface providing tab completions for a command
*/
public interface TabCompletable {
/**
* What should be returned when the player attempts to TAB-complete the command
*
* @param player {@link OnlineUser} doing the TAB completion
* @param args Current command arguments
* @return List of String arguments to offer TAB suggestions
*/
List<String> onTabComplete(@NotNull OnlineUser player, @NotNull String[] args);
}

@ -0,0 +1,139 @@
package net.william278.husksync.config;
import de.themoep.minedown.MineDown;
import dev.dejvokep.boostedyaml.YamlDocument;
import org.jetbrains.annotations.NotNull;
import java.util.HashMap;
import java.util.Optional;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* Loaded locales used by the plugin to display various locales
*/
public class Locales {
public static final String PLUGIN_INFORMATION = """
[HuskSync](#00fb9a bold) [| Version %version%(#00fb9a)
[A modern, cross-server player data synchronization system](gray)
[ Author:](white) [William278](gray show_text=&7Click to visit website open_url=https://william278.net)
[ Contributors:](white) [HarvelsX](gray show_text=&7Code)
[ 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)
[ Plugin Info:](white) [[Link]](#00fb9a show_text=&7Click to open link open_url=https://github.com/WiIIiam278/HuskSync/)
[ Report Issues:](white) [[Link]](#00fb9a show_text=&7Click to open link open_url=https://github.com/WiIIiam278/HuskSync/issues)
[ Support Discord:](white) [[Link]](#00fb9a show_text=&7Click to join open_url=https://discord.gg/tVYhJfyDWG)""";
@NotNull
private final HashMap<String, String> rawLocales;
private Locales(@NotNull YamlDocument localesConfig) {
this.rawLocales = new HashMap<>();
for (String localeId : localesConfig.getRoutesAsStrings(false)) {
rawLocales.put(localeId, localesConfig.getString(localeId));
}
}
/**
* Returns an un-formatted locale loaded from the locales file
*
* @param localeId String identifier of the locale, corresponding to a key in the file
* @return An {@link Optional} containing the locale corresponding to the id, if it exists
*/
public Optional<String> getRawLocale(@NotNull String localeId) {
if (rawLocales.containsKey(localeId)) {
return Optional.of(rawLocales.get(localeId));
}
return Optional.empty();
}
/**
* Returns an un-formatted locale loaded from the locales file, with replacements applied
*
* @param localeId String identifier of the locale, corresponding to a key in the file
* @param replacements Ordered array of replacement strings to fill in placeholders with
* @return An {@link Optional} containing the replacement-applied locale corresponding to the id, if it exists
*/
public Optional<String> getRawLocale(@NotNull String localeId, @NotNull String... replacements) {
return getRawLocale(localeId).map(locale -> applyReplacements(locale, replacements));
}
/**
* Returns a MineDown-formatted locale from the locales file
*
* @param localeId String identifier of the locale, corresponding to a key in the file
* @return An {@link Optional} containing the formatted locale corresponding to the id, if it exists
*/
public Optional<MineDown> getLocale(@NotNull String localeId) {
return getRawLocale(localeId).map(MineDown::new);
}
/**
* Returns a MineDown-formatted locale from the locales file, with replacements applied
*
* @param localeId String identifier of the locale, corresponding to a key in the file
* @param replacements Ordered array of replacement strings to fill in placeholders with
* @return An {@link Optional} containing the replacement-applied, formatted locale corresponding to the id, if it exists
*/
public Optional<MineDown> getLocale(@NotNull String localeId, @NotNull String... replacements) {
return getRawLocale(localeId, replacements).map(MineDown::new);
}
/**
* Apply placeholder replacements to a raw locale
*
* @param rawLocale The raw, unparsed locale
* @param replacements Ordered array of replacement strings to fill in placeholders with
* @return the raw locale, with inserted placeholders
*/
private String applyReplacements(@NotNull String rawLocale, @NotNull String... replacements) {
int replacementIndexer = 1;
for (String replacement : replacements) {
String replacementString = "%" + replacementIndexer + "%";
rawLocale = rawLocale.replace(replacementString, replacement);
replacementIndexer = replacementIndexer + 1;
}
return rawLocale;
}
/**
* Load the locales from a BoostedYaml {@link YamlDocument} locales file
*
* @param localesConfig The loaded {@link YamlDocument} locales.yml file
* @return the loaded {@link Locales}
*/
public static Locales load(@NotNull YamlDocument localesConfig) {
return new Locales(localesConfig);
}
/**
* Strips a string of basic MineDown formatting, used for displaying plugin info to console
*
* @param string The string to strip
* @return The MineDown-stripped string
*/
public String stripMineDown(@NotNull String string) {
final String[] in = string.split("\n");
final StringBuilder out = new StringBuilder();
String regex = "[^\\[\\]() ]*\\[([^()]+)]\\([^()]+open_url=(\\S+).*\\)";
for (int i = 0; i < in.length; i++) {
Pattern pattern = Pattern.compile(regex);
Matcher m = pattern.matcher(in[i]);
if (m.find()) {
out.append(in[i].replace(m.group(0), ""));
out.append(m.group(2));
} else {
out.append(in[i]);
}
if (i + 1 != in.length) {
out.append("\n");
}
}
return out.toString();
}
}

@ -0,0 +1,270 @@
package net.william278.husksync.config;
import dev.dejvokep.boostedyaml.YamlDocument;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
/**
* Settings used for the plugin, as read from the config file
*/
public class Settings {
/**
* Map of {@link ConfigOption}s read from the config file
*/
private final HashMap<ConfigOption, Object> configOptions;
// Load the settings from the document
private Settings(@NotNull YamlDocument config) {
this.configOptions = new HashMap<>();
Arrays.stream(ConfigOption.values()).forEach(configOption -> configOptions
.put(configOption, switch (configOption.optionType) {
case BOOLEAN -> configOption.getBooleanValue(config);
case STRING -> configOption.getStringValue(config);
case DOUBLE -> configOption.getDoubleValue(config);
case FLOAT -> configOption.getFloatValue(config);
case INTEGER -> configOption.getIntValue(config);
case STRING_LIST -> configOption.getStringListValue(config);
}));
}
/**
* Get the value of the specified {@link ConfigOption}
*
* @param option the {@link ConfigOption} to check
* @return the value of the {@link ConfigOption} as a boolean
* @throws ClassCastException if the option is not a boolean
*/
public boolean getBooleanValue(@NotNull ConfigOption option) throws ClassCastException {
return (Boolean) configOptions.get(option);
}
/**
* Get the value of the specified {@link ConfigOption}
*
* @param option the {@link ConfigOption} to check
* @return the value of the {@link ConfigOption} as a string
* @throws ClassCastException if the option is not a string
*/
public String getStringValue(@NotNull ConfigOption option) throws ClassCastException {
return (String) configOptions.get(option);
}
/**
* Get the value of the specified {@link ConfigOption}
*
* @param option the {@link ConfigOption} to check
* @return the value of the {@link ConfigOption} as a double
* @throws ClassCastException if the option is not a double
*/
public double getDoubleValue(@NotNull ConfigOption option) throws ClassCastException {
return (Double) configOptions.get(option);
}
/**
* Get the value of the specified {@link ConfigOption}
*
* @param option the {@link ConfigOption} to check
* @return the value of the {@link ConfigOption} as a float
* @throws ClassCastException if the option is not a float
*/
public double getFloatValue(@NotNull ConfigOption option) throws ClassCastException {
return (Float) configOptions.get(option);
}
/**
* Get the value of the specified {@link ConfigOption}
*
* @param option the {@link ConfigOption} to check
* @return the value of the {@link ConfigOption} as an integer
* @throws ClassCastException if the option is not an integer
*/
public int getIntegerValue(@NotNull ConfigOption option) throws ClassCastException {
return (Integer) configOptions.get(option);
}
/**
* Get the value of the specified {@link ConfigOption}
*
* @param option the {@link ConfigOption} to check
* @return the value of the {@link ConfigOption} as a string {@link List}
* @throws ClassCastException if the option is not a string list
*/
@SuppressWarnings("unchecked")
public List<String> getStringListValue(@NotNull ConfigOption option) throws ClassCastException {
return (List<String>) configOptions.get(option);
}
/**
* Load the settings from a BoostedYaml {@link YamlDocument} config file
*
* @param config The loaded {@link YamlDocument} config.yml file
* @return the loaded {@link Settings}
*/
public static Settings load(@NotNull YamlDocument config) {
return new Settings(config);
}
/**
* Represents an option stored by a path in config.yml
*/
public enum ConfigOption {
LANGUAGE("language", OptionType.STRING, "en-gb"),
CHECK_FOR_UPDATES("check_for_updates", OptionType.BOOLEAN, true),
CLUSTER_ID("cluster_id", OptionType.STRING, ""), //todo implement this
DATABASE_HOST("database.credentials.host", OptionType.STRING, "localhost"),
DATABASE_PORT("database.credentials.port", OptionType.INTEGER, 3306),
DATABASE_NAME("database.credentials.database", OptionType.STRING, "HuskSync"),
DATABASE_USERNAME("database.credentials.username", OptionType.STRING, "root"),
DATABASE_PASSWORD("database.credentials.password", OptionType.STRING, "pa55w0rd"),
DATABASE_CONNECTION_PARAMS("database.credentials.params", OptionType.STRING, "?autoReconnect=true&useSSL=false"),
DATABASE_CONNECTION_POOL_MAX_SIZE("database.connection_pool.maximum_pool_size", OptionType.INTEGER, 10),
DATABASE_CONNECTION_POOL_MIN_IDLE("database.connection_pool.minimum_idle", OptionType.INTEGER, 10),
DATABASE_CONNECTION_POOL_MAX_LIFETIME("database.connection_pool.maximum_lifetime", OptionType.INTEGER, 1800000),
DATABASE_CONNECTION_POOL_KEEPALIVE("database.connection_pool.keepalive_time", OptionType.INTEGER, 0),
DATABASE_CONNECTION_POOL_TIMEOUT("database.connection_pool.connection_timeout", OptionType.INTEGER, 5000),
DATABASE_PLAYERS_TABLE_NAME("database.table_names.players_table", OptionType.STRING, "husksync_players"),
DATABASE_DATA_TABLE_NAME("database.table_names.data_table", OptionType.STRING, "husksync_data"),
REDIS_HOST("redis.credentials.host", OptionType.STRING, "localhost"),
REDIS_PORT("redis.credentials.port", OptionType.INTEGER, 6379),
REDIS_PASSWORD("redis.credentials.password", OptionType.STRING, ""),
REDIS_USE_SSL("redis.use_ssl", OptionType.BOOLEAN, false),
SYNCHRONIZATION_MAX_USER_DATA_RECORDS("synchronization.max_user_data_records", OptionType.INTEGER, 5),
SYNCHRONIZATION_SAVE_ON_WORLD_SAVE("synchronization.save_on_world_save", OptionType.BOOLEAN, true),
SYNCHRONIZATION_SYNC_INVENTORIES("synchronization.features.inventories", OptionType.BOOLEAN, true),
SYNCHRONIZATION_SYNC_ENDER_CHESTS("synchronization.features.ender_chests", OptionType.BOOLEAN, true),
SYNCHRONIZATION_SYNC_HEALTH("synchronization.features.health", OptionType.BOOLEAN, true),
SYNCHRONIZATION_SYNC_MAX_HEALTH("synchronization.features.max_health", OptionType.BOOLEAN, true),
SYNCHRONIZATION_SYNC_HUNGER("synchronization.features.hunger", OptionType.BOOLEAN, true),
SYNCHRONIZATION_SYNC_EXPERIENCE("synchronization.features.experience", OptionType.BOOLEAN, true),
SYNCHRONIZATION_SYNC_POTION_EFFECTS("synchronization.features.potion_effects", OptionType.BOOLEAN, true),
SYNCHRONIZATION_SYNC_ADVANCEMENTS("synchronization.features.advancements", OptionType.BOOLEAN, true),
SYNCHRONIZATION_SYNC_GAME_MODE("synchronization.features.game_mode", OptionType.BOOLEAN, true),
SYNCHRONIZATION_SYNC_STATISTICS("synchronization.features.statistics", OptionType.BOOLEAN, true),
SYNCHRONIZATION_SYNC_PERSISTENT_DATA_CONTAINER("synchronization.features.persistent_data_container", OptionType.BOOLEAN, true),
SYNCHRONIZATION_SYNC_LOCATION("synchronization.features.location", OptionType.BOOLEAN, true);
/**
* The path in the config.yml file to the value
*/
@NotNull
public final String configPath;
/**
* The {@link OptionType} of this option
*/
@NotNull
public final OptionType optionType;
/**
* The default value of this option if not set in config
*/
@Nullable
private final Object defaultValue;
ConfigOption(@NotNull String configPath, @NotNull OptionType optionType, @Nullable Object defaultValue) {
this.configPath = configPath;
this.optionType = optionType;
this.defaultValue = defaultValue;
}
ConfigOption(@NotNull String configPath, @NotNull OptionType optionType) {
this.configPath = configPath;
this.optionType = optionType;
this.defaultValue = null;
}
/**
* Get the value at the path specified (or return default if set), as a string
*
* @param config The {@link YamlDocument} config file
* @return the value defined in the config, as a string
*/
public String getStringValue(@NotNull YamlDocument config) {
return defaultValue != null
? config.getString(configPath, (String) defaultValue)
: config.getString(configPath);
}
/**
* Get the value at the path specified (or return default if set), as a boolean
*
* @param config The {@link YamlDocument} config file
* @return the value defined in the config, as a boolean
*/
public boolean getBooleanValue(@NotNull YamlDocument config) {
return defaultValue != null
? config.getBoolean(configPath, (Boolean) defaultValue)
: config.getBoolean(configPath);
}
/**
* Get the value at the path specified (or return default if set), as a double
*
* @param config The {@link YamlDocument} config file
* @return the value defined in the config, as a double
*/
public double getDoubleValue(@NotNull YamlDocument config) {
return defaultValue != null
? config.getDouble(configPath, (Double) defaultValue)
: config.getDouble(configPath);
}
/**
* Get the value at the path specified (or return default if set), as a float
*
* @param config The {@link YamlDocument} config file
* @return the value defined in the config, as a float
*/
public float getFloatValue(@NotNull YamlDocument config) {
return defaultValue != null
? config.getFloat(configPath, (Float) defaultValue)
: config.getFloat(configPath);
}
/**
* Get the value at the path specified (or return default if set), as an int
*
* @param config The {@link YamlDocument} config file
* @return the value defined in the config, as an int
*/
public int getIntValue(@NotNull YamlDocument config) {
return defaultValue != null
? config.getInt(configPath, (Integer) defaultValue)
: config.getInt(configPath);
}
/**
* Get the value at the path specified (or return default if set), as a string {@link List}
*
* @param config The {@link YamlDocument} config file
* @return the value defined in the config, as a string {@link List}
*/
public List<String> getStringListValue(@NotNull YamlDocument config) {
return config.getStringList(configPath, new ArrayList<>());
}
/**
* Represents the type of the object
*/
public enum OptionType {
BOOLEAN,
STRING,
DOUBLE,
FLOAT,
INTEGER,
STRING_LIST
}
}
}

@ -0,0 +1,28 @@
package net.william278.husksync.data;
import com.google.gson.annotations.SerializedName;
import java.util.Date;
import java.util.Map;
/**
* A mapped piece of advancement data
*/
public class AdvancementData {
/**
* The advancement namespaced key
*/
@SerializedName("key")
public String key;
/**
* A map of completed advancement criteria to when it was completed
*/
@SerializedName("completed_criteria")
public Map<String, Date> completedCriteria;
public AdvancementData() {
}
}

@ -0,0 +1,24 @@
package net.william278.husksync.data;
import com.google.gson.annotations.SerializedName;
import org.jetbrains.annotations.NotNull;
/**
* Stores information about a player's inventory or ender chest
*/
public class InventoryData {
/**
* A base64 string of platform-serialized inventory data
*/
@SerializedName("serialized_inventory")
public String serializedInventory;
public InventoryData(@NotNull final String serializedInventory) {
this.serializedInventory = serializedInventory;
}
public InventoryData() {
}
}

@ -0,0 +1,72 @@
package net.william278.husksync.data;
import com.google.gson.annotations.SerializedName;
import org.jetbrains.annotations.NotNull;
import java.util.UUID;
/**
* Stores information about a player's location
*/
public class LocationData {
/**
* Name of the world on the server
*/
@SerializedName("world_name")
public String worldName;
/**
* Unique id of the world
*/
@SerializedName("world_uuid")
public UUID worldUuid;
/**
* The environment type of the world (one of "NORMAL", "NETHER", "THE_END")
*/
@SerializedName("world_environment")
public String worldEnvironment;
/**
* The x coordinate of the location
*/
@SerializedName("x")
public double x;
/**
* The y coordinate of the location
*/
@SerializedName("y")
public double y;
/**
* The z coordinate of the location
*/
@SerializedName("z")
public double z;
/**
* The location's facing yaw angle
*/
@SerializedName("yaw")
public float yaw;
/**
* The location's facing pitch angle
*/
@SerializedName("pitch")
public float pitch;
public LocationData() {
}
public LocationData(@NotNull String worldName, @NotNull UUID worldUuid,
@NotNull String worldEnvironment,
double x, double y, double z,
float yaw, float pitch) {
this.worldName = worldName;
this.worldUuid = worldUuid;
this.worldEnvironment = worldEnvironment;
this.x = x;
this.y = y;
this.z = z;
this.yaw = yaw;
this.pitch = pitch;
}
}

@ -0,0 +1,24 @@
package net.william278.husksync.data;
import com.google.gson.annotations.SerializedName;
import org.jetbrains.annotations.NotNull;
/**
* Store's a user's persistent data container, holding a map of plugin-set persistent values
*/
public class PersistentDataContainerData {
/**
* A base64 string of platform-serialized PersistentDataContainer data
*/
@SerializedName("serialized_persistent_data_container")
public String serializedPersistentDataContainer;
public PersistentDataContainerData(@NotNull final String serializedPersistentDataContainer) {
this.serializedPersistentDataContainer = serializedPersistentDataContainer;
}
public PersistentDataContainerData() {
}
}

@ -0,0 +1,21 @@
package net.william278.husksync.data;
import com.google.gson.annotations.SerializedName;
import org.jetbrains.annotations.NotNull;
/**
* Stores potion effect data
*/
public class PotionEffectData {
@SerializedName("serialized_potion_effects")
public String serializedPotionEffects;
public PotionEffectData(@NotNull final String serializedInventory) {
this.serializedPotionEffects = serializedInventory;
}
public PotionEffectData() {
}
}

@ -0,0 +1,50 @@
package net.william278.husksync.data;
import com.google.gson.annotations.SerializedName;
import org.jetbrains.annotations.NotNull;
import java.util.HashMap;
/**
* Stores information about a player's statistics
*/
public class StatisticsData {
/**
* Map of untyped statistic names to their values
*/
@SerializedName("untyped_statistics")
public HashMap<String, Integer> untypedStatistic;
/**
* Map of block type statistics to a map of material types to values
*/
@SerializedName("block_statistics")
public HashMap<String, HashMap<String, Integer>> blockStatistics;
/**
* Map of item type statistics to a map of material types to values
*/
@SerializedName("item_statistics")
public HashMap<String, HashMap<String, Integer>> itemStatistics;
/**
* Map of entity type statistics to a map of entity types to values
*/
@SerializedName("entity_statistics")
public HashMap<String, HashMap<String, Integer>> entityStatistics;
public StatisticsData(@NotNull HashMap<String, Integer> untypedStatistic,
@NotNull HashMap<String, HashMap<String, Integer>> blockStatistics,
@NotNull HashMap<String, HashMap<String, Integer>> itemStatistics,
@NotNull HashMap<String, HashMap<String, Integer>> entityStatistics) {
this.untypedStatistic = untypedStatistic;
this.blockStatistics = blockStatistics;
this.itemStatistics = itemStatistics;
this.entityStatistics = entityStatistics;
}
public StatisticsData() {
}
}

@ -0,0 +1,103 @@
package net.william278.husksync.data;
import com.google.gson.annotations.SerializedName;
/**
* Stores status information about a player
*/
public class StatusData {
/**
* The player's health points
*/
@SerializedName("health")
public double health;
/**
* The player's maximum health points
*/
@SerializedName("max_health")
public double maxHealth;
/**
* The player's health scaling factor
*/
@SerializedName("health_scale")
public double healthScale;
/**
* The player's hunger points
*/
@SerializedName("hunger")
public int hunger;
/**
* The player's saturation points
*/
@SerializedName("saturation")
public float saturation;
/**
* The player's saturation exhaustion points
*/
@SerializedName("saturation_exhaustion")
public float saturationExhaustion;
/**
* The player's currently selected item slot
*/
@SerializedName("selected_item_slot")
public int selectedItemSlot;
/**
* The player's total experience points<p>
* (not to be confused with <i>experience level</i> - this is the "points" value shown on the death screen)
*/
@SerializedName("total_experience")
public int totalExperience;
/**
* The player's experience level (shown on the exp bar)
*/
@SerializedName("experience_level")
public int expLevel;
/**
* The player's progress to their next experience level
*/
@SerializedName("experience_progress")
public float expProgress;
/**
* The player's game mode string (one of "survival", "creative", "adventure", "spectator")
*/
@SerializedName("game_mode")
public String gameMode;
/**
* If the player is currently flying
*/
@SerializedName("is_flying")
public boolean isFlying;
public StatusData(final double health, final double maxHealth, final double healthScale,
final int hunger, final float saturation, final float saturationExhaustion,
final int selectedItemSlot, final int totalExperience, final int expLevel,
final float expProgress, final String gameMode, final boolean isFlying) {
this.health = health;
this.maxHealth = maxHealth;
this.healthScale = healthScale;
this.hunger = hunger;
this.saturation = saturation;
this.saturationExhaustion = saturationExhaustion;
this.selectedItemSlot = selectedItemSlot;
this.totalExperience = totalExperience;
this.expLevel = expLevel;
this.expProgress = expProgress;
this.gameMode = gameMode;
this.isFlying = isFlying;
}
public StatusData() {
}
}

@ -0,0 +1,159 @@
package net.william278.husksync.data;
import com.google.gson.GsonBuilder;
import com.google.gson.JsonSyntaxException;
import com.google.gson.annotations.SerializedName;
import org.jetbrains.annotations.NotNull;
import java.time.Instant;
import java.util.HashSet;
import java.util.UUID;
/***
* Stores data about a user
*/
public class UserData implements Comparable<UserData> {
/**
* The unique identifier for this user data version
*/
protected UUID dataUuidVersion;
/**
* An epoch milliseconds timestamp of when this data was created
*/
protected long creationTimestamp;
/**
* Stores the user's status data, including health, food, etc.
*/
@SerializedName("status")
protected StatusData statusData;
/**
* Stores the user's inventory contents
*/
@SerializedName("inventory")
protected InventoryData inventoryData;
/**
* Stores the user's ender chest contents
*/
@SerializedName("ender_chest")
protected InventoryData enderChestData;
/**
* Store's the user's potion effects
*/
@SerializedName("potion_effects")
protected PotionEffectData potionEffectData;
/**
* Stores the set of this user's advancements
*/
@SerializedName("advancements")
protected HashSet<AdvancementData> advancementData;
/**
* Stores the user's set of statistics
*/
@SerializedName("statistics")
protected StatisticsData statisticData;
/**
* Store's the user's world location and coordinates
*/
@SerializedName("location")
protected LocationData locationData;
/**
* Stores the user's serialized persistent data container, which contains metadata keys applied by other plugins
*/
@SerializedName("persistent_data_container")
protected PersistentDataContainerData persistentDataContainerData;
public UserData(@NotNull StatusData statusData, @NotNull InventoryData inventoryData,
@NotNull InventoryData enderChestData, @NotNull PotionEffectData potionEffectData,
@NotNull HashSet<AdvancementData> advancementData, @NotNull StatisticsData statisticData,
@NotNull LocationData locationData, @NotNull PersistentDataContainerData persistentDataContainerData) {
this.dataUuidVersion = UUID.randomUUID();
this.creationTimestamp = Instant.now().toEpochMilli();
this.statusData = statusData;
this.inventoryData = inventoryData;
this.enderChestData = enderChestData;
this.potionEffectData = potionEffectData;
this.advancementData = advancementData;
this.statisticData = statisticData;
this.locationData = locationData;
this.persistentDataContainerData = persistentDataContainerData;
}
protected UserData() {
}
/**
* Compare UserData by creation timestamp
*
* @param other the other UserData to be compared
* @return the comparison result; the more recent UserData is greater than the less recent UserData
*/
@Override
public int compareTo(@NotNull UserData other) {
return Long.compare(this.creationTimestamp, other.creationTimestamp);
}
@NotNull
public static UserData fromJson(String json) throws JsonSyntaxException {
return new GsonBuilder().create().fromJson(json, UserData.class);
}
@NotNull
public String toJson() {
return new GsonBuilder().create().toJson(this);
}
public void setMetadata(@NotNull UUID dataUuidVersion, long creationTimestamp) {
this.dataUuidVersion = dataUuidVersion;
this.creationTimestamp = creationTimestamp;
}
public UUID getDataUuidVersion() {
return dataUuidVersion;
}
public long getCreationTimestamp() {
return creationTimestamp;
}
public StatusData getStatusData() {
return statusData;
}
public InventoryData getInventoryData() {
return inventoryData;
}
public InventoryData getEnderChestData() {
return enderChestData;
}
public PotionEffectData getPotionEffectData() {
return potionEffectData;
}
public HashSet<AdvancementData> getAdvancementData() {
return advancementData;
}
public StatisticsData getStatisticData() {
return statisticData;
}
public LocationData getLocationData() {
return locationData;
}
public PersistentDataContainerData getPersistentDataContainerData() {
return persistentDataContainerData;
}
}

@ -0,0 +1,156 @@
package net.william278.husksync.database;
import net.william278.husksync.data.UserData;
import net.william278.husksync.player.User;
import net.william278.husksync.util.Logger;
import net.william278.husksync.util.ResourceReader;
import org.jetbrains.annotations.NotNull;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
/**
* An abstract representation of the plugin database, storing player data.
* <p>
* Implemented by different database platforms - MySQL, SQLite, etc. - as configured by the administrator.
*/
public abstract class Database {
/**
* Name of the table that stores player information
*/
protected final String playerTableName;
/**
* Name of the table that stores data
*/
protected final String dataTableName;
/**
* The maximum number of user records to store in the database at once per user
*/
protected final int maxUserDataRecords;
/**
* Logger instance used for database error logging
*/
private final Logger logger;
/**
* Returns the {@link Logger} used to log database errors
*
* @return the {@link Logger} instance
*/
protected Logger getLogger() {
return logger;
}
/**
* The {@link ResourceReader} used to read internal resource files by name
*/
private final ResourceReader resourceReader;
protected Database(@NotNull String playerTableName, @NotNull String dataTableName, final int maxUserDataRecords,
@NotNull ResourceReader resourceReader, @NotNull Logger logger) {
this.playerTableName = playerTableName;
this.dataTableName = dataTableName;
this.maxUserDataRecords = maxUserDataRecords;
this.resourceReader = resourceReader;
this.logger = logger;
}
/**
* Loads SQL table creation schema statements from a resource file as a string array
*
* @param schemaFileName database script resource file to load from
* @return Array of string-formatted table creation schema statements
* @throws IOException if the resource could not be read
*/
protected final String[] getSchemaStatements(@NotNull String schemaFileName) throws IOException {
return formatStatementTables(
new String(resourceReader.getResource(schemaFileName)
.readAllBytes(), StandardCharsets.UTF_8))
.split(";");
}
/**
* Format all table name placeholder strings in a SQL statement
*
* @param sql the SQL statement with un-formatted table name placeholders
* @return the formatted statement, with table placeholders replaced with the correct names
*/
protected final String formatStatementTables(@NotNull String sql) {
return sql.replaceAll("%players_table%", playerTableName)
.replaceAll("%data_table%", dataTableName);
}
/**
* Initialize the database and ensure tables are present; create tables if they do not exist.
*
* @return A future returning void when complete
*/
public abstract CompletableFuture<Void> initialize();
/**
* Ensure a {@link User} has an entry in the database and that their username is up-to-date
*
* @param user The {@link User} to ensure
* @return A future returning void when complete
*/
public abstract CompletableFuture<Void> ensureUser(@NotNull User user);
/**
* Get a player by their Minecraft account {@link UUID}
*
* @param uuid Minecraft account {@link UUID} of the {@link User} to get
* @return A future returning an optional with the {@link User} present if they exist
*/
public abstract CompletableFuture<Optional<User>> getUser(@NotNull UUID uuid);
/**
* Get a user by their username (<i>case-insensitive</i>)
*
* @param username Username of the {@link User} to get (<i>case-insensitive</i>)
* @return A future returning an optional with the {@link User} present if they exist
*/
public abstract CompletableFuture<Optional<User>> getUserByName(@NotNull String username);
/**
* Get the current user data for a given user, if it exists.
*
* @param user the user to get data for
* @return an optional containing the user data, if it exists, or an empty optional if it does not
*/
public abstract CompletableFuture<Optional<UserData>> getCurrentUserData(@NotNull User user);
/**
* Get all UserData entries for a user from the database.
*
* @param user The user to get data for
* @return A future returning a list of a user's data
*/
public abstract CompletableFuture<List<UserData>> getUserData(@NotNull User user);
/**
* Prune user data records for a given user to the maximum value as configured
*
* @param user The user to prune data for
* @return A future returning void when complete
*/
protected abstract CompletableFuture<Void> pruneUserDataRecords(@NotNull User user);
/**
* Add user data to the database<p>
* This will remove the oldest data for the user if the amount of data exceeds the limit as configured
*
* @param user The user to add data for
* @param userData The data to add
* @return A future returning void when complete
*/
public abstract CompletableFuture<Void> setUserData(@NotNull User user, @NotNull UserData userData);
}

@ -0,0 +1,289 @@
package net.william278.husksync.database;
import com.zaxxer.hikari.HikariDataSource;
import net.william278.husksync.config.Settings;
import net.william278.husksync.data.UserData;
import net.william278.husksync.player.User;
import net.william278.husksync.util.Logger;
import net.william278.husksync.util.ResourceReader;
import org.jetbrains.annotations.NotNull;
import java.io.IOException;
import java.sql.*;
import java.time.Instant;
import java.util.*;
import java.util.concurrent.CompletableFuture;
import java.util.logging.Level;
public class MySqlDatabase extends Database {
/**
* MySQL server hostname
*/
private final String mySqlHost;
/**
* MySQL server port
*/
private final int mySqlPort;
/**
* Database to use on the MySQL server
*/
private final String mySqlDatabaseName;
private final String mySqlUsername;
private final String mySqlPassword;
private final String mySqlConnectionParameters;
private final int hikariMaximumPoolSize;
private final int hikariMinimumIdle;
private final int hikariMaximumLifetime;
private final int hikariKeepAliveTime;
private final int hikariConnectionTimeOut;
private static final String DATA_POOL_NAME = "HuskHomesHikariPool";
private HikariDataSource dataSource;
public MySqlDatabase(@NotNull Settings settings, @NotNull ResourceReader resourceReader, @NotNull Logger logger) {
super(settings.getStringValue(Settings.ConfigOption.DATABASE_PLAYERS_TABLE_NAME),
settings.getStringValue(Settings.ConfigOption.DATABASE_DATA_TABLE_NAME),
settings.getIntegerValue(Settings.ConfigOption.SYNCHRONIZATION_MAX_USER_DATA_RECORDS),
resourceReader, logger);
mySqlHost = settings.getStringValue(Settings.ConfigOption.DATABASE_HOST);
mySqlPort = settings.getIntegerValue(Settings.ConfigOption.DATABASE_PORT);
mySqlDatabaseName = settings.getStringValue(Settings.ConfigOption.DATABASE_NAME);
mySqlUsername = settings.getStringValue(Settings.ConfigOption.DATABASE_USERNAME);
mySqlPassword = settings.getStringValue(Settings.ConfigOption.DATABASE_PASSWORD);
mySqlConnectionParameters = settings.getStringValue(Settings.ConfigOption.DATABASE_CONNECTION_PARAMS);
hikariMaximumPoolSize = settings.getIntegerValue(Settings.ConfigOption.DATABASE_CONNECTION_POOL_MAX_SIZE);
hikariMinimumIdle = settings.getIntegerValue(Settings.ConfigOption.DATABASE_CONNECTION_POOL_MIN_IDLE);
hikariMaximumLifetime = settings.getIntegerValue(Settings.ConfigOption.DATABASE_CONNECTION_POOL_MAX_LIFETIME);
hikariKeepAliveTime = settings.getIntegerValue(Settings.ConfigOption.DATABASE_CONNECTION_POOL_KEEPALIVE);
hikariConnectionTimeOut = settings.getIntegerValue(Settings.ConfigOption.DATABASE_CONNECTION_POOL_TIMEOUT);
}
/**
* Fetch the auto-closeable connection from the hikariDataSource
*
* @return The {@link Connection} to the MySQL database
* @throws SQLException if the connection fails for some reason
*/
private Connection getConnection() throws SQLException {
return dataSource.getConnection();
}
@Override
public CompletableFuture<Void> initialize() {
return CompletableFuture.runAsync(() -> {
// Create jdbc driver connection url
final String jdbcUrl = "jdbc:mysql://" + mySqlHost + ":" + mySqlPort + "/" + mySqlDatabaseName + mySqlConnectionParameters;
dataSource = new HikariDataSource();
dataSource.setJdbcUrl(jdbcUrl);
// Authenticate
dataSource.setUsername(mySqlUsername);
dataSource.setPassword(mySqlPassword);
// Set various additional parameters
dataSource.setMaximumPoolSize(hikariMaximumPoolSize);
dataSource.setMinimumIdle(hikariMinimumIdle);
dataSource.setMaxLifetime(hikariMaximumLifetime);
dataSource.setKeepaliveTime(hikariKeepAliveTime);
dataSource.setConnectionTimeout(hikariConnectionTimeOut);
dataSource.setPoolName(DATA_POOL_NAME);
// Prepare database schema; make tables if they don't exist
try (Connection connection = dataSource.getConnection()) {
// Load database schema CREATE statements from schema file
final String[] databaseSchema = getSchemaStatements("database/mysql_schema.sql");
try (Statement statement = connection.createStatement()) {
for (String tableCreationStatement : databaseSchema) {
statement.execute(tableCreationStatement);
}
}
} catch (SQLException | IOException e) {
getLogger().log(Level.SEVERE, "An error occurred creating tables on the MySQL database: ", e);
}
});
}
@Override
public CompletableFuture<Void> ensureUser(@NotNull User user) {
return CompletableFuture.runAsync(() -> getUser(user.uuid).thenAccept(optionalUser ->
optionalUser.ifPresentOrElse(existingUser -> {
if (!existingUser.username.equals(user.username)) {
// Update a user's name if it has changed in the database
try (Connection connection = getConnection()) {
try (PreparedStatement statement = connection.prepareStatement(formatStatementTables("""
UPDATE `%players_table%`
SET `username`=?
WHERE `uuid`=?"""))) {
statement.setString(1, user.username);
statement.setString(2, existingUser.uuid.toString());
statement.executeUpdate();
}
getLogger().log(Level.INFO, "Updated " + user.username + "'s name in the database (" + existingUser.username + " -> " + user.username + ")");
} catch (SQLException e) {
getLogger().log(Level.SEVERE, "Failed to update a user's name on the database", e);
}
}
},
() -> {
// Insert new player data into the database
try (Connection connection = getConnection()) {
try (PreparedStatement statement = connection.prepareStatement(formatStatementTables("""
INSERT INTO `%players_table%` (`uuid`,`username`)
VALUES (?,?);"""))) {
statement.setString(1, user.uuid.toString());
statement.setString(2, user.username);
statement.executeUpdate();
}
} catch (SQLException e) {
getLogger().log(Level.SEVERE, "Failed to insert a user into the database", e);
}
})));
}
@Override
public CompletableFuture<Optional<User>> getUser(@NotNull UUID uuid) {
return CompletableFuture.supplyAsync(() -> {
try (Connection connection = getConnection()) {
try (PreparedStatement statement = connection.prepareStatement(formatStatementTables("""
SELECT `uuid`, `username`
FROM `%players_table%`
WHERE `uuid`=?"""))) {
statement.setString(1, uuid.toString());
final ResultSet resultSet = statement.executeQuery();
if (resultSet.next()) {
return Optional.of(new User(UUID.fromString(resultSet.getString("uuid")),
resultSet.getString("username")));
}
}
} catch (SQLException e) {
getLogger().log(Level.SEVERE, "Failed to fetch a user from uuid from the database", e);
}
return Optional.empty();
});
}
@Override
public CompletableFuture<Optional<User>> getUserByName(@NotNull String username) {
return CompletableFuture.supplyAsync(() -> {
try (Connection connection = getConnection()) {
try (PreparedStatement statement = connection.prepareStatement(formatStatementTables("""
SELECT `uuid`, `username`
FROM `%players_table%`
WHERE `username`=?"""))) {
statement.setString(1, username);
final ResultSet resultSet = statement.executeQuery();
if (resultSet.next()) {
return Optional.of(new User(UUID.fromString(resultSet.getString("uuid")),
resultSet.getString("username")));
}
}
} catch (SQLException e) {
getLogger().log(Level.SEVERE, "Failed to fetch a user by name from the database", e);
}
return Optional.empty();
});
}
@Override
public CompletableFuture<Optional<UserData>> getCurrentUserData(@NotNull User user) {
return CompletableFuture.supplyAsync(() -> {
try (Connection connection = getConnection()) {
try (PreparedStatement statement = connection.prepareStatement(formatStatementTables("""
SELECT `version_uuid`, `timestamp`, `data`
FROM `%data_table%`
WHERE `player_uuid`=?
ORDER BY `timestamp` DESC
LIMIT 1;"""))) {
statement.setString(1, user.uuid.toString());
final ResultSet resultSet = statement.executeQuery();
if (resultSet.next()) {
final UserData data = UserData.fromJson(resultSet.getString("data"));
data.setMetadata(UUID.fromString(resultSet.getString("version_uuid")),
resultSet.getTimestamp("timestamp").toInstant().toEpochMilli());
return Optional.of(data);
}
}
} catch (SQLException e) {
getLogger().log(Level.SEVERE, "Failed to fetch a user's current user data from the database", e);
}
return Optional.empty();
});
}
@Override
public CompletableFuture<List<UserData>> getUserData(@NotNull User user) {
return CompletableFuture.supplyAsync(() -> {
final ArrayList<UserData> retrievedData = new ArrayList<>();
try (Connection connection = getConnection()) {
try (PreparedStatement statement = connection.prepareStatement(formatStatementTables("""
SELECT `version_uuid`, `timestamp`, `data`
FROM `%data_table%`
WHERE `player_uuid`=?
ORDER BY `timestamp` DESC;"""))) {
statement.setString(1, user.uuid.toString());
final ResultSet resultSet = statement.executeQuery();
while (resultSet.next()) {
final UserData data = UserData.fromJson(resultSet.getString("data"));
data.setMetadata(UUID.fromString(resultSet.getString("version_uuid")),
resultSet.getTimestamp("timestamp").toInstant().toEpochMilli());
retrievedData.add(data);
}
return retrievedData;
}
} catch (SQLException e) {
getLogger().log(Level.SEVERE, "Failed to fetch a user's current user data from the database", e);
}
return retrievedData;
});
}
@Override
protected CompletableFuture<Void> pruneUserDataRecords(@NotNull User user) {
return CompletableFuture.runAsync(() -> getUserData(user).thenAccept(data -> {
if (data.size() > maxUserDataRecords) {
Collections.reverse(data);
data.subList(0, data.size() - maxUserDataRecords).forEach(dataToDelete -> {
try (Connection connection = getConnection()) {
try (PreparedStatement statement = connection.prepareStatement(formatStatementTables("""
DELETE FROM `%data_table%`
WHERE `version_uuid`=?"""))) {
statement.setString(1, dataToDelete.getDataUuidVersion().toString());
statement.executeUpdate();
}
} catch (SQLException e) {
getLogger().log(Level.SEVERE, "Failed to prune user data from the database", e);
}
});
}
}));
}
@Override
public CompletableFuture<Void> setUserData(@NotNull User user, @NotNull UserData userData) {
return CompletableFuture.runAsync(() -> {
try (Connection connection = getConnection()) {
try (PreparedStatement statement = connection.prepareStatement(formatStatementTables("""
INSERT INTO `%data_table%`
(`player_uuid`,`version_uuid`,`timestamp`,`data`)
VALUES (?,?,?,?);"""))) {
statement.setString(1, user.uuid.toString());
statement.setString(2, userData.getDataUuidVersion().toString());
statement.setTimestamp(3, Timestamp.from(Instant.ofEpochMilli(userData.getCreationTimestamp())));
statement.setString(4, userData.toJson());
statement.executeUpdate();
}
} catch (SQLException e) {
getLogger().log(Level.SEVERE, "Failed to set user data in the database", e);
}
}).thenRunAsync(() -> pruneUserDataRecords(user).join());
}
}

@ -0,0 +1,53 @@
package net.william278.husksync.listener;
import net.william278.husksync.HuskSync;
import net.william278.husksync.player.OnlineUser;
import net.william278.husksync.redis.RedisManager;
import org.jetbrains.annotations.NotNull;
import java.util.HashSet;
import java.util.List;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
public class EventListener {
private final HuskSync huskSync;
private final HashSet<UUID> usersAwaitingSync;
protected EventListener(@NotNull HuskSync huskSync) {
this.huskSync = huskSync;
this.usersAwaitingSync = new HashSet<>();
}
public final void handlePlayerJoin(@NotNull OnlineUser user) {
usersAwaitingSync.add(user.uuid);
huskSync.getRedisManager().getUserData(user, RedisManager.RedisKeyType.SERVER_CHANGE).thenAccept(
cachedUserData -> cachedUserData.ifPresentOrElse(
userData -> user.setData(userData, huskSync.getSettings()).join(),
() -> huskSync.getDatabase().getCurrentUserData(user).thenAccept(
databaseUserData -> databaseUserData.ifPresent(
data -> user.setData(data, huskSync.getSettings()).join())).join())).thenRunAsync(
() -> {
huskSync.getLocales().getLocale("synchronisation_complete").ifPresent(user::sendActionBar);
usersAwaitingSync.remove(user.uuid);
huskSync.getDatabase().ensureUser(user).join();
});
}
public final void handlePlayerQuit(@NotNull OnlineUser user) {
user.getUserData().thenAccept(userData -> huskSync.getRedisManager()
.setPlayerData(user, userData, RedisManager.RedisKeyType.SERVER_CHANGE).thenRun(
() -> huskSync.getDatabase().setUserData(user, userData).join()));
}
public final void handleWorldSave(@NotNull List<OnlineUser> usersInWorld) {
CompletableFuture.runAsync(() -> usersInWorld.forEach(user ->
huskSync.getDatabase().setUserData(user, user.getUserData().join()).join()));
}
public final boolean cancelPlayerEvent(@NotNull OnlineUser user) {
return usersAwaitingSync.contains(user.uuid);
}
}

@ -1,312 +0,0 @@
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";
}
}
}

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

@ -0,0 +1,119 @@
package net.william278.husksync.player;
import de.themoep.minedown.MineDown;
import net.william278.husksync.config.Settings;
import net.william278.husksync.data.*;
import org.jetbrains.annotations.NotNull;
import java.util.HashSet;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
/**
* Represents a logged-in {@link User}
*/
public abstract class OnlineUser extends User {
public OnlineUser(@NotNull UUID uuid, @NotNull String username) {
super(uuid, username);
}
/**
* Get the player's {@link StatusData}
*
* @return the player's {@link StatusData}
*/
public abstract CompletableFuture<StatusData> getStatus();
/**
* Get the player's inventory {@link InventoryData} contents
*
* @return The player's inventory {@link InventoryData} contents
*/
public abstract CompletableFuture<InventoryData> getInventory();
/**
* Get the player's ender chest {@link InventoryData} contents
*
* @return The player's ender chest {@link InventoryData} contents
*/
public abstract CompletableFuture<InventoryData> getEnderChest();
/**
* Get the player's {@link PotionEffectData}
*
* @return The player's {@link PotionEffectData}
*/
public abstract CompletableFuture<PotionEffectData> getPotionEffects();
/**
* Get the player's set of {@link AdvancementData}
*
* @return the player's set of {@link AdvancementData}
*/
public abstract CompletableFuture<HashSet<AdvancementData>> getAdvancements();
/**
* Get the player's {@link StatisticsData}
*
* @return The player's {@link StatisticsData}
*/
public abstract CompletableFuture<StatisticsData> getStatistics();
/**
* Get the player's {@link LocationData}
*
* @return the player's {@link LocationData}
*/
public abstract CompletableFuture<LocationData> getLocation();
/**
* Get the player's {@link PersistentDataContainerData}
*
* @return The player's {@link PersistentDataContainerData} when fetched
*/
public abstract CompletableFuture<PersistentDataContainerData> getPersistentDataContainer();
/**
* Set {@link UserData} to a player
*
* @param data The data to set
* @param settings Plugin settings, for determining what needs setting
* @return a future that will be completed when done
*/
public abstract CompletableFuture<Void> setData(@NotNull UserData data, @NotNull Settings settings);
/**
* Dispatch a MineDown-formatted message to this player
*
* @param mineDown the parsed {@link MineDown} to send
*/
public abstract void sendMessage(@NotNull MineDown mineDown);
/**
* Dispatch a MineDown-formatted action bar message to this player
*
* @param mineDown the parsed {@link MineDown} to send
*/
public abstract void sendActionBar(@NotNull MineDown mineDown);
/**
* Returns if the player has the permission node
*
* @param node The permission node string
* @return {@code true} if the player has permission node; {@code false} otherwise
*/
public abstract boolean hasPermission(@NotNull String node);
/**
* Get the player's current {@link UserData}
*
* @return the player's current {@link UserData}
*/
public final CompletableFuture<UserData> getUserData() {
return CompletableFuture.supplyAsync(() -> new UserData(getStatus().join(), getInventory().join(),
getEnderChest().join(), getPotionEffects().join(), getAdvancements().join(),
getStatistics().join(), getLocation().join(), getPersistentDataContainer().join()));
}
}

@ -0,0 +1,28 @@
package net.william278.husksync.player;
import com.google.gson.annotations.SerializedName;
import org.jetbrains.annotations.NotNull;
import java.util.UUID;
public class User {
@SerializedName("username")
public String username;
@SerializedName("uuid")
public UUID uuid;
public User(@NotNull UUID uuid, @NotNull String username) {
this.username = username;
this.uuid = uuid;
}
@Override
public boolean equals(Object obj) {
if (obj instanceof User other) {
return this.uuid.equals(other.uuid);
}
return super.equals(obj);
}
}

@ -1,17 +0,0 @@
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) { }
}

@ -1,372 +0,0 @@
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;
}
}
}

@ -1,42 +0,0 @@
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;
}

@ -1,113 +0,0 @@
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();
}
}
}

@ -1,126 +0,0 @@
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();
}
}
}

@ -1,126 +0,0 @@
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();
}
}

@ -0,0 +1,84 @@
package net.william278.husksync.redis;
import net.william278.husksync.config.Settings;
import net.william278.husksync.data.UserData;
import net.william278.husksync.player.User;
import org.jetbrains.annotations.NotNull;
import redis.clients.jedis.Jedis;
import redis.clients.jedis.JedisPool;
import redis.clients.jedis.JedisPoolConfig;
import java.util.Optional;
import java.util.concurrent.CompletableFuture;
public class RedisManager {
private static final String KEY_NAMESPACE = "husksync:";
private static String clusterId = "";
private final JedisPool jedisPool;
private RedisManager(@NotNull Settings settings) {
clusterId = settings.getStringValue(Settings.ConfigOption.CLUSTER_ID);
JedisPoolConfig jedisPoolConfig = new JedisPoolConfig();
jedisPoolConfig.setMaxIdle(0);
jedisPoolConfig.setTestOnBorrow(true);
jedisPoolConfig.setTestOnReturn(true);
if (settings.getStringValue(Settings.ConfigOption.REDIS_PASSWORD).isBlank()) {
jedisPool = new JedisPool(jedisPoolConfig,
settings.getStringValue(Settings.ConfigOption.REDIS_HOST),
settings.getIntegerValue(Settings.ConfigOption.REDIS_PORT),
0,
settings.getBooleanValue(Settings.ConfigOption.REDIS_USE_SSL));
} else {
jedisPool = new JedisPool(jedisPoolConfig,
settings.getStringValue(Settings.ConfigOption.REDIS_HOST),
settings.getIntegerValue(Settings.ConfigOption.REDIS_PORT),
0,
settings.getStringValue(Settings.ConfigOption.REDIS_PASSWORD),
settings.getBooleanValue(Settings.ConfigOption.REDIS_USE_SSL));
}
}
public CompletableFuture<Void> setPlayerData(@NotNull User user, @NotNull UserData userData,
@NotNull RedisKeyType redisKeyType) {
return CompletableFuture.runAsync(() -> {
try (Jedis jedis = jedisPool.getResource()) {
jedis.setex(redisKeyType.getKeyPrefix() + user.uuid.toString(),
redisKeyType.timeToLive, userData.toJson());
}
});
}
public CompletableFuture<Optional<UserData>> getUserData(@NotNull User user, @NotNull RedisKeyType redisKeyType) {
return CompletableFuture.supplyAsync(() -> {
try (Jedis jedis = jedisPool.getResource()) {
final String json = jedis.get(redisKeyType.getKeyPrefix() + user.uuid.toString());
if (json == null) {
return Optional.empty();
}
return Optional.of(UserData.fromJson(json));
}
});
}
public static CompletableFuture<RedisManager> initialize(@NotNull Settings settings) {
return CompletableFuture.supplyAsync(() -> new RedisManager(settings));
}
public enum RedisKeyType {
CACHE(60 * 60 * 24),
SERVER_CHANGE(2);
public final int timeToLive;
RedisKeyType(int timeToLive) {
this.timeToLive = timeToLive;
}
@NotNull
public String getKeyPrefix() {
return KEY_NAMESPACE.toLowerCase() + ":" + clusterId.toLowerCase() + ":" + name().toLowerCase() + ":";
}
}
}

@ -1,200 +0,0 @@
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());
}
}

@ -3,7 +3,7 @@ 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
* An abstract, cross-platform representation of a logger
*/
public interface Logger {
@ -16,4 +16,5 @@ public interface Logger {
void severe(String message);
void config(String message);
}
}

@ -1,30 +0,0 @@
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)");
}

@ -0,0 +1,28 @@
package net.william278.husksync.util;
import org.jetbrains.annotations.NotNull;
import java.io.File;
import java.io.InputStream;
/**
* Abstract representation of a reader that reads internal resource files by name
*/
public interface ResourceReader {
/**
* Gets the resource with given filename and reads it as an {@link InputStream}
*
* @param fileName Name of the resource file to read
* @return The resource, read as an {@link InputStream}
*/
@NotNull InputStream getResource(String fileName);
/**
* Gets the plugin data folder where plugin configuration and data are kept
*
* @return the plugin data directory
*/
@NotNull File getDataFolder();
}

@ -1,13 +0,0 @@
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);
}
}
}

@ -1,53 +1,59 @@
package net.william278.husksync.util;
import org.jetbrains.annotations.NotNull;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.net.URL;
import java.net.URLConnection;
import java.util.concurrent.CompletableFuture;
import java.util.logging.Level;
public abstract class UpdateChecker {
public class UpdateChecker {
private final static int SPIGOT_PROJECT_ID = 97144;
private final Logger logger;
private final VersionUtils.Version currentVersion;
private VersionUtils.Version latestVersion;
public UpdateChecker(String currentVersion) {
public UpdateChecker(@NotNull String currentVersion, @NotNull Logger logger) {
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();
}
this.logger = logger;
}
public boolean isUpToDate() {
return this.currentVersion.compareTo(latestVersion) >= 0;
public CompletableFuture<VersionUtils.Version> fetchLatestVersion() {
return CompletableFuture.supplyAsync(() -> {
try {
final URL url = new URL("https://api.spigotmc.org/legacy/update.php?resource=" + SPIGOT_PROJECT_ID);
URLConnection urlConnection = url.openConnection();
return VersionUtils.Version.of(new BufferedReader(new InputStreamReader(urlConnection.getInputStream())).readLine());
} catch (Exception e) {
logger.log(Level.WARNING, "Failed to fetch the latest plugin version", e);
}
return new VersionUtils.Version();
});
}
public String getLatestVersion() {
return latestVersion.toString();
public boolean isUpdateAvailable(@NotNull VersionUtils.Version latestVersion) {
return latestVersion.compareTo(currentVersion) > 0;
}
public String getCurrentVersion() {
return currentVersion.toString();
public VersionUtils.Version getCurrentVersion() {
return currentVersion;
}
public abstract void log(Level level, String message);
public CompletableFuture<Boolean> isUpToDate() {
return fetchLatestVersion().thenApply(this::isUpdateAvailable);
}
public void logToConsole() {
if (!isUpToDate()) {
log(Level.WARNING, "A new version of HuskSync is available: Version "
+ latestVersion + " (Currently running: " + currentVersion + ")");
}
fetchLatestVersion().thenAccept(latestVersion -> {
if (isUpdateAvailable(latestVersion)) {
logger.log(Level.WARNING, "A new version of HuskSync is available: v" + latestVersion);
} else {
logger.log(Level.INFO, "HuskSync is up-to-date! (Running: v" + currentVersion + ")");
}
});
}
}
}

@ -0,0 +1,51 @@
# ------------------------------
# | HuskSync Config |
# | Developed by William278 |
# ------------------------------
# Documentation available at: https://william278.net/docs/husksync/Setup
language: 'en-gb'
check_for_updates: true
cluster_id: ''
database:
credentials:
host: 'localhost'
port: 3306
database: 'HuskSync'
username: 'root'
password: 'pa55w0rd'
params: '?autoReconnect=true&useSSL=false'
connection_pool:
maximum_pool_size: 10
minimum_idle: 10
maximum_lifetime: 1800000
keepalive_time: 0
connection_timeout: 5000
table_names:
players_table: 'husksync_players'
data_table: 'husksync_data'
redis:
credentials:
host: 'localhost'
port: 6379
password: ''
use_ssl: false
synchronization:
max_user_data_records: 5
save_on_world_save: true
features:
inventories: true
ender_chests: true
health: true
max_health: true
hunger: true
experience: true
potion_effects: true
advancements: true
game_mode: true
statistics: true
persistent_data_container: true
location: false

@ -0,0 +1,20 @@
# Create the players table if it does not exist
CREATE TABLE IF NOT EXISTS `%players_table%`
(
`uuid` char(36) NOT NULL UNIQUE,
`username` varchar(16) NOT NULL,
PRIMARY KEY (`uuid`)
);
# Create the player data table if it does not exist
CREATE TABLE IF NOT EXISTS `%data_table%`
(
`version_uuid` char(36) NOT NULL,
`player_uuid` char(36) NOT NULL,
`timestamp` datetime NOT NULL,
`data` json NOT NULL,
PRIMARY KEY (`version_uuid`),
FOREIGN KEY (`player_uuid`) REFERENCES `%players_table%` (`uuid`) ON DELETE CASCADE
);

@ -1,14 +0,0 @@
synchronisation_complete: '[Daten synchronisiert!](#00fb9a)'
viewing_inventory_of: '[Einsicht in das Inventar von](#00fb9a) [%1%](#00fb9a bold)'
viewing_ender_chest_of: '[Einsicht in die Endertruhe von](#00fb9a) [%1%](#00fb9a bold)'
reload_complete: '[HuskSync](#00fb9a bold) [| Die Konfigurations- und Meldungsdateien wurden aktualisiert.](#00fb9a)'
error_invalid_syntax: '[Fehler:](#ff3300) [Falsche Syntax. Nutze: %1%](#ff7e5e)'
error_invalid_player: '[Fehler:](#ff3300) [Dieser Spieler konnte nicht gefunden werden](#ff7e5e)'
error_no_permission: '[Fehler:](#ff3300) [Du hast nicht die benötigten Berechtigungen um diesen Befehl auszuführen](#ff7e5e)'
error_cannot_view_inventory_online: '[Fehler:](#ff3300) [Du kannst nicht über HuskSync auf das Inventar eines Online-Spielers zugreifen](#ff7e5e)'
error_cannot_view_ender_chest_online: '[Fehler:](#ff3300) [Du kannst nicht über HuskSync auf die Endertruhe eines Online-Spielers zugreifen](#ff7e5e)'
error_cannot_view_own_inventory: '[Fehler:](#ff3300) [Du kannst nicht auf dein eigenes Inventar zugreifen!](#ff7e5e)'
error_cannot_view_own_ender_chest: '[Fehler:](#ff3300) [Du kannst nicht auf deine eigene Endertruhe zugreifen!](#ff7e5e)'
error_console_command_only: '[Fehler:](#ff3300) [Dieser Befehl kann nur über die %1% Konsole ausgeführt werden](#ff7e5e)'
error_no_servers_proxied: '[Fehler:](#ff3300) [Vorgang konnte nicht verarbeitet werden; Es sind keine Server online, auf denen HuskSync installiert ist. Bitte stelle sicher, dass HuskSync sowohl auf dem Proxy-Server als auch auf allen Servern installiert ist, zwischen denen du Daten synchronisieren möchtest.](#ff7e5e)'
error_invalid_cluster: '[Fehler:](#ff3300) [Bitte gib die ID eines gültigen Clusters an.](#ff7e5e)'

@ -1,14 +0,0 @@
synchronisation_complete: '[Datos sincronizados!](#00fb9a)'
viewing_inventory_of: '[Viendo el inventario de](#00fb9a) [%1%](#00fb9a bold)'
viewing_ender_chest_of: '[Viendo el Ender Chest de](#00fb9a) [%1%](#00fb9a bold)'
reload_complete: '[HuskSync](#00fb9a bold) [| Se ha reiniciado la configuración y los archivos de los mensajes.](#00fb9a)'
error_invalid_syntax: '[Error:](#ff3300) [Sintaxis incorrecta. Uso: %1%](#ff7e5e)'
error_invalid_player: '[Error:](#ff3300) [No se ha podido encontrar a ese jugador](#ff7e5e)'
error_no_permission: '[Error:](#ff3300) [No tienes permiso para ejecutar este comando](#ff7e5e)'
error_cannot_view_inventory_online: '[Error:](#ff3300) [A traves de HuskSync no puedes acceder al inventario de un jugador conectado](#ff7e5e)'
error_cannot_view_ender_chest_online: '[Error:](#ff3300) [A traves de HuskSync no puedes acceder al Ender Chest de un jugador conectado.](#ff7e5e)'
error_cannot_view_own_inventory: '[Error:](#ff3300) [No puedes acceder a tu inventario!](#ff7e5e)'
error_cannot_view_own_ender_chest: '[Error:](#ff3300) [No puedes acceder a tu Ender Chest!](#ff7e5e)'
error_console_command_only: '[Error:](#ff3300) [Ese comando solo puede ser ejecutado desde la %1% consola](#ff7e5e)'
error_no_servers_proxied: '[Error:](#ff3300) [Ha ocurrido un error mientras se procesaba la acción; no hay servidores online con HusckSync instalado. Por favor, asegúrate que HuskSync está instalado tanto en el proxy como en todos los servidores entre los que quieres sincronizar datos.](#ff7e5e)'
error_invalid_cluster: '[Error:](#ff3300) [Por favor, especifica la ID de un cluster válido.](#ff7e5e)'

@ -1,14 +0,0 @@
synchronisation_complete: '[データが同期されました!](#00fb9a)'
viewing_inventory_of: '[%1%](#00fb9a bold) [のインベントリを表示します](#00fb9a) '
viewing_ender_chest_of: '[%1%](#00fb9a bold) [のエンダーチェストを表示します](#00fb9a) '
reload_complete: '[HuskSync](#00fb9a bold) [| 設定ファイルとメッセージファイルを再読み込みしました。](#00fb9a)'
error_invalid_syntax: '[Error:](#ff3300) [構文が正しくありません。使用法: %1%](#ff7e5e)'
error_invalid_player: '[Error:](#ff3300) [そのプレイヤーは見つかりませんでした](#ff7e5e)'
error_no_permission: '[Error:](#ff3300) [このコマンドを実行する権限がありません](#ff7e5e)'
error_cannot_view_inventory_online: '[Error:](#ff3300) [HuskSyncからオンラインプレイヤーのインベントリにはアクセスできません](#ff7e5e)'
error_cannot_view_ender_chest_online: '[Error:](#ff3300) [HuskSyncからオンラインプレイヤーのエンダーチェストにはアクセスできません](#ff7e5e)'
error_cannot_view_own_inventory: '[Error:](#ff3300) [自分のインベントリにはアクセスできません!](#ff7e5e)'
error_cannot_view_own_ender_chest: '[Error:](#ff3300) [自分のエンダーチェストにはアクセスできません!](#ff7e5e)'
error_console_command_only: '[Error:](#ff3300) [そのコマンドは%1%コンソールからのみ実行できます](#ff7e5e)'
error_no_servers_proxied: '[Error:](#ff3300) [操作の処理に失敗; HuskSyncがインストールされているサーバーがオンラインになっていません。プロキシサーバーとデータを同期させたいすべてのサーバーにHuskSyncがインストールされていることを確認してください。](#ff7e5e)'
error_invalid_cluster: '[Error:](#ff3300) [有効なクラスターのIDを指定してください。](#ff7e5e)'

@ -1,14 +0,0 @@
synchronisation_complete: '[Дані синхронізовано!](#00fb9a)'
viewing_inventory_of: '[Переглядання інвентару](#00fb9a) [%1%](#00fb9a bold)'
viewing_ender_chest_of: '[Переглядання скрині енду](#00fb9a) [%1%](#00fb9a bold)'
reload_complete: '[HuskSync](#00fb9a bold) [| Перезавантажено конфіґ та файли повідомлень.](#00fb9a)'
error_invalid_syntax: '[Помилка:](#ff3300) [Неправильний синтакс. Використання: %1%](#ff7e5e)'
error_invalid_player: '[Помилка:](#ff3300) [Гравця не знайдено](#ff7e5e)'
error_no_permission: '[Помилка:](#ff3300) [Ввас немає дозволу на використання цієї команди](#ff7e5e)'
error_cannot_view_inventory_online: '[Помилка:](#ff3300) [Ви не можете переглядати інвентар гравців, що знаходяться онлайн, з допомогую HuskSync](#ff7e5e)'
error_cannot_view_ender_chest_online: '[Помилка:](#ff3300) [Ви не можете переглядати скриню енду гравців, що знаходяться онлайн, з допомогую HuskSync](#ff7e5e)'
error_cannot_view_own_inventory: '[Помилка:](#ff3300) [Ви не можете переглядати власний інвентар!](#ff7e5e)'
error_cannot_view_own_ender_chest: '[Помилка:](#ff3300) [Ви не можете переглядати власну скриню енду!](#ff7e5e)'
error_console_command_only: '[Помилка:](#ff3300) [Ця команда може бути використана лише з допомогою %1% консолі](#ff7e5e)'
error_no_servers_proxied: '[Помилка:](#ff3300) [Не вдалося опрацювати операцію; не знайдено жодного сервера із встановленим HuskSync. Запевніться, будьласка, що HuskSync встановлено на Проксі та усіх серверах між якими ви хочете синхроніхувати дані.](#ff7e5e)'
error_invalid_cluster: '[Помилка:](#ff3300) [Зазнчте будь ласка ID слушного кластеру.](#ff7e5e)'

@ -1,14 +0,0 @@
synchronisation_complete: '[数据同步完成](#00fb9a)'
viewing_inventory_of: '[查看玩家背包:](#00fb9a) [%1%](#00fb9a bold)'
viewing_ender_chest_of: '[查看玩家末影箱:](#00fb9a) [%1%](#00fb9a bold)'
reload_complete: '[HuskSync](#00fb9a bold) [| 配置与语言文件重载完成.](#00fb9a)'
error_invalid_syntax: '[错误:](#ff3300) [格式错误. 使用方法: %1%](#ff7e5e)'
error_invalid_player: '[错误:](#ff3300) [未找到目标玩家](#ff7e5e)'
error_no_permission: '[错误:](#ff3300) [你没有权限执行此命令](#ff7e5e)'
error_cannot_view_inventory_online: '[错误:](#ff3300) [你不能在玩家在线时通过 HuskSync 查看与编辑玩家物品栏](#ff7e5e)'
error_cannot_view_ender_chest_online: '[错误:](#ff3300) [你不能在玩家在线时通过 HuskSync 查看与编辑玩家末影箱](#ff7e5e)'
error_cannot_view_own_inventory: '[错误:](#ff3300) [你不能查看和编辑自己的物品栏!](#ff7e5e)'
error_cannot_view_own_ender_chest: '[错误:](#ff3300) [你不能查看和编辑自己的末影箱!](#ff7e5e)'
error_console_command_only: '[错误:](#ff3300) [该命令只能由 %1% 控制台执行](#ff7e5e)'
error_no_servers_proxied: '[错误:](#ff3300) [操作处理失败; 没有任何安装了 HuskSync 的后端服务器在线. 请确认 HuskSync 已在 BungeeCord/Velocity 等代理服务器和所有你希望互相同步数据的后端服务器间安装.](#ff7e5e)'
error_invalid_cluster: '[错误:](#ff3300) [请指定一个有效的集群(cluster) ID.](#ff7e5e)'

@ -1,14 +0,0 @@
synchronisation_complete: '[資料已同步!!](#00fb9a)'
viewing_inventory_of: '[查看](#00fb9a) [%1%](#00fb9a bold) [的背包](#00fb9a)'
viewing_ender_chest_of: '[查看](#00fb9a) [%1%](#00fb9a bold) [的終界箱](#00fb9a)'
reload_complete: '[HuskSync](#00fb9a bold) [| 已重新載入配置和訊息文件](#00fb9a)'
error_invalid_syntax: '[錯誤:](#ff3300) [語法不正確,用法: %1%](#ff7e5e)'
error_invalid_player: '[錯誤:](#ff3300) [找不到這位玩家](#ff7e5e)'
error_no_permission: '[錯誤:](#ff3300) [您沒有權限執行這個指令](#ff7e5e)'
error_cannot_view_inventory_online: '[錯誤:](#ff3300) [您無法通過 HuskSync 查看在線玩家的背包](#ff7e5e)'
error_cannot_view_ender_chest_online: '[錯誤:](#ff3300) [您無法通過 HuskSync 查看在線玩家的終界箱](#ff7e5e)'
error_cannot_view_own_inventory: '[錯誤:](#ff3300) [您無法查看自己的背包!](#ff7e5e)'
error_cannot_view_own_ender_chest: '[錯誤:](#ff3300) [你無法查看自己的終界箱!](#ff7e5e)'
error_console_command_only: '[錯誤:](#ff3300) [該指令只能通過 %1% 控制台運行](#ff7e5e)'
error_no_servers_proxied: '[錯誤:](#ff3300) [處理操作失敗: 沒有安裝 HuskSync 的伺服器在線。 請確保在 Proxy 伺服器和您希望在其他同步數據的所有伺服器上都安裝了 HuskSync。](#ff7e5e)'
error_invalid_cluster: '[錯誤:](#ff3300) [請提供有效的 Cluster ID](#ff7e5e)'

@ -1,28 +0,0 @@
language: 'en-gb'
redis_settings:
host: 'localhost'
port: 6379
password: ''
use_ssl: false
data_storage_settings:
database_type: 'sqlite'
mysql_settings:
host: 'localhost'
port: 3306
database: 'HuskSync'
username: 'root'
password: 'pa55w0rd'
params: '?autoReconnect=true&useSSL=false'
hikari_pool_settings:
maximum_pool_size: 10
minimum_idle: 10
maximum_lifetime: 1800000
keepalive_time: 0
connection_timeout: 5000
bounce_back_synchronization: true
clusters:
main:
player_table: 'husksync_players'
data_table: 'husksync_data'
check_for_updates: true
config_file_version: 1.2

@ -1,3 +1,10 @@
org.gradle.jvmargs='-Dfile.encoding=UTF-8'
org.gradle.daemon=true
javaVersion=16
plugin_version=1.4
plugin_archive=husksync
plugin_version=1.5
plugin_archive=husksync
jedis_version=4.2.3
sqlite_driver_version=3.36.0.3

@ -1,20 +1,13 @@
//file:noinspection GroovyAssignabilityCheck
plugins {
id 'maven-publish'
}
dependencies {
implementation project(path: ':bukkit', configuration: 'shadow')
implementation project(path: ':api', configuration: 'shadow')
implementation project(path: ':bungeecord', configuration: 'shadow')
implementation project(path: ':velocity', configuration: 'shadow')
//implementation project(path: ':api', configuration: 'shadow')
}
shadowJar {
dependencies {
exclude dependency(':jedis')
exclude dependency(':commons-pool2')
}
}
publishing {
@ -23,7 +16,6 @@ publishing {
groupId = 'net.william278'
artifactId = 'husksync-plugin'
version = "$rootProject.version"
artifact shadowJar
}
}

@ -8,7 +8,5 @@ rootProject.name = 'HuskSync'
include 'common'
include 'bukkit'
include 'bungeecord'
include 'velocity'
include 'api'
include 'plugin'

@ -1,220 +0,0 @@
package net.william278.husksync;
import com.google.inject.Inject;
import com.velocitypowered.api.command.CommandManager;
import com.velocitypowered.api.command.CommandMeta;
import com.velocitypowered.api.event.Subscribe;
import com.velocitypowered.api.event.proxy.ProxyInitializeEvent;
import com.velocitypowered.api.event.proxy.ProxyShutdownEvent;
import com.velocitypowered.api.plugin.Plugin;
import com.velocitypowered.api.plugin.PluginContainer;
import com.velocitypowered.api.plugin.annotation.DataDirectory;
import com.velocitypowered.api.proxy.ProxyServer;
import net.william278.husksync.Server;
import net.william278.husksync.Settings;
import net.william278.husksync.migrator.MPDBMigrator;
import net.william278.husksync.proxy.data.DataManager;
import net.william278.husksync.redis.RedisMessage;
import net.william278.husksync.velocity.command.VelocityCommand;
import net.william278.husksync.velocity.config.ConfigLoader;
import net.william278.husksync.velocity.config.ConfigManager;
import net.william278.husksync.velocity.listener.VelocityEventListener;
import net.william278.husksync.velocity.listener.VelocityRedisListener;
import net.william278.husksync.velocity.util.VelocityLogger;
import net.william278.husksync.velocity.util.VelocityUpdateChecker;
import net.byteflux.libby.Library;
import net.byteflux.libby.VelocityLibraryManager;
import org.bstats.velocity.Metrics;
import org.slf4j.Logger;
import java.io.File;
import java.io.IOException;
import java.nio.file.Path;
import java.util.HashSet;
import java.util.Objects;
import java.util.logging.Level;
@Plugin(id = "husksync")
public class HuskSyncVelocity {
// Plugin version
public static String VERSION = null;
// Velocity bStats ID (different from Bukkit and BungeeCord)
private static final int METRICS_ID = 13489;
private final Metrics.Factory metricsFactory;
private static HuskSyncVelocity instance;
public static HuskSyncVelocity getInstance() {
return instance;
}
// Whether the plugin is ready to accept redis messages
public static boolean readyForRedis = false;
// Whether the plugin is in the process of disabling and should skip responding to handshake confirmations
public static boolean isDisabling = false;
/**
* Set of all the {@link Server}s that have completed the synchronisation handshake with HuskSync on the proxy
*/
public static HashSet<Server> synchronisedServers;
public static DataManager dataManager;
public static VelocityRedisListener redisListener;
public static MPDBMigrator mpdbMigrator;
private final Logger logger;
private final ProxyServer server;
private final Path dataDirectory;
// Get the data folder
public File getDataFolder() {
return dataDirectory.toFile();
}
// Get the proxy server
public ProxyServer getProxyServer() {
return server;
}
// Velocity logger handling
private VelocityLogger velocityLogger;
public VelocityLogger getVelocityLogger() {
return velocityLogger;
}
@Inject
public HuskSyncVelocity(ProxyServer server, Logger logger, @DataDirectory Path dataDirectory, Metrics.Factory metricsFactory, PluginContainer pluginContainer) {
this.server = server;
this.logger = logger;
this.dataDirectory = dataDirectory;
this.metricsFactory = metricsFactory;
pluginContainer.getDescription().getVersion().ifPresent(s -> VERSION = s);
}
@Subscribe
public void onProxyInitialization(ProxyInitializeEvent event) {
// Set instance
instance = this;
// Load dependencies
fetchDependencies();
// Setup logger
velocityLogger = new VelocityLogger(logger);
// Prepare synchronised servers tracker
synchronisedServers = new HashSet<>();
// Load config
ConfigManager.loadConfig();
// Load settings from config
ConfigLoader.loadSettings(Objects.requireNonNull(ConfigManager.getConfig()));
// Load messages
ConfigManager.loadMessages();
// Load locales from messages
ConfigLoader.loadMessageStrings(Objects.requireNonNull(ConfigManager.getMessages()));
// Do update checker
if (Settings.automaticUpdateChecks) {
new VelocityUpdateChecker(VERSION).logToConsole();
}
// Setup data manager
dataManager = new DataManager(getVelocityLogger(), getDataFolder());
// Ensure the data manager initialized correctly
if (dataManager.hasFailedInitialization) {
getVelocityLogger().severe("Failed to initialize the HuskSync database(s).\n" +
"HuskSync will now abort loading itself (Velocity) v" + VERSION);
}
// Setup player data cache
for (Settings.SynchronisationCluster cluster : Settings.clusters) {
dataManager.playerDataCache.put(cluster, new DataManager.PlayerDataCache());
}
// Initialize the redis listener
redisListener = new VelocityRedisListener();
// Register listener
server.getEventManager().register(this, new VelocityEventListener());
// Register command
CommandManager commandManager = getProxyServer().getCommandManager();
CommandMeta meta = commandManager.metaBuilder("husksync")
.aliases("hs")
.build();
commandManager.register(meta, new VelocityCommand());
// Prepare the migrator for use if needed
mpdbMigrator = new MPDBMigrator(getVelocityLogger());
// Initialize bStats metrics
try {
metricsFactory.make(this, METRICS_ID);
} catch (Exception e) {
getVelocityLogger().info("Skipped metrics initialization");
}
// Log to console
getVelocityLogger().info("Enabled HuskSync (Velocity) v" + VERSION);
// Mark as ready for redis message processing
readyForRedis = true;
}
@Subscribe
public void onProxyShutdown(ProxyShutdownEvent event) {
// Plugin shutdown logic
isDisabling = true;
// Send terminating handshake message
for (Server server : synchronisedServers) {
try {
new RedisMessage(RedisMessage.MessageType.TERMINATE_HANDSHAKE,
new RedisMessage.MessageTarget(Settings.ServerType.BUKKIT, null, server.clusterId()),
server.serverUUID().toString(),
"Velocity").send();
} catch (IOException e) {
getVelocityLogger().log(Level.SEVERE, "Failed to serialize Redis message for handshake termination", e);
}
}
// Close database connections
dataManager.closeDatabases();
// Log to console
getVelocityLogger().info("Disabled HuskSync (Velocity) v" + VERSION);
}
// Load dependencies
private void fetchDependencies() {
VelocityLibraryManager<HuskSyncVelocity> manager = new VelocityLibraryManager<>(logger, dataDirectory, getProxyServer().getPluginManager(), getInstance(), "lib");
Library mySqlLib = Library.builder()
.groupId("mysql")
.artifactId("mysql-connector-java")
.version("8.0.29")
.build();
Library sqLiteLib = Library.builder()
.groupId("org.xerial")
.artifactId("sqlite-jdbc")
.version("3.36.0.3")
.build();
manager.addMavenCentral();
manager.loadLibrary(mySqlLib);
manager.loadLibrary(sqLiteLib);
}
}

@ -1,423 +0,0 @@
package net.william278.husksync.velocity.command;
import com.velocitypowered.api.command.CommandSource;
import com.velocitypowered.api.command.SimpleCommand;
import com.velocitypowered.api.proxy.Player;
import de.themoep.minedown.adventure.MineDown;
import net.william278.husksync.HuskSyncVelocity;
import net.william278.husksync.PlayerData;
import net.william278.husksync.Server;
import net.william278.husksync.Settings;
import net.william278.husksync.migrator.MPDBMigrator;
import net.william278.husksync.proxy.command.HuskSyncCommand;
import net.william278.husksync.redis.RedisMessage;
import net.william278.husksync.util.MessageManager;
import net.william278.husksync.velocity.util.VelocityUpdateChecker;
import net.william278.husksync.velocity.config.ConfigLoader;
import net.william278.husksync.velocity.config.ConfigManager;
import java.io.IOException;
import java.util.*;
import java.util.logging.Level;
import java.util.stream.Collectors;
public class VelocityCommand implements SimpleCommand, HuskSyncCommand {
private static final HuskSyncVelocity plugin = HuskSyncVelocity.getInstance();
@Override
public void execute(Invocation invocation) {
final String[] args = invocation.arguments();
final CommandSource sender = invocation.source();
if (sender instanceof Player player) {
if (HuskSyncVelocity.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 "update" -> {
if (!player.hasPermission("husksync.command.inventory")) {
sender.sendMessage(new MineDown(MessageManager.getMessage("error_no_permission")).toComponent());
return;
}
sender.sendMessage(new MineDown("[Checking for HuskSync updates...](gray)").toComponent());
plugin.getProxyServer().getScheduler().buildTask(plugin, () -> {
// Check Bukkit servers needing updates
int updatesNeeded = 0;
String bukkitBrand = "Spigot";
String bukkitVersion = "1.0";
for (Server server : HuskSyncVelocity.synchronisedServers) {
VelocityUpdateChecker updateChecker = new VelocityUpdateChecker(server.huskSyncVersion());
if (!updateChecker.isUpToDate()) {
updatesNeeded++;
bukkitBrand = server.serverBrand();
bukkitVersion = server.huskSyncVersion();
}
}
// Check Velocity servers needing updates and send message
VelocityUpdateChecker proxyUpdateChecker = new VelocityUpdateChecker(HuskSyncVelocity.VERSION);
if (proxyUpdateChecker.isUpToDate() && updatesNeeded == 0) {
sender.sendMessage(new MineDown("[HuskSync](#00fb9a bold) [| HuskSync is up-to-date, running Version " + proxyUpdateChecker.getLatestVersion() + "](#00fb9a)").toComponent());
} else {
sender.sendMessage(new MineDown("[HuskSync](#00fb9a bold) [| Your server(s) are not up-to-date:](#00fb9a)").toComponent());
if (!proxyUpdateChecker.isUpToDate()) {
sender.sendMessage(new MineDown("[•](white) [HuskSync on the Velocity proxy is outdated (Latest: " + proxyUpdateChecker.getLatestVersion() + ", Running: " + proxyUpdateChecker.getCurrentVersion() + ")](#00fb9a)").toComponent());
}
if (updatesNeeded > 0) {
sender.sendMessage(new MineDown("[•](white) [HuskSync on " + updatesNeeded + " connected " + bukkitBrand + " server(s) are outdated (Latest: " + proxyUpdateChecker.getLatestVersion() + ", Running: " + bukkitVersion + ")](#00fb9a)").toComponent());
}
sender.sendMessage(new MineDown("[•](white) [Download links:](#00fb9a) [[⏩ Spigot]](gray open_url=https://www.spigotmc.org/resources/husktowns.92672/updates) [•](#262626) [[⏩ Polymart]](gray open_url=https://polymart.org/resource/husktowns.1056/updates)").toComponent());
}
}).schedule();
}
case "invsee", "openinv", "inventory" -> {
if (!player.hasPermission("husksync.command.inventory")) {
sender.sendMessage(new MineDown(MessageManager.getMessage("error_no_permission")).toComponent());
return;
}
String clusterId;
if (Settings.clusters.size() > 1) {
if (args.length == 3) {
clusterId = args[2];
} else {
sender.sendMessage(new MineDown(MessageManager.getMessage("error_invalid_cluster")).toComponent());
return;
}
} else {
clusterId = "main";
for (Settings.SynchronisationCluster cluster : Settings.clusters) {
clusterId = cluster.clusterId();
break;
}
}
if (args.length == 2 || args.length == 3) {
String playerName = args[1];
openInventory(player, playerName, clusterId);
} 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;
}
String clusterId;
if (Settings.clusters.size() > 1) {
if (args.length == 3) {
clusterId = args[2];
} else {
sender.sendMessage(new MineDown(MessageManager.getMessage("error_invalid_cluster")).toComponent());
return;
}
} else {
clusterId = "main";
for (Settings.SynchronisationCluster cluster : Settings.clusters) {
clusterId = cluster.clusterId();
break;
}
}
if (args.length == 2 || args.length == 3) {
String playerName = args[1];
openEnderChest(player, playerName, clusterId);
} 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%", "Velocity")).toComponent());
}
case "status" -> {
if (!player.hasPermission("husksync.command.admin")) {
sender.sendMessage(new MineDown(MessageManager.getMessage("error_no_permission")).toComponent());
return;
}
int playerDataSize = 0;
for (Settings.SynchronisationCluster cluster : Settings.clusters) {
playerDataSize += HuskSyncVelocity.dataManager.playerDataCache.get(cluster).playerData.size();
}
sender.sendMessage(new MineDown(MessageManager.PLUGIN_STATUS.toString()
.replaceAll("%1%", String.valueOf(HuskSyncVelocity.synchronisedServers.size()))
.replaceAll("%2%", String.valueOf(playerDataSize))).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();
ConfigLoader.loadMessageStrings(Objects.requireNonNull(ConfigManager.getMessages()));
// Send reload request to all bukkit servers
try {
new RedisMessage(RedisMessage.MessageType.RELOAD_CONFIG,
new RedisMessage.MessageTarget(Settings.ServerType.BUKKIT, null, null),
"reload")
.send();
} catch (IOException e) {
plugin.getVelocityLogger().log(Level.WARNING, "Failed to serialize reload notification message data");
}
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")) {
MPDBMigrator migrator = HuskSyncVelocity.mpdbMigrator;
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%
targetCluster: %9%
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 %10% 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%", migrator.migrationSettings.sourceHost)
.replaceAll("%2%", String.valueOf(migrator.migrationSettings.sourcePort))
.replaceAll("%3%", migrator.migrationSettings.sourceDatabase)
.replaceAll("%4%", migrator.migrationSettings.sourceUsername)
.replaceAll("%5%", migrator.migrationSettings.sourcePassword)
.replaceAll("%6%", migrator.migrationSettings.inventoryDataTable)
.replaceAll("%7%", migrator.migrationSettings.enderChestDataTable)
.replaceAll("%8%", migrator.migrationSettings.expDataTable)
.replaceAll("%9%", migrator.migrationSettings.targetCluster)
.replaceAll("%10%", Settings.dataStorageType.toString())
).toComponent());
case "setting" -> {
if (args.length == 4) {
String value = args[3];
switch (args[2]) {
case "sourceHost", "host" -> migrator.migrationSettings.sourceHost = value;
case "sourcePort", "port" -> {
try {
migrator.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" -> migrator.migrationSettings.sourceDatabase = value;
case "sourceUsername", "username" -> migrator.migrationSettings.sourceUsername = value;
case "sourcePassword", "password" -> migrator.migrationSettings.sourcePassword = value;
case "sourceInventoryTableName", "inventoryTableName", "inventoryTable" -> migrator.migrationSettings.inventoryDataTable = value;
case "sourceEnderChestTableName", "enderChestTableName", "enderChestTable" -> migrator.migrationSettings.enderChestDataTable = value;
case "sourceExperienceTableName", "experienceTableName", "experienceTable" -> migrator.migrationSettings.expDataTable = value;
case "targetCluster", "cluster" -> migrator.migrationSettings.targetCluster = 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());
// If the migrator is ready, execute the migration asynchronously
if (HuskSyncVelocity.mpdbMigrator.readyToMigrate(plugin.getProxyServer().getPlayerCount(),
HuskSyncVelocity.synchronisedServers)) {
plugin.getProxyServer().getScheduler().buildTask(plugin, () ->
HuskSyncVelocity.mpdbMigrator.executeMigrationOperations(HuskSyncVelocity.dataManager,
HuskSyncVelocity.synchronisedServers, HuskSyncVelocity.redisListener)).schedule();
}
}
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(Player viewer, String targetPlayerName, String clusterId) {
if (viewer.getUsername().equalsIgnoreCase(targetPlayerName)) {
viewer.sendMessage(new MineDown(MessageManager.getMessage("error_cannot_view_own_inventory")).toComponent());
return;
}
if (plugin.getProxyServer().getPlayer(targetPlayerName).isPresent()) {
viewer.sendMessage(new MineDown(MessageManager.getMessage("error_cannot_view_inventory_online")).toComponent());
return;
}
plugin.getProxyServer().getScheduler().buildTask(plugin, () -> {
for (Settings.SynchronisationCluster cluster : Settings.clusters) {
if (!cluster.clusterId().equals(clusterId)) continue;
PlayerData playerData = HuskSyncVelocity.dataManager.getPlayerDataByName(targetPlayerName, cluster.clusterId());
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(), null),
targetPlayerName, RedisMessage.serialize(playerData))
.send();
viewer.sendMessage(new MineDown(MessageManager.getMessage("viewing_inventory_of").replaceAll("%1%",
targetPlayerName)).toComponent());
} catch (IOException e) {
plugin.getVelocityLogger().log(Level.WARNING, "Failed to serialize inventory-see player data", e);
}
return;
}
viewer.sendMessage(new MineDown(MessageManager.getMessage("error_invalid_cluster")).toComponent());
}).schedule();
}
// View the ender chest of a player specified by their name
public void openEnderChest(Player viewer, String targetPlayerName, String clusterId) {
if (viewer.getUsername().equalsIgnoreCase(targetPlayerName)) {
viewer.sendMessage(new MineDown(MessageManager.getMessage("error_cannot_view_own_ender_chest")).toComponent());
return;
}
if (plugin.getProxyServer().getPlayer(targetPlayerName).isPresent()) {
viewer.sendMessage(new MineDown(MessageManager.getMessage("error_cannot_view_ender_chest_online")).toComponent());
return;
}
plugin.getProxyServer().getScheduler().buildTask(plugin, () -> {
for (Settings.SynchronisationCluster cluster : Settings.clusters) {
if (!cluster.clusterId().equals(clusterId)) continue;
PlayerData playerData = HuskSyncVelocity.dataManager.getPlayerDataByName(targetPlayerName, cluster.clusterId());
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(), null),
targetPlayerName, RedisMessage.serialize(playerData))
.send();
viewer.sendMessage(new MineDown(MessageManager.getMessage("viewing_ender_chest_of").replaceAll("%1%",
targetPlayerName)).toComponent());
} catch (IOException e) {
plugin.getVelocityLogger().log(Level.WARNING, "Failed to serialize inventory-see player data", e);
}
return;
}
viewer.sendMessage(new MineDown(MessageManager.getMessage("error_invalid_cluster")).toComponent());
}).schedule();
}
/**
* Send information about the plugin
*
* @param player The player to send it to
*/
private void sendAboutInformation(Player player) {
try {
new RedisMessage(RedisMessage.MessageType.SEND_PLUGIN_INFORMATION,
new RedisMessage.MessageTarget(Settings.ServerType.BUKKIT, player.getUniqueId(), null),
"Velocity", HuskSyncVelocity.VERSION).send();
} catch (IOException e) {
plugin.getVelocityLogger().log(Level.WARNING, "Failed to serialize plugin information to send", e);
}
}
@Override
public List<String> suggest(Invocation invocation) {
final CommandSource sender = invocation.source();
final String[] args = invocation.arguments();
if (sender instanceof Player player) {
if (args.length == 1) {
final ArrayList<String> subCommands = new ArrayList<>();
for (SubCommand subCommand : SUB_COMMANDS) {
if (subCommand.permission() != null) {
if (!player.hasPermission(subCommand.permission())) {
continue;
}
}
subCommands.add(subCommand.command());
}
// Return list of subcommands
if (args[0].length() == 0) {
return subCommands;
}
// 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();
}
}
return Collections.emptyList();
}
}

@ -1,101 +0,0 @@
package net.william278.husksync.velocity.config;
import net.william278.husksync.HuskSyncVelocity;
import net.william278.husksync.Settings;
import net.william278.husksync.util.MessageManager;
import ninja.leaping.configurate.ConfigurationNode;
import java.util.HashMap;
public class ConfigLoader {
private static ConfigurationNode copyDefaults(ConfigurationNode configRoot) {
// Get the config version and update if needed
String configVersion = getConfigString(configRoot, "1.0", "config_file_version");
if (configVersion.contains("-dev")) {
configVersion = configVersion.replaceAll("-dev", "");
}
if (!configVersion.equals(HuskSyncVelocity.VERSION)) {
if (configVersion.equalsIgnoreCase("1.0")) {
configRoot.getNode("check_for_updates").setValue(true);
}
if (configVersion.equalsIgnoreCase("1.0") || configVersion.equalsIgnoreCase("1.0.1") || configVersion.equalsIgnoreCase("1.0.2") || configVersion.equalsIgnoreCase("1.0.3")) {
configRoot.getNode("clusters", "main", "player_table").setValue("husksync_players");
configRoot.getNode("clusters", "main", "data_table").setValue("husksync_data");
}
configRoot.getNode("config_file_version").setValue(HuskSyncVelocity.VERSION);
}
// Save the config back
ConfigManager.saveConfig(configRoot);
return configRoot;
}
private static String getConfigString(ConfigurationNode rootNode, String defaultValue, String... nodePath) {
return !rootNode.getNode((Object[]) nodePath).isVirtual() ? rootNode.getNode((Object[]) nodePath).getString() : defaultValue;
}
@SuppressWarnings("SameParameterValue")
private static boolean getConfigBoolean(ConfigurationNode rootNode, boolean defaultValue, String... nodePath) {
return !rootNode.getNode((Object[]) nodePath).isVirtual() ? rootNode.getNode((Object[]) nodePath).getBoolean() : defaultValue;
}
private static int getConfigInt(ConfigurationNode rootNode, int defaultValue, String... nodePath) {
return !rootNode.getNode((Object[]) nodePath).isVirtual() ? rootNode.getNode((Object[]) nodePath).getInt() : defaultValue;
}
private static long getConfigLong(ConfigurationNode rootNode, long defaultValue, String... nodePath) {
return !rootNode.getNode((Object[]) nodePath).isVirtual() ? rootNode.getNode((Object[]) nodePath).getLong() : defaultValue;
}
public static void loadSettings(ConfigurationNode loadedConfig) throws IllegalArgumentException {
ConfigurationNode config = copyDefaults(loadedConfig);
Settings.language = getConfigString(config, "en-gb", "language");
Settings.serverType = Settings.ServerType.PROXY;
Settings.automaticUpdateChecks = getConfigBoolean(config, true, "check_for_updates");
Settings.redisHost = getConfigString(config, "localhost", "redis_settings", "host");
Settings.redisPort = getConfigInt(config, 6379, "redis_settings", "port");
Settings.redisPassword = getConfigString(config, "", "redis_settings", "password");
Settings.redisSSL = getConfigBoolean(config, false, "redis_settings", "use_ssl");
Settings.dataStorageType = Settings.DataStorageType.valueOf(getConfigString(config, "sqlite", "data_storage_settings", "database_type").toUpperCase());
if (Settings.dataStorageType == Settings.DataStorageType.MYSQL) {
Settings.mySQLHost = getConfigString(config, "localhost", "data_storage_settings", "mysql_settings", "host");
Settings.mySQLPort = getConfigInt(config, 3306, "data_storage_settings", "mysql_settings", "port");
Settings.mySQLDatabase = getConfigString(config, "HuskSync", "data_storage_settings", "mysql_settings", "database");
Settings.mySQLUsername = getConfigString(config, "root", "data_storage_settings", "mysql_settings", "username");
Settings.mySQLPassword = getConfigString(config, "pa55w0rd", "data_storage_settings", "mysql_settings", "password");
Settings.mySQLParams = getConfigString(config, "?autoReconnect=true&useSSL=false", "data_storage_settings", "mysql_settings", "params");
}
Settings.hikariMaximumPoolSize = getConfigInt(config, 10, "data_storage_settings", "hikari_pool_settings", "maximum_pool_size");
Settings.hikariMinimumIdle = getConfigInt(config, 10, "data_storage_settings", "hikari_pool_settings", "minimum_idle");
Settings.hikariMaximumLifetime = getConfigLong(config, 1800000, "data_storage_settings", "hikari_pool_settings", "maximum_lifetime");
Settings.hikariKeepAliveTime = getConfigLong(config, 0, "data_storage_settings", "hikari_pool_settings", "keepalive_time");
Settings.hikariConnectionTimeOut = getConfigLong(config, 5000, "data_storage_settings", "hikari_pool_settings", "connection_timeout");
Settings.bounceBackSynchronisation = getConfigBoolean(config, true,"bounce_back_synchronization");
// Read cluster data
ConfigurationNode clusterSection = config.getNode("clusters");
final String settingDatabaseName = Settings.mySQLDatabase != null ? Settings.mySQLDatabase : "HuskSync";
for (ConfigurationNode cluster : clusterSection.getChildrenMap().values()) {
final String clusterId = (String) cluster.getKey();
final String playerTableName = getConfigString(config, "husksync_players", "clusters", clusterId, "player_table");
final String dataTableName = getConfigString(config, "husksync_data", "clusters", clusterId, "data_table");
final String databaseName = getConfigString(config, settingDatabaseName, "clusters", clusterId, "database");
Settings.clusters.add(new Settings.SynchronisationCluster(clusterId, databaseName, playerTableName, dataTableName));
}
}
public static void loadMessageStrings(ConfigurationNode config) {
final HashMap<String, String> messages = new HashMap<>();
for (ConfigurationNode message : config.getChildrenMap().values()) {
final String messageId = (String) message.getKey();
messages.put(messageId, getConfigString(config, "", messageId));
}
MessageManager.setMessages(messages);
}
}

@ -1,96 +0,0 @@
package net.william278.husksync.velocity.config;
import net.william278.husksync.HuskSyncVelocity;
import net.william278.husksync.Settings;
import ninja.leaping.configurate.ConfigurationNode;
import ninja.leaping.configurate.yaml.YAMLConfigurationLoader;
import org.yaml.snakeyaml.DumperOptions;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.util.Objects;
import java.util.logging.Level;
public class ConfigManager {
private static final HuskSyncVelocity plugin = HuskSyncVelocity.getInstance();
public static void loadConfig() {
try {
if (!plugin.getDataFolder().exists()) {
if (plugin.getDataFolder().mkdir()) {
plugin.getVelocityLogger().info("Created HuskSync data folder");
}
}
File configFile = new File(plugin.getDataFolder(), "config.yml");
if (!configFile.exists()) {
Files.copy(Objects.requireNonNull(HuskSyncVelocity.class.getClassLoader().getResourceAsStream("proxy-config.yml")), configFile.toPath());
plugin.getVelocityLogger().info("Created HuskSync config file");
}
} catch (Exception e) {
plugin.getVelocityLogger().log(Level.CONFIG, "An exception occurred loading the configuration file", e);
}
}
public static void saveConfig(ConfigurationNode rootNode) {
try {
getConfigLoader().save(rootNode);
} catch (IOException e) {
plugin.getVelocityLogger().log(Level.CONFIG, "An exception occurred loading the configuration file", e);
}
}
public static void loadMessages() {
try {
if (!plugin.getDataFolder().exists()) {
if (plugin.getDataFolder().mkdir()) {
plugin.getVelocityLogger().info("Created HuskSync data folder");
}
}
File messagesFile = new File(plugin.getDataFolder(), "messages_" + Settings.language + ".yml");
if (!messagesFile.exists()) {
Files.copy(Objects.requireNonNull(HuskSyncVelocity.class.getClassLoader().getResourceAsStream("languages/" + Settings.language + ".yml")),
messagesFile.toPath());
plugin.getVelocityLogger().info("Created HuskSync messages file");
}
} catch (IOException e) {
plugin.getVelocityLogger().log(Level.CONFIG, "An exception occurred loading the messages file", e);
}
}
private static YAMLConfigurationLoader getConfigLoader() {
File configFile = new File(plugin.getDataFolder(), "config.yml");
return YAMLConfigurationLoader.builder()
.setPath(configFile.toPath())
.setFlowStyle(DumperOptions.FlowStyle.BLOCK)
.setIndent(2)
.build();
}
public static ConfigurationNode getConfig() {
try {
return getConfigLoader().load();
} catch (IOException e) {
plugin.getVelocityLogger().log(Level.CONFIG, "An IOException has occurred loading the plugin config.");
return null;
}
}
public static ConfigurationNode getMessages() {
try {
File configFile = new File(plugin.getDataFolder(), "messages_" + Settings.language + ".yml");
return YAMLConfigurationLoader.builder()
.setPath(configFile.toPath())
.setFlowStyle(DumperOptions.FlowStyle.BLOCK)
.setIndent(2)
.build()
.load();
} catch (IOException e) {
plugin.getVelocityLogger().log(Level.CONFIG, "An IOException has occurred loading the plugin messages.");
return null;
}
}
}

@ -1,47 +0,0 @@
package net.william278.husksync.velocity.listener;
import com.velocitypowered.api.event.PostOrder;
import com.velocitypowered.api.event.Subscribe;
import com.velocitypowered.api.event.connection.PostLoginEvent;
import com.velocitypowered.api.proxy.Player;
import net.william278.husksync.HuskSyncVelocity;
import net.william278.husksync.PlayerData;
import net.william278.husksync.Settings;
import net.william278.husksync.redis.RedisMessage;
import java.io.IOException;
import java.util.Map;
import java.util.logging.Level;
public class VelocityEventListener {
private static final HuskSyncVelocity plugin = HuskSyncVelocity.getInstance();
@Subscribe(order = PostOrder.FIRST)
public void onPostLogin(PostLoginEvent event) {
final Player player = event.getPlayer();
plugin.getProxyServer().getScheduler().buildTask(plugin, () -> {
// Ensure the player has data on SQL and that it is up-to-date
HuskSyncVelocity.dataManager.ensurePlayerExists(player.getUniqueId(), player.getUsername());
// Get the player's data from SQL
final Map<Settings.SynchronisationCluster, PlayerData> data = HuskSyncVelocity.dataManager.getPlayerData(player.getUniqueId());
// Update the player's data from SQL onto the cache
assert data != null;
for (Settings.SynchronisationCluster cluster : data.keySet()) {
HuskSyncVelocity.dataManager.playerDataCache.get(cluster).updatePlayer(data.get(cluster));
}
// Send a message asking the bukkit to request data on join
try {
new RedisMessage(RedisMessage.MessageType.REQUEST_DATA_ON_JOIN,
new RedisMessage.MessageTarget(Settings.ServerType.BUKKIT, null, null),
RedisMessage.RequestOnJoinUpdateType.ADD_REQUESTER.toString(), player.getUniqueId().toString()).send();
} catch (IOException e) {
plugin.getVelocityLogger().log(Level.SEVERE, "Failed to serialize request data on join message data");
e.printStackTrace();
}
}).schedule();
}
}

@ -1,231 +0,0 @@
package net.william278.husksync.velocity.listener;
import com.velocitypowered.api.proxy.Player;
import de.themoep.minedown.adventure.MineDown;
import net.william278.husksync.HuskSyncVelocity;
import net.william278.husksync.PlayerData;
import net.william278.husksync.Server;
import net.william278.husksync.Settings;
import net.william278.husksync.migrator.MPDBMigrator;
import net.william278.husksync.redis.RedisListener;
import net.william278.husksync.redis.RedisMessage;
import net.william278.husksync.util.MessageManager;
import java.io.IOException;
import java.util.Objects;
import java.util.Optional;
import java.util.UUID;
import java.util.logging.Level;
public class VelocityRedisListener extends RedisListener {
private static final HuskSyncVelocity plugin = HuskSyncVelocity.getInstance();
// Initialize the listener on the bungee
public VelocityRedisListener() {
super();
listen();
}
private PlayerData getPlayerCachedData(UUID uuid, String clusterId) {
PlayerData data = null;
for (Settings.SynchronisationCluster cluster : Settings.clusters) {
if (cluster.clusterId().equals(clusterId)) {
// Get the player data from the cache
PlayerData cachedData = HuskSyncVelocity.dataManager.playerDataCache.get(cluster).getPlayer(uuid);
if (cachedData != null) {
return cachedData;
}
data = Objects.requireNonNull(HuskSyncVelocity.dataManager.getPlayerData(uuid)).get(cluster); // Get their player data from MySQL
HuskSyncVelocity.dataManager.playerDataCache.get(cluster).updatePlayer(data); // Update the cache
break;
}
}
return data; // Return the data
}
/**
* Handle an incoming {@link RedisMessage}
*
* @param message The {@link RedisMessage} to handle
*/
@Override
public void handleMessage(RedisMessage message) {
// Ignore messages destined for Bukkit servers
if (message.getMessageTarget().targetServerType() != Settings.ServerType.PROXY) {
return;
}
// Only process redis messages when ready
if (!HuskSyncVelocity.readyForRedis) {
return;
}
switch (message.getMessageType()) {
case PLAYER_DATA_REQUEST -> plugin.getProxyServer().getScheduler().buildTask(plugin, () -> {
// Get the UUID of the requesting player
final UUID requestingPlayerUUID = UUID.fromString(message.getMessageData());
try {
// Send the reply, serializing the message data
new RedisMessage(RedisMessage.MessageType.PLAYER_DATA_SET,
new RedisMessage.MessageTarget(Settings.ServerType.BUKKIT, requestingPlayerUUID, message.getMessageTarget().targetClusterId()),
RedisMessage.serialize(getPlayerCachedData(requestingPlayerUUID, message.getMessageTarget().targetClusterId())))
.send();
// Send an update to all bukkit servers removing the player from the requester cache
new RedisMessage(RedisMessage.MessageType.REQUEST_DATA_ON_JOIN,
new RedisMessage.MessageTarget(Settings.ServerType.BUKKIT, null, message.getMessageTarget().targetClusterId()),
RedisMessage.RequestOnJoinUpdateType.REMOVE_REQUESTER.toString(), requestingPlayerUUID.toString())
.send();
// Send synchronisation complete message
Optional<Player> player = plugin.getProxyServer().getPlayer(requestingPlayerUUID);
player.ifPresent(value -> value.sendActionBar(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();
}
}).schedule();
case PLAYER_DATA_UPDATE -> plugin.getProxyServer().getScheduler().buildTask(plugin, () -> {
// Deserialize the PlayerData received
PlayerData playerData;
final String serializedPlayerData = message.getMessageDataElements()[0];
final boolean bounceBack = Boolean.parseBoolean(message.getMessageDataElements()[1]);
try {
playerData = (PlayerData) RedisMessage.deserialize(serializedPlayerData);
} catch (IOException | ClassNotFoundException e) {
log(Level.SEVERE, "Failed to deserialize PlayerData when handling a player update request");
e.printStackTrace();
return;
}
// Update the data in the cache and SQL
for (Settings.SynchronisationCluster cluster : Settings.clusters) {
if (cluster.clusterId().equals(message.getMessageTarget().targetClusterId())) {
HuskSyncVelocity.dataManager.updatePlayerData(playerData, cluster);
break;
}
}
// Reply with the player data if they are still online (switching server)
if (Settings.bounceBackSynchronisation && bounceBack) {
Optional<Player> updatingPlayer = plugin.getProxyServer().getPlayer(playerData.getPlayerUUID());
updatingPlayer.ifPresent(player -> {
try {
new RedisMessage(RedisMessage.MessageType.PLAYER_DATA_SET,
new RedisMessage.MessageTarget(Settings.ServerType.BUKKIT, playerData.getPlayerUUID(), message.getMessageTarget().targetClusterId()),
RedisMessage.serialize(playerData))
.send();
// Send synchronisation complete message
player.sendActionBar(new MineDown(MessageManager.getMessage("synchronisation_complete")).toComponent());
} catch (IOException e) {
log(Level.SEVERE, "Failed to re-serialize PlayerData when handling a player update request");
e.printStackTrace();
}
});
}
}).schedule();
case CONNECTION_HANDSHAKE -> {
// Reply to a Bukkit server's connection handshake to complete the process
if (HuskSyncVelocity.isDisabling) return; // Return if the Proxy is disabling
final UUID serverUUID = UUID.fromString(message.getMessageDataElements()[0]);
final boolean hasMySqlPlayerDataBridge = Boolean.parseBoolean(message.getMessageDataElements()[1]);
final String bukkitBrand = message.getMessageDataElements()[2];
final String huskSyncVersion = message.getMessageDataElements()[3];
try {
new RedisMessage(RedisMessage.MessageType.CONNECTION_HANDSHAKE,
new RedisMessage.MessageTarget(Settings.ServerType.BUKKIT, null, message.getMessageTarget().targetClusterId()),
serverUUID.toString(), "Velocity")
.send();
HuskSyncVelocity.synchronisedServers.add(
new Server(serverUUID, hasMySqlPlayerDataBridge,
huskSyncVersion, bukkitBrand, message.getMessageTarget().targetClusterId()));
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
Server serverToRemove = null;
for (Server server : HuskSyncVelocity.synchronisedServers) {
if (server.serverUUID().equals(serverUUID)) {
serverToRemove = server;
break;
}
}
HuskSyncVelocity.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;
}
// Get the MPDB migrator
MPDBMigrator migrator = HuskSyncVelocity.mpdbMigrator;
// Add the incoming data to the data to be saved
migrator.incomingPlayerData.put(playerData, playerName);
// Increment players migrated
migrator.playersMigrated++;
plugin.getVelocityLogger().log(Level.INFO, "Migrated " + migrator.playersMigrated + "/" + migrator.migratedDataSent + " players.");
// When all the data has been received, save it
if (migrator.migratedDataSent == migrator.playersMigrated) {
migrator.loadIncomingData(migrator.incomingPlayerData, HuskSyncVelocity.dataManager);
}
}
case API_DATA_REQUEST -> plugin.getProxyServer().getScheduler().buildTask(plugin, () -> {
final UUID playerUUID = UUID.fromString(message.getMessageDataElements()[0]);
final UUID requestUUID = UUID.fromString(message.getMessageDataElements()[1]);
try {
final PlayerData data = getPlayerCachedData(playerUUID, message.getMessageTarget().targetClusterId());
if (data == null) {
new RedisMessage(RedisMessage.MessageType.API_DATA_CANCEL,
new RedisMessage.MessageTarget(Settings.ServerType.BUKKIT, null, message.getMessageTarget().targetClusterId()),
requestUUID.toString())
.send();
} else {
// Send the reply alongside the request UUID, serializing the requested message data
new RedisMessage(RedisMessage.MessageType.API_DATA_RETURN,
new RedisMessage.MessageTarget(Settings.ServerType.BUKKIT, null, message.getMessageTarget().targetClusterId()),
requestUUID.toString(),
RedisMessage.serialize(data))
.send();
}
} catch (IOException e) {
plugin.getVelocityLogger().log(Level.SEVERE, "Failed to serialize PlayerData requested via the API");
}
}).schedule();
}
}
/**
* Log to console
*
* @param level The {@link Level} to log
* @param message Message to log
*/
@Override
public void log(Level level, String message) {
plugin.getVelocityLogger().log(level, message);
}
}

@ -1,44 +0,0 @@
package net.william278.husksync.velocity.util;
import net.william278.husksync.util.Logger;
import java.util.logging.Level;
public record VelocityLogger(org.slf4j.Logger parent) implements Logger {
@Override
public void log(Level level, String message, Exception e) {
logMessage(level, message);
e.printStackTrace();
}
@Override
public void log(Level level, String message) {
logMessage(level, message);
}
@Override
public void info(String message) {
logMessage(Level.INFO, message);
}
@Override
public void severe(String message) {
logMessage(Level.SEVERE, message);
}
@Override
public void config(String message) {
logMessage(Level.CONFIG, message);
}
// Logs the message using SLF4J
private void logMessage(Level level, String message) {
switch (level.intValue()) {
case 1000 -> parent.error(message); // Severe
case 900 -> parent.warn(message); // Warning
case 70 -> parent.warn("[Config] " + message);
default -> parent.info(message);
}
}
}

@ -1,20 +0,0 @@
package net.william278.husksync.velocity.util;
import net.william278.husksync.HuskSyncVelocity;
import net.william278.husksync.util.UpdateChecker;
import java.util.logging.Level;
public class VelocityUpdateChecker extends UpdateChecker {
private static final HuskSyncVelocity plugin = HuskSyncVelocity.getInstance();
public VelocityUpdateChecker(String versionToCheck) {
super(versionToCheck);
}
@Override
public void log(Level level, String message) {
plugin.getVelocityLogger().log(level, message);
}
}

@ -1,12 +0,0 @@
{
"id": "husksync",
"name": "HuskSync",
"version": "${version}",
"description": "A modern, cross-server player data synchronization system",
"url": "https://william278.net",
"authors": [
"William278"
],
"dependencies": [],
"main": "net.william278.husksync.HuskSyncVelocity"
}
Loading…
Cancel
Save