From 676ba7a10afad7119374c641a2432479f4dc6ff2 Mon Sep 17 00:00:00 2001 From: William Date: Wed, 10 Apr 2024 19:38:37 +0100 Subject: [PATCH] feat: Add attribute syncing (#276) * refactor: add attribute syncing * fix: don't sync unmodified attributes * fix: register json serializer for Attributes * fix: improve Attribute API methods * docs: update Sync Features * refactor: make attributes a set Because they're unique (by UUID) --- .../william278/husksync/BukkitHuskSync.java | 1 + .../william278/husksync/data/BukkitData.java | 135 +++++++++++------- .../husksync/data/BukkitUserDataHolder.java | 7 + .../husksync/migrator/LegacyMigrator.java | 2 +- .../husksync/util/BukkitLegacyConverter.java | 1 - .../net/william278/husksync/HuskSync.java | 28 ---- .../net/william278/husksync/data/Data.java | 88 +++++++++++- .../william278/husksync/data/DataHolder.java | 9 ++ .../husksync/data/DataSnapshot.java | 15 ++ .../william278/husksync/data/Identifier.java | 5 +- .../william278/husksync/hook/PlanHook.java | 2 +- .../husksync/util/DataSnapshotOverview.java | 13 +- docs/Sync-Features.md | 35 ++--- 13 files changed, 233 insertions(+), 108 deletions(-) diff --git a/bukkit/src/main/java/net/william278/husksync/BukkitHuskSync.java b/bukkit/src/main/java/net/william278/husksync/BukkitHuskSync.java index b07a36b6..5f45e1cc 100644 --- a/bukkit/src/main/java/net/william278/husksync/BukkitHuskSync.java +++ b/bukkit/src/main/java/net/william278/husksync/BukkitHuskSync.java @@ -143,6 +143,7 @@ public class BukkitHuskSync extends JavaPlugin implements HuskSync, BukkitTask.S registerSerializer(Identifier.LOCATION, new BukkitSerializer.Location(this)); registerSerializer(Identifier.HEALTH, new BukkitSerializer.Json<>(this, BukkitData.Health.class)); registerSerializer(Identifier.HUNGER, new BukkitSerializer.Json<>(this, BukkitData.Hunger.class)); + registerSerializer(Identifier.ATTRIBUTES, new BukkitSerializer.Json<>(this, BukkitData.Attributes.class)); registerSerializer(Identifier.GAME_MODE, new BukkitSerializer.Json<>(this, BukkitData.GameMode.class)); registerSerializer(Identifier.FLIGHT_STATUS, new BukkitSerializer.Json<>(this, BukkitData.FlightStatus.class)); registerSerializer(Identifier.POTION_EFFECTS, new BukkitSerializer.PotionEffects(this)); 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 6e88e000..ff565756 100644 --- a/bukkit/src/main/java/net/william278/husksync/data/BukkitData.java +++ b/bukkit/src/main/java/net/william278/husksync/data/BukkitData.java @@ -32,11 +32,12 @@ import net.william278.husksync.adapter.Adaptable; import net.william278.husksync.user.BukkitUser; import org.bukkit.*; import org.bukkit.advancement.AdvancementProgress; -import org.bukkit.attribute.Attribute; import org.bukkit.attribute.AttributeInstance; +import org.bukkit.attribute.AttributeModifier; import org.bukkit.entity.EntityType; import org.bukkit.entity.Player; import org.bukkit.event.inventory.InventoryType; +import org.bukkit.inventory.EquipmentSlot; import org.bukkit.inventory.ItemStack; import org.bukkit.persistence.PersistentDataContainer; import org.bukkit.potion.PotionEffect; @@ -431,7 +432,7 @@ public abstract class BukkitData implements Data { } - // TODO: Consider using Paper's new-ish API for this instead (when it's merged) + // TODO: Move to using Registry.STATISTIC as soon as possible! @AllArgsConstructor(access = AccessLevel.PRIVATE) @NoArgsConstructor(access = AccessLevel.PRIVATE) public static class Statistics extends BukkitData implements Data.Statistics { @@ -667,6 +668,73 @@ public abstract class BukkitData implements Data { container.mergeCompound(persistentData); } + } + + @Getter + @AllArgsConstructor(access = AccessLevel.PRIVATE) + @NoArgsConstructor(access = AccessLevel.PRIVATE) + public static class Attributes extends BukkitData implements Data.Attributes, Adaptable { + + private List attributes; + + @NotNull + public static BukkitData.Attributes adapt(@NotNull Player player) { + final List attributes = Lists.newArrayList(); + Registry.ATTRIBUTE.forEach(id -> { + final AttributeInstance instance = player.getAttribute(id); + if (instance == null || instance.getValue() == instance.getDefaultValue()) { + return; // We don't sync unmodified attributes + } + attributes.add(adapt(instance)); + }); + return new BukkitData.Attributes(attributes); + } + + public Optional getAttribute(@NotNull org.bukkit.attribute.Attribute id) { + return attributes.stream().filter(attribute -> attribute.name().equals(id.getKey().toString())).findFirst(); + } + + @NotNull + private static Attribute adapt(@NotNull AttributeInstance instance) { + return new Attribute( + instance.getAttribute().getKey().toString(), + instance.getBaseValue(), + instance.getModifiers().stream().map(BukkitData.Attributes::adapt).collect(Collectors.toSet()) + ); + } + + @NotNull + private static Modifier adapt(@NotNull AttributeModifier modifier) { + return new Modifier( + modifier.getUniqueId(), + modifier.getName(), + modifier.getAmount(), + modifier.getOperation().ordinal(), + modifier.getSlot() != null ? modifier.getSlot().ordinal() : -1 + ); + } + + @Override + public void apply(@NotNull BukkitUser user, @NotNull BukkitHuskSync plugin) throws IllegalStateException { + Registry.ATTRIBUTE.forEach(id -> applyAttribute(user.getPlayer().getAttribute(id), getAttribute(id).orElse(null))); + } + + private static void applyAttribute(@Nullable AttributeInstance instance, @Nullable Attribute attribute) { + if (instance == null) { + return; + } + instance.setBaseValue(attribute == null ? instance.getDefaultValue() : instance.getBaseValue()); + instance.getModifiers().forEach(instance::removeModifier); + if (attribute != null) { + attribute.modifiers().forEach(modifier -> instance.addModifier(new AttributeModifier( + modifier.uuid(), + modifier.name(), + modifier.amount(), + AttributeModifier.Operation.values()[modifier.operationType()], + modifier.equipmentSlot() != -1 ? EquipmentSlot.values()[modifier.equipmentSlot()] : null + ))); + } + } } @@ -677,86 +745,53 @@ public abstract class BukkitData implements Data { public static class Health extends BukkitData implements Data.Health, Adaptable { @SerializedName("health") private double health; - @SerializedName("max_health") - private double maxHealth; @SerializedName("health_scale") private double healthScale; @NotNull + public static BukkitData.Health from(double health, double healthScale) { + return new BukkitData.Health(health, healthScale); + } + + @NotNull + @Deprecated(forRemoval = true, since = "3.5") + @SuppressWarnings("unused") public static BukkitData.Health from(double health, double maxHealth, double healthScale) { - return new BukkitData.Health(health, maxHealth, healthScale); + return from(health, healthScale); } @NotNull public static BukkitData.Health adapt(@NotNull Player player) { return from( player.getHealth(), - getMaxHealth(player), player.isHealthScaled() ? player.getHealthScale() : 0d ); } @Override + @SuppressWarnings("deprecation") public void apply(@NotNull BukkitUser user, @NotNull BukkitHuskSync plugin) throws IllegalStateException { final Player player = user.getPlayer(); - // Set max health - final AttributeInstance maxHealth = getMaxHealthAttribute(player); - try { - if (plugin.getSettings().getSynchronization().isSynchronizeMaxHealth() && this.maxHealth != 0) { - maxHealth.setBaseValue(this.maxHealth); - } - } catch (Throwable e) { - plugin.log(Level.WARNING, String.format("Failed setting the max health of %s to %s", - player.getName(), this.maxHealth), e); - } - // Set health try { - final double health = player.getHealth(); - player.setHealth(Math.min(health, maxHealth.getBaseValue())); + player.setHealth(Math.min(health, player.getMaxHealth())); } catch (Throwable e) { - plugin.log(Level.WARNING, String.format("Failed setting the health of %s to %s", - player.getName(), this.maxHealth), e); + plugin.log(Level.WARNING, "Error setting %s's health to %s".formatted(player.getName(), health), e); } // Set health scale try { - if (this.healthScale != 0d) { + if (healthScale != 0d) { player.setHealthScaled(true); - player.setHealthScale(this.healthScale); + player.setHealthScale(healthScale); } else { player.setHealthScaled(false); - player.setHealthScale(this.maxHealth); + player.setHealthScale(player.getMaxHealth()); } } catch (Throwable e) { - plugin.log(Level.WARNING, String.format("Failed setting the health scale of %s to %s", - player.getName(), this.healthScale), e); - } - } - - // Returns the max health of a player, accounting for health boost potion effects - private static double getMaxHealth(@NotNull Player player) { - // Get the base value of the attribute (ignore armor, items that give health boosts, etc.) - double maxHealth = getMaxHealthAttribute(player).getBaseValue(); - - // Subtract health boost potion effects from stored max health - if (player.hasPotionEffect(PotionEffectType.HEALTH_BOOST) && maxHealth > 20d) { - final PotionEffect healthBoost = Objects.requireNonNull( - player.getPotionEffect(PotionEffectType.HEALTH_BOOST), "Health boost effect was null" - ); - maxHealth -= (4 * (healthBoost.getAmplifier() + 1)); + plugin.log(Level.WARNING, "Error setting %s's health scale to %s".formatted(player.getName(), healthScale), e); } - - return maxHealth; - } - - // Returns the max health attribute of a player - @NotNull - private static AttributeInstance getMaxHealthAttribute(@NotNull Player player) { - return Objects.requireNonNull( - player.getAttribute(Attribute.GENERIC_MAX_HEALTH), "Max health attribute was null" - ); } } 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 8aef4402..32f6ac12 100644 --- a/bukkit/src/main/java/net/william278/husksync/data/BukkitUserDataHolder.java +++ b/bukkit/src/main/java/net/william278/husksync/data/BukkitUserDataHolder.java @@ -41,6 +41,7 @@ public interface BukkitUserDataHolder extends UserDataHolder { case "statistics" -> getStatistics(); case "health" -> getHealth(); case "hunger" -> getHunger(); + case "attributes" -> getAttributes(); case "experience" -> getExperience(); case "game_mode" -> getGameMode(); case "flight_status" -> getFlightStatus(); @@ -117,6 +118,12 @@ public interface BukkitUserDataHolder extends UserDataHolder { return Optional.of(BukkitData.Hunger.adapt(getBukkitPlayer())); } + @NotNull + @Override + default Optional getAttributes() { + return Optional.of(BukkitData.Attributes.adapt(getBukkitPlayer())); + } + @NotNull @Override default Optional getExperience() { diff --git a/bukkit/src/main/java/net/william278/husksync/migrator/LegacyMigrator.java b/bukkit/src/main/java/net/william278/husksync/migrator/LegacyMigrator.java index f53a1d0c..9938e0c8 100644 --- a/bukkit/src/main/java/net/william278/husksync/migrator/LegacyMigrator.java +++ b/bukkit/src/main/java/net/william278/husksync/migrator/LegacyMigrator.java @@ -331,7 +331,7 @@ public class LegacyMigrator extends Migrator { ))) // Health, hunger, experience & game mode - .health(BukkitData.Health.from(health, maxHealth, healthScale)) + .health(BukkitData.Health.from(health, healthScale)) .hunger(BukkitData.Hunger.from(hunger, saturation, saturationExhaustion)) .experience(BukkitData.Experience.from(totalExp, expLevel, expProgress)) .gameMode(BukkitData.GameMode.from(gameMode)) diff --git a/bukkit/src/main/java/net/william278/husksync/util/BukkitLegacyConverter.java b/bukkit/src/main/java/net/william278/husksync/util/BukkitLegacyConverter.java index 5eae1f3a..9985781a 100644 --- a/bukkit/src/main/java/net/william278/husksync/util/BukkitLegacyConverter.java +++ b/bukkit/src/main/java/net/william278/husksync/util/BukkitLegacyConverter.java @@ -87,7 +87,6 @@ public class BukkitLegacyConverter extends LegacyConverter { if (shouldImport(Identifier.HEALTH)) { containers.put(Identifier.HEALTH, BukkitData.Health.from( status.getDouble("health"), - status.getDouble("max_health"), status.getDouble("health_scale") )); } diff --git a/common/src/main/java/net/william278/husksync/HuskSync.java b/common/src/main/java/net/william278/husksync/HuskSync.java index 4bc46917..4caefd02 100644 --- a/common/src/main/java/net/william278/husksync/HuskSync.java +++ b/common/src/main/java/net/william278/husksync/HuskSync.java @@ -29,9 +29,6 @@ import net.william278.desertwell.util.UpdateChecker; import net.william278.desertwell.util.Version; import net.william278.husksync.adapter.DataAdapter; import net.william278.husksync.config.ConfigProvider; -import net.william278.husksync.config.Locales; -import net.william278.husksync.config.Server; -import net.william278.husksync.config.Settings; import net.william278.husksync.data.Data; import net.william278.husksync.data.Identifier; import net.william278.husksync.data.Serializer; @@ -180,31 +177,6 @@ public interface HuskSync extends Task.Supplier, EventDispatcher, ConfigProvider log(Level.INFO, "Successfully initialized " + name); } - /** - * Returns the plugin {@link Settings} - * - * @return the {@link Settings} - */ - @NotNull - Settings getSettings(); - - void setSettings(@NotNull Settings settings); - - @NotNull - String getServerName(); - - void setServerName(@NotNull Server serverName); - - /** - * Returns the plugin {@link Locales} - * - * @return the {@link Locales} - */ - @NotNull - Locales getLocales(); - - void setLocales(@NotNull Locales locales); - /** * Returns if a dependency is loaded * 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 029dc785..fc4de513 100644 --- a/common/src/main/java/net/william278/husksync/data/Data.java +++ b/common/src/main/java/net/william278/husksync/data/Data.java @@ -19,7 +19,9 @@ package net.william278.husksync.data; +import com.google.common.collect.Sets; import com.google.gson.annotations.SerializedName; +import net.kyori.adventure.key.Key; import net.william278.husksync.HuskSync; import net.william278.husksync.user.OnlineUser; import org.jetbrains.annotations.NotNull; @@ -286,15 +288,97 @@ public interface Data { void setHealth(double health); - double getMaxHealth(); + /** + * @deprecated Use {@link Attributes#getMaxHealth()} instead + */ + @Deprecated(forRemoval = true, since = "3.5") + default double getMaxHealth() { + return getHealth(); + } - void setMaxHealth(double maxHealth); + /** + * @deprecated Use {@link Attributes#setMaxHealth(double)} instead + */ + @Deprecated(forRemoval = true, since = "3.5") + default void setMaxHealth(double maxHealth) { + } double getHealthScale(); void setHealthScale(double healthScale); } + /** + * A data container holding player attribute data + */ + interface Attributes extends Data { + + Key MAX_HEALTH_KEY = Key.key("generic.max_health"); + + List getAttributes(); + + record Attribute( + @NotNull String name, + double baseValue, + @NotNull Set modifiers + ) { + + public double getValue() { + double value = baseValue; + for (Modifier modifier : modifiers) { + value = modifier.modify(value); + } + return value; + } + + } + + record Modifier( + @NotNull UUID uuid, + @NotNull String name, + double amount, + @SerializedName("operation") int operationType, + @SerializedName("equipment_slot") int equipmentSlot + ) { + + @Override + public boolean equals(Object obj) { + return obj instanceof Modifier modifier && modifier.uuid.equals(uuid); + } + + public double modify(double value) { + return switch (operationType) { + case 0 -> value + amount; + case 1 -> value * amount; + case 2 -> value * (1 + amount); + default -> value; + }; + } + } + + default Optional getAttribute(@NotNull Key key) { + return getAttributes().stream() + .filter(attribute -> attribute.name().equals(key.asString())) + .findFirst(); + } + + default void removeAttribute(@NotNull Key key) { + getAttributes().removeIf(attribute -> attribute.name().equals(key.asString())); + } + + default double getMaxHealth() { + return getAttribute(MAX_HEALTH_KEY) + .map(Attribute::getValue) + .orElse(20.0); + } + + default void setMaxHealth(double maxHealth) { + removeAttribute(MAX_HEALTH_KEY); + getAttributes().add(new Attribute(MAX_HEALTH_KEY.asString(), maxHealth, Sets.newHashSet())); + } + + } + /** * A data container holding data for: *
    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 a864b7dc..752ffe79 100644 --- a/common/src/main/java/net/william278/husksync/data/DataHolder.java +++ b/common/src/main/java/net/william278/husksync/data/DataHolder.java @@ -110,6 +110,15 @@ public interface DataHolder { getData().put(Identifier.HUNGER, hunger); } + @NotNull + default Optional getAttributes() { + return Optional.ofNullable((Data.Attributes) getData().get(Identifier.ATTRIBUTES)); + } + + default void setAttributes(@NotNull Data.Attributes attributes) { + getData().put(Identifier.ATTRIBUTES, attributes); + } + @NotNull default Optional getExperience() { return Optional.ofNullable((Data.Experience) getData().get(Identifier.EXPERIENCE)); diff --git a/common/src/main/java/net/william278/husksync/data/DataSnapshot.java b/common/src/main/java/net/william278/husksync/data/DataSnapshot.java index 6bb20f55..97bd93ce 100644 --- a/common/src/main/java/net/william278/husksync/data/DataSnapshot.java +++ b/common/src/main/java/net/william278/husksync/data/DataSnapshot.java @@ -659,6 +659,21 @@ public class DataSnapshot { return data(Identifier.HUNGER, hunger); } + /** + * Set the attributes of the snapshot + *

    + * Equivalent to {@code data(Identifier.ATTRIBUTES, attributes)} + *

    + * + * @param attributes The user's attributes + * @return The builder + * @since 3.5 + */ + @NotNull + public Builder attributes(@NotNull Data.Attributes attributes) { + return data(Identifier.ATTRIBUTES, attributes); + } + /** * Set the experience of the snapshot *

    diff --git a/common/src/main/java/net/william278/husksync/data/Identifier.java b/common/src/main/java/net/william278/husksync/data/Identifier.java index 8582024d..c346c9b0 100644 --- a/common/src/main/java/net/william278/husksync/data/Identifier.java +++ b/common/src/main/java/net/william278/husksync/data/Identifier.java @@ -41,6 +41,7 @@ public class Identifier { public static Identifier STATISTICS = huskSync("statistics", true); public static Identifier HEALTH = huskSync("health", true); public static Identifier HUNGER = huskSync("hunger", true); + public static Identifier ATTRIBUTES = huskSync("attributes", true); public static Identifier EXPERIENCE = huskSync("experience", true); public static Identifier GAME_MODE = huskSync("game_mode", true); public static Identifier FLIGHT_STATUS = huskSync("flight_status", true); @@ -114,8 +115,8 @@ public class Identifier { @SuppressWarnings("unchecked") public static Map getConfigMap() { return Map.ofEntries(Stream.of( - INVENTORY, ENDER_CHEST, POTION_EFFECTS, ADVANCEMENTS, LOCATION, - STATISTICS, HEALTH, HUNGER, EXPERIENCE, GAME_MODE, FLIGHT_STATUS, PERSISTENT_DATA + INVENTORY, ENDER_CHEST, POTION_EFFECTS, ADVANCEMENTS, LOCATION, STATISTICS, + HEALTH, HUNGER, ATTRIBUTES, EXPERIENCE, GAME_MODE, FLIGHT_STATUS, PERSISTENT_DATA ) .map(Identifier::getConfigEntry) .toArray(Map.Entry[]::new)); diff --git a/common/src/main/java/net/william278/husksync/hook/PlanHook.java b/common/src/main/java/net/william278/husksync/hook/PlanHook.java index 6b2581c5..7ffd7fb2 100644 --- a/common/src/main/java/net/william278/husksync/hook/PlanHook.java +++ b/common/src/main/java/net/william278/husksync/hook/PlanHook.java @@ -184,7 +184,7 @@ public class PlanHook { public String getHealth(@NotNull UUID uuid) { return getLatestSnapshot(uuid) .flatMap(DataHolder::getHealth) - .map(health -> String.format("%s / %s", health.getHealth(), health.getMaxHealth())) + .map(health -> String.format("%s", health.getHealth())) .orElse(UNKNOWN_STRING); } diff --git a/common/src/main/java/net/william278/husksync/util/DataSnapshotOverview.java b/common/src/main/java/net/william278/husksync/util/DataSnapshotOverview.java index f0992060..b3e25af3 100644 --- a/common/src/main/java/net/william278/husksync/util/DataSnapshotOverview.java +++ b/common/src/main/java/net/william278/husksync/util/DataSnapshotOverview.java @@ -77,16 +77,17 @@ public class DataSnapshotOverview { // User status data, if present in the snapshot final Optional health = snapshot.getHealth(); + final Optional attributes = snapshot.getAttributes(); final Optional food = snapshot.getHunger(); - final Optional experience = snapshot.getExperience(); - final Optional gameMode = snapshot.getGameMode(); - if (health.isPresent() && food.isPresent() && experience.isPresent() && gameMode.isPresent()) { + final Optional exp = snapshot.getExperience(); + final Optional mode = snapshot.getGameMode(); + if (health.isPresent() && attributes.isPresent() && food.isPresent() && exp.isPresent() && mode.isPresent()) { locales.getLocale("data_manager_status", Integer.toString((int) health.get().getHealth()), - Integer.toString((int) health.get().getMaxHealth()), + Integer.toString((int) attributes.get().getMaxHealth()), Integer.toString(food.get().getFoodLevel()), - Integer.toString(experience.get().getExpLevel()), - gameMode.get().getGameMode().toLowerCase(Locale.ENGLISH)) + Integer.toString(exp.get().getExpLevel()), + mode.get().getGameMode().toLowerCase(Locale.ENGLISH)) .ifPresent(user::sendMessage); } diff --git a/docs/Sync-Features.md b/docs/Sync-Features.md index f1dcabe1..b1816cf4 100644 --- a/docs/Sync-Features.md +++ b/docs/Sync-Features.md @@ -5,23 +5,24 @@ You can customise how much data HuskSync saves about a player by [turning each s ## Feature table ✅—Supported  ❌—Unsupported  ⚠️—Experimental -| Name | Description | Availability | -|---------------------------|-------------------------------------------------------------|:------------:| -| Inventories | Items in player inventories & selected hotbar slot | ✅ | -| Ender chests | Items in ender chests* | ✅ | -| Health | Player health points | ✅ | -| Max health | Player max health points and health scale | ✅ | -| Hunger | Player hunger, saturation & exhaustion | ✅ | -| Experience | Player level, experience points & score | ✅ | -| Potion effects | Active status effects on players | ✅ | -| Advancements | Player advancements, recipes & progress | ✅ | -| Game modes | Player's current game mode | ✅ | -| Statistics | Player's in-game stats (ESC -> Statistics) | ✅ | -| Location | Player's current coordinate positon and world† | ✅ | -| Persistent Data Container | Custom plugin persistent data key map | ✅️ | -| Locked maps | Maps/treasure maps locked in a cartography table | ⚠️ | -| Unlocked maps | Regular, unlocked maps/treasure maps ([why?](#map-syncing)) | ❌ | -| Economy balances | Vault economy balance. ([why?](#economy-syncing)) | ❌ | +| Name | Description | Availability | +|---------------------------|---------------------------------------------------------------------------------------------|:------------:| +| Inventories | Items in player inventories & selected hotbar slot | ✅ | +| Ender chests | Items in ender chests* | ✅ | +| Health | Player health points and scale | ✅ | +| Hunger | Player hunger, saturation & exhaustion | ✅ | +| Attributes | Player max health, movement speed, reach, etc. ([wiki](https://minecraft.wiki/w/Attribute)) | ✅ | +| Experience | Player level, experience points & score | ✅ | +| Potion effects | Active status effects on players | ✅ | +| Advancements | Player advancements, recipes & progress | ✅ | +| Game modes | Player's current game mode | ✅ | +| Flight status | If the player is currently flying / can fly | ✅ | +| Statistics | Player's in-game stats (ESC -> Statistics) | ✅ | +| Location | Player's current coordinate position and world† | ✅ | +| Persistent Data Container | Custom plugin persistent data key map | ✅️ | +| Locked maps | Maps/treasure maps locked in a cartography table | ✅ | +| Unlocked maps | Regular, unlocked maps/treasure maps ([why?](#map-syncing)) | ❌ | +| Economy balances | Vault economy balance. ([why?](#economy-syncing)) | ❌ | What about modded items? Or custom item plugins such as MMOItems or SlimeFun? These items are **not compatible**—check the [[FAQs]] for more information.