From e0b81e4c76f821fbde985c231d93add711b39f44 Mon Sep 17 00:00:00 2001 From: William Date: Sat, 1 Jun 2024 15:35:08 +0100 Subject: [PATCH] refactor: add serialization identifier dependencies for applying data (#309) * refactor: add serialization identifier dependencies for applying data * fix: correct issues with deterministic sync order * refactor: adjust base data type dependencies * refactor: cleanup imports/trim whitespace * docs: Document Identifier dependencies * feat: fix issues with health scaling --- .../william278/husksync/BukkitHuskSync.java | 21 +- .../william278/husksync/data/BukkitData.java | 35 +-- .../husksync/migrator/LegacyMigrator.java | 2 +- .../husksync/util/BukkitLegacyConverter.java | 3 +- .../net/william278/husksync/HuskSync.java | 41 +--- .../husksync/adapter/GsonAdapter.java | 2 +- .../husksync/adapter/SnappyGsonAdapter.java | 3 +- .../william278/husksync/api/HuskSyncAPI.java | 11 + .../husksync/command/HuskSyncCommand.java | 15 +- .../husksync/data/DataSnapshot.java | 32 +-- .../william278/husksync/data/Identifier.java | 203 +++++++++++++++--- .../husksync/data/SerializerRegistry.java | 162 ++++++++++++++ .../husksync/data/UserDataHolder.java | 2 +- docs/Custom-Data-API.md | 14 ++ docs/Data-Snapshot-API.md | 4 +- 15 files changed, 430 insertions(+), 120 deletions(-) create mode 100644 common/src/main/java/net/william278/husksync/data/SerializerRegistry.java diff --git a/bukkit/src/main/java/net/william278/husksync/BukkitHuskSync.java b/bukkit/src/main/java/net/william278/husksync/BukkitHuskSync.java index a92d3881..0efa7037 100644 --- a/bukkit/src/main/java/net/william278/husksync/BukkitHuskSync.java +++ b/bukkit/src/main/java/net/william278/husksync/BukkitHuskSync.java @@ -85,7 +85,9 @@ public class BukkitHuskSync extends JavaPlugin implements HuskSync, BukkitTask.S private static final int METRICS_ID = 13140; private static final String PLATFORM_TYPE_ID = "bukkit"; - private final Map> serializers = Maps.newLinkedHashMap(); + private final TreeMap> serializers = Maps.newTreeMap( + SerializerRegistry.DEPENDENCY_ORDER_COMPARATOR + ); private final Map> playerCustomDataStore = Maps.newConcurrentMap(); private final Map mapViews = Maps.newConcurrentMap(); private final List availableMigrators = Lists.newArrayList(); @@ -143,19 +145,20 @@ public class BukkitHuskSync extends JavaPlugin implements HuskSync, BukkitTask.S // Prepare serializers initialize("data serializers", (plugin) -> { + registerSerializer(Identifier.PERSISTENT_DATA, new BukkitSerializer.PersistentData(this)); registerSerializer(Identifier.INVENTORY, new BukkitSerializer.Inventory(this)); registerSerializer(Identifier.ENDER_CHEST, new BukkitSerializer.EnderChest(this)); registerSerializer(Identifier.ADVANCEMENTS, new BukkitSerializer.Advancements(this)); - registerSerializer(Identifier.LOCATION, new BukkitSerializer.Json<>(this, BukkitData.Location.class)); - 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.STATISTICS, new BukkitSerializer.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.POTION_EFFECTS, new BukkitSerializer.PotionEffects(this)); - registerSerializer(Identifier.STATISTICS, new BukkitSerializer.Json<>(this, BukkitData.Statistics.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.PERSISTENT_DATA, new BukkitSerializer.PersistentData(this)); + registerSerializer(Identifier.LOCATION, new BukkitSerializer.Json<>(this, BukkitData.Location.class)); + validateDependencies(); }); // Setup available migrators @@ -289,7 +292,7 @@ public class BukkitHuskSync extends JavaPlugin implements HuskSync, BukkitTask.S try { new Metrics(this, metricsId); } catch (Throwable e) { - log(Level.WARNING, "Failed to register bStats metrics (" + e.getMessage() + ")"); + log(Level.WARNING, "Failed to register bStats metrics (%s)".formatted(e.getMessage())); } } 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 568a30e6..9acd06e0 100644 --- a/bukkit/src/main/java/net/william278/husksync/data/BukkitData.java +++ b/bukkit/src/main/java/net/william278/husksync/data/BukkitData.java @@ -639,24 +639,38 @@ public abstract class BukkitData implements Data { private double health; @SerializedName("health_scale") private double healthScale; + @SerializedName("is_health_scaled") + private boolean isHealthScaled; @NotNull - public static BukkitData.Health from(double health, double healthScale) { - return new BukkitData.Health(health, healthScale); + public static BukkitData.Health from(double health, double scale, boolean isScaled) { + return new BukkitData.Health(health, scale, isScaled); } + /** + * @deprecated Use {@link #from(double, double, boolean)} instead + */ + @NotNull + @Deprecated(since = "3.5.4") + public static BukkitData.Health from(double health, double scale) { + return from(health, scale, false); + } + + /** + * @deprecated Use {@link #from(double, double, boolean)} instead + */ @NotNull @Deprecated(forRemoval = true, since = "3.5") - @SuppressWarnings("unused") - public static BukkitData.Health from(double health, double maxHealth, double healthScale) { - return from(health, healthScale); + public static BukkitData.Health from(double health, @SuppressWarnings("unused") double max, double scale) { + return from(health, scale, false); } @NotNull public static BukkitData.Health adapt(@NotNull Player player) { return from( player.getHealth(), - player.isHealthScaled() ? player.getHealthScale() : 0d + player.getHealthScale(), + player.isHealthScaled() ); } @@ -674,13 +688,8 @@ public abstract class BukkitData implements Data { // Set health scale try { - if (healthScale != 0d) { - player.setHealthScaled(true); - player.setHealthScale(healthScale); - } else { - player.setHealthScaled(false); - player.setHealthScale(player.getMaxHealth()); - } + player.setHealthScale(healthScale); + player.setHealthScaled(isHealthScaled); } catch (Throwable e) { plugin.log(Level.WARNING, "Error setting %s's health scale to %s".formatted(player.getName(), healthScale), e); } 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 62d29158..2cc6eb84 100644 --- a/bukkit/src/main/java/net/william278/husksync/migrator/LegacyMigrator.java +++ b/bukkit/src/main/java/net/william278/husksync/migrator/LegacyMigrator.java @@ -330,7 +330,7 @@ public class LegacyMigrator extends Migrator { )) // Health, hunger, experience & game mode - .health(BukkitData.Health.from(health, healthScale)) + .health(BukkitData.Health.from(health, healthScale, false)) .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 ca70321d..237bd856 100644 --- a/bukkit/src/main/java/net/william278/husksync/util/BukkitLegacyConverter.java +++ b/bukkit/src/main/java/net/william278/husksync/util/BukkitLegacyConverter.java @@ -85,7 +85,8 @@ public class BukkitLegacyConverter extends LegacyConverter { if (shouldImport(Identifier.HEALTH)) { containers.put(Identifier.HEALTH, BukkitData.Health.from( status.getDouble("health"), - status.getDouble("health_scale") + status.getDouble("health_scale"), + false )); } if (shouldImport(Identifier.HUNGER)) { diff --git a/common/src/main/java/net/william278/husksync/HuskSync.java b/common/src/main/java/net/william278/husksync/HuskSync.java index 4caefd02..ecc68d8e 100644 --- a/common/src/main/java/net/william278/husksync/HuskSync.java +++ b/common/src/main/java/net/william278/husksync/HuskSync.java @@ -31,7 +31,7 @@ import net.william278.husksync.adapter.DataAdapter; import net.william278.husksync.config.ConfigProvider; import net.william278.husksync.data.Data; import net.william278.husksync.data.Identifier; -import net.william278.husksync.data.Serializer; +import net.william278.husksync.data.SerializerRegistry; import net.william278.husksync.database.Database; import net.william278.husksync.event.EventDispatcher; import net.william278.husksync.migrator.Migrator; @@ -52,7 +52,7 @@ import java.util.logging.Level; /** * Abstract implementation of the HuskSync plugin. */ -public interface HuskSync extends Task.Supplier, EventDispatcher, ConfigProvider { +public interface HuskSync extends Task.Supplier, EventDispatcher, ConfigProvider, SerializerRegistry { int SPIGOT_RESOURCE_ID = 97144; @@ -98,43 +98,6 @@ public interface HuskSync extends Task.Supplier, EventDispatcher, ConfigProvider @NotNull DataAdapter getDataAdapter(); - /** - * Returns the data serializer for the given {@link Identifier} - */ - @NotNull - Map> getSerializers(); - - /** - * Register a data serializer for the given {@link Identifier} - * - * @param identifier the {@link Identifier} - * @param serializer the {@link Serializer} - */ - default void registerSerializer(@NotNull Identifier identifier, - @NotNull Serializer serializer) { - if (identifier.isCustom()) { - log(Level.INFO, String.format("Registered custom data type: %s", identifier)); - } - getSerializers().put(identifier, (Serializer) serializer); - } - - /** - * Get the {@link Identifier} for the given key - */ - default Optional getIdentifier(@NotNull String key) { - return getSerializers().keySet().stream().filter(identifier -> identifier.toString().equals(key)).findFirst(); - } - - /** - * Get the set of registered data types - * - * @return the set of registered data types - */ - @NotNull - default Set getRegisteredDataTypes() { - return getSerializers().keySet(); - } - /** * Returns the data syncer implementation * diff --git a/common/src/main/java/net/william278/husksync/adapter/GsonAdapter.java b/common/src/main/java/net/william278/husksync/adapter/GsonAdapter.java index ba3d0514..d3c1ad93 100644 --- a/common/src/main/java/net/william278/husksync/adapter/GsonAdapter.java +++ b/common/src/main/java/net/william278/husksync/adapter/GsonAdapter.java @@ -49,7 +49,7 @@ public class GsonAdapter implements DataAdapter { @Override @NotNull - public A fromBytes(@NotNull byte[] data, @NotNull Class type) throws AdaptionException { + public A fromBytes(byte[] data, @NotNull Class type) throws AdaptionException { return this.fromJson(new String(data, StandardCharsets.UTF_8), type); } diff --git a/common/src/main/java/net/william278/husksync/adapter/SnappyGsonAdapter.java b/common/src/main/java/net/william278/husksync/adapter/SnappyGsonAdapter.java index 22af6ce0..27b00455 100644 --- a/common/src/main/java/net/william278/husksync/adapter/SnappyGsonAdapter.java +++ b/common/src/main/java/net/william278/husksync/adapter/SnappyGsonAdapter.java @@ -31,7 +31,6 @@ public class SnappyGsonAdapter extends GsonAdapter { super(plugin); } - @NotNull @Override public byte[] toBytes(@NotNull A data) throws AdaptionException { try { @@ -43,7 +42,7 @@ public class SnappyGsonAdapter extends GsonAdapter { @NotNull @Override - public A fromBytes(@NotNull byte[] data, @NotNull Class type) throws AdaptionException { + public A fromBytes(byte[] data, @NotNull Class type) throws AdaptionException { try { return super.fromBytes(decompressBytes(data), type); } catch (IOException e) { diff --git a/common/src/main/java/net/william278/husksync/api/HuskSyncAPI.java b/common/src/main/java/net/william278/husksync/api/HuskSyncAPI.java index 17af5ab6..d55f3ad9 100644 --- a/common/src/main/java/net/william278/husksync/api/HuskSyncAPI.java +++ b/common/src/main/java/net/william278/husksync/api/HuskSyncAPI.java @@ -378,6 +378,17 @@ public class HuskSyncAPI { plugin.registerSerializer(identifier, serializer); } + /** + * Get a registered data serializer by its identifier + * + * @param identifier The identifier of the data type to get the serializer for + * @return The serializer for the given identifier, or an empty optional if the serializer isn't registered + * @since 3.5.4 + */ + public Optional> getDataSerializer(@NotNull Identifier identifier) { + return plugin.getSerializer(identifier); + } + /** * Get a {@link DataSnapshot.Unpacked} from a {@link DataSnapshot.Packed} * 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 b23f7eb2..82f19dbc 100644 --- a/common/src/main/java/net/william278/husksync/command/HuskSyncCommand.java +++ b/common/src/main/java/net/william278/husksync/command/HuskSyncCommand.java @@ -241,10 +241,19 @@ public class HuskSyncCommand extends Command implements TabProvider { JoinConfiguration.commas(true), plugin.getRegisteredDataTypes().stream().map(i -> { boolean enabled = plugin.getSettings().getSynchronization().isFeatureEnabled(i); - return Component.textOfChildren(Component - .text(i.toString()).appendSpace().append(Component.text(enabled ? '✔' : '❌'))) + return Component.textOfChildren(Component.text(i.toString()) + .appendSpace().append(Component.text(enabled ? '✔' : '❌'))) .color(enabled ? NamedTextColor.GREEN : NamedTextColor.RED) - .hoverEvent(HoverEvent.showText(Component.text(enabled ? "Enabled" : "Disabled"))); + .hoverEvent(HoverEvent.showText( + Component.text(enabled ? "Enabled" : "Disabled") + .append(Component.newline()) + .append(Component.text("Dependencies: %s".formatted(i.getDependencies() + .isEmpty() ? "(None)" : i.getDependencies().stream() + .map(d -> "%s (%s)".formatted( + d.getKey().value(), d.isRequired() ? "Required" : "Optional" + )).collect(Collectors.joining(", "))) + ).color(NamedTextColor.GRAY)) + )); }).toList() )); 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 447e196d..13b9bbbd 100644 --- a/common/src/main/java/net/william278/husksync/data/DataSnapshot.java +++ b/common/src/main/java/net/william278/husksync/data/DataSnapshot.java @@ -370,7 +370,7 @@ public class DataSnapshot { public static class Unpacked extends DataSnapshot implements DataHolder { @Expose(serialize = false, deserialize = false) - private final Map deserialized; + private final TreeMap deserialized; private Unpacked(@NotNull UUID id, boolean pinned, @NotNull OffsetDateTime timestamp, @NotNull String saveCause, @NotNull String serverName, @NotNull Map data, @@ -381,7 +381,7 @@ public class DataSnapshot { } private Unpacked(@NotNull UUID id, boolean pinned, @NotNull OffsetDateTime timestamp, - @NotNull String saveCause, @NotNull String serverName, @NotNull Map data, + @NotNull String saveCause, @NotNull String serverName, @NotNull TreeMap data, @NotNull Version minecraftVersion, @NotNull String platformType, int formatVersion) { super(id, pinned, timestamp, saveCause, serverName, Map.of(), minecraftVersion, platformType, formatVersion); this.deserialized = data; @@ -389,25 +389,25 @@ public class DataSnapshot { @NotNull @ApiStatus.Internal - private Map deserializeData(@NotNull HuskSync plugin) { + private TreeMap deserializeData(@NotNull HuskSync plugin) { return data.entrySet().stream() - .map((entry) -> plugin.getIdentifier(entry.getKey()).map(id -> Map.entry( - id, plugin.getSerializers().get(id).deserialize(entry.getValue(), getMinecraftVersion()) - )).orElse(null)) - .filter(Objects::nonNull) - .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)); + .filter(e -> plugin.getIdentifier(e.getKey()).isPresent()) + .map(entry -> Map.entry(plugin.getIdentifier(entry.getKey()).orElseThrow(), entry.getValue())) + .collect(Collectors.toMap( + Map.Entry::getKey, + entry -> plugin.deserializeData(entry.getKey(), entry.getValue()), + (a, b) -> b, () -> Maps.newTreeMap(SerializerRegistry.DEPENDENCY_ORDER_COMPARATOR) + )); } @NotNull @ApiStatus.Internal private Map serializeData(@NotNull HuskSync plugin) { return deserialized.entrySet().stream() - .map((entry) -> Map.entry(entry.getKey().toString(), - Objects.requireNonNull( - plugin.getSerializers().get(entry.getKey()), - String.format("No serializer found for %s", entry.getKey()) - ).serialize(entry.getValue()))) - .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)); + .collect(Collectors.toMap( + entry -> entry.getKey().toString(), + entry -> plugin.serializeData(entry.getKey(), entry.getValue()) + )); } /** @@ -453,12 +453,12 @@ public class DataSnapshot { private String serverName; private boolean pinned; private OffsetDateTime timestamp; - private final Map data; + private final TreeMap data; private Builder(@NotNull HuskSync plugin) { this.plugin = plugin; this.pinned = false; - this.data = Maps.newHashMap(); + this.data = Maps.newTreeMap(SerializerRegistry.DEPENDENCY_ORDER_COMPARATOR); this.timestamp = OffsetDateTime.now(); this.id = UUID.randomUUID(); this.serverName = plugin.getServerName(); 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 c346c9b0..6147fbda 100644 --- a/common/src/main/java/net/william278/husksync/data/Identifier.java +++ b/common/src/main/java/net/william278/husksync/data/Identifier.java @@ -19,55 +19,93 @@ package net.william278.husksync.data; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; import net.kyori.adventure.key.InvalidKeyException; import net.kyori.adventure.key.Key; import org.intellij.lang.annotations.Subst; import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; +import java.util.Collections; +import java.util.Comparator; import java.util.Map; +import java.util.Set; import java.util.stream.Stream; /** * Identifiers of different types of {@link Data}s */ +@Getter public class Identifier { - public static Identifier INVENTORY = huskSync("inventory", true); - public static Identifier ENDER_CHEST = huskSync("ender_chest", true); - public static Identifier POTION_EFFECTS = huskSync("potion_effects", true); - public static Identifier ADVANCEMENTS = huskSync("advancements", true); - public static Identifier LOCATION = huskSync("location", false); - 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); - public static Identifier PERSISTENT_DATA = huskSync("persistent_data", true); + // Built-in identifiers + public static final Identifier PERSISTENT_DATA = huskSync("persistent_data", true); + public static final Identifier INVENTORY = huskSync("inventory", true); + public static final Identifier ENDER_CHEST = huskSync("ender_chest", true); + public static final Identifier ADVANCEMENTS = huskSync("advancements", true); + public static final Identifier STATISTICS = huskSync("statistics", true); + public static final Identifier POTION_EFFECTS = huskSync("potion_effects", true); + public static final Identifier GAME_MODE = huskSync("game_mode", false); + public static final Identifier FLIGHT_STATUS = huskSync("flight_status", true, + Dependency.optional("game_mode") + ); + public static final Identifier ATTRIBUTES = huskSync("attributes", true, + Dependency.required("potion_effects") + ); + public static final Identifier HEALTH = huskSync("health", true, + Dependency.optional("attributes") + ); + public static final Identifier HUNGER = huskSync("hunger", true, + Dependency.optional("attributes") + ); + public static final Identifier EXPERIENCE = huskSync("experience", true, + Dependency.optional("advancements") + ); + public static final Identifier LOCATION = huskSync("location", false, + Dependency.optional("flight_status"), + Dependency.optional("potion_effects") + ); private final Key key; - private final boolean configDefault; + private final boolean enabledByDefault; + @Getter + private final Set dependencies; - private Identifier(@NotNull Key key, boolean configDefault) { + private Identifier(@NotNull Key key, boolean enabledByDefault, @NotNull Set dependencies) { this.key = key; - this.configDefault = configDefault; + this.enabledByDefault = enabledByDefault; + this.dependencies = dependencies; } /** * Create an identifier from a {@link Key} * - * @param key the key + * @param key the key + * @param dependencies the dependencies * @return the identifier - * @since 3.0 + * @since 3.5.4 */ @NotNull - public static Identifier from(@NotNull Key key) { + public static Identifier from(@NotNull Key key, @NotNull Set dependencies) { if (key.namespace().equals("husksync")) { throw new IllegalArgumentException("You cannot register a key with \"husksync\" as the namespace!"); } - return new Identifier(key, true); + return new Identifier(key, true, dependencies); + } + + /** + * Create an identifier from a {@link Key} + * + * @param key the key + * @return the identifier + * @since 3.0 + */ + @NotNull + public static Identifier from(@NotNull Key key) { + return from(key, Collections.emptySet()); } /** @@ -83,25 +121,34 @@ public class Identifier { return from(Key.key(plugin, name)); } + /** + * Create an identifier from a namespace, value, and dependencies + * + * @param plugin the namespace + * @param name the value + * @param dependencies the dependencies + * @return the identifier + * @since 3.5.4 + */ @NotNull - private static Identifier huskSync(@Subst("null") @NotNull String name, - boolean configDefault) throws InvalidKeyException { - return new Identifier(Key.key("husksync", name), configDefault); + public static Identifier from(@Subst("plugin") @NotNull String plugin, @Subst("null") @NotNull String name, + @NotNull Set dependencies) { + return from(Key.key(plugin, name), dependencies); } + // Return an identifier with a HuskSync namespace @NotNull - @SuppressWarnings("unused") - private static Identifier parse(@NotNull String key) throws InvalidKeyException { - return huskSync(key, true); - } - - public boolean isEnabledByDefault() { - return configDefault; + private static Identifier huskSync(@Subst("null") @NotNull String name, + boolean configDefault) throws InvalidKeyException { + return new Identifier(Key.key("husksync", name), configDefault, Collections.emptySet()); } + // Return an identifier with a HuskSync namespace @NotNull - private Map.Entry getConfigEntry() { - return Map.entry(getKeyValue(), configDefault); + private static Identifier huskSync(@Subst("null") @NotNull String name, + @SuppressWarnings("SameParameterValue") boolean configDefault, + @NotNull Dependency... dependents) throws InvalidKeyException { + return new Identifier(Key.key("husksync", name), configDefault, Set.of(dependents)); } /** @@ -122,6 +169,17 @@ public class Identifier { .toArray(Map.Entry[]::new)); } + /** + * Returns {@code true} if the identifier depends on the given identifier + * + * @param identifier the identifier to check + * @return {@code true} if the identifier depends on the given identifier + * @since 3.5.4 + */ + public boolean dependsOn(@NotNull Identifier identifier) { + return dependencies.contains(Dependency.required(identifier.key)); + } + /** * Get the namespace of the identifier * @@ -176,4 +234,85 @@ public class Identifier { return false; } + // Get the config entry for the identifier + @NotNull + private Map.Entry getConfigEntry() { + return Map.entry(getKeyValue(), enabledByDefault); + } + + /** + * Compares two identifiers based on their dependencies. + *

+ * If this identifier contains a dependency on the other, it should come after & vice versa + * + * @since 3.5.4 + */ + @NoArgsConstructor(access = AccessLevel.PACKAGE) + static class DependencyOrderComparator implements Comparator { + + @Override + public int compare(@NotNull Identifier i1, @NotNull Identifier i2) { + if (i1.equals(i2)) { + return 0; + } + if (i1.dependsOn(i2)) { + if (i2.dependsOn(i1)) { + throw new IllegalArgumentException( + "Found circular dependency between %s and %s".formatted(i1.getKey(), i2.getKey()) + ); + } + return 1; + } + return -1; + } + + } + + /** + * Represents a data dependency of an identifier + * + * @since 3.5.4 + */ + @Getter + @AllArgsConstructor(access = AccessLevel.PRIVATE) + public static class Dependency { + /** + * Key of the data dependency see {@code Identifier#key()} + */ + private Key key; + /** + * Whether the data dependency is required to be present & enabled for the dependant data to enabled + */ + private boolean required; + + @NotNull + protected static Dependency required(@NotNull Key identifier) { + return new Dependency(identifier, true); + } + + @NotNull + public static Dependency optional(@NotNull Key identifier) { + return new Dependency(identifier, false); + } + + @NotNull + @SuppressWarnings("SameParameterValue") + private static Dependency required(@Subst("null") @NotNull String identifier) { + return required(Key.key("husksync", identifier)); + } + + @NotNull + private static Dependency optional(@Subst("null") @NotNull String identifier) { + return optional(Key.key("husksync", identifier)); + } + + @Override + public boolean equals(Object obj) { + if (obj instanceof Dependency other) { + return key.equals(other.key); + } + return false; + } + } + } diff --git a/common/src/main/java/net/william278/husksync/data/SerializerRegistry.java b/common/src/main/java/net/william278/husksync/data/SerializerRegistry.java new file mode 100644 index 00000000..0ce03e55 --- /dev/null +++ b/common/src/main/java/net/william278/husksync/data/SerializerRegistry.java @@ -0,0 +1,162 @@ +/* + * 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.william278.husksync.HuskSync; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; + +import java.util.*; +import java.util.logging.Level; + +public interface SerializerRegistry { + + // Comparator for ordering identifiers based on dependency + @NotNull + @ApiStatus.Internal + Comparator DEPENDENCY_ORDER_COMPARATOR = new Identifier.DependencyOrderComparator(); + + /** + * Returns the data serializer for the given {@link Identifier} + * + * @since 3.0 + */ + @NotNull + TreeMap> getSerializers(); + + /** + * Register a data serializer for the given {@link Identifier} + * + * @param identifier the {@link Identifier} + * @param serializer the {@link Serializer} + * @since 3.0 + */ + @SuppressWarnings("unchecked") + default void registerSerializer(@NotNull Identifier identifier, + @NotNull Serializer serializer) { + if (identifier.isCustom()) { + getPlugin().log(Level.INFO, "Registered custom data type: %s".formatted(identifier)); + } + getSerializers().put(identifier, (Serializer) serializer); + } + + /** + * Ensure dependencies for identifiers that have required dependencies are met + *

+ * This checks the dependencies of all registered identifiers and throws an {@link IllegalStateException} + * if a dependency has not been registered or enabled via the config + * + * @since 3.5.4 + */ + default void validateDependencies() throws IllegalStateException { + getSerializers().keySet().stream().filter(this::isDataTypeEnabled) + .forEach(identifier -> { + final List unmet = identifier.getDependencies().stream() + .filter(Identifier.Dependency::isRequired) + .filter(dep -> !isDataTypeAvailable(dep.getKey().asString())) + .map(dep -> dep.getKey().asString()).toList(); + if (!unmet.isEmpty()) { + throw new IllegalStateException( + "\"%s\" data requires the following disabled data types to facilitate syncing: %s" + .formatted(identifier, String.join(", ", unmet)) + ); + } + }); + } + + /** + * Get the {@link Identifier} for the given key + * + * @since 3.0 + */ + default Optional getIdentifier(@NotNull String key) { + return getSerializers().keySet().stream() + .filter(id -> id.getKey().asString().equals(key)).findFirst(); + } + + /** + * Get a data serializer for the given {@link Identifier} + * + * @param identifier the {@link Identifier} to get the serializer for + * @return the {@link Serializer} for the given {@link Identifier} + * @since 3.5.4 + */ + default Optional> getSerializer(@NotNull Identifier identifier) { + return getSerializers().entrySet().stream() + .filter(entry -> entry.getKey().getKey().equals(identifier.getKey())) + .map(Map.Entry::getValue).findFirst(); + } + + /** + * Serialize data for the given {@link Identifier} + * + * @param identifier the {@link Identifier} to serialize data for + * @param data the data to serialize + * @return the serialized data + * @throws IllegalArgumentException if no serializer is found for the given {@link Identifier} + * @since 3.5.4 + */ + @NotNull + default String serializeData(@NotNull Identifier identifier, @NotNull Data data) throws IllegalStateException { + return getSerializer(identifier).map(serializer -> serializer.serialize(data)) + .orElseThrow(() -> new IllegalStateException("No serializer found for %s".formatted(identifier))); + } + + /** + * Deserialize data for the given {@link Identifier} + * + * @param identifier the {@link Identifier} to deserialize data for + * @param data the data to deserialize + * @return the deserialized data + * @throws IllegalStateException if no serializer is found for the given {@link Identifier} + * @since 3.5.4 + */ + @NotNull + default Data deserializeData(@NotNull Identifier identifier, @NotNull String data) throws IllegalStateException { + return getSerializer(identifier).map(serializer -> serializer.deserialize(data)).orElseThrow( + () -> new IllegalStateException("No serializer found for %s".formatted(identifier)) + ); + } + + /** + * Get the set of registered data types + * + * @return the set of registered data types + * @since 3.0 + */ + @NotNull + default Set getRegisteredDataTypes() { + return getSerializers().keySet(); + } + + // Returns if a data type is available and enabled in the config + private boolean isDataTypeAvailable(@NotNull String key) { + return getIdentifier(key).map(this::isDataTypeEnabled).orElse(false); + } + + // Returns if a data type is enabled in the config + private boolean isDataTypeEnabled(@NotNull Identifier identifier) { + return getPlugin().getSettings().getSynchronization().isFeatureEnabled(identifier); + } + + @NotNull + HuskSync getPlugin(); + +} diff --git a/common/src/main/java/net/william278/husksync/data/UserDataHolder.java b/common/src/main/java/net/william278/husksync/data/UserDataHolder.java index 7321e6aa..d173cc2b 100644 --- a/common/src/main/java/net/william278/husksync/data/UserDataHolder.java +++ b/common/src/main/java/net/william278/husksync/data/UserDataHolder.java @@ -34,7 +34,7 @@ import java.util.logging.Level; public interface UserDataHolder extends DataHolder { /** - * Get the data that is enabled for syncing in the config + * Get the data enabled for syncing in the config * * @return the data that is enabled for syncing * @since 3.0 diff --git a/docs/Custom-Data-API.md b/docs/Custom-Data-API.md index 0a0c12b9..9927aee9 100644 --- a/docs/Custom-Data-API.md +++ b/docs/Custom-Data-API.md @@ -116,12 +116,26 @@ public static Identifier LOGIN_PARTICLES_ID = Identifier.from("myplugin", "login huskSyncAPI.registerSerializer(LOGIN_PARTICLES_ID, new LoginParticleSerializer(HuskSyncAPI.getInstance())); ``` +### 3.1 Identifier dependencies +* HuskSync lets you specify a set of `Dependency` objects when creating an `Identifier`. These are used to deterministically apply data in a specific order. +* Dependencies are references to other data type identifiers. HuskSync will apply data in dependency-order; that is, it will apply the data of the dependencies before applying the data of the dependent. +* This is useful when you have data that relies on other data to be applied first; for example, if you're writing an add-on for additional modded inventory data and you need to apply the base inventory data first. +* You can specify whether a dependency is required or optional. HuskSync will not sync data of a type that has a required dependency that is missing (for instance, if it is disabled in the config, or - if provided by another plugin - has failed to register). +* Use `Identifer#from(String, String, Set)` or `Identifier#from(Key, Set)` to create an identifier with dependencies +* Dependencies can be created with `Dependency.optional(Identifier)` or `Dependency.required(Identifier)` for optional or required dependencies respectively. + ## 4. Setting and getting our Data to/from a User * Now that we've registered our `Data` and `Serializer` classes, we can set our data to a user, applying it to them. * To do this, we use the `OnlineUser#setData(Identifier, Data)` method. * This method will apply the data to the user, and store the data to the plugin player custom data map, to allow the data to be retrieved later and be saved to snapshots. * Snapshots created on servers where the data type is registered will now contain our data and synchronise between instances! +```java +// Create an identifier for our data requiring the user's location to have been set first +public static Identifier LOGIN_PARTICLES_ID = Identifier.from("myplugin", "login_particles", Set.of(Dependency.optional(Key.key("husksync", "location")))); +// We can then register this as we did previously (...) +``` + ```java // Create an instance of our data LoginParticleData loginParticleData = new LoginParticleData("FIREWORKS_SPARK", 10); diff --git a/docs/Data-Snapshot-API.md b/docs/Data-Snapshot-API.md index 0cf1ace1..ec777c95 100644 --- a/docs/Data-Snapshot-API.md +++ b/docs/Data-Snapshot-API.md @@ -213,8 +213,8 @@ huskSyncAPI.getCurrentData(user).thenAccept(optionalSnapshot -> { // Get the health data Data.Health health = healthOptional.get(); double currentHealth = health.getCurrentHealth(); // Current health - double healthScale = health.getHealthScale(); // Health scale (e.g., 20 for 20 hearts) - snapshot.setHealth(BukkitData.Health.from(20, 20)); + double healthScale = health.getHealthScale(); // Health scale (used to determine health/damage display hearts) + snapshot.setHealth(BukkitData.Health.from(20, 20, true)); // Need max health? Look at the Attributes data type. // Get the game mode data