diff --git a/README.md b/README.md
index a16d5043..dd092aa6 100644
--- a/README.md
+++ b/README.md
@@ -44,15 +44,15 @@
**Ready?** [It's syncing time!](https://william278.net/docs/husksync/setup)
## Setup
-Requires a MySQL/Mongo/PostgreSQL database, a Redis (v5.0+) server and any number of Spigot-based 1.17.1+ Minecraft servers, running Java 17+.
+Requires a MySQL/Mongo/PostgreSQL database, a Redis (v5.0+) server and a network of Spigot (1.17.1+) or Fabric (1.20.1) Minecraft servers, running Java 17+.
-1. Place the plugin jar file in the /plugins/ directory of each Spigot server. You do not need to install HuskSync as a proxy plugin.
+1. Place the plugin jar file in the `/plugins` or `/mods` directory of each Spigot/Fabric server. You do not need to install HuskSync as a proxy plugin.
2. Start, then stop every server to let HuskSync generate the config file.
-3. Navigate to the HuskSync config file on each server (~/plugins/HuskSync/config.yml) and fill in both your database and Redis server credentials.
+3. Navigate to the HuskSync config file on each server and fill in both your database and Redis server credentials.
4. Start every server again and synchronization will begin.
## Development
-To build HuskSync, simply run the following in the root of the repository:
+To build HuskSync, simply run the following in the root of the repository (building requires Java 17). Builds will be output in `/target`:
```bash
./gradlew clean build
@@ -66,7 +66,7 @@ HuskSync is licensed under the Apache 2.0 license.
Contributions to the project are welcome—feel free to open a pull request with new features, improvements and/or fixes!
### Support
-Due to its complexity, official binaries and customer support for HuskSync is provided through a paid model. This means that support is only available to users who have purchased a license to the plugin from Spigot, Polymart, Craftaro, or BuiltByBit and have provided proof of purchase. Please join our Discord server if you have done so and need help!
+Due to its complexity, official binaries and customer support for HuskSync is provided through a paid model. This means that support is only available to users who have purchased a license to the plugin from Spigot, Polymart, or BuiltByBit and have provided proof of purchase. Please join our Discord server if you have done so and need help!
### Translations
Translations of the plugin locales are welcome to help make the plugin more accessible. Please submit a pull request with your translations as a `.yml` file.
diff --git a/build.gradle b/build.gradle
index ac5dff76..2c946fe9 100644
--- a/build.gradle
+++ b/build.gradle
@@ -69,6 +69,7 @@ allprojects {
repositories {
mavenLocal()
mavenCentral()
+ maven { url 'https://oss.sonatype.org/content/repositories/snapshots' }
maven { url 'https://hub.spigotmc.org/nexus/content/repositories/snapshots/' }
maven { url "https://repo.dmulloy2.net/repository/public/" }
maven { url 'https://repo.codemc.io/repository/maven-public/' }
@@ -117,8 +118,13 @@ subprojects {
archiveClassifier.set('')
}
+ // Append the Minecraft to the version for Fabric projects
+ if (project.name == 'fabric') {
+ version += "+mc.${fabric_minecraft_version}"
+ }
+
// API publishing
- if (['common', 'bukkit'].contains(project.name)) {
+ if (['common', 'bukkit', 'fabric'].contains(project.name)) {
java {
withSourcesJar()
withJavadocJar()
@@ -157,6 +163,19 @@ subprojects {
}
}
}
+
+ if (['fabric'].contains(project.name)) {
+ publications {
+ mavenJavaFabric(MavenPublication) {
+ groupId = 'net.william278.husksync'
+ artifactId = 'husksync-fabric'
+ version = "$rootProject.version"
+ artifact shadowJar
+ artifact sourcesJar
+ artifact javadocJar
+ }
+ }
+ }
}
}
diff --git a/bukkit/src/main/java/net/william278/husksync/BukkitHuskSync.java b/bukkit/src/main/java/net/william278/husksync/BukkitHuskSync.java
index 87467e95..48d96c49 100644
--- a/bukkit/src/main/java/net/william278/husksync/BukkitHuskSync.java
+++ b/bukkit/src/main/java/net/william278/husksync/BukkitHuskSync.java
@@ -150,15 +150,15 @@ public class BukkitHuskSync extends JavaPlugin implements HuskSync, BukkitTask.S
registerSerializer(Identifier.INVENTORY, new BukkitSerializer.Inventory(this));
registerSerializer(Identifier.ENDER_CHEST, new BukkitSerializer.EnderChest(this));
registerSerializer(Identifier.ADVANCEMENTS, new BukkitSerializer.Advancements(this));
- registerSerializer(Identifier.STATISTICS, new BukkitSerializer.Json<>(this, BukkitData.Statistics.class));
+ registerSerializer(Identifier.STATISTICS, new Serializer.Json<>(this, BukkitData.Statistics.class));
registerSerializer(Identifier.POTION_EFFECTS, new BukkitSerializer.PotionEffects(this));
- registerSerializer(Identifier.GAME_MODE, new BukkitSerializer.Json<>(this, BukkitData.GameMode.class));
- registerSerializer(Identifier.FLIGHT_STATUS, new BukkitSerializer.Json<>(this, BukkitData.FlightStatus.class));
- registerSerializer(Identifier.ATTRIBUTES, new BukkitSerializer.Json<>(this, BukkitData.Attributes.class));
- registerSerializer(Identifier.HEALTH, new BukkitSerializer.Json<>(this, BukkitData.Health.class));
- registerSerializer(Identifier.HUNGER, new BukkitSerializer.Json<>(this, BukkitData.Hunger.class));
- registerSerializer(Identifier.EXPERIENCE, new BukkitSerializer.Json<>(this, BukkitData.Experience.class));
- registerSerializer(Identifier.LOCATION, new BukkitSerializer.Json<>(this, BukkitData.Location.class));
+ registerSerializer(Identifier.GAME_MODE, new Serializer.Json<>(this, BukkitData.GameMode.class));
+ registerSerializer(Identifier.FLIGHT_STATUS, new Serializer.Json<>(this, BukkitData.FlightStatus.class));
+ registerSerializer(Identifier.ATTRIBUTES, new Serializer.Json<>(this, BukkitData.Attributes.class));
+ registerSerializer(Identifier.HEALTH, new Serializer.Json<>(this, BukkitData.Health.class));
+ registerSerializer(Identifier.HUNGER, new Serializer.Json<>(this, BukkitData.Hunger.class));
+ registerSerializer(Identifier.EXPERIENCE, new Serializer.Json<>(this, BukkitData.Experience.class));
+ registerSerializer(Identifier.LOCATION, new Serializer.Json<>(this, BukkitData.Location.class));
validateDependencies();
});
diff --git a/bukkit/src/main/java/net/william278/husksync/data/BukkitData.java b/bukkit/src/main/java/net/william278/husksync/data/BukkitData.java
index 9acd06e0..ce61087b 100644
--- a/bukkit/src/main/java/net/william278/husksync/data/BukkitData.java
+++ b/bukkit/src/main/java/net/william278/husksync/data/BukkitData.java
@@ -69,7 +69,6 @@ public abstract class BukkitData implements Data {
private final @Nullable ItemStack @NotNull [] contents;
private Items(@Nullable ItemStack @NotNull [] contents) {
-
this.contents = Arrays.stream(contents.clone())
.map(i -> i == null || i.getType() == Material.AIR ? null : i)
.toArray(ItemStack[]::new);
@@ -127,8 +126,6 @@ public abstract class BukkitData implements Data {
@Getter
public static class Inventory extends BukkitData.Items implements Data.Items.Inventory {
- public static final int INVENTORY_SLOT_COUNT = 41;
-
@Range(from = 0, to = 8)
private int heldItemSlot;
@@ -175,15 +172,18 @@ public abstract class BukkitData implements Data {
public static class EnderChest extends BukkitData.Items implements Data.Items.EnderChest {
- public static final int ENDER_CHEST_SLOT_COUNT = 27;
-
- private EnderChest(@NotNull ItemStack[] contents) {
+ private EnderChest(@Nullable ItemStack @NotNull [] contents) {
super(contents);
}
@NotNull
- public static BukkitData.Items.EnderChest adapt(@NotNull ItemStack[] items) {
- return new BukkitData.Items.EnderChest(items);
+ public static BukkitData.Items.EnderChest adapt(@Nullable ItemStack @NotNull [] contents) {
+ return new BukkitData.Items.EnderChest(contents);
+ }
+
+ @NotNull
+ public static BukkitData.Items.EnderChest adapt(@NotNull Collection items) {
+ return adapt(items.toArray(ItemStack[]::new));
}
@NotNull
@@ -200,7 +200,7 @@ public abstract class BukkitData implements Data {
public static class ItemArray extends BukkitData.Items implements Data.Items {
- private ItemArray(@NotNull ItemStack[] contents) {
+ private ItemArray(@Nullable ItemStack @NotNull [] contents) {
super(contents);
}
@@ -210,7 +210,7 @@ public abstract class BukkitData implements Data {
}
@NotNull
- public static ItemArray adapt(@NotNull ItemStack[] drops) {
+ public static ItemArray adapt(@Nullable ItemStack @NotNull [] drops) {
return new ItemArray(drops);
}
@@ -341,9 +341,12 @@ public abstract class BukkitData implements Data {
}));
}
- private void setAdvancement(@NotNull HuskSync plugin, @NotNull org.bukkit.advancement.Advancement advancement,
- @NotNull Player player, @NotNull BukkitUser user,
- @NotNull Collection toAward, @NotNull Collection toRevoke) {
+ private void setAdvancement(@NotNull HuskSync plugin,
+ @NotNull org.bukkit.advancement.Advancement advancement,
+ @NotNull Player player,
+ @NotNull BukkitUser user,
+ @NotNull Collection toAward,
+ @NotNull Collection toRevoke) {
plugin.runSync(() -> {
// Track player exp level & progress
final int expLevel = player.getLevel();
@@ -355,7 +358,8 @@ public abstract class BukkitData implements Data {
toRevoke.forEach(progress::revokeCriteria);
// Set player experience and level (prevent advancement awards applying twice), reset game rule
- if (!toAward.isEmpty() && player.getLevel() != expLevel || player.getExp() != expProgress) {
+ if (!toAward.isEmpty()
+ && (player.getLevel() != expLevel || player.getExp() != expProgress)) {
player.setLevel(expLevel);
player.setExp(expProgress);
}
diff --git a/bukkit/src/main/java/net/william278/husksync/data/BukkitSerializer.java b/bukkit/src/main/java/net/william278/husksync/data/BukkitSerializer.java
index ef3bd752..97b14a0f 100644
--- a/bukkit/src/main/java/net/william278/husksync/data/BukkitSerializer.java
+++ b/bukkit/src/main/java/net/william278/husksync/data/BukkitSerializer.java
@@ -29,6 +29,7 @@ import de.tr7zw.changeme.nbtapi.utils.DataFixerUtil;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import net.william278.desertwell.util.Version;
+import net.william278.husksync.BukkitHuskSync;
import net.william278.husksync.HuskSync;
import net.william278.husksync.adapter.Adaptable;
import net.william278.husksync.api.HuskSyncAPI;
@@ -41,6 +42,8 @@ import org.jetbrains.annotations.Nullable;
import java.util.List;
import static net.william278.husksync.data.BukkitData.Items.Inventory.INVENTORY_SLOT_COUNT;
+import static net.william278.husksync.data.Data.Items.Inventory.HELD_ITEM_SLOT_TAG;
+import static net.william278.husksync.data.Data.Items.Inventory.ITEMS_TAG;
@AllArgsConstructor(access = AccessLevel.PRIVATE)
public class BukkitSerializer {
@@ -60,8 +63,6 @@ public class BukkitSerializer {
public static class Inventory extends BukkitSerializer implements Serializer,
ItemDeserializer {
- private static final String ITEMS_TAG = "items";
- private static final String HELD_ITEM_SLOT_TAG = "held_item_slot";
public Inventory(@NotNull HuskSync plugin) {
super(plugin);
@@ -74,7 +75,7 @@ public class BukkitSerializer {
final ReadWriteNBT items = root.hasTag(ITEMS_TAG) ? root.getCompound(ITEMS_TAG) : null;
return BukkitData.Items.Inventory.from(
items != null ? getItems(items, dataMcVersion) : new ItemStack[INVENTORY_SLOT_COUNT],
- root.getInteger(HELD_ITEM_SLOT_TAG)
+ root.hasTag(HELD_ITEM_SLOT_TAG) ? root.getInteger(HELD_ITEM_SLOT_TAG) : 0
);
}
@@ -126,15 +127,15 @@ public class BukkitSerializer {
@Nullable
default ItemStack[] getItems(@NotNull ReadWriteNBT tag, @NotNull Version mcVersion) {
if (mcVersion.compareTo(getPlugin().getMinecraftVersion()) < 0) {
- return upgradeItemStack((NBTCompound) tag, mcVersion);
+ return upgradeItemStacks((NBTCompound) tag, mcVersion);
}
return NBT.itemStackArrayFromNBT(tag);
}
@NotNull
- private ItemStack @NotNull [] upgradeItemStack(@NotNull NBTCompound compound, @NotNull Version mcVersion) {
- final ReadWriteNBTCompoundList items = compound.getCompoundList("items");
- final ItemStack[] itemStacks = new ItemStack[compound.getInteger("size")];
+ private ItemStack @NotNull [] upgradeItemStacks(@NotNull NBTCompound itemsNbt, @NotNull Version mcVersion) {
+ final ReadWriteNBTCompoundList items = itemsNbt.getCompoundList("items");
+ final ItemStack[] itemStacks = new ItemStack[itemsNbt.getInteger("size")];
for (int i = 0; i < items.size(); i++) {
if (items.get(i) == null) {
itemStacks[i] = new ItemStack(Material.AIR);
@@ -163,6 +164,7 @@ public class BukkitSerializer {
case "1.19", "1.19.1", "1.19.2" -> DataFixerUtil.VERSION1_19_2;
case "1.20", "1.20.1", "1.20.2" -> DataFixerUtil.VERSION1_20_2;
case "1.20.3", "1.20.4" -> DataFixerUtil.VERSION1_20_4;
+ case "1.20.5", "1.20.6" -> DataFixerUtil.VERSION1_20_5;
default -> DataFixerUtil.getCurrentVersion();
};
}
@@ -237,24 +239,19 @@ public class BukkitSerializer {
}
- public static class Json extends BukkitSerializer implements Serializer {
+ /**
+ * @deprecated Use {@link Serializer.Json} in the common module instead
+ */
+ @Deprecated(since = "2.6")
+ public class Json extends Serializer.Json {
- private final Class type;
-
- public Json(@NotNull HuskSync plugin, Class type) {
- super(plugin);
- this.type = type;
- }
-
- @Override
- public T deserialize(@NotNull String serialized) throws DeserializationException {
- return plugin.getDataAdapter().fromJson(serialized, type);
+ public Json(@NotNull HuskSync plugin, @NotNull Class type) {
+ super(plugin, type);
}
@NotNull
- @Override
- public String serialize(@NotNull T element) throws SerializationException {
- return plugin.getDataAdapter().toJson(element);
+ public BukkitHuskSync getPlugin() {
+ return (BukkitHuskSync) plugin;
}
}
diff --git a/bukkit/src/main/java/net/william278/husksync/data/BukkitUserDataHolder.java b/bukkit/src/main/java/net/william278/husksync/data/BukkitUserDataHolder.java
index da51ff32..9eb93aaf 100644
--- a/bukkit/src/main/java/net/william278/husksync/data/BukkitUserDataHolder.java
+++ b/bukkit/src/main/java/net/william278/husksync/data/BukkitUserDataHolder.java
@@ -67,9 +67,9 @@ public interface BukkitUserDataHolder extends UserDataHolder {
.isSyncDeadPlayersChangingServer())) {
return Optional.of(BukkitData.Items.Inventory.empty());
}
- final PlayerInventory inventory = getBukkitPlayer().getInventory();
+ final PlayerInventory inventory = getPlayer().getInventory();
return Optional.of(BukkitData.Items.Inventory.from(
- getMapPersister().persistLockedMaps(inventory.getContents(), getBukkitPlayer()),
+ getMapPersister().persistLockedMaps(inventory.getContents(), getPlayer()),
inventory.getHeldItemSlot()
));
}
@@ -78,80 +78,89 @@ public interface BukkitUserDataHolder extends UserDataHolder {
@Override
default Optional getEnderChest() {
return Optional.of(BukkitData.Items.EnderChest.adapt(
- getMapPersister().persistLockedMaps(getBukkitPlayer().getEnderChest().getContents(), getBukkitPlayer())
+ getMapPersister().persistLockedMaps(getPlayer().getEnderChest().getContents(), getPlayer())
));
}
@NotNull
@Override
default Optional getPotionEffects() {
- return Optional.of(BukkitData.PotionEffects.from(getBukkitPlayer().getActivePotionEffects()));
+ return Optional.of(BukkitData.PotionEffects.from(getPlayer().getActivePotionEffects()));
}
@NotNull
@Override
default Optional getAdvancements() {
- return Optional.of(BukkitData.Advancements.adapt(getBukkitPlayer()));
+ return Optional.of(BukkitData.Advancements.adapt(getPlayer()));
}
@NotNull
@Override
default Optional getLocation() {
- return Optional.of(BukkitData.Location.adapt(getBukkitPlayer().getLocation()));
+ return Optional.of(BukkitData.Location.adapt(getPlayer().getLocation()));
}
@NotNull
@Override
default Optional getStatistics() {
- return Optional.of(BukkitData.Statistics.adapt(getBukkitPlayer()));
+ return Optional.of(BukkitData.Statistics.adapt(getPlayer()));
}
@NotNull
@Override
default Optional getHealth() {
- return Optional.of(BukkitData.Health.adapt(getBukkitPlayer()));
+ return Optional.of(BukkitData.Health.adapt(getPlayer()));
}
@NotNull
@Override
default Optional getHunger() {
- return Optional.of(BukkitData.Hunger.adapt(getBukkitPlayer()));
+ return Optional.of(BukkitData.Hunger.adapt(getPlayer()));
}
@NotNull
@Override
default Optional getAttributes() {
- return Optional.of(BukkitData.Attributes.adapt(getBukkitPlayer(), getPlugin()));
+ return Optional.of(BukkitData.Attributes.adapt(getPlayer(), getPlugin()));
}
@NotNull
@Override
default Optional getExperience() {
- return Optional.of(BukkitData.Experience.adapt(getBukkitPlayer()));
+ return Optional.of(BukkitData.Experience.adapt(getPlayer()));
}
@NotNull
@Override
default Optional getGameMode() {
- return Optional.of(BukkitData.GameMode.adapt(getBukkitPlayer()));
+ return Optional.of(BukkitData.GameMode.adapt(getPlayer()));
}
@NotNull
@Override
default Optional getFlightStatus() {
- return Optional.of(BukkitData.FlightStatus.adapt(getBukkitPlayer()));
+ return Optional.of(BukkitData.FlightStatus.adapt(getPlayer()));
}
@NotNull
@Override
default Optional getPersistentData() {
- return Optional.of(BukkitData.PersistentData.adapt(getBukkitPlayer().getPersistentDataContainer()));
+ return Optional.of(BukkitData.PersistentData.adapt(getPlayer().getPersistentDataContainer()));
}
boolean isDead();
@NotNull
- Player getBukkitPlayer();
+ Player getPlayer();
+
+ /**
+ * @deprecated Use {@link #getPlayer()} instead
+ */
+ @Deprecated(since = "3.6")
+ @NotNull
+ default Player getBukkitPlayer() {
+ return getPlayer();
+ }
@NotNull
default BukkitMapPersister getMapPersister() {
diff --git a/bukkit/src/main/java/net/william278/husksync/user/BukkitUser.java b/bukkit/src/main/java/net/william278/husksync/user/BukkitUser.java
index b2218ca8..3be35d81 100644
--- a/bukkit/src/main/java/net/william278/husksync/user/BukkitUser.java
+++ b/bukkit/src/main/java/net/william278/husksync/user/BukkitUser.java
@@ -62,17 +62,6 @@ public class BukkitUser extends OnlineUser implements BukkitUserDataHolder {
return new BukkitUser(player, plugin);
}
- /**
- * Get the Bukkit {@link Player} instance of this user
- *
- * @return the {@link Player} instance
- * @since 3.0
- */
- @NotNull
- public Player getPlayer() {
- return player;
- }
-
@Override
public boolean isOffline() {
return player == null || !player.isOnline();
@@ -132,9 +121,14 @@ public class BukkitUser extends OnlineUser implements BukkitUserDataHolder {
return player.hasMetadata("NPC");
}
+ /**
+ * Get the Bukkit {@link Player} instance of this user
+ *
+ * @return the {@link Player} instance
+ * @since 3.6
+ */
@NotNull
- @Override
- public Player getBukkitPlayer() {
+ public Player getPlayer() {
return player;
}
diff --git a/common/build.gradle b/common/build.gradle
index a81fc971..6b567862 100644
--- a/common/build.gradle
+++ b/common/build.gradle
@@ -5,6 +5,7 @@ plugins {
dependencies {
api 'commons-io:commons-io:2.16.1'
api 'org.apache.commons:commons-text:1.12.0'
+ api 'org.apache.commons:commons-pool2:2.12.0'
api 'net.william278:minedown:1.8.2'
api 'org.json:json:20240303'
api 'com.google.code.gson:gson:2.11.0'
diff --git a/common/src/main/java/net/william278/husksync/HuskSync.java b/common/src/main/java/net/william278/husksync/HuskSync.java
index ecc68d8e..189e8cc2 100644
--- a/common/src/main/java/net/william278/husksync/HuskSync.java
+++ b/common/src/main/java/net/william278/husksync/HuskSync.java
@@ -43,7 +43,6 @@ import net.william278.husksync.util.LegacyConverter;
import net.william278.husksync.util.Task;
import org.jetbrains.annotations.NotNull;
-import java.io.File;
import java.io.InputStream;
import java.text.SimpleDateFormat;
import java.util.*;
@@ -86,7 +85,6 @@ public interface HuskSync extends Task.Supplier, EventDispatcher, ConfigProvider
*
* @return the {@link RedisManager} implementation
*/
-
@NotNull
RedisManager getRedisManager();
@@ -122,7 +120,17 @@ public interface HuskSync extends Task.Supplier, EventDispatcher, ConfigProvider
List getAvailableMigrators();
@NotNull
- Map getPlayerCustomDataStore(@NotNull OnlineUser user);
+ Map> getPlayerCustomDataStore();
+
+ @NotNull
+ default Map getPlayerCustomDataStore(@NotNull OnlineUser user) {
+ if (getPlayerCustomDataStore().containsKey(user.getUuid())) {
+ return getPlayerCustomDataStore().get(user.getUuid());
+ }
+ final Map data = new HashMap<>();
+ getPlayerCustomDataStore().put(user.getUuid(), data);
+ return data;
+ }
/**
* Initialize a faucet of the plugin.
@@ -156,14 +164,6 @@ public interface HuskSync extends Task.Supplier, EventDispatcher, ConfigProvider
*/
InputStream getResource(@NotNull String name);
- /**
- * Returns the plugin data folder
- *
- * @return the plugin data folder as a {@link File}
- */
- @NotNull
- File getDataFolder();
-
/**
* Log a message to the console
*
diff --git a/common/src/main/java/net/william278/husksync/command/Command.java b/common/src/main/java/net/william278/husksync/command/Command.java
index 37bfac63..48a802fa 100644
--- a/common/src/main/java/net/william278/husksync/command/Command.java
+++ b/common/src/main/java/net/william278/husksync/command/Command.java
@@ -51,6 +51,16 @@ public abstract class Command extends Node {
public abstract void execute(@NotNull CommandUser executor, @NotNull String[] args);
+ @NotNull
+ protected String[] removeFirstArg(@NotNull String[] args) {
+ if (args.length <= 1) {
+ return new String[0];
+ }
+ String[] newArgs = new String[args.length - 1];
+ System.arraycopy(args, 1, newArgs, 0, args.length - 1);
+ return newArgs;
+ }
+
@NotNull
public final String getRawUsage() {
return usage;
diff --git a/common/src/main/java/net/william278/husksync/command/HuskSyncCommand.java b/common/src/main/java/net/william278/husksync/command/HuskSyncCommand.java
index f7f96679..ecd09b4e 100644
--- a/common/src/main/java/net/william278/husksync/command/HuskSyncCommand.java
+++ b/common/src/main/java/net/william278/husksync/command/HuskSyncCommand.java
@@ -68,7 +68,9 @@ public class HuskSyncCommand extends Command implements TabProvider {
.credits("Contributors",
AboutMenu.Credit.of("HarvelsX").description("Code"),
AboutMenu.Credit.of("HookWoods").description("Code"),
- AboutMenu.Credit.of("Preva1l").description("Code"))
+ AboutMenu.Credit.of("Preva1l").description("Code"),
+ AboutMenu.Credit.of("hanbings").description("Code (Fabric porting)"),
+ AboutMenu.Credit.of("Stampede2011").description("Code (Fabric mixins)"))
.credits("Translators",
AboutMenu.Credit.of("Namiu").description("Japanese (ja-jp)"),
AboutMenu.Credit.of("anchelthe").description("Spanish (es-es)"),
diff --git a/common/src/main/java/net/william278/husksync/command/InventoryCommand.java b/common/src/main/java/net/william278/husksync/command/InventoryCommand.java
index ad710147..5dc3847d 100644
--- a/common/src/main/java/net/william278/husksync/command/InventoryCommand.java
+++ b/common/src/main/java/net/william278/husksync/command/InventoryCommand.java
@@ -45,6 +45,7 @@ public class InventoryCommand extends ItemsCommand {
@NotNull User user, boolean allowEdit) {
final Optional optionalInventory = snapshot.getInventory();
if (optionalInventory.isEmpty()) {
+ viewer.sendMessage(new MineDown("what the FUCK is happening"));
plugin.getLocales().getLocale("error_no_data_to_display")
.ifPresent(viewer::sendMessage);
return;
diff --git a/common/src/main/java/net/william278/husksync/data/Data.java b/common/src/main/java/net/william278/husksync/data/Data.java
index fc4de513..7cba6059 100644
--- a/common/src/main/java/net/william278/husksync/data/Data.java
+++ b/common/src/main/java/net/william278/husksync/data/Data.java
@@ -78,6 +78,7 @@ public interface Data {
*/
interface Inventory extends Items {
+ int INVENTORY_SLOT_COUNT = 41;
String ITEMS_TAG = "items";
String HELD_ITEM_SLOT_TAG = "held_item_slot";
@@ -110,7 +111,7 @@ public interface Data {
* Data container holding data for ender chests
*/
interface EnderChest extends Items {
-
+ int ENDER_CHEST_SLOT_COUNT = 27;
}
}
diff --git a/common/src/main/java/net/william278/husksync/data/DataHolder.java b/common/src/main/java/net/william278/husksync/data/DataHolder.java
index 752ffe79..b901ba4f 100644
--- a/common/src/main/java/net/william278/husksync/data/DataHolder.java
+++ b/common/src/main/java/net/william278/husksync/data/DataHolder.java
@@ -30,8 +30,8 @@ public interface DataHolder {
@NotNull
Map getData();
- default Optional extends Data> getData(@NotNull Identifier identifier) {
- return Optional.ofNullable(getData().get(identifier));
+ default Optional extends Data> getData(@NotNull Identifier id) {
+ return getData().entrySet().stream().filter(e -> e.getKey().equals(id)).map(Map.Entry::getValue).findFirst();
}
default void setData(@NotNull Identifier identifier, @NotNull Data data) {
diff --git a/common/src/main/java/net/william278/husksync/data/Serializer.java b/common/src/main/java/net/william278/husksync/data/Serializer.java
index 9b4a12b0..c8c8bd4e 100644
--- a/common/src/main/java/net/william278/husksync/data/Serializer.java
+++ b/common/src/main/java/net/william278/husksync/data/Serializer.java
@@ -20,6 +20,8 @@
package net.william278.husksync.data;
import net.william278.desertwell.util.Version;
+import net.william278.husksync.HuskSync;
+import net.william278.husksync.adapter.Adaptable;
import org.jetbrains.annotations.NotNull;
public interface Serializer {
@@ -46,4 +48,26 @@ public interface Serializer {
}
+ class Json implements Serializer {
+
+ private final HuskSync plugin;
+ private final Class type;
+
+ public Json(@NotNull HuskSync plugin, @NotNull Class type) {
+ this.type = type;
+ this.plugin = plugin;
+ }
+
+ @Override
+ public T deserialize(@NotNull String serialized) throws DeserializationException {
+ return plugin.getDataAdapter().fromJson(serialized, type);
+ }
+
+ @NotNull
+ @Override
+ public String serialize(@NotNull T element) throws SerializationException {
+ return plugin.getDataAdapter().toJson(element);
+ }
+
+ }
}
diff --git a/common/src/main/java/net/william278/husksync/event/Cancellable.java b/common/src/main/java/net/william278/husksync/event/Cancellable.java
index ef0becd5..156e9f96 100644
--- a/common/src/main/java/net/william278/husksync/event/Cancellable.java
+++ b/common/src/main/java/net/william278/husksync/event/Cancellable.java
@@ -19,7 +19,6 @@
package net.william278.husksync.event;
-@SuppressWarnings("unused")
public interface Cancellable extends Event {
default boolean isCancelled() {
diff --git a/common/src/main/java/net/william278/husksync/event/PreSyncEvent.java b/common/src/main/java/net/william278/husksync/event/PreSyncEvent.java
index c47352d5..f43c58db 100644
--- a/common/src/main/java/net/william278/husksync/event/PreSyncEvent.java
+++ b/common/src/main/java/net/william278/husksync/event/PreSyncEvent.java
@@ -27,7 +27,7 @@ import org.jetbrains.annotations.NotNull;
import java.util.function.Consumer;
@SuppressWarnings("unused")
-public interface PreSyncEvent extends PlayerEvent {
+public interface PreSyncEvent extends PlayerEvent, Cancellable {
@NotNull
DataSnapshot.Packed getData();
diff --git a/common/src/main/java/net/william278/husksync/sync/LockstepDataSyncer.java b/common/src/main/java/net/william278/husksync/sync/LockstepDataSyncer.java
index b51485a8..dbafcdae 100644
--- a/common/src/main/java/net/william278/husksync/sync/LockstepDataSyncer.java
+++ b/common/src/main/java/net/william278/husksync/sync/LockstepDataSyncer.java
@@ -59,16 +59,13 @@ public class LockstepDataSyncer extends DataSyncer {
@Override
public void saveUserData(@NotNull OnlineUser onlineUser) {
- plugin.runAsync(() -> {
- getRedis().setUserServerSwitch(onlineUser);
- saveData(
- onlineUser, onlineUser.createSnapshot(DataSnapshot.SaveCause.DISCONNECT),
- (user, data) -> {
- getRedis().setUserData(user, data, RedisKeyType.TTL_1_YEAR);
- getRedis().setUserCheckedOut(user, false);
- }
- );
- });
+ plugin.runAsync(() -> saveData(
+ onlineUser, onlineUser.createSnapshot(DataSnapshot.SaveCause.DISCONNECT),
+ (user, data) -> {
+ getRedis().setUserData(user, data, RedisKeyType.TTL_1_YEAR);
+ getRedis().setUserCheckedOut(user, false);
+ }
+ ));
}
}
diff --git a/common/src/main/java/net/william278/husksync/util/DataDumper.java b/common/src/main/java/net/william278/husksync/util/DataDumper.java
index f0f510a1..1cb0495b 100644
--- a/common/src/main/java/net/william278/husksync/util/DataDumper.java
+++ b/common/src/main/java/net/william278/husksync/util/DataDumper.java
@@ -31,6 +31,8 @@ import java.net.HttpURLConnection;
import java.net.URL;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
import java.time.format.DateTimeFormatter;
import java.util.Locale;
import java.util.StringJoiner;
@@ -133,16 +135,13 @@ public class DataDumper {
*/
@NotNull
public String toFile() throws IOException {
- final File filePath = getFilePath();
-
- // Write the data from #getString to the file using a writer
- try (final FileWriter writer = new FileWriter(filePath, StandardCharsets.UTF_8, false)) {
- writer.write(toString());
+ final Path filePath = getFilePath();
+ try (final FileWriter writer = new FileWriter(filePath.toFile(), StandardCharsets.UTF_8, false)) {
+ writer.write(toString()); // Write the data from #getString to the file using a writer
+ return filePath.toString();
} catch (IOException e) {
throw new IOException("Failed to write data to file", e);
}
-
- return "~/plugins/HuskSync/dumps/" + filePath.getName();
}
/**
@@ -152,8 +151,8 @@ public class DataDumper {
* @throws IOException if the prerequisite dumps parent folder could not be created
*/
@NotNull
- private File getFilePath() throws IOException {
- return new File(getDumpsFolder(), getFileName());
+ private Path getFilePath() throws IOException {
+ return getDumpsFolder().resolve(getFileName());
}
/**
@@ -163,14 +162,12 @@ public class DataDumper {
* @throws IOException if the folder could not be created
*/
@NotNull
- private File getDumpsFolder() throws IOException {
- final File dumpsFolder = new File(plugin.getDataFolder(), "dumps");
- if (!dumpsFolder.exists()) {
- if (!dumpsFolder.mkdirs()) {
- throw new IOException("Failed to create user data dumps folder");
- }
+ private Path getDumpsFolder() throws IOException {
+ final Path dumps = plugin.getConfigDirectory().resolve("dumps");
+ if (!Files.exists(dumps)) {
+ Files.createDirectory(dumps);
}
- return dumpsFolder;
+ return dumps;
}
/**
diff --git a/docs/API-Events.md b/docs/API-Events.md
index ecb7bcbe..6f0b3ad0 100644
--- a/docs/API-Events.md
+++ b/docs/API-Events.md
@@ -4,11 +4,20 @@ Consult the Javadocs for more information. Please note that carrying out expensi
## Bukkit Platform Events
> **Tip:** Don't forget to register your listener when listening for these event calls.
->
+
| Bukkit Event class | Cancellable | Description |
|---------------------------|:-----------:|---------------------------------------------------------------------------------------------|
| `BukkitDataSaveEvent` | ✅ | Called when player data snapshot is created, saved and cached due to a DataSaveCause |
| `BukkitPreSyncEvent` | ✅ | Called before a player has their data updated from the cache or database, just after login |
| `BukkitSyncCompleteEvent` | ❌ | Called once a player has completed their data synchronization on login successfully† |
+## Fabric Platform Callbacks
+> Access the callback via the static EVENT field in each interface class.
+
+| Fabric Callback | Cancellable | Description |
+|------------------------------|:-----------:|---------------------------------------------------------------------------------------------|
+| `FabricDataSaveCallback` | ✅ | Called when player data snapshot is created, saved and cached due to a DataSaveCause |
+| `FabricPreSyncCallback` | ✅ | Called before a player has their data updated from the cache or database, just after login |
+| `FabricSyncCompleteCallback` | ❌ | Called once a player has completed their data synchronization on login successfully† |
+
†This can also fire when a user's data is updated while the player is logged in; i.e., when an admin rolls back the user, updates their inventory or Ender Chest through the respective commands, or when an API call is made forcing the user to have their data updated.
diff --git a/docs/API.md b/docs/API.md
index b5642ad6..e6d3a973 100644
--- a/docs/API.md
+++ b/docs/API.md
@@ -17,6 +17,7 @@ The HuskSync API shares version numbering with the plugin itself for consistency
The HuskSync API is available for the following platforms:
* `bukkit` - Bukkit, Spigot, Paper, etc. Provides Bukkit API event listeners and adapters to `org.bukkit` objects.
+* `fabric` - Fabric API for Minecraft. Provides Fabric API event listeners and adapters to `net.minecraft` objects.
* `common` - Common API for all platforms.
diff --git a/docs/FAQs.md b/docs/FAQs.md
index 13480682..bf48aa44 100644
--- a/docs/FAQs.md
+++ b/docs/FAQs.md
@@ -12,23 +12,29 @@ HuskSync supports synchronising a wide range of different data elements, each of
Are modded items supported?
-If you're running HuskSync on Arclight or similar, please note we will not be able to provide you with support, but have been reported to save & sync correctly with HuskSync v3.x+.
+On Fabric, modded items should usually sync as you would expect with HuskSync. Note that mods which store additional data separate from item NBT on each server may not work as expected. Mod developers — check out the [[Custom Data API]] for information on how to get your mod's data syncing!
-**TL;DR** — modded items may work, but since we can't guarantee compatibility, we do not officially mark them as supported. Be sure to test thoroughly before deploying on production!
+On Spigot, if you're running HuskSync on Arclight or similar, please note we will not be able to provide you with support, but have been reported to save & sync correctly with HuskSync v3.x+.
+
+Please note we cannot guarantee compatibility with everything — test thoroughly!
Are MMOItems / SlimeFun / ItemsAdder items supported?
-These plugins, which provide custom items, should be supported as of HuskSync v3.x+; but do note we cannot guarantee compatibility with all methods of injecting custom data to create custom items. Be sure to test thoroughly before deploying on production!
+These custom item Spigot plugins should work as expected provided they inject data into item NBT in a standard way.
+
+Please note we cannot guarantee compatibility with everything — test thoroughly!
Is Redis required? What is Redis?
-HuskSync requires Redis to operate (for reasons demonstrated below). Redis is an in-memory database server used for caching data at scale and sending messages across a network. You have a Redis server in a similar fashion to the way you have a MySQL database server. If you're using a Minecraft hosting company, you'll want to contact their support and ask if they offer Redis. If you're looking for a host, I have a list of some popular hosts and whether they support Redis [available to read here.](https://william278.net/redis-hosts)
+Yes! HuskSync requires Redis to operate (for reasons demonstrated below).
+
+Redis is an in-memory database server used for caching data at scale and sending messages across a network. You have a Redis server in a similar fashion to the way you have a MySQL database server. If you're using a Minecraft hosting company, you'll want to contact their support and ask if they offer Redis. If you're looking for a host, I have a list of some popular hosts and whether they support Redis [available to read here.](https://william278.net/redis-hosts)
diff --git a/docs/Home.md b/docs/Home.md
index 9b8eaa13..e148f287 100644
--- a/docs/Home.md
+++ b/docs/Home.md
@@ -30,5 +30,5 @@ Welcome! This is the plugin documentation for HuskSync v3.x+. Please click throu
* 📂 [Buy HuskSync](https://william278.net/project/husksync/)
* 🚰 [Spigot](https://www.spigotmc.org/resources/husksync.97144/)
* 🛒 [Polymart](https://polymart.org/resource/husksync.1634)
- * ⚒️ [Craftaro](https://craftaro.com/marketplace/product/husksync.758)
+ * ⚒️ [BuiltByBit](https://craftaro.com/marketplace/product/husksync.758)
* 💬 [Discord Support](https://discord.gg/tVYhJfyDWG)
\ No newline at end of file
diff --git a/docs/Setup.md b/docs/Setup.md
index 1774e263..01caac52 100644
--- a/docs/Setup.md
+++ b/docs/Setup.md
@@ -1,25 +1,31 @@
-This will walk you through installing HuskSync on your network of Spigot servers.
+> **Warning:** Fabric support is currently in beta and is not production ready yet. Customers can get in touch on Discord to request the Fabric build, or you can self-compile.
+
+This will walk you through installing HuskSync on your network of Spigot or Fabric servers.
## Requirements
-> **Note:** If the plugin fails to load, please check that you are not running an [incompatible version combination](Unsupported-Versions)
+> **Warning:** Mixing and matching Fabric/Spigot servers is not supported, and all servers must be running the same Minecraft version.
+
+> **Note:** Please also note some specific legacy Paper/Purpur versions are [not compatible](Unsupported-Versions) with HuskSync.
-* A MySQL Database (v8.0+) (MariaDB, PostrgreSQL or MongoDB are also supported)
+* A MySQL Database (v8.0+)
+ * **OR** a MariaDB, PostrgreSQL or MongoDB database, which are also supported
* A Redis Database (v5.0+) — see [[FAQs]] for more details.
* Any number of Spigot servers, connected by a BungeeCord or Velocity-based proxy (Minecraft v1.17.1+, running Java 17+)
+ * **OR** a network of Fabric servers, connected by a Fabric proxy (Minecraft v1.20.1, running Java 17+)
## Setup Instructions
### 1. Install the jar
-- Place the plugin jar file in the `/plugins/` directory of each Spigot server.
+- Place the plugin jar file in the `/plugins/` or `/mods/` directory of each Spigot/Fabric server respectively.
- You do not need to install HuskSync as a proxy plugin.
-- You can additionally install [ProtocolLib](https://www.spigotmc.org/resources/protocollib.1997/) or [PacketEvents](https://www.spigotmc.org/resources/packetevents-api.80279/) for better locked user handling, and [Plan](https://www.spigotmc.org/resources/plan-player-analytics.32536/) for analytics.
+- _Spigot users_: You can additionally install [ProtocolLib](https://www.spigotmc.org/resources/protocollib.1997/) or [PacketEvents](https://www.spigotmc.org/resources/packetevents-api.80279/) for better locked user handling.
+- _Fabric users_: Ensure the latest Fabric API mod jar is installed!
### 2. Restart servers
- Start, then stop every server to let HuskSync generate the [[config file]].
- HuskSync will throw an error in the console and disable itself as it is unable to connect to the database. You haven't set the credentials yet, so this is expected.
-- Advanced users: If you'd prefer, you can create one config.yml file and create symbolic links in each `/plugins/HuskSync/` folder to it to make updating it easier.
### 3. Enter Mysql & Redis database credentials
-- Navigate to the HuskSync config file on each server (`~/plugins/HuskSync/config.yml`)
+- Navigate to the new config file on each server (`~/plugins/HuskSync/config.yml` on Spigot, `~/config/husksync/config.yml` on Fabric)
- Under `credentials` in the `database` section, enter the credentials of your (MySQL/MariaDB/MongoDB/PostgreSQL) Database. You shouldn't touch the `connection_pool` properties.
- Under `credentials` in the `redis` section, enter the credentials of your Redis Database. If your Redis server doesn't have a password, leave the password blank as it is.
- Unless you want to have multiple clusters of servers within your network, each with separate user data, you should not change the value of `cluster_id`.
@@ -44,7 +50,7 @@ This will walk you through installing HuskSync on your network of Spigot servers
### 4. Set server names in server.yml files
-- Navigate to the HuskSync server name file on each server (`~/plugins/HuskSync/server.yml`)
+- Navigate to the server name file on each server (`~/plugins/HuskSync/server.yml` on Spigot, `~/config/husksync/server.yml` on Fabric)
- Set the `name:` of the server in this file to the ID of this server as defined in the config of your proxy (e.g., if this is the "hub" server you access with `/server hub`, put `'hub'` here)
### 5. Start every server again
diff --git a/docs/_Sidebar.md b/docs/_Sidebar.md
index b184ba3d..290984e1 100644
--- a/docs/_Sidebar.md
+++ b/docs/_Sidebar.md
@@ -27,6 +27,5 @@
* 📂 [Buy HuskSync](https://william278.net/project/husksync/)
* 🚰 [Spigot](https://www.spigotmc.org/resources/husksync.97144/)
* 🛒 [Polymart](https://polymart.org/resource/husksync.1634)
- * ⚒️ [Craftaro](https://craftaro.com/marketplace/product/husksync.758)
- * 🛒 [BuiltByBit](https://craftaro.com/marketplace/product/husksync.758)
+ * ⚒️ [BuiltByBit](https://craftaro.com/marketplace/product/husksync.758)
* 💬 [Discord Support](https://discord.gg/tVYhJfyDWG)
diff --git a/fabric/build.gradle b/fabric/build.gradle
new file mode 100644
index 00000000..3d799dd6
--- /dev/null
+++ b/fabric/build.gradle
@@ -0,0 +1,71 @@
+plugins {
+ id 'fabric-loom' version '1.6-SNAPSHOT'
+}
+
+apply plugin: 'fabric-loom'
+loom.serverOnlyMinecraftJar()
+
+repositories {
+ maven { url 'https://s01.oss.sonatype.org/content/repositories/snapshots/' }
+ maven { url 'https://maven.nucleoid.xyz' }
+}
+
+dependencies {
+ minecraft "com.mojang:minecraft:${fabric_minecraft_version}"
+ mappings "net.fabricmc:yarn:${fabric_yarn_mappings}:v2"
+ modImplementation "net.fabricmc:fabric-loader:${fabric_loader_version}"
+
+ modImplementation include("net.kyori:adventure-platform-fabric:${adventure_platform_fabric_version}")
+ modImplementation include("me.lucko:fabric-permissions-api:${fabric_permissions_api_version}")
+ modImplementation include("eu.pb4:sgui:${sgui_version}")
+ modCompileOnly "net.fabricmc.fabric-api:fabric-api:${fabric_api_version}"
+
+ // Runtime dependencies on Bukkit; "include" them on Fabric. (todo: minify JAR?)
+ implementation include("redis.clients:jedis:$jedis_version")
+ implementation include("com.mysql:mysql-connector-j:$mysql_driver_version")
+ implementation include("org.mariadb.jdbc:mariadb-java-client:$mariadb_driver_version")
+ implementation include("org.xerial.snappy:snappy-java:$snappy_version")
+
+ compileOnly 'org.jetbrains:annotations:24.0.1'
+ compileOnly 'net.william278:DesertWell:2.0.4'
+ compileOnly 'org.projectlombok:lombok:1.18.30'
+
+ annotationProcessor 'org.projectlombok:lombok:1.18.30'
+
+ shadow project(path: ":common")
+}
+
+shadowJar {
+ configurations = [project.configurations.shadow]
+ destinationDirectory.set(file("$projectDir/build/libs"))
+
+ exclude('net.fabricmc:.*')
+ exclude('net.kyori:.*')
+ exclude '/mappings/*'
+
+ relocate 'org.apache.commons.io', 'net.william278.husksync.libraries.commons.io'
+ relocate 'org.apache.commons.text', 'net.william278.husksync.libraries.commons.text'
+ relocate 'org.apache.commons.lang3', 'net.william278.husksync.libraries.commons.lang3'
+ relocate 'com.google.gson', 'net.william278.husksync.libraries.gson'
+ relocate 'com.fatboyindustrial', '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 'de.exlll', 'net.william278.husksync.libraries'
+ relocate 'net.william278.desertwell', 'net.william278.husksync.libraries.desertwell'
+ relocate 'net.william278.paginedown', 'net.william278.husksync.libraries.paginedown'
+ relocate 'org.json', 'net.william278.husksync.libraries.json'
+}
+
+remapJar {
+ dependsOn tasks.shadowJar
+ mustRunAfter tasks.shadowJar
+ inputFile = shadowJar.archiveFile.get()
+ addNestedDependencies = true
+
+ destinationDirectory.set(file("$rootDir/target/"))
+ archiveClassifier.set('')
+}
+
+shadowJar.finalizedBy(remapJar)
\ No newline at end of file
diff --git a/fabric/src/main/java/net/william278/husksync/FabricHuskSync.java b/fabric/src/main/java/net/william278/husksync/FabricHuskSync.java
new file mode 100644
index 00000000..6bb4f5ae
--- /dev/null
+++ b/fabric/src/main/java/net/william278/husksync/FabricHuskSync.java
@@ -0,0 +1,341 @@
+/*
+ * This file is part of HuskSync, licensed under the Apache License 2.0.
+ *
+ * Copyright (c) William278
+ * Copyright (c) contributors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package net.william278.husksync;
+
+import com.google.common.collect.Lists;
+import com.google.common.collect.Maps;
+import com.google.common.collect.Sets;
+import com.google.gson.Gson;
+import lombok.AccessLevel;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import lombok.Setter;
+import net.fabricmc.api.DedicatedServerModInitializer;
+import net.fabricmc.fabric.api.command.v2.CommandRegistrationCallback;
+import net.fabricmc.fabric.api.event.lifecycle.v1.ServerLifecycleEvents;
+import net.fabricmc.loader.api.FabricLoader;
+import net.fabricmc.loader.api.ModContainer;
+import net.kyori.adventure.platform.AudienceProvider;
+import net.kyori.adventure.platform.fabric.FabricServerAudiences;
+import net.minecraft.server.MinecraftServer;
+import net.william278.desertwell.util.Version;
+import net.william278.husksync.adapter.DataAdapter;
+import net.william278.husksync.adapter.GsonAdapter;
+import net.william278.husksync.adapter.SnappyGsonAdapter;
+import net.william278.husksync.api.FabricHuskSyncAPI;
+import net.william278.husksync.command.Command;
+import net.william278.husksync.command.FabricCommand;
+import net.william278.husksync.config.Locales;
+import net.william278.husksync.config.Server;
+import net.william278.husksync.config.Settings;
+import net.william278.husksync.data.*;
+import net.william278.husksync.database.Database;
+import net.william278.husksync.database.MySqlDatabase;
+import net.william278.husksync.event.FabricEventDispatcher;
+import net.william278.husksync.hook.PlanHook;
+import net.william278.husksync.listener.EventListener;
+import net.william278.husksync.listener.FabricEventListener;
+import net.william278.husksync.migrator.Migrator;
+import net.william278.husksync.redis.RedisManager;
+import net.william278.husksync.sync.DataSyncer;
+import net.william278.husksync.user.ConsoleUser;
+import net.william278.husksync.user.FabricUser;
+import net.william278.husksync.user.OnlineUser;
+import net.william278.husksync.util.FabricTask;
+import net.william278.husksync.util.LegacyConverter;
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.slf4j.spi.LoggingEventBuilder;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.*;
+import java.util.logging.Level;
+import java.util.stream.Collectors;
+
+@Getter
+@NoArgsConstructor
+public class FabricHuskSync implements DedicatedServerModInitializer, HuskSync, FabricTask.Supplier,
+ FabricEventDispatcher {
+
+ private static final String PLATFORM_TYPE_ID = "fabric";
+
+ private final TreeMap> serializers = Maps.newTreeMap(
+ SerializerRegistry.DEPENDENCY_ORDER_COMPARATOR
+ );
+ private final Map> playerCustomDataStore = Maps.newConcurrentMap();
+ private final Map permissions = Maps.newHashMap();
+ private final List availableMigrators = Lists.newArrayList();
+ private final Set lockedPlayers = Sets.newConcurrentHashSet();
+
+ private Logger logger;
+ private ModContainer mod;
+ private MinecraftServer minecraftServer;
+ private boolean disabling;
+ private Gson gson;
+ private AudienceProvider audiences;
+ private Database database;
+ private RedisManager redisManager;
+ private EventListener eventListener;
+ private DataAdapter dataAdapter;
+ @Setter
+ private DataSyncer dataSyncer;
+ @Setter
+ private Settings settings;
+ @Setter
+ private Locales locales;
+ @Setter
+ @Getter(AccessLevel.NONE)
+ private Server serverName;
+
+ @Override
+ public void onInitializeServer() {
+ // Get the logger and mod container
+ this.logger = LoggerFactory.getLogger("HuskSync");
+ this.mod = FabricLoader.getInstance().getModContainer("husksync").orElseThrow();
+ this.disabling = false;
+ this.gson = createGson();
+
+ // Load settings and locales
+ initialize("plugin config & locale files", (plugin) -> {
+ loadSettings();
+ loadLocales();
+ loadServer();
+ });
+
+ // Register commands
+ initialize("commands", (plugin) -> this.registerCommands());
+
+ // Load HuskSync after server startup
+ ServerLifecycleEvents.SERVER_STARTED.register(server -> {
+ this.minecraftServer = server;
+ this.onEnable();
+ });
+
+ // Unload HuskSync before server shutdown
+ ServerLifecycleEvents.SERVER_STOPPING.register(server -> this.onDisable());
+ }
+
+ private void onEnable() {
+ // Initial plugin setup
+ this.audiences = FabricServerAudiences.of(minecraftServer);
+
+ // Prepare data adapter
+ initialize("data adapter", (plugin) -> {
+ if (getSettings().getSynchronization().isCompressData()) {
+ this.dataAdapter = new SnappyGsonAdapter(this);
+ } else {
+ this.dataAdapter = new GsonAdapter(this);
+ }
+ });
+
+ initialize("data serializers", (plugin) -> {
+ // PERSISTENT_DATA is not registered / available on the Fabric platform
+ registerSerializer(Identifier.INVENTORY, new FabricSerializer.Inventory(this));
+ registerSerializer(Identifier.ENDER_CHEST, new FabricSerializer.EnderChest(this));
+ registerSerializer(Identifier.ADVANCEMENTS, new FabricSerializer.Advancements(this));
+ registerSerializer(Identifier.STATISTICS, new Serializer.Json<>(this, FabricData.Statistics.class)); // TODO APPLY
+ registerSerializer(Identifier.POTION_EFFECTS, new FabricSerializer.PotionEffects(this));
+ registerSerializer(Identifier.GAME_MODE, new Serializer.Json<>(this, FabricData.GameMode.class));
+ registerSerializer(Identifier.FLIGHT_STATUS, new Serializer.Json<>(this, FabricData.FlightStatus.class));
+ registerSerializer(Identifier.ATTRIBUTES, new Serializer.Json<>(this, FabricData.Attributes.class));
+ registerSerializer(Identifier.HEALTH, new Serializer.Json<>(this, FabricData.Health.class));
+ registerSerializer(Identifier.HUNGER, new Serializer.Json<>(this, FabricData.Hunger.class));
+ registerSerializer(Identifier.EXPERIENCE, new Serializer.Json<>(this, FabricData.Experience.class));
+ registerSerializer(Identifier.LOCATION, new Serializer.Json<>(this, FabricData.Location.class));
+ validateDependencies();
+ });
+
+ // Initialize the database
+ initialize(getSettings().getDatabase().getType().getDisplayName() + " database connection", (plugin) -> {
+ this.database = new MySqlDatabase(this);
+ this.database.initialize();
+ });
+
+ // Prepare redis connection
+ initialize("Redis server connection", (plugin) -> {
+ this.redisManager = new RedisManager(this);
+ this.redisManager.initialize();
+ });
+
+ // Prepare data syncer
+ initialize("data syncer", (plugin) -> {
+ dataSyncer = getSettings().getSynchronization().getMode().create(this);
+ dataSyncer.initialize();
+ });
+
+ // Register events
+ initialize("events", (plugin) -> this.eventListener = new FabricEventListener(this));
+
+ // Register plugin hooks
+ initialize("hooks", (plugin) -> {
+ if (isDependencyLoaded("Plan") && getSettings().isEnablePlanHook()) {
+ new PlanHook(this).hookIntoPlan();
+ }
+ });
+
+ // Register API
+ initialize("api", (plugin) -> {
+ FabricHuskSyncAPI.register(this);
+ });
+
+ // Check for updates
+ this.checkForUpdates();
+ }
+
+ private void onDisable() {
+ // Handle shutdown
+ this.disabling = true;
+
+ // Close the event listener / data syncer
+ if (this.dataSyncer != null) {
+ this.dataSyncer.terminate();
+ }
+ if (this.eventListener != null) {
+ this.eventListener.handlePluginDisable();
+ }
+
+ // Cancel tasks, close audiences
+ if (audiences != null) {
+ this.audiences.close();
+ }
+ this.cancelTasks();
+
+ // Complete shutdown
+ log(Level.INFO, "Successfully disabled HuskSync v" + getPluginVersion());
+ }
+
+ private void registerCommands() {
+ final List commands = FabricCommand.Type.getCommands(this);
+ CommandRegistrationCallback.EVENT.register((dispatcher, registry, environment) ->
+ commands.forEach(command -> new FabricCommand(command, this).register(dispatcher))
+ );
+ }
+
+ @NotNull
+ @Override
+ public String getServerName() {
+ return serverName.getName();
+ }
+
+ @Override
+ public boolean isDependencyLoaded(@NotNull String name) {
+ return FabricLoader.getInstance().isModLoaded(name);
+ }
+
+ @Override
+ @NotNull
+ public Set getOnlineUsers() {
+ return minecraftServer.getPlayerManager().getPlayerList()
+ .stream().map(user -> (OnlineUser) FabricUser.adapt(user, this))
+ .collect(Collectors.toSet());
+ }
+
+ @Override
+ @NotNull
+ public Optional getOnlineUser(@NotNull UUID uuid) {
+ return Optional.ofNullable(minecraftServer.getPlayerManager().getPlayer(uuid))
+ .map(user -> FabricUser.adapt(user, this));
+ }
+
+ @Override
+ @Nullable
+ public InputStream getResource(@NotNull String name) {
+ return this.mod.findPath(name)
+ .map(path -> {
+ try {
+ return Files.newInputStream(path);
+ } catch (IOException e) {
+ log(Level.WARNING, "Failed to load resource: " + name, e);
+ }
+ return null;
+ })
+ .orElse(this.getClass().getClassLoader().getResourceAsStream(name));
+ }
+
+ @Override
+ @NotNull
+ public Path getConfigDirectory() {
+ final Path path = FabricLoader.getInstance().getConfigDir().resolve("husksync");
+ if (!Files.isDirectory(path)) {
+ try {
+ Files.createDirectory(path);
+ } catch (IOException e) {
+ log(Level.SEVERE, "Failed to create config directory", e);
+ }
+ }
+ return path;
+ }
+
+ @Override
+ public void log(@NotNull Level level, @NotNull String message, @NotNull Throwable... throwable) {
+ LoggingEventBuilder logEvent = logger.makeLoggingEventBuilder(
+ switch (level.getName()) {
+ case "WARNING" -> org.slf4j.event.Level.WARN;
+ case "SEVERE" -> org.slf4j.event.Level.ERROR;
+ default -> org.slf4j.event.Level.INFO;
+ }
+ );
+ if (throwable.length >= 1) {
+ logEvent = logEvent.setCause(throwable[0]);
+ }
+ logEvent.log(message);
+ }
+
+ @NotNull
+ @Override
+ public ConsoleUser getConsole() {
+ return new ConsoleUser(audiences);
+ }
+
+ @Override
+ @NotNull
+ public Version getPluginVersion() {
+ return Version.fromString(mod.getMetadata().getVersion().getFriendlyString(), "-");
+ }
+
+ @Override
+ @NotNull
+ public Version getMinecraftVersion() {
+ return Version.fromString(minecraftServer.getVersion());
+ }
+
+ @NotNull
+ @Override
+ public String getPlatformType() {
+ return PLATFORM_TYPE_ID;
+ }
+
+ @Override
+ public Optional getLegacyConverter() {
+ return Optional.empty();
+ }
+
+ @Override
+ @NotNull
+ public FabricHuskSync getPlugin() {
+ return this;
+ }
+
+}
diff --git a/fabric/src/main/java/net/william278/husksync/api/FabricHuskSyncAPI.java b/fabric/src/main/java/net/william278/husksync/api/FabricHuskSyncAPI.java
new file mode 100644
index 00000000..6b9bd820
--- /dev/null
+++ b/fabric/src/main/java/net/william278/husksync/api/FabricHuskSyncAPI.java
@@ -0,0 +1,260 @@
+/*
+ * This file is part of HuskSync, licensed under the Apache License 2.0.
+ *
+ * Copyright (c) William278
+ * Copyright (c) contributors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package net.william278.husksync.api;
+
+import net.minecraft.item.ItemStack;
+import net.minecraft.server.network.ServerPlayerEntity;
+import net.william278.desertwell.util.ThrowingConsumer;
+import net.william278.husksync.FabricHuskSync;
+import net.william278.husksync.data.DataHolder;
+import net.william278.husksync.data.FabricData;
+import net.william278.husksync.user.FabricUser;
+import net.william278.husksync.user.OnlineUser;
+import net.william278.husksync.user.User;
+import org.jetbrains.annotations.ApiStatus;
+import org.jetbrains.annotations.NotNull;
+
+import java.util.Optional;
+import java.util.concurrent.CompletableFuture;
+import java.util.function.Consumer;
+
+/**
+ * The HuskSync API implementation for the Fabric platform
+ *
+ * Retrieve an instance of the API class via {@link #getInstance()}.
+ */
+@SuppressWarnings("unused")
+public class FabricHuskSyncAPI extends HuskSyncAPI {
+
+ /**
+ * (Internal use only) - Constructor, instantiating the API.
+ */
+ @ApiStatus.Internal
+ private FabricHuskSyncAPI(@NotNull FabricHuskSync plugin) {
+ super(plugin);
+ }
+
+ /**
+ * Entrypoint to the HuskSync API on the Fabric platform - returns an instance of the API
+ *
+ * @return instance of the HuskSync API
+ * @since 3.6
+ */
+ @NotNull
+ public static FabricHuskSyncAPI getInstance() {
+ if (instance == null) {
+ throw new NotRegisteredException();
+ }
+ return (FabricHuskSyncAPI) instance;
+ }
+
+ /**
+ * (Internal use only) - Register the API for this platform.
+ *
+ * @param plugin the plugin instance
+ * @since 3.6
+ */
+ @ApiStatus.Internal
+ public static void register(@NotNull FabricHuskSync plugin) {
+ instance = new FabricHuskSyncAPI(plugin);
+ }
+
+
+ /**
+ * Returns a {@link OnlineUser} instance for the given Fabric {@link ServerPlayerEntity}.
+ *
+ * @param player the Fabric player to get the {@link OnlineUser} instance for
+ * @return the {@link OnlineUser} instance for the given Fabric {@link ServerPlayerEntity}
+ * @since 2.0
+ */
+ @NotNull
+ public FabricUser getUser(@NotNull ServerPlayerEntity player) {
+ return FabricUser.adapt(player, plugin);
+ }
+
+ /**
+ * Get the current {@link FabricData.Items.Inventory} of the given {@link User}
+ *
+ * @param user the user to get the inventory of
+ * @return the {@link FabricData.Items.Inventory} of the given {@link User}
+ * @since 3.6
+ */
+ public CompletableFuture> getCurrentInventory(@NotNull User user) {
+ return getCurrentData(user).thenApply(data -> data.flatMap(DataHolder::getInventory)
+ .map(FabricData.Items.Inventory.class::cast));
+ }
+
+ /**
+ * Get the current {@link FabricData.Items.Inventory} of the given {@link ServerPlayerEntity}
+ *
+ * @param user the user to get the inventory of
+ * @return the {@link FabricData.Items.Inventory} of the given {@link ServerPlayerEntity}
+ * @since 3.6
+ */
+ public CompletableFuture> getCurrentInventoryContents(@NotNull User user) {
+ return getCurrentInventory(user)
+ .thenApply(inventory -> inventory.map(FabricData.Items.Inventory::getContents));
+ }
+
+ /**
+ * Set the current {@link FabricData.Items.Inventory} of the given {@link User}
+ *
+ * @param user the user to set the inventory of
+ * @param contents the contents to set the inventory to
+ * @since 3.6
+ */
+ public void setCurrentInventory(@NotNull User user, @NotNull FabricData.Items.Inventory contents) {
+ editCurrentData(user, dataHolder -> dataHolder.setInventory(contents));
+ }
+
+ /**
+ * Set the current {@link FabricData.Items.Inventory} of the given {@link User}
+ *
+ * @param user the user to set the inventory of
+ * @param contents the contents to set the inventory to
+ * @since 3.6
+ */
+ public void setCurrentInventoryContents(@NotNull User user, @NotNull ItemStack[] contents) {
+ editCurrentData(
+ user,
+ dataHolder -> dataHolder.getInventory().ifPresent(
+ inv -> inv.setContents(adaptItems(contents))
+ )
+ );
+ }
+
+ /**
+ * Edit the current {@link FabricData.Items.Inventory} of the given {@link User}
+ *
+ * @param user the user to edit the inventory of
+ * @param editor the editor to apply to the inventory
+ * @since 3.6
+ */
+ public void editCurrentInventory(@NotNull User user, ThrowingConsumer editor) {
+ editCurrentData(user, dataHolder -> dataHolder.getInventory()
+ .map(FabricData.Items.Inventory.class::cast)
+ .ifPresent(editor));
+ }
+
+ /**
+ * Edit the current {@link FabricData.Items.Inventory} of the given {@link User}
+ *
+ * @param user the user to edit the inventory of
+ * @param editor the editor to apply to the inventory
+ * @since 3.6
+ */
+ public void editCurrentInventoryContents(@NotNull User user, ThrowingConsumer editor) {
+ editCurrentData(user, dataHolder -> dataHolder.getInventory()
+ .map(FabricData.Items.Inventory.class::cast)
+ .ifPresent(inventory -> editor.accept(inventory.getContents())));
+ }
+
+ /**
+ * Get the current {@link FabricData.Items.EnderChest} of the given {@link User}
+ *
+ * @param user the user to get the ender chest of
+ * @return the {@link FabricData.Items.EnderChest} of the given {@link User}, or {@link Optional#empty()} if the
+ * user data could not be found
+ * @since 3.6
+ */
+ public CompletableFuture> getCurrentEnderChest(@NotNull User user) {
+ return getCurrentData(user).thenApply(data -> data.flatMap(DataHolder::getEnderChest)
+ .map(FabricData.Items.EnderChest.class::cast));
+ }
+
+ /**
+ * Get the current {@link FabricData.Items.EnderChest} of the given {@link ServerPlayerEntity}
+ *
+ * @param user the user to get the ender chest of
+ * @return the {@link FabricData.Items.EnderChest} of the given {@link ServerPlayerEntity}, or {@link Optional#empty()} if the
+ * user data could not be found
+ * @since 3.6
+ */
+ public CompletableFuture> getCurrentEnderChestContents(@NotNull User user) {
+ return getCurrentEnderChest(user)
+ .thenApply(enderChest -> enderChest.map(FabricData.Items.EnderChest::getContents));
+ }
+
+ /**
+ * Set the current {@link FabricData.Items.EnderChest} of the given {@link User}
+ *
+ * @param user the user to set the ender chest of
+ * @param contents the contents to set the ender chest to
+ * @since 3.6
+ */
+ public void setCurrentEnderChest(@NotNull User user, @NotNull FabricData.Items.EnderChest contents) {
+ editCurrentData(user, dataHolder -> dataHolder.setEnderChest(contents));
+ }
+
+ /**
+ * Set the current {@link FabricData.Items.EnderChest} of the given {@link User}
+ *
+ * @param user the user to set the ender chest of
+ * @param contents the contents to set the ender chest to
+ * @since 3.6
+ */
+ public void setCurrentEnderChestContents(@NotNull User user, @NotNull ItemStack[] contents) {
+ editCurrentData(
+ user,
+ dataHolder -> dataHolder.getEnderChest().ifPresent(
+ enderChest -> enderChest.setContents(adaptItems(contents))
+ )
+ );
+ }
+
+ /**
+ * Edit the current {@link FabricData.Items.EnderChest} of the given {@link User}
+ *
+ * @param user the user to edit the ender chest of
+ * @param editor the editor to apply to the ender chest
+ * @since 3.6
+ */
+ public void editCurrentEnderChest(@NotNull User user, Consumer editor) {
+ editCurrentData(user, dataHolder -> dataHolder.getEnderChest()
+ .map(FabricData.Items.EnderChest.class::cast)
+ .ifPresent(editor));
+ }
+
+ /**
+ * Edit the current {@link FabricData.Items.EnderChest} of the given {@link User}
+ *
+ * @param user the user to edit the ender chest of
+ * @param editor the editor to apply to the ender chest
+ * @since 3.6
+ */
+ public void editCurrentEnderChestContents(@NotNull User user, Consumer editor) {
+ editCurrentData(user, dataHolder -> dataHolder.getEnderChest()
+ .map(FabricData.Items.EnderChest.class::cast)
+ .ifPresent(enderChest -> editor.accept(enderChest.getContents())));
+ }
+
+ /**
+ * Adapts an array of {@link ItemStack} to a {@link FabricData.Items} instance
+ *
+ * @param contents the contents to adapt
+ * @return the adapted {@link FabricData.Items} instance
+ * @since 3.6
+ */
+ @NotNull
+ public FabricData.Items adaptItems(@NotNull ItemStack[] contents) {
+ return FabricData.Items.ItemArray.adapt(contents);
+ }
+
+}
diff --git a/fabric/src/main/java/net/william278/husksync/command/FabricCommand.java b/fabric/src/main/java/net/william278/husksync/command/FabricCommand.java
new file mode 100644
index 00000000..14b02cf9
--- /dev/null
+++ b/fabric/src/main/java/net/william278/husksync/command/FabricCommand.java
@@ -0,0 +1,153 @@
+/*
+ * This file is part of HuskSync, licensed under the Apache License 2.0.
+ *
+ * Copyright (c) William278
+ * Copyright (c) contributors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package net.william278.husksync.command;
+
+import com.mojang.brigadier.CommandDispatcher;
+import com.mojang.brigadier.builder.LiteralArgumentBuilder;
+import com.mojang.brigadier.tree.LiteralCommandNode;
+import me.lucko.fabric.api.permissions.v0.PermissionCheckEvent;
+import me.lucko.fabric.api.permissions.v0.Permissions;
+import net.fabricmc.fabric.api.util.TriState;
+import net.minecraft.server.command.ServerCommandSource;
+import net.minecraft.server.network.ServerPlayerEntity;
+import net.william278.husksync.FabricHuskSync;
+import net.william278.husksync.HuskSync;
+import net.william278.husksync.user.CommandUser;
+import net.william278.husksync.user.FabricUser;
+import org.jetbrains.annotations.NotNull;
+
+import java.util.Arrays;
+import java.util.List;
+import java.util.Map;
+import java.util.function.Function;
+import java.util.function.Predicate;
+
+import static com.mojang.brigadier.arguments.StringArgumentType.greedyString;
+import static net.minecraft.server.command.CommandManager.argument;
+import static net.minecraft.server.command.CommandManager.literal;
+
+public class FabricCommand {
+
+ private final FabricHuskSync plugin;
+ private final Command command;
+
+ public FabricCommand(@NotNull Command command, @NotNull FabricHuskSync plugin) {
+ this.command = command;
+ this.plugin = plugin;
+ }
+
+ public void register(@NotNull CommandDispatcher dispatcher) {
+ // Register brigadier command
+ final Predicate predicate = Permissions
+ .require(command.getPermission(), command.isOperatorCommand() ? 3 : 0);
+ final LiteralArgumentBuilder builder = literal(command.getName())
+ .requires(predicate).executes(getBrigadierExecutor());
+ plugin.getPermissions().put(command.getPermission(), command.isOperatorCommand());
+ if (!command.getRawUsage().isBlank()) {
+ builder.then(argument(command.getRawUsage().replaceAll("[<>\\[\\]]", ""), greedyString())
+ .executes(getBrigadierExecutor())
+ .suggests(getBrigadierSuggester()));
+ }
+
+ // Register additional permissions
+ final Map permissions = command.getAdditionalPermissions();
+ permissions.forEach((permission, isOp) -> plugin.getPermissions().put(permission, isOp));
+ PermissionCheckEvent.EVENT.register((player, node) -> {
+ if (permissions.containsKey(node) && permissions.get(node) && player.hasPermissionLevel(3)) {
+ return TriState.TRUE;
+ }
+ return TriState.DEFAULT;
+ });
+
+ // Register aliases
+ final LiteralCommandNode node = dispatcher.register(builder);
+ dispatcher.register(literal("husksync:" + command.getName())
+ .requires(predicate).executes(getBrigadierExecutor()).redirect(node));
+ command.getAliases().forEach(alias -> dispatcher.register(literal(alias)
+ .requires(predicate).executes(getBrigadierExecutor()).redirect(node)));
+ }
+
+ private com.mojang.brigadier.Command getBrigadierExecutor() {
+ return (context) -> {
+ command.onExecuted(
+ resolveExecutor(context.getSource()),
+ command.removeFirstArg(context.getInput().split(" "))
+ );
+ return 1;
+ };
+ }
+
+ private com.mojang.brigadier.suggestion.SuggestionProvider getBrigadierSuggester() {
+ if (!(command instanceof TabProvider provider)) {
+ return (context, builder) -> com.mojang.brigadier.suggestion.Suggestions.empty();
+ }
+ return (context, builder) -> {
+ final String[] args = command.removeFirstArg(context.getInput().split(" ", -1));
+ provider.getSuggestions(resolveExecutor(context.getSource()), args).stream()
+ .map(suggestion -> {
+ final String completedArgs = String.join(" ", args);
+ int lastIndex = completedArgs.lastIndexOf(" ");
+ if (lastIndex == -1) {
+ return suggestion;
+ }
+ return completedArgs.substring(0, lastIndex + 1) + suggestion;
+ })
+ .forEach(builder::suggest);
+ return builder.buildFuture();
+ };
+ }
+
+ private CommandUser resolveExecutor(@NotNull ServerCommandSource source) {
+ if (source.getEntity() instanceof ServerPlayerEntity player) {
+ return FabricUser.adapt(player, plugin);
+ }
+ return plugin.getConsole();
+ }
+
+
+ /**
+ * Commands available on the Fabric HuskSync implementation.
+ */
+ public enum Type {
+
+ HUSKSYNC_COMMAND(HuskSyncCommand::new),
+ USERDATA_COMMAND(UserDataCommand::new),
+ INVENTORY_COMMAND(InventoryCommand::new),
+ ENDER_CHEST_COMMAND(EnderChestCommand::new);
+
+ private final Function supplier;
+
+ Type(@NotNull Function supplier) {
+ this.supplier = supplier;
+ }
+
+ @NotNull
+ public Command createCommand(@NotNull HuskSync plugin) {
+ return supplier.apply(plugin);
+ }
+
+ @NotNull
+ public static List getCommands(@NotNull FabricHuskSync plugin) {
+ return Arrays.stream(values()).map(type -> type.createCommand(plugin)).toList();
+ }
+
+ }
+
+}
\ No newline at end of file
diff --git a/fabric/src/main/java/net/william278/husksync/data/FabricData.java b/fabric/src/main/java/net/william278/husksync/data/FabricData.java
new file mode 100644
index 00000000..0f3eb7a6
--- /dev/null
+++ b/fabric/src/main/java/net/william278/husksync/data/FabricData.java
@@ -0,0 +1,808 @@
+/*
+ * This file is part of HuskSync, licensed under the Apache License 2.0.
+ *
+ * Copyright (c) William278
+ * Copyright (c) contributors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package net.william278.husksync.data;
+
+import com.google.common.collect.Lists;
+import com.google.common.collect.Maps;
+import com.google.common.collect.Sets;
+import com.google.gson.annotations.SerializedName;
+import lombok.*;
+import net.fabricmc.fabric.api.dimension.v1.FabricDimensions;
+import net.minecraft.advancement.AdvancementProgress;
+import net.minecraft.advancement.PlayerAdvancementTracker;
+import net.minecraft.enchantment.EnchantmentHelper;
+import net.minecraft.entity.attribute.EntityAttribute;
+import net.minecraft.entity.attribute.EntityAttributeInstance;
+import net.minecraft.entity.attribute.EntityAttributeModifier;
+import net.minecraft.entity.effect.StatusEffect;
+import net.minecraft.entity.effect.StatusEffectInstance;
+import net.minecraft.entity.player.HungerManager;
+import net.minecraft.item.ItemStack;
+import net.minecraft.nbt.NbtCompound;
+import net.minecraft.registry.Registries;
+import net.minecraft.registry.Registry;
+import net.minecraft.server.MinecraftServer;
+import net.minecraft.server.network.ServerPlayerEntity;
+import net.minecraft.stat.StatType;
+import net.minecraft.stat.Stats;
+import net.minecraft.util.Identifier;
+import net.minecraft.util.math.Vec3d;
+import net.minecraft.world.TeleportTarget;
+import net.william278.desertwell.util.ThrowingConsumer;
+import net.william278.husksync.FabricHuskSync;
+import net.william278.husksync.HuskSync;
+import net.william278.husksync.adapter.Adaptable;
+import net.william278.husksync.user.FabricUser;
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+import org.jetbrains.annotations.Range;
+
+import java.util.*;
+
+import static net.william278.husksync.util.FabricKeyedAdapter.*;
+
+public abstract class FabricData implements Data {
+
+ @Override
+ public void apply(@NotNull UserDataHolder user, @NotNull HuskSync plugin) {
+ this.apply((FabricUser) user, (FabricHuskSync) plugin);
+ }
+
+ protected abstract void apply(@NotNull FabricUser user, @NotNull FabricHuskSync plugin);
+
+ @Getter
+ public static abstract class Items extends FabricData implements Data.Items {
+
+ private final @Nullable ItemStack @NotNull [] contents;
+
+ private Items(@Nullable ItemStack @NotNull [] contents) {
+ this.contents = Arrays.stream(contents.clone())
+ .map(i -> i == null || i.isEmpty() ? null : i)
+ .toArray(ItemStack[]::new);
+ }
+
+ @Nullable
+ @Override
+ public Stack @NotNull [] getStack() {
+ return Arrays.stream(contents)
+ .map(stack -> stack != null ? new Stack(
+ stack.getItem().toString(),
+ stack.getCount(),
+ stack.getName().getString(),
+ Optional.ofNullable(stack.getSubNbt(ItemStack.DISPLAY_KEY))
+ .flatMap(display -> Optional.ofNullable(display.get(ItemStack.LORE_KEY))
+ .map(lore -> ((List) lore).stream().toList())) //todo check this is ok
+ .orElse(null),
+ stack.getEnchantments().stream()
+ .map(element -> EnchantmentHelper.getIdFromNbt((NbtCompound) element))
+ .filter(Objects::nonNull).map(Identifier::toString)
+ .toList()
+ ) : null)
+ .toArray(Stack[]::new);
+ }
+
+ @Override
+ public void clear() {
+ Arrays.fill(contents, null);
+ }
+
+ @Override
+ public void setContents(@NotNull Data.Items contents) {
+ this.setContents(((FabricData.Items) contents).getContents());
+ }
+
+ public void setContents(@Nullable ItemStack @NotNull [] contents) {
+ // Ensure the array is the correct length for the inventory
+ if (contents.length != this.contents.length) {
+ contents = Arrays.copyOf(contents, this.contents.length);
+ }
+ System.arraycopy(contents, 0, this.contents, 0, this.contents.length);
+ }
+
+ @Override
+ public boolean equals(Object obj) {
+ if (obj instanceof FabricData.Items items) {
+ return Arrays.equals(contents, items.getContents());
+ }
+ return false;
+ }
+
+ @Setter
+ @Getter
+ public static class Inventory extends FabricData.Items implements Data.Items.Inventory {
+
+ @Range(from = 0, to = 8)
+ private int heldItemSlot;
+
+ public Inventory(@Nullable ItemStack @NotNull [] contents, int heldItemSlot) {
+ super(contents);
+ this.heldItemSlot = heldItemSlot;
+ }
+
+ @NotNull
+ public static FabricData.Items.Inventory from(@Nullable ItemStack @NotNull [] contents, int heldItemSlot) {
+ return new FabricData.Items.Inventory(contents, heldItemSlot);
+ }
+
+ @NotNull
+ public static FabricData.Items.Inventory from(@NotNull Collection contents, int heldItemSlot) {
+ return from(contents.toArray(ItemStack[]::new), heldItemSlot);
+ }
+
+ @NotNull
+ public static FabricData.Items.Inventory empty() {
+ return new FabricData.Items.Inventory(new ItemStack[INVENTORY_SLOT_COUNT], 0);
+ }
+
+ @Override
+ public int getSlotCount() {
+ return INVENTORY_SLOT_COUNT;
+ }
+
+ @Override
+ public void apply(@NotNull FabricUser user, @NotNull FabricHuskSync plugin) throws IllegalStateException {
+ final ServerPlayerEntity player = user.getPlayer();
+ this.clearInventoryCraftingSlots(player);
+ player.currentScreenHandler.setCursorStack(ItemStack.EMPTY);
+ final ItemStack[] items = getContents();
+ for (int slot = 0; slot < player.getInventory().size(); slot++) {
+ player.getInventory().setStack(
+ slot, items[slot] == null ? ItemStack.EMPTY : items[slot]
+ );
+ }
+ player.getInventory().selectedSlot = heldItemSlot;
+ player.playerScreenHandler.sendContentUpdates();
+ player.getInventory().updateItems();
+ }
+
+ private void clearInventoryCraftingSlots(@NotNull ServerPlayerEntity player) {
+ player.playerScreenHandler.clearCraftingSlots();
+ }
+
+ }
+
+ public static class EnderChest extends FabricData.Items implements Data.Items.EnderChest {
+
+ private EnderChest(@Nullable ItemStack @NotNull [] contents) {
+ super(contents);
+ }
+
+ @NotNull
+ public static FabricData.Items.EnderChest adapt(@Nullable ItemStack @NotNull [] contents) {
+ return new FabricData.Items.EnderChest(contents);
+ }
+
+ @NotNull
+ public static FabricData.Items.EnderChest adapt(@NotNull Collection items) {
+ return adapt(items.toArray(ItemStack[]::new));
+ }
+
+ @NotNull
+ public static FabricData.Items.EnderChest empty() {
+ return new FabricData.Items.EnderChest(new ItemStack[ENDER_CHEST_SLOT_COUNT]);
+ }
+
+ @Override
+ public void apply(@NotNull FabricUser user, @NotNull FabricHuskSync plugin) throws IllegalStateException {
+ final ItemStack[] items = getContents();
+ for (int slot = 0; slot < user.getPlayer().getEnderChestInventory().size(); slot++) {
+ user.getPlayer().getEnderChestInventory().setStack(
+ slot, items[slot] == null ? ItemStack.EMPTY : items[slot]
+ );
+ }
+ }
+
+ }
+
+ public static class ItemArray extends FabricData.Items implements Data.Items {
+
+ private ItemArray(@Nullable ItemStack @NotNull [] contents) {
+ super(contents);
+ }
+
+ @NotNull
+ public static ItemArray adapt(@NotNull Collection drops) {
+ return new ItemArray(drops.toArray(ItemStack[]::new));
+ }
+
+ @NotNull
+ public static ItemArray adapt(@Nullable ItemStack @NotNull [] drops) {
+ return new ItemArray(drops);
+ }
+
+ @Override
+ public void apply(@NotNull FabricUser user, @NotNull FabricHuskSync plugin) throws IllegalStateException {
+ throw new UnsupportedOperationException("A generic item array cannot be applied to a player");
+ }
+
+ }
+
+ }
+
+ @Getter
+ @AllArgsConstructor(access = AccessLevel.PRIVATE)
+ public static class PotionEffects extends FabricData implements Data.PotionEffects {
+
+ private final Collection effects;
+
+ @NotNull
+ public static FabricData.PotionEffects from(@NotNull Collection effects) {
+ return new FabricData.PotionEffects(effects);
+ }
+
+ @NotNull
+ public static FabricData.PotionEffects adapt(@NotNull Collection effects) {
+ return from(effects.stream()
+ .map(effect -> {
+ final StatusEffect type = matchEffectType(effect.type());
+ return type != null ? new StatusEffectInstance(
+ type,
+ effect.duration(),
+ effect.amplifier(),
+ effect.isAmbient(),
+ effect.showParticles(),
+ effect.hasIcon()
+ ) : null;
+ })
+ .filter(Objects::nonNull)
+ .toList()
+ );
+ }
+
+ @NotNull
+ @SuppressWarnings("unused")
+ public static FabricData.PotionEffects empty() {
+ return new FabricData.PotionEffects(List.of());
+ }
+
+ @Override
+ public void apply(@NotNull FabricUser user, @NotNull FabricHuskSync plugin) throws IllegalStateException {
+ final ServerPlayerEntity player = user.getPlayer();
+ player.getActiveStatusEffects().forEach((effect, instance) -> player.removeStatusEffect(effect));
+ getEffects().forEach(player::addStatusEffect);
+ }
+
+ @NotNull
+ @Override
+ public List getActiveEffects() {
+ return effects.stream()
+ .map(potionEffect -> {
+ final String key = getEffectId(potionEffect.getEffectType());
+ return key != null ? new Effect(
+ key,
+ potionEffect.getAmplifier(),
+ potionEffect.getDuration(),
+ potionEffect.isAmbient(),
+ potionEffect.shouldShowParticles(),
+ potionEffect.shouldShowIcon()
+ ) : null;
+ })
+ .filter(Objects::nonNull)
+ .toList();
+ }
+
+ }
+
+ @Getter
+ @Setter
+ @AllArgsConstructor(access = AccessLevel.PRIVATE)
+ @NoArgsConstructor(access = AccessLevel.PRIVATE)
+ public static class Advancements extends FabricData implements Data.Advancements {
+
+ private List completed;
+
+ @NotNull
+ public static FabricData.Advancements adapt(@NotNull ServerPlayerEntity player) {
+ final MinecraftServer server = Objects.requireNonNull(player.getServer(), "Server is null");
+ final List advancements = Lists.newArrayList();
+ forEachAdvancement(server, advancement -> {
+ final AdvancementProgress advancementProgress = player.getAdvancementTracker().getProgress(advancement);
+ final Map awardedCriteria = Maps.newHashMap();
+
+ advancementProgress.getObtainedCriteria().forEach((criteria) -> awardedCriteria.put(criteria,
+ advancementProgress.getEarliestProgressObtainDate()));
+
+ // Only save the advancement if criteria has been completed
+ if (!awardedCriteria.isEmpty()) {
+ advancements.add(Advancement.adapt(advancement.getId().asString(), awardedCriteria));
+ }
+ });
+ return new FabricData.Advancements(advancements);
+ }
+
+ @NotNull
+ public static FabricData.Advancements from(@NotNull List advancements) {
+ return new FabricData.Advancements(advancements);
+ }
+
+ @Override
+ public void apply(@NotNull FabricUser user, @NotNull FabricHuskSync plugin) throws IllegalStateException {
+ final ServerPlayerEntity player = user.getPlayer();
+ final MinecraftServer server = Objects.requireNonNull(player.getServer(), "Server is null");
+ plugin.runAsync(() -> forEachAdvancement(server, advancement -> {
+ final AdvancementProgress progress = player.getAdvancementTracker().getProgress(advancement);
+ final Optional record = completed.stream()
+ .filter(r -> r.getKey().equals(advancement.getId().toString()))
+ .findFirst();
+ if (record.isEmpty()) {
+ return;
+ }
+
+ final Map criteria = record.get().getCompletedCriteria();
+ final List awarded = Lists.newArrayList(progress.getObtainedCriteria());
+ this.setAdvancement(
+ plugin, advancement, player, user,
+ criteria.keySet().stream().filter(key -> !awarded.contains(key)).toList(),
+ awarded.stream().filter(key -> !criteria.containsKey(key)).toList()
+ );
+ }));
+ }
+
+ private void setAdvancement(@NotNull FabricHuskSync plugin,
+ @NotNull net.minecraft.advancement.Advancement advancement,
+ @NotNull ServerPlayerEntity player,
+ @NotNull FabricUser user,
+ @NotNull List toAward,
+ @NotNull List toRevoke) {
+ plugin.runSync(() -> {
+ // Track player exp level & progress
+ final int expLevel = player.experienceLevel;
+ final float expProgress = player.experienceProgress;
+
+ // Award and revoke advancement criteria
+ final PlayerAdvancementTracker progress = player.getAdvancementTracker();
+ toAward.forEach(a -> progress.grantCriterion(advancement, a));
+ toRevoke.forEach(r -> progress.revokeCriterion(advancement, r));
+
+ // Restore player exp level & progress
+ if (!toAward.isEmpty()
+ && (player.experienceLevel != expLevel || player.experienceProgress != expProgress)) {
+ player.setExperienceLevel(expLevel);
+ player.setExperiencePoints((int) (player.getNextLevelExperience() * expProgress));
+ }
+ });
+ }
+
+ // Performs a consuming function for every advancement registered on the server
+ private static void forEachAdvancement(@NotNull MinecraftServer server,
+ @NotNull ThrowingConsumer con) {
+ server.getAdvancementLoader().getAdvancements().forEach(con);
+ }
+
+ }
+
+ @Getter
+ @Setter
+ @NoArgsConstructor(access = AccessLevel.PRIVATE)
+ @AllArgsConstructor(access = AccessLevel.PRIVATE)
+ public static class Location extends FabricData implements Data.Location, Adaptable {
+ @SerializedName("x")
+ private double x;
+ @SerializedName("y")
+ private double y;
+ @SerializedName("z")
+ private double z;
+ @SerializedName("yaw")
+ private float yaw;
+ @SerializedName("pitch")
+ private float pitch;
+ @SerializedName("world")
+ private World world;
+
+ @NotNull
+ public static FabricData.Location from(double x, double y, double z,
+ float yaw, float pitch, @NotNull World world) {
+ return new FabricData.Location(x, y, z, yaw, pitch, world);
+ }
+
+ @NotNull
+ public static FabricData.Location adapt(@NotNull ServerPlayerEntity player) {
+ return from(
+ player.getX(),
+ player.getY(),
+ player.getZ(),
+ player.getYaw(),
+ player.getPitch(),
+ new World(
+ Objects.requireNonNull(
+ player.getWorld(), "World is null"
+ ).getRegistryKey().getValue().toString(),
+ UUID.nameUUIDFromBytes(
+ player.getWorld().getDimensionKey().getValue().toString().getBytes()
+ ),
+ player.getWorld().getDimensionKey().getValue().toString()
+ )
+ );
+ }
+
+ @Override
+ public void apply(@NotNull FabricUser user, @NotNull FabricHuskSync plugin) throws IllegalStateException {
+ final ServerPlayerEntity player = user.getPlayer();
+ final MinecraftServer server = plugin.getMinecraftServer();
+ try {
+ player.dismountVehicle();
+ FabricDimensions.teleport(
+ player,
+ server.getWorld(server.getWorldRegistryKeys().stream()
+ .filter(key -> key.getValue().equals(Identifier.tryParse(world.name())))
+ .findFirst().orElseThrow(
+ () -> new IllegalStateException("Invalid world")
+ )),
+ new TeleportTarget(
+ new Vec3d(x, y, z),
+ Vec3d.ZERO,
+ yaw,
+ pitch
+ )
+ );
+ } catch (Throwable e) {
+ throw new IllegalStateException("Failed to apply location", e);
+ }
+ }
+
+ }
+
+ @Getter
+ @AllArgsConstructor(access = AccessLevel.PRIVATE)
+ @NoArgsConstructor(access = AccessLevel.PRIVATE)
+ public static class Statistics extends FabricData implements Data.Statistics, Adaptable {
+
+ private static final String BLOCK_STAT_TYPE = "block";
+ private static final String ITEM_STAT_TYPE = "item";
+ private static final String ENTITY_STAT_TYPE = "entity_type";
+
+ @SerializedName("generic")
+ private Map genericStatistics;
+ @SerializedName("blocks")
+ private Map> blockStatistics;
+ @SerializedName("items")
+ private Map> itemStatistics;
+ @SerializedName("entities")
+ private Map> entityStatistics;
+
+ @NotNull
+ public static FabricData.Statistics adapt(@NotNull ServerPlayerEntity player) throws IllegalStateException {
+ // Adapt typed stats
+ final Map> blocks = Maps.newHashMap(),
+ items = Maps.newHashMap(), entities = Maps.newHashMap();
+ Registries.STAT_TYPE.getEntrySet().forEach(stat -> {
+ final Registry> registry = stat.getValue().getRegistry();
+
+ final String registryId = registry.getKey().getValue().value();
+ if (registryId.equals("custom_stat")) {
+ return;
+ }
+ final Map map = (switch (registryId) {
+ case BLOCK_STAT_TYPE -> blocks;
+ case ITEM_STAT_TYPE -> items;
+ case ENTITY_STAT_TYPE -> entities;
+ default -> throw new IllegalStateException("Unexpected value: %s".formatted(registryId));
+ }).compute(stat.getKey().getValue().asString(), (k, v) -> v == null ? Maps.newHashMap() : v);
+
+ registry.getEntrySet().forEach(entry -> {
+ @SuppressWarnings({"unchecked", "rawtypes"}) final int value = player.getStatHandler()
+ .getStat((StatType) stat.getValue(), entry.getValue());
+ if (value != 0) {
+ map.put(entry.getKey().getValue().asString(), value);
+ }
+ });
+ });
+
+ // Add generic stats
+ final Map generic = Maps.newHashMap();
+ Registries.CUSTOM_STAT.getEntrySet().forEach(stat -> {
+ final int value = player.getStatHandler().getStat(Stats.CUSTOM.getOrCreateStat(stat.getValue()));
+ if (value != 0) {
+ generic.put(stat.getKey().getValue().asString(), value);
+ }
+ });
+
+ return new FabricData.Statistics(generic, blocks, items, entities);
+ }
+
+ @NotNull
+ public static FabricData.Statistics from(@NotNull Map generic,
+ @NotNull Map> blocks,
+ @NotNull Map> items,
+ @NotNull Map> entities) {
+ return new FabricData.Statistics(generic, blocks, items, entities);
+ }
+
+ @Override
+ public void apply(@NotNull FabricUser user, @NotNull FabricHuskSync plugin) {
+ final ServerPlayerEntity player = user.getPlayer();
+ genericStatistics.forEach((id, v) -> applyStat(player, id, null, v));
+ blockStatistics.forEach((id, m) -> m.forEach((b, v) -> applyStat(player, id, BLOCK_STAT_TYPE, v, b)));
+ itemStatistics.forEach((id, m) -> m.forEach((i, v) -> applyStat(player, id, ITEM_STAT_TYPE, v, i)));
+ entityStatistics.forEach((id, m) -> m.forEach((e, v) -> applyStat(player, id, ENTITY_STAT_TYPE, v, e)));
+ player.getStatHandler().updateStatSet();
+ player.getStatHandler().sendStats(player);
+ }
+
+ @SuppressWarnings("unchecked")
+ private void applyStat(@NotNull ServerPlayerEntity player, @NotNull String id,
+ @Nullable String type, int value, @NotNull String... key) {
+ final Identifier statId = Identifier.tryParse(id);
+ if (statId == null) {
+ return;
+ }
+ if (type == null) {
+ player.getStatHandler().setStat(
+ player,
+ Stats.CUSTOM.getOrCreateStat(Registries.CUSTOM_STAT.get(statId)),
+ value
+ );
+ return;
+ }
+ final Identifier typeId = Identifier.tryParse(type);
+ final StatType statType = (StatType) Registries.STAT_TYPE.get(typeId);
+ if (statType == null) {
+ return;
+ }
+
+ final Registry typeReg = statType.getRegistry();
+ final T typeInstance = typeReg.get(Identifier.tryParse(key[0]));
+ if (typeInstance == null) {
+ return;
+ }
+
+ player.getStatHandler().setStat(player, statType.getOrCreateStat(typeInstance), value);
+ }
+
+ }
+
+ @Getter
+ @AllArgsConstructor(access = AccessLevel.PRIVATE)
+ @NoArgsConstructor(access = AccessLevel.PRIVATE)
+ public static class Attributes extends FabricData implements Data.Attributes, Adaptable {
+
+ private List attributes;
+
+ @NotNull
+ public static FabricData.Attributes adapt(@NotNull ServerPlayerEntity player, @NotNull HuskSync plugin) {
+ final List attributes = Lists.newArrayList();
+ Registries.ATTRIBUTE.forEach(id -> {
+ final EntityAttributeInstance instance = player.getAttributeInstance(id);
+ final Identifier key = Registries.ATTRIBUTE.getId(id);
+ if (instance == null || key == null) {
+ return;
+ }
+ final Set modifiers = Sets.newHashSet();
+ instance.getModifiers().forEach(modifier -> modifiers.add(new Modifier(
+ modifier.getId(),
+ modifier.getName(),
+ modifier.getValue(),
+ modifier.getOperation().getId(),
+ -1
+ )));
+ attributes.add(new Attribute(
+ key.asString(),
+ instance.getBaseValue(),
+ modifiers
+ ));
+ });
+ return new FabricData.Attributes(attributes);
+ }
+
+ public Optional getAttribute(@NotNull EntityAttribute id) {
+ return Optional.ofNullable(Registries.ATTRIBUTE.getId(id)).map(Identifier::asString)
+ .flatMap(key -> attributes.stream().filter(attribute -> attribute.name().equals(key)).findFirst());
+ }
+
+ @SuppressWarnings("unused")
+ public Optional getAttribute(@NotNull String key) {
+ final EntityAttribute attribute = matchAttribute(key);
+ if (attribute == null) {
+ return Optional.empty();
+ }
+ return getAttribute(attribute);
+ }
+
+ @Override
+ protected void apply(@NotNull FabricUser user, @NotNull FabricHuskSync plugin) {
+ Registries.ATTRIBUTE.forEach(id -> applyAttribute(
+ user.getPlayer().getAttributeInstance(id),
+ getAttribute(id).orElse(null)
+ ));
+
+ }
+
+ private static void applyAttribute(@Nullable EntityAttributeInstance instance,
+ @Nullable Attribute attribute) {
+ if (instance == null) {
+ return;
+ }
+ instance.setBaseValue(attribute == null ? instance.getAttribute().getDefaultValue() : attribute.baseValue());
+ instance.getModifiers().forEach(instance::removeModifier);
+ if (attribute != null) {
+ attribute.modifiers().forEach(modifier -> instance.addPersistentModifier(new EntityAttributeModifier(
+ modifier.uuid(),
+ modifier.name(),
+ modifier.amount(),
+ EntityAttributeModifier.Operation.fromId(modifier.operationType())
+ )));
+ }
+ }
+
+ }
+
+ @Getter
+ @Setter
+ @AllArgsConstructor(access = AccessLevel.PRIVATE)
+ @NoArgsConstructor(access = AccessLevel.PRIVATE)
+ public static class Health extends FabricData implements Data.Health, Adaptable {
+ @SerializedName("health")
+ private double health;
+ @SerializedName("health_scale")
+ private double healthScale;
+ @SerializedName("is_health_scaled")
+ private boolean isHealthScaled;
+
+
+ @NotNull
+ public static FabricData.Health from(double health, double scale, boolean isScaled) {
+ return new FabricData.Health(health, scale, isScaled);
+ }
+
+ @NotNull
+ public static FabricData.Health adapt(@NotNull ServerPlayerEntity player) {
+ return from(
+ player.getHealth(),
+ 20.0f, false // Health scale is a Bukkit API feature, not used in Fabric
+ );
+ }
+
+ @Override
+ public void apply(@NotNull FabricUser user, @NotNull FabricHuskSync plugin) throws IllegalStateException {
+ final ServerPlayerEntity player = user.getPlayer();
+ player.setHealth((float) health);
+ }
+
+ }
+
+
+ @Getter
+ @Setter
+ @AllArgsConstructor(access = AccessLevel.PRIVATE)
+ @NoArgsConstructor(access = AccessLevel.PRIVATE)
+ public static class Hunger extends FabricData implements Data.Hunger, Adaptable {
+
+ @SerializedName("food_level")
+ private int foodLevel;
+ @SerializedName("saturation")
+ private float saturation;
+ @SerializedName("exhaustion")
+ private float exhaustion;
+
+ @NotNull
+ public static FabricData.Hunger adapt(@NotNull ServerPlayerEntity player) {
+ final HungerManager hunger = player.getHungerManager();
+ return from(hunger.getFoodLevel(), hunger.getSaturationLevel(), hunger.getExhaustion());
+ }
+
+ @NotNull
+ public static FabricData.Hunger from(int foodLevel, float saturation, float exhaustion) {
+ return new FabricData.Hunger(foodLevel, saturation, exhaustion);
+ }
+
+ @Override
+ public void apply(@NotNull FabricUser user, @NotNull FabricHuskSync plugin) throws IllegalStateException {
+ final ServerPlayerEntity player = user.getPlayer();
+ final HungerManager hunger = player.getHungerManager();
+ hunger.setFoodLevel(foodLevel);
+ hunger.setSaturationLevel(saturation);
+ hunger.setExhaustion(exhaustion);
+ }
+
+ }
+
+ @Getter
+ @Setter
+ @AllArgsConstructor(access = AccessLevel.PRIVATE)
+ @NoArgsConstructor(access = AccessLevel.PRIVATE)
+ public static class Experience extends FabricData implements Data.Experience, Adaptable {
+
+ @SerializedName("total_experience")
+ private int totalExperience;
+
+ @SerializedName("exp_level")
+ private int expLevel;
+
+ @SerializedName("exp_progress")
+ private float expProgress;
+
+ @NotNull
+ public static FabricData.Experience from(int totalExperience, int expLevel, float expProgress) {
+ return new FabricData.Experience(totalExperience, expLevel, expProgress);
+ }
+
+ @NotNull
+ public static FabricData.Experience adapt(@NotNull ServerPlayerEntity player) {
+ return from(player.totalExperience, player.experienceLevel, player.experienceProgress);
+ }
+
+ @Override
+ public void apply(@NotNull FabricUser user, @NotNull FabricHuskSync plugin) throws IllegalStateException {
+ final ServerPlayerEntity player = user.getPlayer();
+ player.totalExperience = totalExperience;
+ player.setExperienceLevel(expLevel);
+ player.setExperiencePoints((int) (player.getNextLevelExperience() * expProgress));
+ }
+
+ }
+
+ @Getter
+ @Setter
+ @AllArgsConstructor(access = AccessLevel.PRIVATE)
+ @NoArgsConstructor(access = AccessLevel.PRIVATE)
+ public static class GameMode extends FabricData implements Data.GameMode, Adaptable {
+
+ @SerializedName("game_mode")
+ private String gameMode;
+
+ @NotNull
+ public static FabricData.GameMode from(@NotNull String gameMode) {
+ return new FabricData.GameMode(gameMode);
+ }
+
+ @NotNull
+ public static FabricData.GameMode adapt(@NotNull ServerPlayerEntity player) {
+ return from(player.interactionManager.getGameMode().asString());
+ }
+
+ @Override
+ public void apply(@NotNull FabricUser user, @NotNull FabricHuskSync plugin) throws IllegalStateException {
+ user.getPlayer().interactionManager.changeGameMode(net.minecraft.world.GameMode.byName(gameMode));
+ }
+
+ }
+
+ @Getter
+ @Setter
+ @AllArgsConstructor(access = AccessLevel.PRIVATE)
+ @NoArgsConstructor(access = AccessLevel.PRIVATE)
+ public static class FlightStatus extends FabricData implements Data.FlightStatus, Adaptable {
+
+ @SerializedName("allow_flight")
+ private boolean allowFlight;
+ @SerializedName("is_flying")
+ private boolean flying;
+
+ @NotNull
+ public static FabricData.FlightStatus from(boolean allowFlight, boolean flying) {
+ return new FabricData.FlightStatus(allowFlight, allowFlight && flying);
+ }
+
+ @NotNull
+ public static FabricData.FlightStatus adapt(@NotNull ServerPlayerEntity player) {
+ return from(player.getAbilities().allowFlying, player.getAbilities().flying);
+ }
+
+ @Override
+ public void apply(@NotNull FabricUser user, @NotNull FabricHuskSync plugin) throws IllegalStateException {
+ final ServerPlayerEntity player = user.getPlayer();
+ player.getAbilities().allowFlying = allowFlight;
+ player.getAbilities().flying = allowFlight && flying;
+ player.sendAbilitiesUpdate();
+ }
+
+ }
+
+}
diff --git a/fabric/src/main/java/net/william278/husksync/data/FabricSerializer.java b/fabric/src/main/java/net/william278/husksync/data/FabricSerializer.java
new file mode 100644
index 00000000..0a02295f
--- /dev/null
+++ b/fabric/src/main/java/net/william278/husksync/data/FabricSerializer.java
@@ -0,0 +1,288 @@
+/*
+ * This file is part of HuskSync, licensed under the Apache License 2.0.
+ *
+ * Copyright (c) William278
+ * Copyright (c) contributors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package net.william278.husksync.data;
+
+import com.google.gson.reflect.TypeToken;
+import com.mojang.serialization.Dynamic;
+import com.mojang.serialization.DynamicOps;
+import lombok.AccessLevel;
+import lombok.AllArgsConstructor;
+import net.minecraft.datafixer.TypeReferences;
+import net.minecraft.item.ItemStack;
+import net.minecraft.nbt.*;
+import net.william278.desertwell.util.Version;
+import net.william278.husksync.FabricHuskSync;
+import net.william278.husksync.HuskSync;
+import net.william278.husksync.api.HuskSyncAPI;
+import org.jetbrains.annotations.ApiStatus;
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+
+import java.util.Arrays;
+import java.util.List;
+
+import static net.william278.husksync.data.Data.Items.Inventory.*;
+
+@AllArgsConstructor(access = AccessLevel.PRIVATE)
+public abstract class FabricSerializer {
+
+ @ApiStatus.Internal
+ protected final HuskSync plugin;
+
+ @SuppressWarnings("unused")
+ public FabricSerializer(@NotNull HuskSyncAPI api) {
+ this.plugin = api.getPlugin();
+ }
+
+ @ApiStatus.Internal
+ @NotNull
+ public HuskSync getPlugin() {
+ return plugin;
+ }
+
+ public static class Inventory extends FabricSerializer implements Serializer,
+ ItemDeserializer {
+
+ public Inventory(@NotNull HuskSync plugin) {
+ super(plugin);
+ }
+
+ @Override
+ public FabricData.Items.Inventory deserialize(@NotNull String serialized, @NotNull Version dataMcVersion)
+ throws DeserializationException {
+ // Read item NBT from string
+ final FabricHuskSync plugin = (FabricHuskSync) getPlugin();
+ final NbtCompound root;
+ try {
+ root = StringNbtReader.parse(serialized);
+ } catch (Throwable e) {
+ throw new DeserializationException("Failed to read item NBT from string (%s)".formatted(serialized), e);
+ }
+
+ // Deserialize the inventory data
+ final NbtCompound items = root.contains(ITEMS_TAG) ? root.getCompound(ITEMS_TAG) : null;
+ return FabricData.Items.Inventory.from(
+ items != null ? getItems(items, dataMcVersion, plugin) : new ItemStack[INVENTORY_SLOT_COUNT],
+ root.contains(HELD_ITEM_SLOT_TAG) ? root.getInt(HELD_ITEM_SLOT_TAG) : 0
+ );
+ }
+
+ @Override
+ public FabricData.Items.Inventory deserialize(@NotNull String serialized) {
+ return deserialize(serialized, plugin.getMinecraftVersion());
+ }
+
+ @NotNull
+ @Override
+ public String serialize(@NotNull FabricData.Items.Inventory data) throws SerializationException {
+ try {
+ final NbtCompound root = new NbtCompound();
+ root.put(ITEMS_TAG, serializeItemArray(data.getContents()));
+ root.putInt(HELD_ITEM_SLOT_TAG, data.getHeldItemSlot());
+ return root.toString();
+ } catch (Throwable e) {
+ throw new SerializationException("Failed to serialize inventory item NBT to string", e);
+ }
+ }
+
+ }
+
+ public static class EnderChest extends FabricSerializer implements Serializer,
+ ItemDeserializer {
+
+ public EnderChest(@NotNull HuskSync plugin) {
+ super(plugin);
+ }
+
+ @Override
+ public FabricData.Items.EnderChest deserialize(@NotNull String serialized, @NotNull Version dataMcVersion)
+ throws DeserializationException {
+ final FabricHuskSync plugin = (FabricHuskSync) getPlugin();
+ try {
+ final NbtCompound items = StringNbtReader.parse(serialized);
+ return FabricData.Items.EnderChest.adapt(getItems(items, dataMcVersion, plugin));
+ } catch (Throwable e) {
+ throw new DeserializationException("Failed to read item NBT from string (%s)".formatted(serialized), e);
+ }
+ }
+
+ @Override
+ public FabricData.Items.EnderChest deserialize(@NotNull String serialized) {
+ return deserialize(serialized, plugin.getMinecraftVersion());
+ }
+
+ @NotNull
+ @Override
+ public String serialize(@NotNull FabricData.Items.EnderChest data) throws SerializationException {
+ try {
+ return serializeItemArray(data.getContents()).toString();
+ } catch (Throwable e) {
+ throw new SerializationException("Failed to serialize ender chest item NBT to string", e);
+ }
+ }
+ }
+
+ private interface ItemDeserializer {
+
+ int VERSION1_16_5 = 2586;
+ int VERSION1_17_1 = 2730;
+ int VERSION1_18_2 = 2975;
+ int VERSION1_19_2 = 3120;
+ int VERSION1_19_4 = 3337;
+ int VERSION1_20_1 = 3465;
+ int VERSION1_20_2 = 3578; // Future
+ int VERSION1_20_4 = 3700; // Future
+ int VERSION1_20_5 = 3837; // Future
+
+ @NotNull
+ default ItemStack[] getItems(@NotNull NbtCompound tag, @NotNull Version mcVersion, @NotNull FabricHuskSync plugin) {
+ try {
+ if (mcVersion.compareTo(plugin.getMinecraftVersion()) < 0) {
+ return upgradeItemStacks(tag, mcVersion, plugin);
+ }
+
+ final int size = tag.getInt("size");
+ final NbtList items = tag.getList("items", NbtElement.COMPOUND_TYPE);
+ final ItemStack[] itemStacks = new ItemStack[size];
+ for (int i = 0; i < size; i++) {
+ final NbtCompound compound = items.getCompound(i);
+ final int slot = compound.getInt("Slot");
+ itemStacks[slot] = ItemStack.fromNbt(compound);
+ }
+ return itemStacks;
+ } catch (Throwable e) {
+ throw new Serializer.DeserializationException("Failed to read item NBT string (%s)".formatted(tag), e);
+ }
+ }
+
+ // Serialize items slot-by-slot
+ @NotNull
+ default NbtCompound serializeItemArray(@Nullable ItemStack @NotNull [] items) {
+ final NbtCompound container = new NbtCompound();
+ container.putInt("size", items.length);
+ final NbtList itemList = new NbtList();
+ for (int i = 0; i < items.length; i++) {
+ final ItemStack item = items[i];
+ if (item == null || item.isEmpty()) {
+ continue;
+ }
+ NbtCompound entry = new NbtCompound();
+ entry.putInt("Slot", i);
+ item.writeNbt(entry);
+ itemList.add(entry);
+ }
+ container.put(ITEMS_TAG, itemList);
+ return container;
+ }
+
+ @NotNull
+ private ItemStack @NotNull [] upgradeItemStacks(@NotNull NbtCompound items, @NotNull Version mcVersion,
+ @NotNull FabricHuskSync plugin) {
+ final int size = items.getInt("size");
+ final NbtList list = items.getList("items", NbtElement.COMPOUND_TYPE);
+ final ItemStack[] itemStacks = new ItemStack[size];
+ Arrays.fill(itemStacks, ItemStack.EMPTY);
+ for (int i = 0; i < size; i++) {
+ if (list.getCompound(i) == null) {
+ continue;
+ }
+ final NbtCompound compound = list.getCompound(i);
+ final int slot = compound.getInt("Slot");
+ itemStacks[slot] = ItemStack.fromNbt(upgradeItemData(list.getCompound(i), mcVersion, plugin));
+ }
+ return itemStacks;
+ }
+
+
+ @NotNull
+ @SuppressWarnings({"rawtypes", "unchecked"}) // For NBTOps lookup
+ private NbtCompound upgradeItemData(@NotNull NbtCompound tag, @NotNull Version mcVersion,
+ @NotNull FabricHuskSync plugin) {
+ return (NbtCompound) plugin.getMinecraftServer().getDataFixer().update(
+ TypeReferences.ITEM_STACK, new Dynamic