From 89368778f36925c9cf877da33ee270853728faf3 Mon Sep 17 00:00:00 2001 From: William Date: Sun, 9 Jun 2024 22:41:37 +0100 Subject: [PATCH] feat: add support for Fabric targeting Minecraft 1.20.1 (#217) * Upgrade the Fabric version and rewrite the code. * Migrate the completed code of version 1.19.2. * fabric: some events. * Updated open source license to Apache 2.0. * Add Plan analyzer support. * Fix build. * `UnsupportedOperationException` * More fabric implementation work, update to v3's structure * Suppress compiler warnings * Add commands, adjust registration order * Inventory and ender chest data/serializers * Update license headers * Fixup shaded library relocations * Fix build * Potion effects & location serializers * Catch `Files.createDirectory(path);` in `#getDataFolder` * Update fabric.mod.json metadata, correct icon * Events for Fabric (#218) * Added apache commons pool2 dependency A NoClassDefFoundError would get thrown without this dependency. Relocation appears to not work very well either, so it has been excluded for now * Added in Item Pickup and Drop events and mixins * Update husksync.mixins.json * Switch drop item event to using Network Handler mixin * Implemented even more events - Interact block (place too) - Interact Entity - Use Item - Block Break - Player damage - Inventory Click (handles drops) - Player Commands * Re-implement the dropItem mixin * Set dropItem mixin as cancellable * deps: Include all bukkit runtime deps * fix/fabric: Supply AudienceProvider to `ConsoleUser` constructor * docs: credit Fabric porters :) * fix: Item deserialization now working * refactor: Remove inventory debug log * docs: Update `fabric.mod.json` * refactor: update with upstream changes * fix: dangling JD comment * fix: config file reference fixes * refactor: optimize imports, fix relocation * refactor: move tag references to common * refactor: use lombok for data / serializer methods * fix: bad annotating * refactor: adjust callback formatting * fabric: bump deps, refactor to match main branch * fabric: more serializer type work * feat: register more fabric data serializers also fixes a compile issue on bukkit, and refactors the JSON serializer to be in the common module * feat: implement remaining Fabric serializers * feat: add on-the-fly DFU for Fabric Now auto-upgrades item data to support version bumps. Also improved the schema a lil' bit. * feat: add missing mixins * feat: implement toKeep/toDrop option on Fabric * feat: apply stats on sync * build: append fabric MC version to file name * feat: add HuskSync API support for Fabric Also updates the docs * refactor: fixup a deprecation in the wrong spot * refactor: optimize fabric item serializing in-line with Bukkit * feat: implement viewer GUIs on Fabric * docs: Fabric is in Alpha for now --------- Co-authored-by: hanbings Co-authored-by: Stampede --- README.md | 10 +- build.gradle | 21 +- .../william278/husksync/BukkitHuskSync.java | 16 +- .../william278/husksync/data/BukkitData.java | 32 +- .../husksync/data/BukkitSerializer.java | 39 +- .../husksync/data/BukkitUserDataHolder.java | 39 +- .../william278/husksync/user/BukkitUser.java | 20 +- common/build.gradle | 1 + .../net/william278/husksync/HuskSync.java | 22 +- .../william278/husksync/command/Command.java | 10 + .../husksync/command/HuskSyncCommand.java | 4 +- .../husksync/command/InventoryCommand.java | 1 + .../net/william278/husksync/data/Data.java | 3 +- .../william278/husksync/data/DataHolder.java | 4 +- .../william278/husksync/data/Serializer.java | 24 + .../husksync/event/Cancellable.java | 1 - .../husksync/event/PreSyncEvent.java | 2 +- .../husksync/sync/LockstepDataSyncer.java | 17 +- .../william278/husksync/util/DataDumper.java | 29 +- docs/API-Events.md | 11 +- docs/API.md | 1 + docs/FAQs.md | 14 +- docs/Home.md | 2 +- docs/Setup.md | 22 +- docs/_Sidebar.md | 3 +- fabric/build.gradle | 71 ++ .../william278/husksync/FabricHuskSync.java | 341 ++++++++ .../husksync/api/FabricHuskSyncAPI.java | 260 ++++++ .../husksync/command/FabricCommand.java | 153 ++++ .../william278/husksync/data/FabricData.java | 808 ++++++++++++++++++ .../husksync/data/FabricSerializer.java | 288 +++++++ .../husksync/data/FabricUserDataHolder.java | 186 ++++ .../event/FabricDataSaveCallback.java | 90 ++ .../husksync/event/FabricEventCallback.java | 30 + .../husksync/event/FabricEventDispatcher.java | 70 ++ .../husksync/event/FabricPreSyncCallback.java | 90 ++ .../event/FabricSyncCompleteCallback.java | 61 ++ .../event/InventoryClickCallback.java | 48 ++ .../husksync/event/ItemDropCallback.java | 48 ++ .../husksync/event/ItemPickupCallback.java | 48 ++ .../husksync/event/PlayerCommandCallback.java | 47 + .../event/PlayerDeathDropsCallback.java | 44 + .../husksync/event/WorldSaveCallback.java | 39 + .../listener/FabricEventListener.java | 153 ++++ .../husksync/mixins/ItemEntityMixin.java | 41 + .../husksync/mixins/PlayerEntityMixin.java | 81 ++ .../mixins/ServerPlayNetworkHandlerMixin.java | 107 +++ .../mixins/ServerPlayerEntityMixin.java | 46 + .../husksync/mixins/ServerWorldMixin.java | 37 + .../william278/husksync/user/FabricUser.java | 175 ++++ .../husksync/util/FabricKeyedAdapter.java | 76 ++ .../william278/husksync/util/FabricTask.java | 137 +++ .../main/resources/assets/husksync/icon.png | Bin 0 -> 69847 bytes fabric/src/main/resources/fabric.mod.json | 55 ++ .../src/main/resources/husksync.mixins.json | 17 + gradle.properties | 10 +- gradle/wrapper/gradle-wrapper.jar | Bin 61608 -> 62076 bytes gradle/wrapper/gradle-wrapper.properties | 2 +- gradlew | 7 +- settings.gradle | 4 +- 60 files changed, 3876 insertions(+), 142 deletions(-) create mode 100644 fabric/build.gradle create mode 100644 fabric/src/main/java/net/william278/husksync/FabricHuskSync.java create mode 100644 fabric/src/main/java/net/william278/husksync/api/FabricHuskSyncAPI.java create mode 100644 fabric/src/main/java/net/william278/husksync/command/FabricCommand.java create mode 100644 fabric/src/main/java/net/william278/husksync/data/FabricData.java create mode 100644 fabric/src/main/java/net/william278/husksync/data/FabricSerializer.java create mode 100644 fabric/src/main/java/net/william278/husksync/data/FabricUserDataHolder.java create mode 100644 fabric/src/main/java/net/william278/husksync/event/FabricDataSaveCallback.java create mode 100644 fabric/src/main/java/net/william278/husksync/event/FabricEventCallback.java create mode 100644 fabric/src/main/java/net/william278/husksync/event/FabricEventDispatcher.java create mode 100644 fabric/src/main/java/net/william278/husksync/event/FabricPreSyncCallback.java create mode 100644 fabric/src/main/java/net/william278/husksync/event/FabricSyncCompleteCallback.java create mode 100644 fabric/src/main/java/net/william278/husksync/event/InventoryClickCallback.java create mode 100644 fabric/src/main/java/net/william278/husksync/event/ItemDropCallback.java create mode 100644 fabric/src/main/java/net/william278/husksync/event/ItemPickupCallback.java create mode 100644 fabric/src/main/java/net/william278/husksync/event/PlayerCommandCallback.java create mode 100644 fabric/src/main/java/net/william278/husksync/event/PlayerDeathDropsCallback.java create mode 100644 fabric/src/main/java/net/william278/husksync/event/WorldSaveCallback.java create mode 100644 fabric/src/main/java/net/william278/husksync/listener/FabricEventListener.java create mode 100644 fabric/src/main/java/net/william278/husksync/mixins/ItemEntityMixin.java create mode 100644 fabric/src/main/java/net/william278/husksync/mixins/PlayerEntityMixin.java create mode 100644 fabric/src/main/java/net/william278/husksync/mixins/ServerPlayNetworkHandlerMixin.java create mode 100644 fabric/src/main/java/net/william278/husksync/mixins/ServerPlayerEntityMixin.java create mode 100644 fabric/src/main/java/net/william278/husksync/mixins/ServerWorldMixin.java create mode 100644 fabric/src/main/java/net/william278/husksync/user/FabricUser.java create mode 100644 fabric/src/main/java/net/william278/husksync/util/FabricKeyedAdapter.java create mode 100644 fabric/src/main/java/net/william278/husksync/util/FabricTask.java create mode 100644 fabric/src/main/resources/assets/husksync/icon.png create mode 100644 fabric/src/main/resources/fabric.mod.json create mode 100644 fabric/src/main/resources/husksync.mixins.json 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 getData(@NotNull Identifier identifier) { - return Optional.ofNullable(getData().get(identifier)); + default Optional 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((DynamicOps) NbtOps.INSTANCE, tag), + getDataVersion(mcVersion), getDataVersion(plugin.getMinecraftVersion()) + ).getValue(); + } + + private int getDataVersion(@NotNull Version mcVersion) { + return switch (mcVersion.toStringWithoutMetadata()) { + case "1.16", "1.16.1", "1.16.2", "1.16.3", "1.16.4", "1.16.5" -> VERSION1_16_5; + case "1.17", "1.17.1" -> VERSION1_17_1; + case "1.18", "1.18.1", "1.18.2" -> VERSION1_18_2; + case "1.19", "1.19.1", "1.19.2" -> VERSION1_19_2; + case "1.19.4" -> VERSION1_19_4; + case "1.20", "1.20.1" -> VERSION1_20_1; + case "1.20.2" -> VERSION1_20_2; // Future + case "1.20.4" -> VERSION1_20_4; // Future + case "1.20.5", "1.20.6" -> VERSION1_20_5; // Future + default -> VERSION1_20_1; // Current supported ver + }; + } + + } + + public static class PotionEffects extends FabricSerializer implements Serializer { + + private static final TypeToken> TYPE = new TypeToken<>() { + }; + + public PotionEffects(@NotNull HuskSync plugin) { + super(plugin); + } + + @Override + public FabricData.PotionEffects deserialize(@NotNull String serialized) throws DeserializationException { + return FabricData.PotionEffects.adapt( + plugin.getGson().fromJson(serialized, TYPE.getType()) + ); + } + + @NotNull + @Override + public String serialize(@NotNull FabricData.PotionEffects element) throws SerializationException { + return plugin.getGson().toJson(element.getActiveEffects()); + } + + } + + public static class Advancements extends FabricSerializer implements Serializer { + + private static final TypeToken> TYPE = new TypeToken<>() { + }; + + public Advancements(@NotNull HuskSync plugin) { + super(plugin); + } + + @Override + public FabricData.Advancements deserialize(@NotNull String serialized) throws DeserializationException { + return FabricData.Advancements.from( + plugin.getGson().fromJson(serialized, TYPE.getType()) + ); + } + + @NotNull + @Override + public String serialize(@NotNull FabricData.Advancements element) throws SerializationException { + return plugin.getGson().toJson(element.getCompleted()); + } + } + +} diff --git a/fabric/src/main/java/net/william278/husksync/data/FabricUserDataHolder.java b/fabric/src/main/java/net/william278/husksync/data/FabricUserDataHolder.java new file mode 100644 index 00000000..298fa4e8 --- /dev/null +++ b/fabric/src/main/java/net/william278/husksync/data/FabricUserDataHolder.java @@ -0,0 +1,186 @@ +/* + * 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 net.minecraft.entity.player.PlayerInventory; +import net.minecraft.item.ItemStack; +import net.minecraft.server.network.ServerPlayerEntity; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.Map; +import java.util.Optional; + +import static net.william278.husksync.config.Settings.SynchronizationSettings.SaveOnDeathSettings; + +public interface FabricUserDataHolder extends UserDataHolder { + + @Override + default Optional getData(@NotNull Identifier id) { + if (!id.isCustom()) { + try { + return switch (id.getKeyValue()) { + case "inventory" -> getInventory(); + case "ender_chest" -> getEnderChest(); + case "potion_effects" -> getPotionEffects(); + case "advancements" -> getAdvancements(); + case "location" -> getLocation(); + case "statistics" -> getStatistics(); + case "health" -> getHealth(); + case "hunger" -> getHunger(); + case "attributes" -> getAttributes(); + case "experience" -> getExperience(); + case "game_mode" -> getGameMode(); + case "flight_status" -> getFlightStatus(); + case "persistent_data" -> getPersistentData(); + default -> throw new IllegalStateException(String.format("Unexpected data type: %s", id)); + }; + } catch (Throwable e) { + getPlugin().debug("Failed to get data for key: " + id.getKeyValue(), e); + } + } + return Optional.ofNullable(getCustomDataStore().get(id)); + } + + @Override + default void setData(@NotNull Identifier id, @NotNull Data data) { + if (id.isCustom()) { + getCustomDataStore().put(id, data); + } + UserDataHolder.super.setData(id, data); + } + + @NotNull + @Override + default Optional getInventory() { + final SaveOnDeathSettings death = getPlugin().getSettings().getSynchronization().getSaveOnDeath(); + if ((isDead() && !death.isSyncDeadPlayersChangingServer())) { + return Optional.of(FabricData.Items.Inventory.empty()); + } + final PlayerInventory inventory = getPlayer().getInventory(); + return Optional.of(FabricData.Items.Inventory.from( + getCombinedInventory(inventory), + inventory.selectedSlot + )); + } + + // Gets the player's combined inventory; their inventory, plus offhand and armor. + @Nullable + private ItemStack @NotNull [] getCombinedInventory(@NotNull PlayerInventory inv) { + final ItemStack[] combined = new ItemStack[inv.main.size() + inv.armor.size() + inv.offHand.size()]; + System.arraycopy( + inv.main.toArray(new ItemStack[0]), 0, combined, + 0, inv.main.size() + ); + System.arraycopy( + inv.armor.toArray(new ItemStack[0]), 0, combined, + inv.main.size(), inv.armor.size() + ); + System.arraycopy( + inv.offHand.toArray(new ItemStack[0]), 0, combined, + inv.main.size() + inv.armor.size(), inv.offHand.size() + ); + return combined; + } + + @NotNull + @Override + default Optional getEnderChest() { + return Optional.of(FabricData.Items.EnderChest.adapt( + getPlayer().getEnderChestInventory().stacks + )); + } + + @NotNull + @Override + default Optional getPotionEffects() { + return Optional.of(FabricData.PotionEffects.from(getPlayer().getActiveStatusEffects().values())); + } + + @NotNull + @Override + default Optional getAdvancements() { + return Optional.of(FabricData.Advancements.adapt(getPlayer())); + } + + @NotNull + @Override + default Optional getLocation() { + return Optional.of(FabricData.Location.adapt(getPlayer())); + } + + @NotNull + @Override + default Optional getStatistics() { + return Optional.of(FabricData.Statistics.adapt(getPlayer())); + } + + @Override + @NotNull + default Optional getAttributes() { + return Optional.of(FabricData.Attributes.adapt(getPlayer(), getPlugin())); + } + + @NotNull + @Override + default Optional getHealth() { + return Optional.of(FabricData.Health.adapt(getPlayer())); + } + + @NotNull + @Override + default Optional getHunger() { + return Optional.of(FabricData.Hunger.adapt(getPlayer())); + } + + @NotNull + @Override + default Optional getExperience() { + return Optional.of(FabricData.Experience.adapt(getPlayer())); + } + + @NotNull + @Override + default Optional getGameMode() { + return Optional.of(FabricData.GameMode.adapt(getPlayer())); + } + + @NotNull + @Override + default Optional getFlightStatus() { + return Optional.of(FabricData.FlightStatus.adapt(getPlayer())); + } + + @NotNull + @Override + default Optional getPersistentData() { + return Optional.empty(); // Not implemented on Fabric, but maybe we'll do data keys or something + } + + boolean isDead(); + + @NotNull + ServerPlayerEntity getPlayer(); + + @NotNull + @Override + Map getCustomDataStore(); + +} diff --git a/fabric/src/main/java/net/william278/husksync/event/FabricDataSaveCallback.java b/fabric/src/main/java/net/william278/husksync/event/FabricDataSaveCallback.java new file mode 100644 index 00000000..1f709f37 --- /dev/null +++ b/fabric/src/main/java/net/william278/husksync/event/FabricDataSaveCallback.java @@ -0,0 +1,90 @@ +/* + * 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.event; + +import net.fabricmc.fabric.api.event.Event; +import net.fabricmc.fabric.api.event.EventFactory; +import net.minecraft.util.ActionResult; +import net.william278.husksync.HuskSync; +import net.william278.husksync.data.DataSnapshot; +import net.william278.husksync.user.User; +import org.apache.commons.lang3.function.TriFunction; +import org.jetbrains.annotations.NotNull; + +public interface FabricDataSaveCallback extends FabricEventCallback { + + @NotNull + Event EVENT = EventFactory.createArrayBacked(FabricDataSaveCallback.class, + (listeners) -> (event) -> { + for (FabricDataSaveCallback listener : listeners) { + final ActionResult result = listener.invoke(event); + if (event.isCancelled()) { + return ActionResult.CONSUME; + } else if (result != ActionResult.PASS) { + event.setCancelled(true); + return result; + } + } + + return ActionResult.PASS; + }); + + @NotNull + TriFunction SUPPLIER = (user, data, plugin) -> + + new DataSaveEvent() { + private boolean cancelled = false; + + @Override + public boolean isCancelled() { + return cancelled; + } + + @Override + public void setCancelled(boolean cancelled) { + this.cancelled = cancelled; + } + + @NotNull + @Override + public DataSnapshot.Packed getData() { + return data; + } + + @NotNull + @Override + public HuskSync getPlugin() { + return plugin; + } + + @NotNull + @Override + public User getUser() { + return user; + } + + @NotNull + @SuppressWarnings("unused") + public Event getEvent() { + return EVENT; + } + }; + +} diff --git a/fabric/src/main/java/net/william278/husksync/event/FabricEventCallback.java b/fabric/src/main/java/net/william278/husksync/event/FabricEventCallback.java new file mode 100644 index 00000000..406ae21b --- /dev/null +++ b/fabric/src/main/java/net/william278/husksync/event/FabricEventCallback.java @@ -0,0 +1,30 @@ +/* + * 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.event; + +import net.minecraft.util.ActionResult; +import org.jetbrains.annotations.NotNull; + +public interface FabricEventCallback { + + @NotNull + ActionResult invoke(@NotNull E event); + +} diff --git a/fabric/src/main/java/net/william278/husksync/event/FabricEventDispatcher.java b/fabric/src/main/java/net/william278/husksync/event/FabricEventDispatcher.java new file mode 100644 index 00000000..a6f028c5 --- /dev/null +++ b/fabric/src/main/java/net/william278/husksync/event/FabricEventDispatcher.java @@ -0,0 +1,70 @@ +/* + * 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.event; + +import net.minecraft.util.ActionResult; +import net.william278.husksync.data.DataSnapshot; +import net.william278.husksync.user.OnlineUser; +import net.william278.husksync.user.User; +import org.jetbrains.annotations.NotNull; + +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.util.logging.Level; + +public interface FabricEventDispatcher extends EventDispatcher { + + @SuppressWarnings("unchecked") + @Override + default boolean fireIsCancelled(@NotNull T event) { + try { + final Method field = event.getClass().getDeclaredMethod("getEvent"); + field.setAccessible(true); + + net.fabricmc.fabric.api.event.Event fabricEvent = + (net.fabricmc.fabric.api.event.Event) field.invoke(event); + + final FabricEventCallback invoker = (FabricEventCallback) fabricEvent.invoker(); + return invoker.invoke(event) == ActionResult.FAIL; + } catch (InvocationTargetException | NoSuchMethodException | IllegalAccessException e) { + getPlugin().log(Level.WARNING, "Failed to fire event (" + event.getClass().getName() + ")", e); + return false; + } + } + + @NotNull + @Override + default PreSyncEvent getPreSyncEvent(@NotNull OnlineUser user, @NotNull DataSnapshot.Packed userData) { + return FabricPreSyncCallback.SUPPLIER.apply(user, userData, getPlugin()); + } + + @NotNull + @Override + default DataSaveEvent getDataSaveEvent(@NotNull User user, @NotNull DataSnapshot.Packed saveCause) { + return FabricDataSaveCallback.SUPPLIER.apply(user, saveCause, getPlugin()); + } + + @NotNull + @Override + default SyncCompleteEvent getSyncCompleteEvent(@NotNull OnlineUser user) { + return FabricSyncCompleteCallback.SUPPLIER.apply(user, getPlugin()); + } + +} diff --git a/fabric/src/main/java/net/william278/husksync/event/FabricPreSyncCallback.java b/fabric/src/main/java/net/william278/husksync/event/FabricPreSyncCallback.java new file mode 100644 index 00000000..1b17f868 --- /dev/null +++ b/fabric/src/main/java/net/william278/husksync/event/FabricPreSyncCallback.java @@ -0,0 +1,90 @@ +/* + * 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.event; + +import net.fabricmc.fabric.api.event.Event; +import net.fabricmc.fabric.api.event.EventFactory; +import net.minecraft.util.ActionResult; +import net.william278.husksync.HuskSync; +import net.william278.husksync.data.DataSnapshot; +import net.william278.husksync.user.OnlineUser; +import org.apache.commons.lang3.function.TriFunction; +import org.jetbrains.annotations.NotNull; + +public interface FabricPreSyncCallback extends FabricEventCallback { + + @NotNull + Event EVENT = EventFactory.createArrayBacked(FabricPreSyncCallback.class, + (listeners) -> (event) -> { + for (FabricPreSyncCallback listener : listeners) { + final ActionResult result = listener.invoke(event); + if (event.isCancelled()) { + return ActionResult.CONSUME; + } else if (result != ActionResult.PASS) { + event.setCancelled(true); + return result; + } + } + + return ActionResult.PASS; + }); + + @NotNull + TriFunction SUPPLIER = (user, data, plugin) -> + + new PreSyncEvent() { + private boolean cancelled = false; + + @Override + public boolean isCancelled() { + return cancelled; + } + + @Override + public void setCancelled(boolean cancelled) { + this.cancelled = cancelled; + } + + @NotNull + @Override + public DataSnapshot.Packed getData() { + return data; + } + + @NotNull + @Override + public HuskSync getPlugin() { + return plugin; + } + + @NotNull + @Override + public OnlineUser getUser() { + return user; + } + + @NotNull + @SuppressWarnings("unused") + public Event getEvent() { + return EVENT; + } + }; + +} diff --git a/fabric/src/main/java/net/william278/husksync/event/FabricSyncCompleteCallback.java b/fabric/src/main/java/net/william278/husksync/event/FabricSyncCompleteCallback.java new file mode 100644 index 00000000..53d78557 --- /dev/null +++ b/fabric/src/main/java/net/william278/husksync/event/FabricSyncCompleteCallback.java @@ -0,0 +1,61 @@ +/* + * 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.event; + +import net.fabricmc.fabric.api.event.Event; +import net.fabricmc.fabric.api.event.EventFactory; +import net.minecraft.util.ActionResult; +import net.william278.husksync.HuskSync; +import net.william278.husksync.user.OnlineUser; +import org.jetbrains.annotations.NotNull; + +import java.util.function.BiFunction; + +public interface FabricSyncCompleteCallback extends FabricEventCallback { + + @NotNull + Event EVENT = EventFactory.createArrayBacked(FabricSyncCompleteCallback.class, + (listeners) -> (event) -> { + for (FabricSyncCompleteCallback listener : listeners) { + listener.invoke(event); + } + + return ActionResult.PASS; + }); + + @NotNull + BiFunction SUPPLIER = (user, plugin) -> + + new SyncCompleteEvent() { + + @NotNull + @Override + public OnlineUser getUser() { + return user; + } + + @NotNull + @SuppressWarnings("unused") + public Event getEvent() { + return EVENT; + } + }; + +} diff --git a/fabric/src/main/java/net/william278/husksync/event/InventoryClickCallback.java b/fabric/src/main/java/net/william278/husksync/event/InventoryClickCallback.java new file mode 100644 index 00000000..c027ae2b --- /dev/null +++ b/fabric/src/main/java/net/william278/husksync/event/InventoryClickCallback.java @@ -0,0 +1,48 @@ +/* + * 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.event; + +import net.fabricmc.fabric.api.event.Event; +import net.fabricmc.fabric.api.event.EventFactory; +import net.minecraft.entity.player.PlayerEntity; +import net.minecraft.item.ItemStack; +import net.minecraft.util.ActionResult; +import org.jetbrains.annotations.NotNull; + +public interface InventoryClickCallback { + + @NotNull + Event EVENT = EventFactory.createArrayBacked(InventoryClickCallback.class, + (listeners) -> (player, itemStack) -> { + for (InventoryClickCallback listener : listeners) { + ActionResult result = listener.interact(player, itemStack); + + if (result != ActionResult.PASS) { + return result; + } + } + + return ActionResult.PASS; + }); + + @NotNull + ActionResult interact(PlayerEntity player, ItemStack sheep); + +} diff --git a/fabric/src/main/java/net/william278/husksync/event/ItemDropCallback.java b/fabric/src/main/java/net/william278/husksync/event/ItemDropCallback.java new file mode 100644 index 00000000..edc60e74 --- /dev/null +++ b/fabric/src/main/java/net/william278/husksync/event/ItemDropCallback.java @@ -0,0 +1,48 @@ +/* + * 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.event; + +import net.fabricmc.fabric.api.event.Event; +import net.fabricmc.fabric.api.event.EventFactory; +import net.minecraft.entity.player.PlayerEntity; +import net.minecraft.item.ItemStack; +import net.minecraft.util.ActionResult; +import org.jetbrains.annotations.NotNull; + +public interface ItemDropCallback { + + @NotNull + Event EVENT = EventFactory.createArrayBacked(ItemDropCallback.class, + (listeners) -> (player, itemStack) -> { + for (ItemDropCallback listener : listeners) { + ActionResult result = listener.interact(player, itemStack); + + if (result != ActionResult.PASS) { + return result; + } + } + + return ActionResult.PASS; + }); + + @NotNull + ActionResult interact(PlayerEntity player, ItemStack sheep); + +} diff --git a/fabric/src/main/java/net/william278/husksync/event/ItemPickupCallback.java b/fabric/src/main/java/net/william278/husksync/event/ItemPickupCallback.java new file mode 100644 index 00000000..f788bcf7 --- /dev/null +++ b/fabric/src/main/java/net/william278/husksync/event/ItemPickupCallback.java @@ -0,0 +1,48 @@ +/* + * 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.event; + +import net.fabricmc.fabric.api.event.Event; +import net.fabricmc.fabric.api.event.EventFactory; +import net.minecraft.entity.player.PlayerEntity; +import net.minecraft.item.ItemStack; +import net.minecraft.util.ActionResult; +import org.jetbrains.annotations.NotNull; + +public interface ItemPickupCallback { + + @NotNull + Event EVENT = EventFactory.createArrayBacked(ItemPickupCallback.class, + (listeners) -> (player, itemStack) -> { + for (ItemPickupCallback listener : listeners) { + ActionResult result = listener.interact(player, itemStack); + + if (result != ActionResult.PASS) { + return result; + } + } + + return ActionResult.PASS; + }); + + @NotNull + ActionResult interact(PlayerEntity player, ItemStack sheep); + +} diff --git a/fabric/src/main/java/net/william278/husksync/event/PlayerCommandCallback.java b/fabric/src/main/java/net/william278/husksync/event/PlayerCommandCallback.java new file mode 100644 index 00000000..b1ac5122 --- /dev/null +++ b/fabric/src/main/java/net/william278/husksync/event/PlayerCommandCallback.java @@ -0,0 +1,47 @@ +/* + * 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.event; + +import net.fabricmc.fabric.api.event.Event; +import net.fabricmc.fabric.api.event.EventFactory; +import net.minecraft.entity.player.PlayerEntity; +import net.minecraft.util.ActionResult; +import org.jetbrains.annotations.NotNull; + +public interface PlayerCommandCallback { + + @NotNull + Event EVENT = EventFactory.createArrayBacked(PlayerCommandCallback.class, + (listeners) -> (player, command) -> { + for (PlayerCommandCallback listener : listeners) { + ActionResult result = listener.interact(player, command); + + if (result != ActionResult.PASS) { + return result; + } + } + + return ActionResult.PASS; + }); + + @NotNull + ActionResult interact(PlayerEntity player, String command); + +} diff --git a/fabric/src/main/java/net/william278/husksync/event/PlayerDeathDropsCallback.java b/fabric/src/main/java/net/william278/husksync/event/PlayerDeathDropsCallback.java new file mode 100644 index 00000000..12463ec6 --- /dev/null +++ b/fabric/src/main/java/net/william278/husksync/event/PlayerDeathDropsCallback.java @@ -0,0 +1,44 @@ +/* + * 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.event; + +import net.fabricmc.fabric.api.event.Event; +import net.fabricmc.fabric.api.event.EventFactory; +import net.minecraft.item.ItemStack; +import net.minecraft.server.network.ServerPlayerEntity; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.Arrays; + +public interface PlayerDeathDropsCallback { + + @NotNull + Event EVENT = EventFactory.createArrayBacked( + PlayerDeathDropsCallback.class, + (listeners) -> (player, itemsToKeep, itemsToDrop) -> Arrays.stream(listeners) + .forEach(listener -> listener.drops(player, itemsToKeep, itemsToDrop)) + ); + + void drops(@NotNull ServerPlayerEntity player, + @Nullable ItemStack @NotNull [] itemsToKeep, + @Nullable ItemStack @NotNull [] itemsToDrop); + +} diff --git a/fabric/src/main/java/net/william278/husksync/event/WorldSaveCallback.java b/fabric/src/main/java/net/william278/husksync/event/WorldSaveCallback.java new file mode 100644 index 00000000..21cdb629 --- /dev/null +++ b/fabric/src/main/java/net/william278/husksync/event/WorldSaveCallback.java @@ -0,0 +1,39 @@ +/* + * 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.event; + +import net.fabricmc.fabric.api.event.Event; +import net.fabricmc.fabric.api.event.EventFactory; +import net.minecraft.server.world.ServerWorld; +import org.jetbrains.annotations.NotNull; + +import java.util.Arrays; + +public interface WorldSaveCallback { + + @NotNull + Event EVENT = EventFactory.createArrayBacked( + WorldSaveCallback.class, + (listeners) -> (world) -> Arrays.stream(listeners).forEach(listener -> listener.save(world)) + ); + + void save(@NotNull ServerWorld world); + +} diff --git a/fabric/src/main/java/net/william278/husksync/listener/FabricEventListener.java b/fabric/src/main/java/net/william278/husksync/listener/FabricEventListener.java new file mode 100644 index 00000000..2533b5dc --- /dev/null +++ b/fabric/src/main/java/net/william278/husksync/listener/FabricEventListener.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.listener; + +import net.fabricmc.fabric.api.entity.event.v1.ServerLivingEntityEvents; +import net.fabricmc.fabric.api.event.player.PlayerBlockBreakEvents; +import net.fabricmc.fabric.api.event.player.UseBlockCallback; +import net.fabricmc.fabric.api.event.player.UseEntityCallback; +import net.fabricmc.fabric.api.event.player.UseItemCallback; +import net.fabricmc.fabric.api.networking.v1.PacketSender; +import net.fabricmc.fabric.api.networking.v1.ServerPlayConnectionEvents; +import net.minecraft.block.BlockState; +import net.minecraft.block.entity.BlockEntity; +import net.minecraft.entity.Entity; +import net.minecraft.entity.LivingEntity; +import net.minecraft.entity.damage.DamageSource; +import net.minecraft.entity.player.PlayerEntity; +import net.minecraft.item.ItemStack; +import net.minecraft.server.MinecraftServer; +import net.minecraft.server.network.ServerPlayNetworkHandler; +import net.minecraft.server.network.ServerPlayerEntity; +import net.minecraft.server.world.ServerWorld; +import net.minecraft.util.ActionResult; +import net.minecraft.util.Hand; +import net.minecraft.util.TypedActionResult; +import net.minecraft.util.hit.BlockHitResult; +import net.minecraft.util.hit.EntityHitResult; +import net.minecraft.util.math.BlockPos; +import net.minecraft.world.World; +import net.william278.husksync.HuskSync; +import net.william278.husksync.config.Settings.SynchronizationSettings.SaveOnDeathSettings; +import net.william278.husksync.data.FabricData; +import net.william278.husksync.event.*; +import net.william278.husksync.user.FabricUser; +import net.william278.husksync.user.OnlineUser; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.stream.Collectors; + +public class FabricEventListener extends EventListener implements LockedHandler { + + public FabricEventListener(@NotNull HuskSync plugin) { + super(plugin); + this.registerEvents(); + } + + public void registerEvents() { + ServerPlayConnectionEvents.JOIN.register(this::handlePlayerJoin); + ServerPlayConnectionEvents.DISCONNECT.register(this::handlePlayerQuit); + WorldSaveCallback.EVENT.register(this::handleWorldSave); + PlayerDeathDropsCallback.EVENT.register(this::handlePlayerDeathDrops); + + // TODO: Events of extra things to cancel if the player has not been set yet + ItemPickupCallback.EVENT.register(this::handleItemPickup); + ItemDropCallback.EVENT.register(this::handleItemDrop); + UseBlockCallback.EVENT.register(this::handleBlockInteract); + UseEntityCallback.EVENT.register(this::handleEntityInteract); + UseItemCallback.EVENT.register(this::handleItemInteract); + PlayerBlockBreakEvents.BEFORE.register(this::handleBlockBreak); + ServerLivingEntityEvents.ALLOW_DAMAGE.register(this::handleEntityDamage); + InventoryClickCallback.EVENT.register(this::handleInventoryClick); + PlayerCommandCallback.EVENT.register(this::handlePlayerCommand); + } + + private void handlePlayerJoin(@NotNull ServerPlayNetworkHandler handler, @NotNull PacketSender sender, + @NotNull MinecraftServer server) { + handlePlayerJoin(FabricUser.adapt(handler.player, plugin)); + } + + private void handlePlayerQuit(@NotNull ServerPlayNetworkHandler handler, @NotNull MinecraftServer server) { + handlePlayerQuit(FabricUser.adapt(handler.player, plugin)); + } + + private void handleWorldSave(@NotNull ServerWorld world) { + saveOnWorldSave(world.getPlayers().stream() + .map(player -> (OnlineUser) FabricUser.adapt(player, plugin)).collect(Collectors.toList())); + } + + private void handlePlayerDeathDrops(@NotNull ServerPlayerEntity player, @Nullable ItemStack @NotNull [] toKeep, + @Nullable ItemStack @NotNull [] toDrop) { + final SaveOnDeathSettings settings = plugin.getSettings().getSynchronization().getSaveOnDeath(); + saveOnPlayerDeath( + FabricUser.adapt(player, plugin), + FabricData.Items.ItemArray.adapt( + settings.getItemsToSave() == SaveOnDeathSettings.DeathItemsMode.DROPS ? toDrop : toKeep + ) + ); + } + + private ActionResult handleItemPickup(PlayerEntity player, ItemStack itemStack) { + return (cancelPlayerEvent(player.getUuid())) ? ActionResult.FAIL : ActionResult.PASS; + } + + private ActionResult handleItemDrop(PlayerEntity player, ItemStack itemStack) { + return (cancelPlayerEvent(player.getUuid())) ? ActionResult.FAIL : ActionResult.PASS; + } + + private ActionResult handleBlockInteract(PlayerEntity player, World world, Hand hand, BlockHitResult blockHitResult) { + return (cancelPlayerEvent(player.getUuid())) ? ActionResult.FAIL : ActionResult.PASS; + } + + private ActionResult handleEntityInteract(PlayerEntity player, World world, Hand hand, Entity entity, EntityHitResult entityHitResult) { + return (cancelPlayerEvent(player.getUuid())) ? ActionResult.FAIL : ActionResult.PASS; + } + + private TypedActionResult handleItemInteract(PlayerEntity player, World world, Hand hand) { + ItemStack stackInHand = player.getStackInHand(hand); + return (cancelPlayerEvent(player.getUuid())) ? TypedActionResult.fail(stackInHand) : TypedActionResult.pass(stackInHand); + } + + private boolean handleBlockBreak(World world, PlayerEntity player, BlockPos blockPos, BlockState blockState, BlockEntity blockEntity) { + return !cancelPlayerEvent(player.getUuid()); + } + + private boolean handleEntityDamage(LivingEntity livingEntity, DamageSource damageSource, float v) { + if (livingEntity instanceof ServerPlayerEntity player) { + return !cancelPlayerEvent(player.getUuid()); + } + return true; + } + + private ActionResult handleInventoryClick(PlayerEntity player, ItemStack itemStack) { + return (cancelPlayerEvent(player.getUuid())) ? ActionResult.FAIL : ActionResult.PASS; + } + + private ActionResult handlePlayerCommand(PlayerEntity player, String s) { + return (cancelPlayerEvent(player.getUuid())) ? ActionResult.FAIL : ActionResult.PASS; + } + + @Override + @NotNull + public HuskSync getPlugin() { + return plugin; + } +} diff --git a/fabric/src/main/java/net/william278/husksync/mixins/ItemEntityMixin.java b/fabric/src/main/java/net/william278/husksync/mixins/ItemEntityMixin.java new file mode 100644 index 00000000..7a7ebfd0 --- /dev/null +++ b/fabric/src/main/java/net/william278/husksync/mixins/ItemEntityMixin.java @@ -0,0 +1,41 @@ +/* + * 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.mixins; + +import net.minecraft.entity.ItemEntity; +import net.minecraft.entity.player.PlayerInventory; +import net.minecraft.item.ItemStack; +import net.minecraft.util.ActionResult; +import net.william278.husksync.event.ItemPickupCallback; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Redirect; + +@Mixin(ItemEntity.class) +public class ItemEntityMixin { + + @Redirect(at = @At(value = "INVOKE", target = "Lnet/minecraft/entity/player/PlayerInventory;insertStack(Lnet/minecraft/item/ItemStack;)Z"), + method = "onPlayerCollision") + public boolean onPlayerCollision(PlayerInventory inventory, ItemStack stack) { + ActionResult result = ItemPickupCallback.EVENT.invoker().interact(inventory.player, stack); + return (result != ActionResult.FAIL && inventory.insertStack(stack)); + } + +} diff --git a/fabric/src/main/java/net/william278/husksync/mixins/PlayerEntityMixin.java b/fabric/src/main/java/net/william278/husksync/mixins/PlayerEntityMixin.java new file mode 100644 index 00000000..c7e186f4 --- /dev/null +++ b/fabric/src/main/java/net/william278/husksync/mixins/PlayerEntityMixin.java @@ -0,0 +1,81 @@ +/* + * 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.mixins; + +import net.minecraft.enchantment.EnchantmentHelper; +import net.minecraft.entity.player.PlayerEntity; +import net.minecraft.entity.player.PlayerInventory; +import net.minecraft.item.ItemStack; +import net.minecraft.server.network.ServerPlayerEntity; +import net.william278.husksync.event.PlayerDeathDropsCallback; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.spongepowered.asm.mixin.Final; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Shadow; +import org.spongepowered.asm.mixin.Unique; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +@Mixin(PlayerEntity.class) +public class PlayerEntityMixin { + + @Final + @Shadow + private PlayerInventory inventory; + + @Inject(method = "dropInventory", at = @At(value = "INVOKE", target = "Lnet/minecraft/entity/player/PlayerEntity;vanishCursedItems()V")) + protected void dropInventory(@NotNull CallbackInfo ci) { + final PlayerEntity player = (PlayerEntity) (Object) this; + PlayerDeathDropsCallback.EVENT.invoker().drops((ServerPlayerEntity) player, getItemsToKeep(), getItemsToDrop()); + } + + @Unique + @Nullable + private ItemStack @NotNull [] getItemsToKeep() { + final @Nullable ItemStack @NotNull [] toKeep = new ItemStack[inventory.size()]; + for (int i = 0; i < inventory.size(); ++i) { + ItemStack itemStack = inventory.getStack(i); + if (!itemStack.isEmpty() && EnchantmentHelper.hasVanishingCurse(itemStack)) { + toKeep[i] = null; + continue; + } + toKeep[i] = itemStack; + } + return toKeep; + } + + @Unique + @Nullable + private ItemStack @NotNull [] getItemsToDrop() { + final @Nullable ItemStack @NotNull [] toDrop = new ItemStack[inventory.size()]; + for (int i = 0; i < inventory.size(); ++i) { + ItemStack itemStack = inventory.getStack(i); + if (!itemStack.isEmpty() && EnchantmentHelper.hasVanishingCurse(itemStack)) { + toDrop[i] = itemStack; + continue; + } + toDrop[i] = null; + } + return toDrop; + } + +} diff --git a/fabric/src/main/java/net/william278/husksync/mixins/ServerPlayNetworkHandlerMixin.java b/fabric/src/main/java/net/william278/husksync/mixins/ServerPlayNetworkHandlerMixin.java new file mode 100644 index 00000000..344692bc --- /dev/null +++ b/fabric/src/main/java/net/william278/husksync/mixins/ServerPlayNetworkHandlerMixin.java @@ -0,0 +1,107 @@ +/* + * 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.mixins; + +import net.minecraft.item.ItemStack; +import net.minecraft.network.packet.Packet; +import net.minecraft.network.packet.c2s.play.ClickSlotC2SPacket; +import net.minecraft.network.packet.c2s.play.CommandExecutionC2SPacket; +import net.minecraft.network.packet.c2s.play.CreativeInventoryActionC2SPacket; +import net.minecraft.network.packet.c2s.play.PlayerActionC2SPacket; +import net.minecraft.network.packet.s2c.play.ScreenHandlerSlotUpdateS2CPacket; +import net.minecraft.server.network.ServerPlayNetworkHandler; +import net.minecraft.server.network.ServerPlayerEntity; +import net.minecraft.util.ActionResult; +import net.minecraft.util.Hand; +import net.william278.husksync.event.ItemDropCallback; +import net.william278.husksync.event.PlayerCommandCallback; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Shadow; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +// Adapted from simplerauth (https://github.com/lolicode-org/simplerauth), which is licensed under the MIT License +@Mixin(ServerPlayNetworkHandler.class) +public abstract class ServerPlayNetworkHandlerMixin { + @Shadow + public ServerPlayerEntity player; + + @Shadow + public abstract void sendPacket(Packet packet); + + @Inject(method = "onPlayerAction", at = @At("HEAD"), cancellable = true) + public void onPlayerAction(PlayerActionC2SPacket packet, CallbackInfo ci) { + if (packet.getAction() == PlayerActionC2SPacket.Action.DROP_ITEM + || packet.getAction() == PlayerActionC2SPacket.Action.DROP_ALL_ITEMS) { + ItemStack stack = player.getStackInHand(Hand.MAIN_HAND); + ActionResult result = ItemDropCallback.EVENT.invoker().interact(player, stack); + + if (result == ActionResult.FAIL) { + ci.cancel(); + this.sendPacket(new ScreenHandlerSlotUpdateS2CPacket( + -2, + 1, + player.getInventory().getSlotWithStack(stack), + stack + )); + } + } + } + + @Inject(method = "onClickSlot", at = @At("HEAD"), cancellable = true) + public void onClickSlot(ClickSlotC2SPacket packet, CallbackInfo ci) { + int slot = packet.getSlot(); + if (slot < 0) return; + + ItemStack stack = this.player.getInventory().getStack(slot); + ActionResult result = ItemDropCallback.EVENT.invoker().interact(player, stack); + + if (result == ActionResult.FAIL) { + ci.cancel(); + this.sendPacket(new ScreenHandlerSlotUpdateS2CPacket(-2, 1, slot, stack)); + this.sendPacket(new ScreenHandlerSlotUpdateS2CPacket(-1, 1, -1, ItemStack.EMPTY)); + } + } + + @Inject(method = "onCreativeInventoryAction", at = @At("HEAD"), cancellable = true) + public void onCreativeInventoryAction(CreativeInventoryActionC2SPacket packet, CallbackInfo ci) { + int slot = packet.getSlot(); + if (slot < 0) return; + + ItemStack stack = this.player.getInventory().getStack(slot); + ActionResult result = ItemDropCallback.EVENT.invoker().interact(player, stack); + + if (result == ActionResult.FAIL) { + ci.cancel(); + this.sendPacket(new ScreenHandlerSlotUpdateS2CPacket(-2, 1, slot, stack)); + this.sendPacket(new ScreenHandlerSlotUpdateS2CPacket(-1, 1, -1, ItemStack.EMPTY)); + } + } + + @Inject(method = "onCommandExecution", at = @At("HEAD"), cancellable = true) + public void onCommandExecution(CommandExecutionC2SPacket packet, CallbackInfo ci) { + ActionResult result = PlayerCommandCallback.EVENT.invoker().interact(player, packet.command()); + + if (result == ActionResult.FAIL) { + ci.cancel(); + } + } +} diff --git a/fabric/src/main/java/net/william278/husksync/mixins/ServerPlayerEntityMixin.java b/fabric/src/main/java/net/william278/husksync/mixins/ServerPlayerEntityMixin.java new file mode 100644 index 00000000..fa27c7c7 --- /dev/null +++ b/fabric/src/main/java/net/william278/husksync/mixins/ServerPlayerEntityMixin.java @@ -0,0 +1,46 @@ +/* + * 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.mixins; + +import net.minecraft.entity.ItemEntity; +import net.minecraft.item.ItemStack; +import net.minecraft.server.network.ServerPlayerEntity; +import net.minecraft.util.ActionResult; +import net.william278.husksync.event.ItemDropCallback; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable; + +@Mixin(ServerPlayerEntity.class) +public class ServerPlayerEntityMixin { + + @Inject(method = "dropItem", at = @At("HEAD"), cancellable = true) + private void onPlayerDropItem(ItemStack stack, boolean dropAtFeet, boolean saveThrower, + final CallbackInfoReturnable ci) { + ServerPlayerEntity player = (ServerPlayerEntity) (Object) this; + ActionResult result = ItemDropCallback.EVENT.invoker().interact(player, stack); + + if (result == ActionResult.FAIL) { + ci.cancel(); + } + } + +} diff --git a/fabric/src/main/java/net/william278/husksync/mixins/ServerWorldMixin.java b/fabric/src/main/java/net/william278/husksync/mixins/ServerWorldMixin.java new file mode 100644 index 00000000..8ccea757 --- /dev/null +++ b/fabric/src/main/java/net/william278/husksync/mixins/ServerWorldMixin.java @@ -0,0 +1,37 @@ +/* + * 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.mixins; + +import net.minecraft.server.world.ServerWorld; +import net.william278.husksync.event.WorldSaveCallback; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +@Mixin(ServerWorld.class) +public class ServerWorldMixin { + + @Inject(method = "saveLevel", at = @At("HEAD")) + public void saveLevel(CallbackInfo ci) { + WorldSaveCallback.EVENT.invoker().save((ServerWorld) (Object) this); + } + +} diff --git a/fabric/src/main/java/net/william278/husksync/user/FabricUser.java b/fabric/src/main/java/net/william278/husksync/user/FabricUser.java new file mode 100644 index 00000000..33cd97db --- /dev/null +++ b/fabric/src/main/java/net/william278/husksync/user/FabricUser.java @@ -0,0 +1,175 @@ +/* + * 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.user; + +import de.themoep.minedown.adventure.MineDown; +import eu.pb4.sgui.api.ClickType; +import eu.pb4.sgui.api.elements.GuiElementInterface; +import eu.pb4.sgui.api.gui.SimpleGui; +import me.lucko.fabric.api.permissions.v0.Permissions; +import net.kyori.adventure.audience.Audience; +import net.kyori.adventure.platform.fabric.FabricServerAudiences; +import net.minecraft.item.ItemStack; +import net.minecraft.screen.GenericContainerScreenHandler; +import net.minecraft.screen.ScreenHandlerType; +import net.minecraft.screen.slot.SlotActionType; +import net.minecraft.server.network.ServerPlayerEntity; +import net.william278.husksync.FabricHuskSync; +import net.william278.husksync.HuskSync; +import net.william278.husksync.data.Data; +import net.william278.husksync.data.FabricData; +import net.william278.husksync.data.FabricUserDataHolder; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; + +import java.util.function.Consumer; + +public class FabricUser extends OnlineUser implements FabricUserDataHolder { + + private final HuskSync plugin; + private final ServerPlayerEntity player; + + private FabricUser(@NotNull ServerPlayerEntity player, @NotNull HuskSync plugin) { + super(player.getUuid(), player.getName().getString()); + this.player = player; + this.plugin = plugin; + } + + @NotNull + @ApiStatus.Internal + public static FabricUser adapt(@NotNull ServerPlayerEntity player, @NotNull HuskSync plugin) { + return new FabricUser(player, plugin); + } + + @Override + public boolean isOffline() { + return player == null || player.isDisconnected(); + } + + @NotNull + @Override + public Audience getAudience() { + return plugin.getAudiences().player(player.getUuid()); + } + + @Override + public void sendToast(@NotNull MineDown title, @NotNull MineDown description, @NotNull String iconMaterial, + @NotNull String backgroundType) { + player.sendActionBar(title.toComponent()); // Toasts unimplemented for now + } + + @Override + public void showGui(@NotNull Data.Items.Items items, @NotNull MineDown title, boolean editable, int size, + @NotNull Consumer onClose) { + plugin.runSync( + () -> new ItemViewerGui(size, player, title, (FabricData.Items) items, onClose, editable, plugin).open() + ); + } + + private static class ItemViewerGui extends SimpleGui { + + private final Consumer onClose; + private final int size; + private final boolean editable; + + public ItemViewerGui(int size, @NotNull ServerPlayerEntity player, @NotNull MineDown title, + @NotNull FabricData.Items items, @NotNull Consumer onClose, + boolean editable, @NotNull HuskSync plugin) { + super(getScreenHandler(size), player, false); + this.onClose = onClose; + this.size = size; + this.editable = editable; + + // Set title, items + this.setTitle(((FabricServerAudiences) plugin.getAudiences()).toNative(title.toComponent())); + this.setLockPlayerInventory(!editable); + for (int i = 0; i < size; i++) { + final ItemStack item = items.getContents()[i]; + this.setSlot(i, item == null ? ItemStack.EMPTY : item); + } + } + + @Override + public void onClose() { + final ItemStack[] contents = new ItemStack[size]; + for (int i = 0; i < size; i++) { + contents[i] = this.getSlot(i) == null ? null : this.getSlot(i).getItemStack(); + } + onClose.accept(FabricData.Items.ItemArray.adapt(contents)); + } + + @Override + public boolean onAnyClick(int index, @NotNull ClickType type, @NotNull SlotActionType action) { + return editable; + } + + @Override + public boolean onClick(int index, @NotNull ClickType type, @NotNull SlotActionType action, + @NotNull GuiElementInterface element) { + return editable; + } + + @NotNull + private static ScreenHandlerType getScreenHandler(int size) { + return switch (size / 9 + (size % 9 == 0 ? 0 : 1)) { + case 3 -> ScreenHandlerType.GENERIC_9X3; + case 4 -> ScreenHandlerType.GENERIC_9X4; + case 5 -> ScreenHandlerType.GENERIC_9X5; + default -> ScreenHandlerType.GENERIC_9X6; + }; + } + } + + @Override + public boolean hasPermission(@NotNull String node) { + final boolean requiresOp = Boolean.TRUE.equals( + ((FabricHuskSync) plugin).getPermissions().getOrDefault(node, true) + ); + return Permissions.check(player, node, !requiresOp || player.hasPermissionLevel(3)); + } + + @Override + public boolean isDead() { + return player.getHealth() <= 0.0f; + } + + @Override + public boolean isLocked() { + return plugin.getLockedPlayers().contains(player.getUuid()); + } + + @Override + public boolean isNpc() { + return false; + } + + @Override + @NotNull + public ServerPlayerEntity getPlayer() { + return player; + } + + @NotNull + @Override + @ApiStatus.Internal + public HuskSync getPlugin() { + return plugin; + } +} diff --git a/fabric/src/main/java/net/william278/husksync/util/FabricKeyedAdapter.java b/fabric/src/main/java/net/william278/husksync/util/FabricKeyedAdapter.java new file mode 100644 index 00000000..864e9363 --- /dev/null +++ b/fabric/src/main/java/net/william278/husksync/util/FabricKeyedAdapter.java @@ -0,0 +1,76 @@ +/* + * 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.util; + +import net.minecraft.entity.EntityType; +import net.minecraft.entity.attribute.EntityAttribute; +import net.minecraft.entity.effect.StatusEffect; +import net.minecraft.registry.Registries; +import net.minecraft.registry.Registry; +import net.minecraft.util.Identifier; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +// Utility class for adapting "Keyed" Minecraft objects +public final class FabricKeyedAdapter { + + @Nullable + public static EntityType matchEntityType(@NotNull String key) { + return getRegistryValue(Registries.ENTITY_TYPE, key); + } + + @Nullable + public static String getEntityTypeId(@NotNull EntityType entityType) { + return getRegistryKey(Registries.ENTITY_TYPE, entityType); + } + + @Nullable + public static EntityAttribute matchAttribute(@NotNull String key) { + return getRegistryValue(Registries.ATTRIBUTE, key); + } + + @Nullable + public static String getAttributeId(@NotNull EntityAttribute attribute) { + return getRegistryKey(Registries.ATTRIBUTE, attribute); + } + + @Nullable + public static StatusEffect matchEffectType(@NotNull String key) { + return getRegistryValue(Registries.STATUS_EFFECT, key); + } + + @Nullable + public static String getEffectId(@NotNull StatusEffect effect) { + return getRegistryKey(Registries.STATUS_EFFECT, effect); + } + + @Nullable + private static T getRegistryValue(@NotNull Registry registry, @NotNull String keyString) { + final Identifier key = Identifier.tryParse(keyString); + return key != null ? registry.get(key) : null; + } + + @Nullable + private static String getRegistryKey(@NotNull Registry registry, @NotNull T value) { + final Identifier key = registry.getId(value); + return key != null ? key.toString() : null; + } + +} diff --git a/fabric/src/main/java/net/william278/husksync/util/FabricTask.java b/fabric/src/main/java/net/william278/husksync/util/FabricTask.java new file mode 100644 index 00000000..73556db9 --- /dev/null +++ b/fabric/src/main/java/net/william278/husksync/util/FabricTask.java @@ -0,0 +1,137 @@ +/* + * 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.util; + +import net.william278.husksync.FabricHuskSync; +import net.william278.husksync.HuskSync; +import net.william278.husksync.data.UserDataHolder; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; + +public interface FabricTask extends Task { + + class Sync extends Task.Sync implements FabricTask { + + protected Sync(@NotNull HuskSync plugin, @NotNull Runnable runnable, long delayTicks) { + super(plugin, runnable, delayTicks); + } + + @Override + public void cancel() { + super.cancel(); + } + + @Override + public void run() { + if (!cancelled) { + Executors.newSingleThreadScheduledExecutor().schedule( + () -> ((FabricHuskSync) getPlugin()).getMinecraftServer().executeSync(runnable), + delayTicks * 50, + TimeUnit.MILLISECONDS + ); + } + } + } + + class Async extends Task.Async implements FabricTask { + private CompletableFuture task; + + protected Async(@NotNull HuskSync plugin, @NotNull Runnable runnable, long delayTicks) { + super(plugin, runnable, delayTicks); + } + + @Override + public void cancel() { + if (task != null && !cancelled) { + task.cancel(true); + } + super.cancel(); + } + + @Override + public void run() { + if (!cancelled) { + this.task = CompletableFuture.runAsync(runnable, ((FabricHuskSync) getPlugin()).getMinecraftServer()); + } + } + } + + class Repeating extends Task.Repeating implements FabricTask { + + private ScheduledFuture task; + + protected Repeating(@NotNull HuskSync plugin, @NotNull Runnable runnable, long repeatingTicks) { + super(plugin, runnable, repeatingTicks); + } + + @Override + public void cancel() { + if (task != null && !cancelled) { + task.cancel(true); + } + super.cancel(); + } + + @Override + public void run() { + if (!cancelled) { + this.task = Executors.newSingleThreadScheduledExecutor().scheduleAtFixedRate( + runnable, + 0, + repeatingTicks * 50, + TimeUnit.MILLISECONDS + ); + } + } + } + + interface Supplier extends Task.Supplier { + + @NotNull + @Override + default Task.Sync getSyncTask(@NotNull Runnable runnable, @Nullable UserDataHolder user, long delayTicks) { + return new Sync(getPlugin(), runnable, delayTicks); + } + + @NotNull + @Override + default Task.Async getAsyncTask(@NotNull Runnable runnable, long delayTicks) { + return new Async(getPlugin(), runnable, delayTicks); + } + + @NotNull + @Override + default Task.Repeating getRepeatingTask(@NotNull Runnable runnable, long repeatingTicks) { + return new Repeating(getPlugin(), runnable, repeatingTicks); + } + + @Override + default void cancelTasks() { + // Do nothing + } + + } + +} diff --git a/fabric/src/main/resources/assets/husksync/icon.png b/fabric/src/main/resources/assets/husksync/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..b932dabcf453aa286073c0823644aa546390618d GIT binary patch literal 69847 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 zw$%=${fabric_loader_version}", + "minecraft": ">=${fabric_minecraft_version}", + "fabric-api": "*" + }, + "suggests": { + "plan": "*" + }, + "mixins": [ + "husksync.mixins.json" + ], + "custom": { + "modmenu:api": true + } +} diff --git a/fabric/src/main/resources/husksync.mixins.json b/fabric/src/main/resources/husksync.mixins.json new file mode 100644 index 00000000..0e9ddce3 --- /dev/null +++ b/fabric/src/main/resources/husksync.mixins.json @@ -0,0 +1,17 @@ +{ + "required": true, + "minVersion": "0.8", + "package": "net.william278.husksync.mixins", + "compatibilityLevel": "JAVA_17", + "server": [ + "ItemEntityMixin", + "PlayerEntityMixin", + "ServerPlayerEntityMixin", + "ServerPlayNetworkHandlerMixin", + "ServerWorldMixin" + ], + "client": [], + "injectors": { + "defaultRequire": 1 + } +} \ No newline at end of file diff --git a/gradle.properties b/gradle.properties index e3d4036e..797566c4 100644 --- a/gradle.properties +++ b/gradle.properties @@ -3,7 +3,7 @@ org.gradle.jvmargs='-Dfile.encoding=UTF-8' org.gradle.daemon=true javaVersion=17 -plugin_version=3.5.3 +plugin_version=3.6 plugin_archive=husksync plugin_description=A modern, cross-server player data synchronization system @@ -13,3 +13,11 @@ mariadb_driver_version=3.4.0 postgres_driver_version=42.7.3 mongodb_driver_version=5.1.0 snappy_version=1.1.10.5 + +fabric_minecraft_version=1.20.1 +fabric_loader_version=0.15.11 +fabric_yarn_mappings=1.20.1+build.10 +fabric_api_version=0.92.2+1.20.1 +adventure_platform_fabric_version=5.9.0 +fabric_permissions_api_version=0.2-SNAPSHOT +sgui_version=1.2.2+1.20 \ No newline at end of file diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index ccebba7710deaf9f98673a68957ea02138b60d0a..c1962a79e29d3e0ab67b14947c167a862655af9b 100644 GIT binary patch delta 8979 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 F{TFmrHLU;u delta 8469 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