Basic bukkit implementation

feat/data-edit-commands
William 2 years ago
parent 9471e0cbff
commit 38c261871a

@ -1,16 +1,25 @@
dependencies {
compileOnly project(path: ':common')
implementation project(path: ':bukkit')
compileOnly project(path: ':common')
compileOnly 'org.spigotmc:spigot-api:1.16.5-R0.1-SNAPSHOT'
compileOnly 'org.jetbrains:annotations:23.0.0'
}
shadowJar {
relocate 'org.apache', 'net.william278.husksync.libraries'
relocate 'dev.dejvokep', 'net.william278.husksync.libraries'
relocate 'de.themoep', 'net.william278.husksync.libraries'
relocate 'org.bstats', 'net.william278.husksync.libraries.bstats'
relocate 'org.jetbrains', 'net.william278.husksync.libraries'
relocate 'org.intellij', 'net.william278.husksync.libraries'
relocate 'com.zaxxer', 'net.william278.husksync.libraries'
relocate 'org.slf4j', 'net.william278.husksync.libraries.slf4j'
relocate 'com.google', 'net.william278.husksync.libraries'
//relocate 'org.xerial', 'net.william278.husksync.libraries'
relocate 'redis.clients', 'net.william278.husksync.libraries'
relocate 'org.apache', 'net.william278.husksync.libraries'
relocate 'net.byteflux.libby', 'net.william278.husksync.libraries.libby'
relocate 'org.bstats', 'net.william278.husksync.libraries.bstats'
relocate 'net.william278.mpdbconverter', 'net.william278.husksync.libraries.mpdbconverter'
}

@ -0,0 +1,4 @@
package net.william278.husksync.api;
public class HuskSyncAPI {
}

@ -1,6 +1,6 @@
plugins {
id 'com.github.johnrengelman.shadow' version '7.1.0'
id 'org.ajoberstar.grgit' version '4.1.1'
id 'com.github.johnrengelman.shadow' version '7.1.2'
id 'org.ajoberstar.grgit' version '5.0.0'
id 'java'
}
@ -9,8 +9,6 @@ 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
@ -34,13 +32,6 @@ allprojects {
maven { url 'https://jitpack.io' }
}
dependencies {
implementation('redis.clients:jedis:4.2.3') {
//noinspection GroovyAssignabilityCheck
exclude module: 'slf4j-api'
}
}
processResources {
filter ReplaceTokens as Class, beginToken: '${', endToken: '}',
tokens: rootProject.ext.properties

@ -3,19 +3,28 @@ dependencies {
implementation 'org.bstats:bstats-bukkit:3.0.0'
implementation 'net.william278:mpdbdataconverter:1.0'
implementation 'net.byteflux:libby-bukkit:1.1.5'
compileOnly 'redis.clients:jedis:4.2.3'
compileOnly 'commons-io:commons-io:2.11.0'
compileOnly 'de.themoep:minedown:1.7.1-SNAPSHOT'
compileOnly 'dev.dejvokep:boosted-yaml:1.2'
compileOnly 'org.spigotmc:spigot-api:1.16.5-R0.1-SNAPSHOT'
compileOnly 'org.jetbrains:annotations:23.0.0'
}
shadowJar {
relocate 'org.apache', 'net.william278.husksync.libraries'
relocate 'dev.dejvokep', 'net.william278.husksync.libraries'
relocate 'de.themoep', 'net.william278.husksync.libraries'
relocate 'org.jetbrains', 'net.william278.husksync.libraries'
relocate 'org.intellij', 'net.william278.husksync.libraries'
relocate 'com.zaxxer', 'net.william278.husksync.libraries'
relocate 'org.slf4j', 'net.william278.husksync.libraries.slf4j'
relocate 'com.google', 'net.william278.husksync.libraries'
//relocate 'org.xerial', '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'
relocate 'net.byteflux.libby', 'net.william278.husksync.libraries.libby'
relocate 'org.bstats', 'net.william278.husksync.libraries.bstats'
relocate 'net.william278.mpdbconverter', 'net.william278.husksync.libraries.mpdbconverter'
}

@ -0,0 +1,244 @@
package net.william278.husksync;
import dev.dejvokep.boostedyaml.YamlDocument;
import dev.dejvokep.boostedyaml.dvs.versioning.BasicVersioning;
import dev.dejvokep.boostedyaml.settings.dumper.DumperSettings;
import dev.dejvokep.boostedyaml.settings.general.GeneralSettings;
import dev.dejvokep.boostedyaml.settings.loader.LoaderSettings;
import dev.dejvokep.boostedyaml.settings.updater.UpdaterSettings;
import net.william278.husksync.command.BukkitCommand;
import net.william278.husksync.command.CommandBase;
import net.william278.husksync.command.HuskSyncCommand;
import net.william278.husksync.command.Permission;
import net.william278.husksync.config.Locales;
import net.william278.husksync.config.Settings;
import net.william278.husksync.database.Database;
import net.william278.husksync.database.MySqlDatabase;
import net.william278.husksync.listener.BukkitEventListener;
import net.william278.husksync.listener.EventListener;
import net.william278.husksync.player.BukkitPlayer;
import net.william278.husksync.player.OnlineUser;
import net.william278.husksync.redis.RedisManager;
import net.william278.husksync.util.*;
import org.bukkit.Bukkit;
import org.bukkit.command.PluginCommand;
import org.bukkit.entity.Player;
import org.bukkit.permissions.PermissionDefault;
import org.bukkit.plugin.java.JavaPlugin;
import org.jetbrains.annotations.NotNull;
import java.io.File;
import java.io.IOException;
import java.util.*;
import java.util.concurrent.CompletableFuture;
import java.util.logging.Level;
import java.util.stream.Collectors;
public class BukkitHuskSync extends JavaPlugin implements HuskSync {
private Database database;
private RedisManager redisManager;
private Logger logger;
private ResourceReader resourceReader;
private EventListener eventListener;
private Settings settings;
private Locales locales;
private static BukkitHuskSync instance;
public static BukkitHuskSync getInstance() {
return instance;
}
@Override
public void onLoad() {
instance = this;
/*getLogger().log(Level.INFO, "Loading runtime libraries...");
final BukkitLibraryManager libraryManager = new BukkitLibraryManager(this);
final Library[] libraries = new Library[]{
Library.builder().groupId("redis{}clients")
.artifactId("jedis")
.version("4.2.3")
.id("jedis")
.build()
};
libraryManager.addMavenCentral();
Arrays.stream(libraries).forEach(libraryManager::loadLibrary);
getLogger().log(Level.INFO, "Successfully loaded runtime libraries.");*/
}
@Override
public void onEnable() {
// Process initialization stages
CompletableFuture.supplyAsync(() -> {
// Set the logging adapter and resource reader
this.logger = new BukkitLogger(this.getLogger());
this.resourceReader = new BukkitResourceReader(this);
// Load settings and locales
getLoggingAdapter().log(Level.INFO, "Loading plugin configuration settings & locales...");
return reload().thenApply(loadedSettings -> {
if (loadedSettings) {
getLoggingAdapter().log(Level.INFO, "Successfully loaded plugin configuration settings & locales");
} else {
getLoggingAdapter().log(Level.SEVERE, "Failed to load plugin configuration settings and/or locales");
}
return loadedSettings;
}).join();
}).thenApply(succeeded -> {
// Establish connection to the database
this.database = new MySqlDatabase(settings, resourceReader, logger);
if (succeeded) {
getLoggingAdapter().log(Level.INFO, "Attempting to establish connection to the database...");
final CompletableFuture<Boolean> databaseConnectFuture = new CompletableFuture<>();
Bukkit.getScheduler().runTask(this, () -> {
final boolean initialized = this.database.initialize();
if (!initialized) {
getLoggingAdapter().log(Level.SEVERE, "Failed to establish a connection to the database. "
+ "Please check the supplied database credentials in the config file");
databaseConnectFuture.completeAsync(() -> false);
return;
}
getLoggingAdapter().log(Level.INFO, "Successfully established a connection to the database");
databaseConnectFuture.completeAsync(() -> true);
});
return databaseConnectFuture.join();
}
return false;
}).thenApply(succeeded -> {
// Establish connection to the Redis server
this.redisManager = new RedisManager(settings);
if (succeeded) {
getLoggingAdapter().log(Level.INFO, "Attempting to establish connection to the Redis server...");
return this.redisManager.initialize().thenApply(initialized -> {
if (!initialized) {
getLoggingAdapter().log(Level.SEVERE, "Failed to establish a connection to the Redis server. "
+ "Please check the supplied Redis credentials in the config file");
return false;
}
getLoggingAdapter().log(Level.INFO, "Successfully established a connection to the Redis server");
return true;
}).join();
}
return false;
}).thenApply(succeeded -> {
// Register events
if (succeeded) {
getLoggingAdapter().log(Level.INFO, "Registering events...");
this.eventListener = new BukkitEventListener(this);
getLoggingAdapter().log(Level.INFO, "Successfully registered events listener");
}
return succeeded;
}).thenApply(succeeded -> {
// Register permissions
if (succeeded) {
getLoggingAdapter().log(Level.INFO, "Registering permissions & commands...");
Arrays.stream(Permission.values()).forEach(permission -> getServer().getPluginManager().addPermission(new org.bukkit.permissions.Permission(permission.node, switch (permission.defaultAccess) {
case EVERYONE -> PermissionDefault.TRUE;
case NOBODY -> PermissionDefault.FALSE;
case OPERATORS -> PermissionDefault.OP;
})));
// Register commands
final CommandBase[] commands = new CommandBase[]{new HuskSyncCommand(this)};
for (CommandBase commandBase : commands) {
final PluginCommand pluginCommand = getCommand(commandBase.command);
if (pluginCommand != null) {
new BukkitCommand(commandBase, this).register(pluginCommand);
}
}
getLoggingAdapter().log(Level.INFO, "Successfully registered permissions & commands");
}
return succeeded;
}).thenApply(succeeded -> {
// Check for updates
if (settings.getBooleanValue(Settings.ConfigOption.CHECK_FOR_UPDATES) && succeeded) {
getLoggingAdapter().log(Level.INFO, "Checking for updates...");
new UpdateChecker(getVersion(), getLoggingAdapter()).logToConsole();
}
return succeeded;
}).thenAccept(succeeded -> {
// Handle failed initialization
if (!succeeded) {
getLoggingAdapter().log(Level.SEVERE, "Failed to initialize HuskSync. " +
"The plugin will now be disabled");
getServer().getPluginManager().disablePlugin(this);
} else {
getLoggingAdapter().log(Level.INFO, "Successfully enabled HuskSync v" + getVersion());
}
});
}
@Override
public void onDisable() {
if (this.eventListener != null) {
this.eventListener.handlePluginDisable();
}
getLoggingAdapter().log(Level.INFO, "Successfully disabled HuskSync v" + getVersion());
}
@Override
public @NotNull Set<OnlineUser> getOnlineUsers() {
return Bukkit.getOnlinePlayers().stream().map(BukkitPlayer::adapt).collect(Collectors.toSet());
}
@Override
public @NotNull Optional<OnlineUser> getOnlineUser(@NotNull UUID uuid) {
final Player player = Bukkit.getPlayer(uuid);
if (player == null) {
return Optional.empty();
}
return Optional.of(BukkitPlayer.adapt(player));
}
@Override
public @NotNull Database getDatabase() {
return database;
}
@Override
public @NotNull RedisManager getRedisManager() {
return redisManager;
}
@Override
public @NotNull Settings getSettings() {
return settings;
}
@Override
public @NotNull Locales getLocales() {
return locales;
}
@Override
public @NotNull Logger getLoggingAdapter() {
return logger;
}
@Override
public @NotNull String getVersion() {
return getDescription().getVersion();
}
@Override
public CompletableFuture<Boolean> reload() {
return CompletableFuture.supplyAsync(() -> {
try {
this.settings = Settings.load(YamlDocument.create(new File(getDataFolder(), "config.yml"), Objects.requireNonNull(resourceReader.getResource("config.yml")), GeneralSettings.builder().setUseDefaults(false).build(), LoaderSettings.builder().setAutoUpdate(true).build(), DumperSettings.builder().setEncoding(DumperSettings.Encoding.UNICODE).build(), UpdaterSettings.builder().setVersioning(new BasicVersioning("config_version")).build()));
this.locales = Locales.load(YamlDocument.create(new File(getDataFolder(), "messages-" + settings.getStringValue(Settings.ConfigOption.LANGUAGE) + ".yml"), Objects.requireNonNull(resourceReader.getResource("locales/" + settings.getStringValue(Settings.ConfigOption.LANGUAGE) + ".yml"))));
return true;
} catch (IOException | NullPointerException e) {
getLoggingAdapter().log(Level.SEVERE, "Failed to load data from the config", e);
return false;
}
});
}
}

@ -0,0 +1,70 @@
package net.william278.husksync.command;
import net.william278.husksync.HuskSync;
import net.william278.husksync.player.BukkitPlayer;
import org.bukkit.command.*;
import org.bukkit.entity.Player;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.util.Collections;
import java.util.List;
/**
* Bukkit executor that implements and executes {@link CommandBase}s
*/
public class BukkitCommand implements CommandExecutor, TabExecutor {
/**
* The {@link CommandBase} that will be executed
*/
private final CommandBase command;
/**
* The implementing plugin
*/
private final HuskSync plugin;
public BukkitCommand(@NotNull CommandBase command, @NotNull HuskSync implementor) {
this.command = command;
this.plugin = implementor;
}
/**
* Registers a {@link PluginCommand} to this implementation
*
* @param pluginCommand {@link PluginCommand} to register
*/
public void register(@NotNull PluginCommand pluginCommand) {
pluginCommand.setExecutor(this);
pluginCommand.setTabCompleter(this);
pluginCommand.setPermission(command.permission);
pluginCommand.setDescription(command.getDescription());
}
@Override
public boolean onCommand(@NotNull CommandSender sender, @NotNull Command command,
@NotNull String label, @NotNull String[] args) {
if (sender instanceof Player player) {
this.command.onExecute(BukkitPlayer.adapt(player), args);
} else {
if (command instanceof ConsoleExecutable consoleExecutable) {
consoleExecutable.onConsoleExecute(args);
} else {
plugin.getLocales().getLocale("error_in_game_only").
ifPresent(locale -> sender.spigot().sendMessage(locale.toComponent()));
}
}
return true;
}
@Nullable
@Override
public List<String> onTabComplete(@NotNull CommandSender sender, @NotNull Command command,
@NotNull String alias, @NotNull String[] args) {
if (this.command instanceof TabCompletable tabCompletable) {
return tabCompletable.onTabComplete(BukkitPlayer.adapt((Player) sender), args);
}
return Collections.emptyList();
}
}

@ -0,0 +1,199 @@
package net.william278.husksync.data;
import org.bukkit.inventory.ItemStack;
import org.bukkit.potion.PotionEffect;
import org.bukkit.util.io.BukkitObjectInputStream;
import org.bukkit.util.io.BukkitObjectOutputStream;
import org.jetbrains.annotations.Nullable;
import org.yaml.snakeyaml.external.biz.base64Coder.Base64Coder;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.util.Map;
import java.util.concurrent.CompletableFuture;
public class BukkitSerializer {
/**
* Returns a serialized array of {@link ItemStack}s
*
* @param inventoryContents The contents of the inventory
* @return The serialized inventory contents
*/
public static CompletableFuture<String> serializeInventory(ItemStack[] inventoryContents) {
return CompletableFuture.supplyAsync(() -> {
// 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
*/
public static CompletableFuture<ItemStack[]> deserializeInventory(String inventoryData) {
return CompletableFuture.supplyAsync(() -> {
// 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;
}
} catch (IOException | ClassNotFoundException e) {
throw new RuntimeException("Failed to deserialize item stack data");
}
});
}
/**
* 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 CompletableFuture<String> serializePotionEffects(PotionEffect[] potionEffects) {
return CompletableFuture.supplyAsync(() -> {
// 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
*/
public static CompletableFuture<PotionEffect[]> deserializePotionEffects(String potionEffectData) {
return CompletableFuture.supplyAsync(() -> {
// 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;
}
} catch (IOException | ClassNotFoundException e) {
throw new RuntimeException("Failed to deserialize potion effects", e);
}
});
}
/**
* 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}
*/
@Nullable
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
@Nullable
private static PotionEffect deserializePotionEffect(Object serializedPotionEffect) {
return serializedPotionEffect != null ? new PotionEffect((Map<String, Object>) serializedPotionEffect) : null;
}
}

@ -0,0 +1,48 @@
package net.william278.husksync.listener;
import net.william278.husksync.BukkitHuskSync;
import net.william278.husksync.player.BukkitPlayer;
import org.bukkit.Bukkit;
import org.bukkit.event.Cancellable;
import org.bukkit.event.EventHandler;
import org.bukkit.event.Listener;
import org.bukkit.event.player.PlayerEvent;
import org.bukkit.event.player.PlayerJoinEvent;
import org.bukkit.event.player.PlayerQuitEvent;
import org.bukkit.event.world.WorldSaveEvent;
import org.jetbrains.annotations.NotNull;
import java.util.stream.Collectors;
public class BukkitEventListener extends EventListener implements Listener {
public BukkitEventListener(@NotNull BukkitHuskSync huskSync) {
super(huskSync);
Bukkit.getServer().getPluginManager().registerEvents(this, huskSync);
}
@EventHandler
public void onPlayerJoin(@NotNull PlayerJoinEvent event) {
super.handlePlayerJoin(BukkitPlayer.adapt(event.getPlayer()));
}
@EventHandler
public void onPlayerQuit(@NotNull PlayerQuitEvent event) {
super.handlePlayerQuit(BukkitPlayer.adapt(event.getPlayer()));
BukkitPlayer.remove(event.getPlayer());
}
@EventHandler
public void onWorldSave(@NotNull WorldSaveEvent event) {
super.handleWorldSave(event.getWorld().getPlayers().stream().map(BukkitPlayer::adapt)
.collect(Collectors.toList()));
}
/*@EventHandler(ignoreCancelled = true)
public void onGenericPlayerEvent(@NotNull PlayerEvent event) {
if (event instanceof Cancellable) {
((Cancellable) event).setCancelled(cancelPlayerEvent(BukkitPlayer.adapt(event.getPlayer())));
}
}*/
}

@ -0,0 +1,434 @@
package net.william278.husksync.player;
import de.themoep.minedown.MineDown;
import net.md_5.bungee.api.ChatMessageType;
import net.william278.husksync.BukkitHuskSync;
import net.william278.husksync.data.*;
import org.apache.commons.lang.ArrayUtils;
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.event.player.PlayerTeleportEvent;
import org.bukkit.persistence.PersistentDataContainer;
import org.bukkit.persistence.PersistentDataType;
import org.bukkit.potion.PotionEffect;
import org.bukkit.potion.PotionEffectType;
import org.jetbrains.annotations.NotNull;
import java.util.*;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference;
/**
* Bukkit implementation of an {@link OnlineUser}
*/
public class BukkitPlayer extends OnlineUser {
private static final HashMap<UUID, BukkitPlayer> cachedPlayers = new HashMap<>();
private final Player player;
private BukkitPlayer(@NotNull Player player) {
super(player.getUniqueId(), player.getName());
this.player = player;
}
public static BukkitPlayer adapt(@NotNull Player player) {
if (cachedPlayers.containsKey(player.getUniqueId())) {
return cachedPlayers.get(player.getUniqueId());
}
final BukkitPlayer bukkitPlayer = new BukkitPlayer(player);
cachedPlayers.put(player.getUniqueId(), bukkitPlayer);
return bukkitPlayer;
}
public static void remove(@NotNull Player player) {
cachedPlayers.remove(player.getUniqueId());
}
@Override
public CompletableFuture<StatusData> getStatus() {
return CompletableFuture.supplyAsync(() -> {
final double maxHealth = getMaxHealth(player);
return new StatusData(Math.min(player.getHealth(), maxHealth),
maxHealth,
player.isHealthScaled() ? player.getHealthScale() : 0d,
player.getFoodLevel(),
player.getSaturation(),
player.getExhaustion(),
player.getInventory().getHeldItemSlot(),
player.getTotalExperience(),
player.getLevel(),
player.getExp(),
player.getGameMode().name(),
player.getAllowFlight() && player.isFlying());
});
}
@Override
public CompletableFuture<Void> setStatus(@NotNull StatusData statusData,
boolean setHealth, boolean setMaxHealth,
boolean setHunger, boolean setExperience,
boolean setGameMode, boolean setFlying) {
return CompletableFuture.runAsync(() -> {
double currentMaxHealth = Objects.requireNonNull(player.getAttribute(Attribute.GENERIC_MAX_HEALTH))
.getBaseValue();
if (setMaxHealth) {
if (statusData.maxHealth != 0d) {
Objects.requireNonNull(player.getAttribute(Attribute.GENERIC_MAX_HEALTH))
.setBaseValue(statusData.maxHealth);
currentMaxHealth = statusData.maxHealth;
}
}
if (setHealth) {
final double currentHealth = player.getHealth();
if (statusData.health != currentHealth) {
player.setHealth(currentHealth > currentMaxHealth ? currentMaxHealth : statusData.health);
}
if (statusData.healthScale != 0d) {
player.setHealthScale(statusData.healthScale);
} else {
player.setHealthScale(statusData.maxHealth);
}
player.setHealthScaled(statusData.healthScale != 0D);
}
if (setHunger) {
player.setFoodLevel(statusData.hunger);
player.setSaturation(statusData.saturation);
player.setExhaustion(statusData.saturationExhaustion);
}
if (setExperience) {
player.setTotalExperience(statusData.totalExperience);
player.setLevel(statusData.expLevel);
player.setExp(statusData.expProgress);
}
if (setGameMode) {
player.setGameMode(GameMode.valueOf(statusData.gameMode));
}
if (setFlying) {
if (statusData.isFlying) {
player.setAllowFlight(true);
player.setFlying(true);
}
player.setFlying(false);
}
});
}
@Override
public CompletableFuture<InventoryData> getInventory() {
return BukkitSerializer.serializeInventory(player.getInventory().getContents())
.thenApply(InventoryData::new);
}
@Override
public CompletableFuture<Void> setInventory(@NotNull InventoryData inventoryData) {
return BukkitSerializer.deserializeInventory(inventoryData.serializedInventory).thenAccept(contents ->
Bukkit.getScheduler().runTask(BukkitHuskSync.getInstance(),
() -> player.getInventory().setContents(contents)));
}
@Override
public CompletableFuture<InventoryData> getEnderChest() {
return BukkitSerializer.serializeInventory(player.getEnderChest().getContents())
.thenApply(InventoryData::new);
}
@Override
public CompletableFuture<Void> setEnderChest(@NotNull InventoryData enderChestData) {
return BukkitSerializer.deserializeInventory(enderChestData.serializedInventory).thenAccept(contents ->
Bukkit.getScheduler().runTask(BukkitHuskSync.getInstance(),
() -> player.getEnderChest().setContents(contents)));
}
@Override
public CompletableFuture<PotionEffectData> getPotionEffects() {
return BukkitSerializer.serializePotionEffects(player.getActivePotionEffects()
.toArray(new PotionEffect[0])).thenApply(PotionEffectData::new);
}
@Override
public CompletableFuture<Void> setPotionEffects(@NotNull PotionEffectData potionEffectData) {
return BukkitSerializer.deserializePotionEffects(potionEffectData.serializedPotionEffects).thenAccept(
effects -> Bukkit.getScheduler().runTask(BukkitHuskSync.getInstance(), () -> {
for (PotionEffect effect : player.getActivePotionEffects()) {
player.removePotionEffect(effect.getType());
}
for (PotionEffect effect : effects) {
player.addPotionEffect(effect);
}
}));
}
@Override
public CompletableFuture<List<AdvancementData>> getAdvancements() {
return CompletableFuture.supplyAsync(() -> {
final Iterator<Advancement> serverAdvancements = Bukkit.getServer().advancementIterator();
final ArrayList<AdvancementData> advancementData = new ArrayList<>();
// Iterate through the server advancement set and add all advancements to the list
serverAdvancements.forEachRemaining(advancement -> {
final AdvancementProgress advancementProgress = player.getAdvancementProgress(advancement);
final Map<String, Date> awardedCriteria = new HashMap<>();
advancementProgress.getAwardedCriteria().forEach(criteriaKey -> awardedCriteria.put(criteriaKey,
advancementProgress.getDateAwarded(criteriaKey)));
// Only save the advancement if criteria has been completed
if (!awardedCriteria.isEmpty()) {
advancementData.add(new AdvancementData(advancement.getKey().toString(), awardedCriteria));
}
});
return advancementData;
});
}
@Override
public CompletableFuture<Void> setAdvancements(@NotNull List<AdvancementData> advancementData) {
return CompletableFuture.runAsync(() -> {
// 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;
// Save current experience and level
final int experienceLevel = player.getLevel();
final float expProgress = player.getExp();
// Determines whether the experience might have changed warranting an update
final AtomicBoolean correctExperience = new AtomicBoolean(false);
// Apply the advancements to the player
final Iterator<Advancement> serverAdvancements = Bukkit.getServer().advancementIterator();
while (serverAdvancements.hasNext()) {
// Iterate through all advancements
final Advancement advancement = serverAdvancements.next();
final AdvancementProgress playerProgress = player.getAdvancementProgress(advancement);
advancementData.stream().filter(record -> record.key.equals(advancement.getKey().toString())).findFirst().ifPresentOrElse(
// Award all criteria that the player does not have that they do on the cache
record -> {
record.completedCriteria.keySet().stream()
.filter(criterion -> !playerProgress.getAwardedCriteria().contains(criterion))
.forEach(criterion -> {
Bukkit.getScheduler().runTask(BukkitHuskSync.getInstance(),
() -> player.getAdvancementProgress(advancement).awardCriteria(criterion));
correctExperience.set(true);
});
// Revoke all criteria that the player does have but should not
new ArrayList<>(playerProgress.getAwardedCriteria()).stream().filter(criterion -> !record.completedCriteria.containsKey(criterion))
.forEach(criterion -> Bukkit.getScheduler().runTask(BukkitHuskSync.getInstance(),
() -> player.getAdvancementProgress(advancement).revokeCriteria(criterion)));
},
// Revoke the criteria as the player shouldn't have any
() -> new ArrayList<>(playerProgress.getAwardedCriteria()).forEach(criterion ->
Bukkit.getScheduler().runTask(BukkitHuskSync.getInstance(),
() -> player.getAdvancementProgress(advancement).revokeCriteria(criterion))));
// Update the player's experience in case the advancement changed that
if (correctExperience.get()) {
player.setLevel(experienceLevel);
player.setExp(expProgress);
correctExperience.set(false);
}
}
// Re-enable announcing advancements (back on main thread again)
Bukkit.getScheduler().runTask(BukkitHuskSync.getInstance(), () -> {
if (finalAnnounceAdvancementUpdate) {
player.getWorld().setGameRule(GameRule.ANNOUNCE_ADVANCEMENTS, true);
}
});
});
}
@Override
public CompletableFuture<StatisticsData> getStatistics() {
return CompletableFuture.supplyAsync(() -> {
final Map<String, Integer> untypedStatisticValues = new HashMap<>();
final Map<String, Map<String, Integer>> blockStatisticValues = new HashMap<>();
final Map<String, Map<String, Integer>> itemStatisticValues = new HashMap<>();
final Map<String, Map<String, Integer>> entityStatisticValues = new HashMap<>();
for (Statistic statistic : Statistic.values()) {
switch (statistic.getType()) {
case ITEM -> {
final Map<String, Integer> itemValues = new HashMap<>();
Arrays.stream(Material.values()).filter(Material::isItem)
.filter(itemMaterial -> (player.getStatistic(statistic, itemMaterial)) != 0)
.forEach(itemMaterial -> itemValues.put(itemMaterial.name(),
player.getStatistic(statistic, itemMaterial)));
if (!itemValues.isEmpty()) {
itemStatisticValues.put(statistic.name(), itemValues);
}
}
case BLOCK -> {
final Map<String, Integer> blockValues = new HashMap<>();
Arrays.stream(Material.values()).filter(Material::isBlock)
.filter(blockMaterial -> (player.getStatistic(statistic, blockMaterial)) != 0)
.forEach(blockMaterial -> blockValues.put(blockMaterial.name(),
player.getStatistic(statistic, blockMaterial)));
if (!blockValues.isEmpty()) {
blockStatisticValues.put(statistic.name(), blockValues);
}
}
case ENTITY -> {
final Map<String, Integer> entityValues = new HashMap<>();
Arrays.stream(EntityType.values()).filter(EntityType::isAlive)
.filter(entityType -> (player.getStatistic(statistic, entityType)) != 0)
.forEach(entityType -> entityValues.put(entityType.name(),
player.getStatistic(statistic, entityType)));
if (!entityValues.isEmpty()) {
entityStatisticValues.put(statistic.name(), entityValues);
}
}
case UNTYPED -> {
if (player.getStatistic(statistic) != 0) {
untypedStatisticValues.put(statistic.name(), player.getStatistic(statistic));
}
}
}
}
return new StatisticsData(untypedStatisticValues, blockStatisticValues,
itemStatisticValues, entityStatisticValues);
});
}
@Override
public CompletableFuture<Void> setStatistics(@NotNull StatisticsData statisticsData) {
return CompletableFuture.runAsync(() -> {
// Set untyped statistics
for (String statistic : statisticsData.untypedStatistic.keySet()) {
player.setStatistic(Statistic.valueOf(statistic), statisticsData.untypedStatistic.get(statistic));
}
// Set block statistics
for (String statistic : statisticsData.blockStatistics.keySet()) {
for (String blockMaterial : statisticsData.blockStatistics.get(statistic).keySet()) {
player.setStatistic(Statistic.valueOf(statistic), Material.valueOf(blockMaterial),
statisticsData.blockStatistics.get(statistic).get(blockMaterial));
}
}
// Set item statistics
for (String statistic : statisticsData.itemStatistics.keySet()) {
for (String itemMaterial : statisticsData.itemStatistics.get(statistic).keySet()) {
player.setStatistic(Statistic.valueOf(statistic), Material.valueOf(itemMaterial),
statisticsData.itemStatistics.get(statistic).get(itemMaterial));
}
}
// Set entity statistics
for (String statistic : statisticsData.entityStatistics.keySet()) {
for (String entityType : statisticsData.entityStatistics.get(statistic).keySet()) {
player.setStatistic(Statistic.valueOf(statistic), EntityType.valueOf(entityType),
statisticsData.entityStatistics.get(statistic).get(entityType));
}
}
});
}
@Override
public CompletableFuture<LocationData> getLocation() {
return CompletableFuture.supplyAsync(() ->
new LocationData(player.getWorld().getName(), player.getWorld().getUID(), player.getWorld().getEnvironment().name(),
player.getLocation().getX(), player.getLocation().getY(), player.getLocation().getZ(),
player.getLocation().getYaw(), player.getLocation().getPitch()));
}
@Override
public CompletableFuture<Void> setLocation(@NotNull LocationData locationData) {
final CompletableFuture<Void> completableFuture = new CompletableFuture<>();
AtomicReference<World> bukkitWorld = new AtomicReference<>(Bukkit.getWorld(locationData.worldName));
if (bukkitWorld.get() == null) {
bukkitWorld.set(Bukkit.getWorld(locationData.worldUuid));
}
if (bukkitWorld.get() == null) {
Bukkit.getWorlds().stream().filter(world -> world.getEnvironment() == World.Environment
.valueOf(locationData.worldEnvironment)).findFirst().ifPresent(bukkitWorld::set);
}
if (bukkitWorld.get() != null) {
player.teleport(new Location(bukkitWorld.get(),
locationData.x, locationData.y, locationData.z,
locationData.yaw, locationData.pitch), PlayerTeleportEvent.TeleportCause.PLUGIN);
}
CompletableFuture.runAsync(() -> completableFuture.completeAsync(() -> null));
return completableFuture;
}
@Override
public CompletableFuture<PersistentDataContainerData> getPersistentDataContainer() {
return CompletableFuture.supplyAsync(() -> {
final PersistentDataContainer container = player.getPersistentDataContainer();
if (container.isEmpty()) {
return new PersistentDataContainerData(new HashMap<>());
}
final HashMap<String, Byte[]> persistentDataMap = new HashMap<>();
for (NamespacedKey key : container.getKeys()) {
persistentDataMap.put(key.toString(), ArrayUtils.toObject(container.get(key, PersistentDataType.BYTE_ARRAY)));
}
return new PersistentDataContainerData(persistentDataMap);
});
}
@Override
public CompletableFuture<Void> setPersistentDataContainer(@NotNull PersistentDataContainerData persistentDataContainerData) {
return CompletableFuture.runAsync(() -> {
player.getPersistentDataContainer().getKeys().forEach(namespacedKey ->
player.getPersistentDataContainer().remove(namespacedKey));
persistentDataContainerData.persistentDataMap.keySet().forEach(keyString -> {
final NamespacedKey key = NamespacedKey.fromString(keyString);
if (key != null) {
final byte[] data = ArrayUtils.toPrimitive(persistentDataContainerData
.persistentDataMap.get(keyString));
player.getPersistentDataContainer().set(key, PersistentDataType.BYTE_ARRAY, data);
}
});
});
}
@Override
public boolean hasPermission(@NotNull String node) {
return player.hasPermission(node);
}
@Override
public void sendActionBar(@NotNull MineDown mineDown) {
player.spigot().sendMessage(ChatMessageType.ACTION_BAR, mineDown.toComponent());
}
@Override
public void sendMessage(@NotNull MineDown mineDown) {
player.spigot().sendMessage(mineDown.toComponent());
}
/**
* 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(@NotNull 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;
}
}

@ -0,0 +1,38 @@
package net.william278.husksync.util;
import java.util.logging.Level;
public class BukkitLogger implements Logger {
private final java.util.logging.Logger logger;
public BukkitLogger(java.util.logging.Logger logger) {
this.logger = logger;
}
@Override
public void log(Level level, String message, Exception e) {
logger.log(level, message, e);
}
@Override
public void log(Level level, String message) {
logger.log(level, message);
}
@Override
public void info(String message) {
logger.info(message);
}
@Override
public void severe(String message) {
logger.severe(message);
}
@Override
public void config(String message) {
logger.config(message);
}
}

@ -0,0 +1,28 @@
package net.william278.husksync.util;
import net.william278.husksync.BukkitHuskSync;
import org.jetbrains.annotations.NotNull;
import java.io.File;
import java.io.InputStream;
import java.util.Objects;
public class BukkitResourceReader implements ResourceReader {
private final BukkitHuskSync plugin;
public BukkitResourceReader(BukkitHuskSync plugin) {
this.plugin = plugin;
}
@Override
public @NotNull InputStream getResource(String fileName) {
return Objects.requireNonNull(plugin.getResource(fileName));
}
@Override
public @NotNull File getDataFolder() {
return plugin.getDataFolder();
}
}

@ -1,8 +1,13 @@
name: HuskSync
version: ${version}
main: net.william278.husksync.HuskSyncBukkit
main: net.william278.husksync.BukkitHuskSync
api-version: 1.16
author: William278
description: 'A modern, cross-server player data synchronization system'
website: 'https://william278.net'
softdepend: [MysqlPlayerDataBridge]
softdepend: [ MysqlPlayerDataBridge ]
libraries:
- 'mysql:mysql-connector-java:8.0.29'
commands:
husksync:
usage: '/husksync <update|info|reload>'

@ -1,23 +0,0 @@
dependencies {
implementation project(path: ':common')
implementation 'com.zaxxer:HikariCP:5.0.1'
implementation 'org.bstats:bstats-bungeecord:3.0.0'
implementation 'de.themoep:minedown:1.7.1-SNAPSHOT'
implementation 'net.byteflux:libby-bungee:1.1.5'
compileOnly 'net.md-5:bungeecord-api:1.16-R0.5-SNAPSHOT'
}
shadowJar {
relocate 'de.themoep', 'net.william278.husksync.libraries'
relocate 'net.byteflux', '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'
dependencies {
//noinspection GroovyAssignabilityCheck
exclude dependency(':slf4j-api')
}
}

@ -1,5 +0,0 @@
name: HuskSync
version: ${version}
main: net.william278.husksync.HuskSyncBungeeCord
author: William278
description: 'A modern, cross-server player data synchronization system'

@ -4,20 +4,21 @@ dependencies {
implementation 'de.themoep:minedown:1.7.1-SNAPSHOT'
implementation 'com.zaxxer:HikariCP:5.0.1'
implementation 'com.google.code.gson:gson:2.9.0'
implementation 'org.xerial.snappy:snappy-java:1.1.8.4'
implementation 'redis.clients:jedis:4.2.3'
compileOnly 'org.jetbrains:annotations:23.0.0'
compileOnly 'org.xerial:sqlite-jdbc:' + sqlite_driver_version
compileOnly 'redis.clients:jedis:' + jedis_version
}
shadowJar {
relocate 'org.apache', 'net.william278.husksync.libraries'
relocate 'dev.dejvokep', 'net.william278.husksync.libraries'
relocate 'de.themoep', 'net.william278.husksync.libraries'
relocate 'org.jetbrains', 'net.william278.husksync.libraries'
relocate 'org.intellij', 'net.william278.husksync.libraries'
relocate 'com.zaxxer', 'net.william278.husksync.libraries'
relocate 'org.slf4j', 'net.william278.husksync.libraries.slf4j'
relocate 'com.google', 'net.william278.husksync.libraries'
//relocate 'org.xerial', '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'
}

@ -2,16 +2,16 @@ 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.database.Database;
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;
import java.util.concurrent.CompletableFuture;
public interface HuskSync {
@ -19,8 +19,6 @@ public interface HuskSync {
@NotNull Optional<OnlineUser> getOnlineUser(@NotNull UUID uuid);
@NotNull EventListener getEventListener();
@NotNull Database getDatabase();
@NotNull RedisManager getRedisManager();
@ -29,10 +27,10 @@ public interface HuskSync {
@NotNull Locales getLocales();
@NotNull Logger getLogger();
@NotNull Logger getLoggingAdapter();
@NotNull String getVersion();
void reload();
CompletableFuture<Boolean> reload();
}

@ -28,7 +28,7 @@ public class HuskSyncCommand extends CommandBase implements TabCompletable, Cons
plugin.getLocales().getLocale("error_no_permission").ifPresent(player::sendMessage);
return;
}
final UpdateChecker updateChecker = new UpdateChecker(plugin.getVersion(), plugin.getLogger());
final UpdateChecker updateChecker = new UpdateChecker(plugin.getVersion(), plugin.getLoggingAdapter());
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)" +
@ -56,22 +56,22 @@ public class HuskSyncCommand extends CommandBase implements TabCompletable, Cons
@Override
public void onConsoleExecute(@NotNull String[] args) {
if (args.length < 1) {
plugin.getLogger().log(Level.INFO, "Console usage: /husksync <update|info|reload|migrate>");
plugin.getLoggingAdapter().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(
case "update", "version" -> new UpdateChecker(plugin.getVersion(), plugin.getLoggingAdapter()).logToConsole();
case "info", "about" -> plugin.getLoggingAdapter().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.");
plugin.getLoggingAdapter().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>");
plugin.getLoggingAdapter().log(Level.INFO, "Invalid syntax. Console usage: /husksync <update|info|reload|migrate>");
}
}

@ -118,7 +118,7 @@ public class Settings {
LANGUAGE("language", OptionType.STRING, "en-gb"),
CHECK_FOR_UPDATES("check_for_updates", OptionType.BOOLEAN, true),
CLUSTER_ID("cluster_id", OptionType.STRING, ""), //todo implement this
CLUSTER_ID("cluster_id", OptionType.STRING, ""),
DATABASE_HOST("database.credentials.host", OptionType.STRING, "localhost"),
DATABASE_PORT("database.credentials.port", OptionType.INTEGER, 3306),

@ -1,6 +1,7 @@
package net.william278.husksync.data;
import com.google.gson.annotations.SerializedName;
import org.jetbrains.annotations.NotNull;
import java.util.Date;
import java.util.Map;
@ -25,4 +26,8 @@ public class AdvancementData {
public AdvancementData() {
}
public AdvancementData(@NotNull String key, @NotNull Map<String, Date> awardedCriteria) {
this.key = key;
this.completedCriteria = awardedCriteria;
}
}

@ -3,19 +3,21 @@ package net.william278.husksync.data;
import com.google.gson.annotations.SerializedName;
import org.jetbrains.annotations.NotNull;
import java.util.Map;
/**
* 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
* Map of namespaced key strings to a byte array representing the persistent data
*/
@SerializedName("serialized_persistent_data_container")
public String serializedPersistentDataContainer;
@SerializedName("persistent_data_map")
public Map<String, Byte[]> persistentDataMap;
public PersistentDataContainerData(@NotNull final String serializedPersistentDataContainer) {
this.serializedPersistentDataContainer = serializedPersistentDataContainer;
public PersistentDataContainerData(@NotNull final Map<String, Byte[]> persistentDataMap) {
this.persistentDataMap = persistentDataMap;
}
public PersistentDataContainerData() {

@ -4,6 +4,7 @@ import com.google.gson.annotations.SerializedName;
import org.jetbrains.annotations.NotNull;
import java.util.HashMap;
import java.util.Map;
/**
* Stores information about a player's statistics
@ -14,30 +15,30 @@ public class StatisticsData {
* Map of untyped statistic names to their values
*/
@SerializedName("untyped_statistics")
public HashMap<String, Integer> untypedStatistic;
public Map<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;
public Map<String, Map<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;
public Map<String, Map<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 Map<String, Map<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) {
public StatisticsData(@NotNull Map<String, Integer> untypedStatistic,
@NotNull Map<String, Map<String, Integer>> blockStatistics,
@NotNull Map<String, Map<String, Integer>> itemStatistics,
@NotNull Map<String, Map<String, Integer>> entityStatistics) {
this.untypedStatistic = untypedStatistic;
this.blockStatistics = blockStatistics;
this.itemStatistics = itemStatistics;

@ -69,7 +69,7 @@ public class StatusData {
public float expProgress;
/**
* The player's game mode string (one of "survival", "creative", "adventure", "spectator")
* The player's game mode string (one of "SURVIVAL", "CREATIVE", "ADVENTURE", "SPECTATOR")
*/
@SerializedName("game_mode")
public String gameMode;

@ -5,24 +5,12 @@ 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;
import java.util.List;
/***
* 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;
public class UserData {
/**
* Stores the user's status data, including health, food, etc.
@ -52,7 +40,7 @@ public class UserData implements Comparable<UserData> {
* Stores the set of this user's advancements
*/
@SerializedName("advancements")
protected HashSet<AdvancementData> advancementData;
protected List<AdvancementData> advancementData;
/**
* Stores the user's set of statistics
@ -74,10 +62,8 @@ public class UserData implements Comparable<UserData> {
public UserData(@NotNull StatusData statusData, @NotNull InventoryData inventoryData,
@NotNull InventoryData enderChestData, @NotNull PotionEffectData potionEffectData,
@NotNull HashSet<AdvancementData> advancementData, @NotNull StatisticsData statisticData,
@NotNull List<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;
@ -91,40 +77,6 @@ public class UserData implements Comparable<UserData> {
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;
}
@ -141,7 +93,7 @@ public class UserData implements Comparable<UserData> {
return potionEffectData;
}
public HashSet<AdvancementData> getAdvancementData() {
public List<AdvancementData> getAdvancementData() {
return advancementData;
}
@ -156,4 +108,15 @@ public class UserData implements Comparable<UserData> {
public PersistentDataContainerData getPersistentDataContainerData() {
return persistentDataContainerData;
}
@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);
}
}

@ -0,0 +1,40 @@
package net.william278.husksync.data;
import org.jetbrains.annotations.NotNull;
import java.util.Date;
import java.util.UUID;
/**
* Represents a uniquely versioned and timestamped snapshot of a user's data
*
* @param versionUUID The unique identifier for this user data version
* @param versionTimestamp An epoch milliseconds timestamp of when this data was created
* @param userData The {@link UserData} that has been versioned
*/
public record VersionedUserData(@NotNull UUID versionUUID, @NotNull Date versionTimestamp,
@NotNull UserData userData) implements Comparable<VersionedUserData> {
public VersionedUserData(@NotNull final UUID versionUUID, @NotNull final Date versionTimestamp,
@NotNull UserData userData) {
this.versionUUID = versionUUID;
this.versionTimestamp = versionTimestamp;
this.userData = userData;
}
public static VersionedUserData version(@NotNull UserData userData) {
return new VersionedUserData(UUID.randomUUID(), new Date(), 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 VersionedUserData other) {
return Long.compare(this.versionTimestamp.getTime(), other.versionTimestamp.getTime());
}
}

@ -1,6 +1,7 @@
package net.william278.husksync.database;
import net.william278.husksync.data.UserData;
import net.william278.husksync.data.VersionedUserData;
import net.william278.husksync.player.User;
import net.william278.husksync.util.Logger;
import net.william278.husksync.util.ResourceReader;
@ -71,10 +72,8 @@ public abstract class Database {
* @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(";");
return formatStatementTables(new String(resourceReader.getResource(schemaFileName)
.readAllBytes(), StandardCharsets.UTF_8)).split(";");
}
/**
@ -91,9 +90,9 @@ public abstract class Database {
/**
* Initialize the database and ensure tables are present; create tables if they do not exist.
*
* @return A future returning void when complete
* @return A future returning boolean - if the connection could be established.
*/
public abstract CompletableFuture<Void> initialize();
public abstract boolean initialize();
/**
* Ensure a {@link User} has an entry in the database and that their username is up-to-date
@ -120,20 +119,20 @@ public abstract class Database {
public abstract CompletableFuture<Optional<User>> getUserByName(@NotNull String username);
/**
* Get the current user data for a given user, if it exists.
* Get the current uniquely versioned 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
* @return an optional containing the {@link VersionedUserData}, if it exists, or an empty optional if it does not
*/
public abstract CompletableFuture<Optional<UserData>> getCurrentUserData(@NotNull User user);
public abstract CompletableFuture<Optional<VersionedUserData>> getCurrentUserData(@NotNull User user);
/**
* Get all UserData entries for a user from the database.
* Get all {@link VersionedUserData} 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
* @return A future returning a list of a user's {@link VersionedUserData} entries
*/
public abstract CompletableFuture<List<UserData>> getUserData(@NotNull User user);
public abstract CompletableFuture<List<VersionedUserData>> getUserData(@NotNull User user);
/**
* Prune user data records for a given user to the maximum value as configured
@ -148,9 +147,14 @@ public abstract class Database {
* 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
* @param userData The uniquely versioned data to add as a {@link VersionedUserData}
* @return A future returning void when complete
*/
public abstract CompletableFuture<Void> setUserData(@NotNull User user, @NotNull UserData userData);
public abstract CompletableFuture<Void> setUserData(@NotNull User user, @NotNull VersionedUserData userData);
/**
* Close the database connection
*/
public abstract void close();
}

@ -3,19 +3,24 @@ 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.data.VersionedUserData;
import net.william278.husksync.player.User;
import net.william278.husksync.util.Logger;
import net.william278.husksync.util.ResourceReader;
import org.jetbrains.annotations.NotNull;
import org.xerial.snappy.Snappy;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.sql.*;
import java.time.Instant;
import java.util.*;
import java.util.Date;
import java.util.concurrent.CompletableFuture;
import java.util.logging.Level;
public class MySqlDatabase extends Database {
/**
* MySQL server hostname
*/
@ -40,9 +45,12 @@ public class MySqlDatabase extends Database {
private final int hikariKeepAliveTime;
private final int hikariConnectionTimeOut;
private static final String DATA_POOL_NAME = "HuskHomesHikariPool";
private static final String DATA_POOL_NAME = "HuskSyncHikariPool";
private HikariDataSource dataSource;
/**
* The Hikari data source - a pool of database connections that can be fetched on-demand
*/
private HikariDataSource connectionPool;
public MySqlDatabase(@NotNull Settings settings, @NotNull ResourceReader resourceReader, @NotNull Logger logger) {
super(settings.getStringValue(Settings.ConfigOption.DATABASE_PLAYERS_TABLE_NAME),
@ -69,31 +77,31 @@ public class MySqlDatabase extends Database {
* @throws SQLException if the connection fails for some reason
*/
private Connection getConnection() throws SQLException {
return dataSource.getConnection();
return connectionPool.getConnection();
}
@Override
public CompletableFuture<Void> initialize() {
return CompletableFuture.runAsync(() -> {
public boolean initialize() {
try {
// Create jdbc driver connection url
final String jdbcUrl = "jdbc:mysql://" + mySqlHost + ":" + mySqlPort + "/" + mySqlDatabaseName + mySqlConnectionParameters;
dataSource = new HikariDataSource();
dataSource.setJdbcUrl(jdbcUrl);
connectionPool = new HikariDataSource();
connectionPool.setJdbcUrl(jdbcUrl);
// Authenticate
dataSource.setUsername(mySqlUsername);
dataSource.setPassword(mySqlPassword);
connectionPool.setUsername(mySqlUsername);
connectionPool.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);
connectionPool.setMaximumPoolSize(hikariMaximumPoolSize);
connectionPool.setMinimumIdle(hikariMinimumIdle);
connectionPool.setMaxLifetime(hikariMaximumLifetime);
connectionPool.setKeepaliveTime(hikariKeepAliveTime);
connectionPool.setConnectionTimeout(hikariConnectionTimeOut);
connectionPool.setPoolName(DATA_POOL_NAME);
// Prepare database schema; make tables if they don't exist
try (Connection connection = dataSource.getConnection()) {
try (Connection connection = connectionPool.getConnection()) {
// Load database schema CREATE statements from schema file
final String[] databaseSchema = getSchemaStatements("database/mysql_schema.sql");
try (Statement statement = connection.createStatement()) {
@ -101,10 +109,14 @@ public class MySqlDatabase extends Database {
statement.execute(tableCreationStatement);
}
}
return true;
} catch (SQLException | IOException e) {
getLogger().log(Level.SEVERE, "An error occurred creating tables on the MySQL database: ", e);
getLogger().log(Level.SEVERE, "Failed to perform database setup: " + e.getMessage());
}
});
} catch (Exception e) {
e.printStackTrace();
}
return false;
}
@Override
@ -194,7 +206,7 @@ public class MySqlDatabase extends Database {
}
@Override
public CompletableFuture<Optional<UserData>> getCurrentUserData(@NotNull User user) {
public CompletableFuture<Optional<VersionedUserData>> getCurrentUserData(@NotNull User user) {
return CompletableFuture.supplyAsync(() -> {
try (Connection connection = getConnection()) {
try (PreparedStatement statement = connection.prepareStatement(formatStatementTables("""
@ -206,13 +218,17 @@ public class MySqlDatabase extends Database {
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);
final Blob blob = resultSet.getBlob("data");
final byte[] compressedDataJson = blob.getBytes(1, (int) blob.length());
blob.free();
return Optional.of(new VersionedUserData(
UUID.fromString(resultSet.getString("version_uuid")),
Date.from(resultSet.getTimestamp("timestamp").toInstant()),
UserData.fromJson(new String(Snappy.uncompress(compressedDataJson),
StandardCharsets.UTF_8))));
}
}
} catch (SQLException e) {
} catch (SQLException | IOException e) {
getLogger().log(Level.SEVERE, "Failed to fetch a user's current user data from the database", e);
}
return Optional.empty();
@ -220,9 +236,9 @@ public class MySqlDatabase extends Database {
}
@Override
public CompletableFuture<List<UserData>> getUserData(@NotNull User user) {
public CompletableFuture<List<VersionedUserData>> getUserData(@NotNull User user) {
return CompletableFuture.supplyAsync(() -> {
final ArrayList<UserData> retrievedData = new ArrayList<>();
final List<VersionedUserData> retrievedData = new ArrayList<>();
try (Connection connection = getConnection()) {
try (PreparedStatement statement = connection.prepareStatement(formatStatementTables("""
SELECT `version_uuid`, `timestamp`, `data`
@ -232,14 +248,19 @@ public class MySqlDatabase extends Database {
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());
final Blob blob = resultSet.getBlob("data");
final byte[] compressedDataJson = blob.getBytes(1, (int) blob.length());
blob.free();
final VersionedUserData data = new VersionedUserData(
UUID.fromString(resultSet.getString("version_uuid")),
Date.from(resultSet.getTimestamp("timestamp").toInstant()),
UserData.fromJson(new String(Snappy.uncompress(compressedDataJson),
StandardCharsets.UTF_8)));
retrievedData.add(data);
}
return retrievedData;
}
} catch (SQLException e) {
} catch (SQLException | IOException e) {
getLogger().log(Level.SEVERE, "Failed to fetch a user's current user data from the database", e);
}
return retrievedData;
@ -256,7 +277,7 @@ public class MySqlDatabase extends Database {
try (PreparedStatement statement = connection.prepareStatement(formatStatementTables("""
DELETE FROM `%data_table%`
WHERE `version_uuid`=?"""))) {
statement.setString(1, dataToDelete.getDataUuidVersion().toString());
statement.setString(1, dataToDelete.versionUUID().toString());
statement.executeUpdate();
}
} catch (SQLException e) {
@ -268,7 +289,7 @@ public class MySqlDatabase extends Database {
}
@Override
public CompletableFuture<Void> setUserData(@NotNull User user, @NotNull UserData userData) {
public CompletableFuture<Void> setUserData(@NotNull User user, @NotNull VersionedUserData userData) {
return CompletableFuture.runAsync(() -> {
try (Connection connection = getConnection()) {
try (PreparedStatement statement = connection.prepareStatement(formatStatementTables("""
@ -276,14 +297,25 @@ public class MySqlDatabase extends Database {
(`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.setString(2, userData.versionUUID().toString());
statement.setTimestamp(3, Timestamp.from(userData.versionTimestamp().toInstant()));
statement.setBlob(4, new ByteArrayInputStream(Snappy
.compress(userData.userData().toJson().getBytes(StandardCharsets.UTF_8))));
statement.executeUpdate();
}
} catch (SQLException e) {
} catch (SQLException | IOException e) {
getLogger().log(Level.SEVERE, "Failed to set user data in the database", e);
}
}).thenRunAsync(() -> pruneUserDataRecords(user).join());
})/*.thenRunAsync(() -> pruneUserDataRecords(user).join())*/;
}
@Override
public void close() {
if (connectionPool != null) {
if (!connectionPool.isClosed()) {
connectionPool.close();
}
}
}
}

@ -12,12 +12,25 @@ import java.util.concurrent.CompletableFuture;
public class EventListener {
/**
* The plugin instance
*/
private final HuskSync huskSync;
/**
* Set of UUIDs current awaiting item synchronization. Events will be cancelled for these users
*/
private final HashSet<UUID> usersAwaitingSync;
/**
* Whether the plugin is currently being disabled
*/
private boolean disabling;
protected EventListener(@NotNull HuskSync huskSync) {
this.huskSync = huskSync;
this.usersAwaitingSync = new HashSet<>();
this.disabling = false;
}
public final void handlePlayerJoin(@NotNull OnlineUser user) {
@ -27,7 +40,7 @@ public class EventListener {
userData -> user.setData(userData, huskSync.getSettings()).join(),
() -> huskSync.getDatabase().getCurrentUserData(user).thenAccept(
databaseUserData -> databaseUserData.ifPresent(
data -> user.setData(data, huskSync.getSettings()).join())).join())).thenRunAsync(
data -> user.setData(data.userData(), huskSync.getSettings()).join())).join())).thenRunAsync(
() -> {
huskSync.getLocales().getLocale("synchronisation_complete").ifPresent(user::sendActionBar);
usersAwaitingSync.remove(user.uuid);
@ -36,16 +49,35 @@ public class EventListener {
}
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()));
if (disabling) {
return;
}
user.getUserData().thenAccept(userData -> {
System.out.println(userData.userData().toJson());
huskSync.getRedisManager()
.setUserData(user, userData.userData(), RedisManager.RedisKeyType.SERVER_CHANGE).thenRun(
() -> huskSync.getDatabase().setUserData(user, userData).join());
});
}
public final void handleWorldSave(@NotNull List<OnlineUser> usersInWorld) {
if (disabling) {
return;
}
CompletableFuture.runAsync(() -> usersInWorld.forEach(user ->
huskSync.getDatabase().setUserData(user, user.getUserData().join()).join()));
}
public final void handlePluginDisable() {
disabling = true;
huskSync.getOnlineUsers().stream().filter(user -> !usersAwaitingSync.contains(user.uuid)).forEach(user ->
huskSync.getDatabase().setUserData(user, user.getUserData().join()).join());
huskSync.getDatabase().close();
huskSync.getRedisManager().close();
}
public final boolean cancelPlayerEvent(@NotNull OnlineUser user) {
return usersAwaitingSync.contains(user.uuid);
}

@ -5,7 +5,7 @@ import net.william278.husksync.config.Settings;
import net.william278.husksync.data.*;
import org.jetbrains.annotations.NotNull;
import java.util.HashSet;
import java.util.List;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
@ -25,6 +25,22 @@ public abstract class OnlineUser extends User {
*/
public abstract CompletableFuture<StatusData> getStatus();
/**
* Set the player's {@link StatusData}
*
* @param statusData the player's {@link StatusData}
* @param setHealth whether to set the player's health
* @param setMaxHealth whether to set the player's max health
* @param setHunger whether to set the player's hunger
* @param setExperience whether to set the player's experience
* @param setGameMode whether to set the player's game mode
* @return a future returning void when complete
*/
public abstract CompletableFuture<Void> setStatus(@NotNull StatusData statusData,
final boolean setHealth, final boolean setMaxHealth,
final boolean setHunger, final boolean setExperience,
final boolean setGameMode, boolean setFlying);
/**
* Get the player's inventory {@link InventoryData} contents
*
@ -32,6 +48,14 @@ public abstract class OnlineUser extends User {
*/
public abstract CompletableFuture<InventoryData> getInventory();
/**
* Set the player's {@link InventoryData}
*
* @param inventoryData The player's {@link InventoryData}
* @return a future returning void when complete
*/
public abstract CompletableFuture<Void> setInventory(@NotNull InventoryData inventoryData);
/**
* Get the player's ender chest {@link InventoryData} contents
*
@ -39,6 +63,15 @@ public abstract class OnlineUser extends User {
*/
public abstract CompletableFuture<InventoryData> getEnderChest();
/**
* Set the player's {@link InventoryData}
*
* @param enderChestData The player's {@link InventoryData}
* @return a future returning void when complete
*/
public abstract CompletableFuture<Void> setEnderChest(@NotNull InventoryData enderChestData);
/**
* Get the player's {@link PotionEffectData}
*
@ -46,12 +79,28 @@ public abstract class OnlineUser extends User {
*/
public abstract CompletableFuture<PotionEffectData> getPotionEffects();
/**
* Set the player's {@link PotionEffectData}
*
* @param potionEffectData The player's {@link PotionEffectData}
* @return a future returning void when complete
*/
public abstract CompletableFuture<Void> setPotionEffects(@NotNull PotionEffectData potionEffectData);
/**
* Get the player's set of {@link AdvancementData}
*
* @return the player's set of {@link AdvancementData}
*/
public abstract CompletableFuture<HashSet<AdvancementData>> getAdvancements();
public abstract CompletableFuture<List<AdvancementData>> getAdvancements();
/**
* Set the player's {@link AdvancementData}
*
* @param advancementData List of the player's {@link AdvancementData}
* @return a future returning void when complete
*/
public abstract CompletableFuture<Void> setAdvancements(@NotNull List<AdvancementData> advancementData);
/**
* Get the player's {@link StatisticsData}
@ -60,6 +109,14 @@ public abstract class OnlineUser extends User {
*/
public abstract CompletableFuture<StatisticsData> getStatistics();
/**
* Set the player's {@link StatisticsData}
*
* @param statisticsData The player's {@link StatisticsData}
* @return a future returning void when complete
*/
public abstract CompletableFuture<Void> setStatistics(@NotNull StatisticsData statisticsData);
/**
* Get the player's {@link LocationData}
*
@ -67,6 +124,14 @@ public abstract class OnlineUser extends User {
*/
public abstract CompletableFuture<LocationData> getLocation();
/**
* Set the player's {@link LocationData}
*
* @param locationData the player's {@link LocationData}
* @return a future returning void when complete
*/
public abstract CompletableFuture<Void> setLocation(@NotNull LocationData locationData);
/**
* Get the player's {@link PersistentDataContainerData}
*
@ -74,6 +139,14 @@ public abstract class OnlineUser extends User {
*/
public abstract CompletableFuture<PersistentDataContainerData> getPersistentDataContainer();
/**
* Set the player's {@link PersistentDataContainerData}
*
* @param persistentDataContainerData The player's {@link PersistentDataContainerData} to set
* @return A future returning void when complete
*/
public abstract CompletableFuture<Void> setPersistentDataContainer(@NotNull PersistentDataContainerData persistentDataContainerData);
/**
* Set {@link UserData} to a player
*
@ -81,7 +154,37 @@ public abstract class OnlineUser extends User {
* @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);
public final CompletableFuture<Void> setData(@NotNull UserData data, @NotNull Settings settings) {
return CompletableFuture.runAsync(() -> {
if (settings.getBooleanValue(Settings.ConfigOption.SYNCHRONIZATION_SYNC_INVENTORIES)) {
setInventory(data.getInventoryData()).join();
}
if (settings.getBooleanValue(Settings.ConfigOption.SYNCHRONIZATION_SYNC_ENDER_CHESTS)) {
setEnderChest(data.getEnderChestData()).join();
}
setStatus(data.getStatusData(), settings.getBooleanValue(Settings.ConfigOption.SYNCHRONIZATION_SYNC_HEALTH),
settings.getBooleanValue(Settings.ConfigOption.SYNCHRONIZATION_SYNC_MAX_HEALTH),
settings.getBooleanValue(Settings.ConfigOption.SYNCHRONIZATION_SYNC_HUNGER),
settings.getBooleanValue(Settings.ConfigOption.SYNCHRONIZATION_SYNC_EXPERIENCE),
settings.getBooleanValue(Settings.ConfigOption.SYNCHRONIZATION_SYNC_GAME_MODE),
settings.getBooleanValue(Settings.ConfigOption.SYNCHRONIZATION_SYNC_LOCATION)).join();
if (settings.getBooleanValue(Settings.ConfigOption.SYNCHRONIZATION_SYNC_POTION_EFFECTS)) {
setPotionEffects(data.getPotionEffectData()).join();
}
if (settings.getBooleanValue(Settings.ConfigOption.SYNCHRONIZATION_SYNC_ADVANCEMENTS)) {
setAdvancements(data.getAdvancementData()).join();
}
if (settings.getBooleanValue(Settings.ConfigOption.SYNCHRONIZATION_SYNC_STATISTICS)) {
setStatistics(data.getStatisticData()).join();
}
if (settings.getBooleanValue(Settings.ConfigOption.SYNCHRONIZATION_SYNC_PERSISTENT_DATA_CONTAINER)) {
setPersistentDataContainer(data.getPersistentDataContainerData()).join();
}
if (settings.getBooleanValue(Settings.ConfigOption.SYNCHRONIZATION_SYNC_LOCATION)) {
setLocation(data.getLocationData()).join();
}
});
}
/**
* Dispatch a MineDown-formatted message to this player
@ -110,10 +213,11 @@ public abstract class OnlineUser extends User {
*
* @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()));
public final CompletableFuture<VersionedUserData> getUserData() {
return CompletableFuture.supplyAsync(
() -> VersionedUserData.version(new UserData(getStatus().join(), getInventory().join(),
getEnderChest().join(), getPotionEffects().join(), getAdvancements().join(),
getStatistics().join(), getLocation().join(), getPersistentDataContainer().join())));
}
}

@ -4,65 +4,130 @@ import net.william278.husksync.config.Settings;
import net.william278.husksync.data.UserData;
import net.william278.husksync.player.User;
import org.jetbrains.annotations.NotNull;
import org.xerial.snappy.Snappy;
import redis.clients.jedis.Jedis;
import redis.clients.jedis.JedisPool;
import redis.clients.jedis.JedisPoolConfig;
import redis.clients.jedis.exceptions.JedisException;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
/**
* Manages the connection to the Redis server, handling the caching of user data
*/
public class RedisManager {
private static final String KEY_NAMESPACE = "husksync:";
private static String clusterId = "";
private final JedisPool jedisPool;
private RedisManager(@NotNull Settings settings) {
private final JedisPoolConfig jedisPoolConfig;
private final String redisHost;
private final int redisPort;
private final String redisPassword;
private final boolean redisUseSsl;
private JedisPool jedisPool;
public 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));
}
this.redisHost = settings.getStringValue(Settings.ConfigOption.REDIS_HOST);
this.redisPort = settings.getIntegerValue(Settings.ConfigOption.REDIS_PORT);
this.redisPassword = settings.getStringValue(Settings.ConfigOption.REDIS_PASSWORD);
this.redisUseSsl = settings.getBooleanValue(Settings.ConfigOption.REDIS_USE_SSL);
// Configure the jedis pool
this.jedisPoolConfig = new JedisPoolConfig();
this.jedisPoolConfig.setMaxIdle(0);
this.jedisPoolConfig.setTestOnBorrow(true);
this.jedisPoolConfig.setTestOnReturn(true);
}
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());
/**
* Initialize the redis connection pool
*
* @return a future returning void when complete
*/
public CompletableFuture<Boolean> initialize() {
return CompletableFuture.supplyAsync(() -> {
if (redisPassword.isBlank()) {
jedisPool = new JedisPool(jedisPoolConfig, redisHost, redisPort, 0, redisUseSsl);
} else {
jedisPool = new JedisPool(jedisPoolConfig, redisHost, redisPort, 0, redisPassword, redisUseSsl);
}
try {
jedisPool.getResource().ping();
} catch (JedisException e) {
return false;
}
return true;
});
}
public CompletableFuture<Optional<UserData>> getUserData(@NotNull User user, @NotNull RedisKeyType redisKeyType) {
/**
* Set a user's data to the Redis server
*
* @param user the user to set data for
* @param userData the user's data to set
* @param redisKeyType the type of key to set the data with. This determines the time to live for the data.
* @return a future returning void when complete
*/
public CompletableFuture<Void> setUserData(@NotNull User user, @NotNull UserData userData,
@NotNull RedisKeyType redisKeyType) {
try {
return CompletableFuture.runAsync(() -> {
try (Jedis jedis = jedisPool.getResource()) {
// Set the user's data as a compressed byte array of the json using Snappy
jedis.setex(getKey(redisKeyType, user.uuid), redisKeyType.timeToLive,
Snappy.compress(userData.toJson().getBytes(StandardCharsets.UTF_8)));
} catch (IOException e) {
throw new RuntimeException(e);
}
});
} catch (Exception e) {
e.printStackTrace();
}
return null;
}
/**
* Fetch a user's data from the Redis server
*
* @param user The user to fetch data for
* @param redisKeyType The type of key to fetch
* @return The user's data, if it's present on the database. Otherwise, an empty optional.
*/
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) {
final byte[] compressedJson = jedis.get(getKey(redisKeyType, user.uuid));
if (compressedJson == null) {
return Optional.empty();
}
return Optional.of(UserData.fromJson(json));
// Use Snappy to decompress the json
return Optional.of(UserData.fromJson(new String(Snappy.uncompress(compressedJson),
StandardCharsets.UTF_8)));
} catch (IOException e) {
throw new RuntimeException(e);
}
});
}
public static CompletableFuture<RedisManager> initialize(@NotNull Settings settings) {
return CompletableFuture.supplyAsync(() -> new RedisManager(settings));
public void close() {
if (jedisPool != null) {
if (!jedisPool.isClosed()) {
jedisPool.close();
}
}
}
private static byte[] getKey(@NotNull RedisKeyType keyType, @NotNull UUID uuid) {
return (keyType.getKeyPrefix() + ":" + uuid).getBytes(StandardCharsets.UTF_8);
}
public enum RedisKeyType {
@ -77,7 +142,7 @@ public class RedisManager {
@NotNull
public String getKeyPrefix() {
return KEY_NAMESPACE.toLowerCase() + ":" + clusterId.toLowerCase() + ":" + name().toLowerCase() + ":";
return KEY_NAMESPACE.toLowerCase() + ":" + clusterId.toLowerCase() + ":" + name().toLowerCase();
}
}

@ -48,4 +48,6 @@ synchronization:
game_mode: true
statistics: true
persistent_data_container: true
location: false
location: false
config_version: 1

@ -10,10 +10,10 @@ CREATE TABLE IF NOT EXISTS `%players_table%`
# 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,
`version_uuid` char(36) NOT NULL,
`player_uuid` char(36) NOT NULL,
`timestamp` datetime NOT NULL,
`data` mediumblob NOT NULL,
PRIMARY KEY (`version_uuid`),
FOREIGN KEY (`player_uuid`) REFERENCES `%players_table%` (`uuid`) ON DELETE CASCADE

@ -0,0 +1,14 @@
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)'

@ -0,0 +1,14 @@
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)'

@ -0,0 +1,14 @@
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)'

@ -0,0 +1,14 @@
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)'

@ -0,0 +1,14 @@
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)'

@ -0,0 +1,14 @@
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)'

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

@ -4,7 +4,7 @@ plugins {
dependencies {
implementation project(path: ':bukkit', configuration: 'shadow')
//implementation project(path: ':api', configuration: 'shadow')
implementation project(path: ':api', configuration: 'shadow')
}
shadowJar {

@ -1,23 +0,0 @@
dependencies {
implementation project(path: ':common')
implementation 'com.zaxxer:HikariCP:5.0.1'
implementation 'org.bstats:bstats-velocity:3.0.0'
implementation 'de.themoep:minedown-adventure:1.7.1-SNAPSHOT'
implementation 'net.byteflux:libby-velocity:1.1.5'
compileOnly 'com.velocitypowered:velocity-api:3.1.0'
}
shadowJar {
relocate 'de.themoep', 'net.william278.husksync.libraries'
relocate 'net.byteflux', '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'
dependencies {
//noinspection GroovyAssignabilityCheck
exclude dependency(':slf4j-api')
}
}
Loading…
Cancel
Save