diff --git a/annotation-processors/build.gradle.kts b/annotation-processors/build.gradle.kts index 00605ba6b..ff45d3a34 100644 --- a/annotation-processors/build.gradle.kts +++ b/annotation-processors/build.gradle.kts @@ -3,7 +3,7 @@ plugins { } dependencies { - annotationProcessor(libs.autoService.processor) + annotationProcessor(libs.autoService) compileOnlyApi(libs.autoService.annotations) api(libs.jetbrainsAnnotations) } diff --git a/api/build.gradle.kts b/api/build.gradle.kts index b6eeac9a0..23be56c7b 100644 --- a/api/build.gradle.kts +++ b/api/build.gradle.kts @@ -16,6 +16,8 @@ dependencies { compileOnlyApi(libs.jetbrainsAnnotations) testImplementation(libs.guava) annotationProcessor(projects.adventureAnnotationProcessors) + testCompileOnly(libs.autoService.annotations) + testAnnotationProcessor(libs.autoService) } applyJarMetadata("net.kyori.adventure") diff --git a/api/src/main/java/net/kyori/adventure/nbt/api/BinaryTagHolder.java b/api/src/main/java/net/kyori/adventure/nbt/api/BinaryTagHolder.java index 030247c2d..4922c888a 100644 --- a/api/src/main/java/net/kyori/adventure/nbt/api/BinaryTagHolder.java +++ b/api/src/main/java/net/kyori/adventure/nbt/api/BinaryTagHolder.java @@ -23,6 +23,7 @@ */ package net.kyori.adventure.nbt.api; +import net.kyori.adventure.text.event.DataComponentValue; import net.kyori.adventure.util.Codec; import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; @@ -37,7 +38,7 @@ * * @since 4.0.0 */ -public interface BinaryTagHolder { +public interface BinaryTagHolder extends DataComponentValue.TagSerializable { /** * Encodes {@code nbt} using {@code codec}. * @@ -86,6 +87,11 @@ public interface BinaryTagHolder { */ @NotNull String string(); + @Override + default @NotNull BinaryTagHolder asBinaryTag() { + return this; + } + /** * Gets the held value as a binary tag. * diff --git a/api/src/main/java/net/kyori/adventure/text/event/DataComponentValue.java b/api/src/main/java/net/kyori/adventure/text/event/DataComponentValue.java new file mode 100644 index 000000000..911ea6096 --- /dev/null +++ b/api/src/main/java/net/kyori/adventure/text/event/DataComponentValue.java @@ -0,0 +1,80 @@ +/* + * This file is part of adventure, licensed under the MIT License. + * + * Copyright (c) 2017-2024 KyoriPowered + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package net.kyori.adventure.text.event; + +import net.kyori.adventure.nbt.api.BinaryTagHolder; +import net.kyori.examination.Examinable; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; + +/** + * A holder for the value of an item's data component. + * + *

The exact value is platform-specific. Serializers may provide their + * own implementations as well, and any logic to serialize or deserialize + * should be done per-serializer.

+ * + * @since 4.17.0 + * @sinceMinecraft 1.20.5 + */ +public interface DataComponentValue extends Examinable { + /** + * Get a marker value to indicate that a data component's value should be removed. + * + * @return the removed holder + * @since 4.17.0 + * @sinceMinecraft 1.20.5 + */ + static DataComponentValue.@NotNull Removed removed() { + return RemovedDataComponentValueImpl.REMOVED; + } + + /** + * Represent an {@link DataComponentValue} that can be represented as a binary tag. + * + * @since 4.17.0 + * @sinceMinecraft 1.20.5 + */ + interface TagSerializable extends DataComponentValue { + + /** + * Convert this value into a binary tag value. + * + * @return the binary tag value + * @since 4.17.0 + * @sinceMinecraft 1.20.5 + */ + @NotNull BinaryTagHolder asBinaryTag(); + } + + /** + * Only valid in a patch-style usage, indicating that the data component with a certain key should be removed. + * + * @since 4.17.0 + * @sinceMinecraft 1.20.5 + */ + @ApiStatus.NonExtendable + interface Removed extends DataComponentValue { + } +} diff --git a/api/src/main/java/net/kyori/adventure/text/event/DataComponentValueConversionImpl.java b/api/src/main/java/net/kyori/adventure/text/event/DataComponentValueConversionImpl.java new file mode 100644 index 000000000..2469ea01c --- /dev/null +++ b/api/src/main/java/net/kyori/adventure/text/event/DataComponentValueConversionImpl.java @@ -0,0 +1,90 @@ +/* + * This file is part of adventure, licensed under the MIT License. + * + * Copyright (c) 2017-2024 KyoriPowered + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package net.kyori.adventure.text.event; + +import java.util.Objects; +import java.util.function.BiFunction; +import java.util.stream.Stream; +import net.kyori.adventure.internal.Internals; +import net.kyori.adventure.key.Key; +import net.kyori.examination.ExaminableProperty; +import org.jetbrains.annotations.NotNull; + +import static java.util.Objects.requireNonNull; + +final class DataComponentValueConversionImpl implements DataComponentValueConverterRegistry.Conversion { + private final Class source; + private final Class destination; + private final BiFunction conversion; + + DataComponentValueConversionImpl(final @NotNull Class source, final @NotNull Class destination, final @NotNull BiFunction conversion) { + this.source = source; + this.destination = destination; + this.conversion = conversion; + } + + @Override + public @NotNull Class source() { + return this.source; + } + + @Override + public @NotNull Class destination() { + return this.destination; + } + + @Override + public @NotNull O convert(final @NotNull Key key, final @NotNull I input) { + return this.conversion.apply(requireNonNull(key, "key"), requireNonNull(input, "input")); + } + + @Override + public @NotNull Stream examinableProperties() { + return Stream.of( + ExaminableProperty.of("source", this.source), + ExaminableProperty.of("destination", this.destination), + ExaminableProperty.of("conversion", this.conversion) + ); + } + + @Override + public String toString() { + return Internals.toString(this); + } + + @Override + public boolean equals(final Object other) { + if (this == other) return true; + if (other == null || getClass() != other.getClass()) return false; + final DataComponentValueConversionImpl that = (DataComponentValueConversionImpl) other; + return Objects.equals(this.source, that.source) + && Objects.equals(this.destination, that.destination) + && Objects.equals(this.conversion, that.conversion); + } + + @Override + public int hashCode() { + return Objects.hash(this.source, this.destination, this.conversion); + } +} diff --git a/api/src/main/java/net/kyori/adventure/text/event/DataComponentValueConverterRegistry.java b/api/src/main/java/net/kyori/adventure/text/event/DataComponentValueConverterRegistry.java new file mode 100644 index 000000000..4b136c9c1 --- /dev/null +++ b/api/src/main/java/net/kyori/adventure/text/event/DataComponentValueConverterRegistry.java @@ -0,0 +1,260 @@ +/* + * This file is part of adventure, licensed under the MIT License. + * + * Copyright (c) 2017-2024 KyoriPowered + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package net.kyori.adventure.text.event; + +import java.util.ArrayDeque; +import java.util.Arrays; +import java.util.Collections; +import java.util.Deque; +import java.util.Map; +import java.util.ServiceLoader; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import java.util.function.BiFunction; +import java.util.stream.Collectors; +import net.kyori.adventure.key.Key; +import net.kyori.adventure.util.Services; +import net.kyori.examination.Examinable; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.Contract; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import static java.util.Objects.requireNonNull; + +/** + * A registry for conversions between different data component value holder classes. + * + *

Conversions are discovered by {@link ServiceLoader} lookup of implementations of the {@link Provider} interface (on the classloader which loaded Adventure).

+ * + * @since 4.17.0 + */ +public final class DataComponentValueConverterRegistry { + private static final Set PROVIDERS = Services.services(Provider.class); + + private DataComponentValueConverterRegistry() { + } + + /** + * Get the id's of all registered conversion providers. + * + * @return an unmodifiable set of the known provider ids + * @since 4.1.7.0 + */ + public static Set knownProviders() { + return Collections.unmodifiableSet(PROVIDERS.stream() + .map(Provider::id) + .collect(Collectors.toSet())); + } + + /** + * Try to convert the data component value {@code in} to the provided output type. + * + * @param target the target type + * @param key the key this value is for + * @param in the input value + * @param the output type + * @return a value of target type + * @since 4.17.0 + */ + @SuppressWarnings({"unchecked", "rawtypes"}) + public static @NotNull O convert(final @NotNull Class target, final @NotNull Key key, final @NotNull DataComponentValue in) { + if (target.isInstance(in)) { + return target.cast(in); + } + + final @Nullable RegisteredConversion converter = ConversionCache.converter(in.getClass(), target); + if (converter == null) { + throw new IllegalArgumentException("There is no data holder converter registered to convert from a " + in.getClass() + " instance to a " + target + " (on field " + key + ")"); + } + + try { + return (O) ((Conversion) converter.conversion).convert(key, in); + } catch (final Exception ex) { + throw new IllegalStateException("Failed to convert data component value of type " + in.getClass() + " to type " + target + " due to an error in a converter provided by " + converter.provider.asString() + "!", ex); + } + } + + /** + * A provider for data component value converters. + * + * @since 4.17.0 + */ + public interface Provider { + /** + * An identifier for this provider. + * + * @return the provider id + * @since 4.17.0 + */ + @NotNull Key id(); + + /** + * Return conversions available from this provider. + * + *

Conversions may only be queried once at application initialization, so changes to the result of this method may not have any effect.

+ * + * @return the conversions available + * @since 4.17.0 + */ + @NotNull Iterable> conversions(); + } + + /** + * A single conversion that may be provided by a provider. + * + * @param input type + * @param output type + * @since 4.17.0 + */ + @ApiStatus.NonExtendable + public interface Conversion extends Examinable { + /** + * Create a new conversion. + * + * @param src the source type + * @param dst the destination type + * @param op the conversion operation + * @param the input type + * @param the output type + * @return a conversion object + * @since 4.17.0 + */ + static @NotNull Conversion convert(final @NotNull Class src, final @NotNull Class dst, final @NotNull BiFunction op) { + return new DataComponentValueConversionImpl<>( + requireNonNull(src, "src"), + requireNonNull(dst, "dst"), + requireNonNull(op, "op") + ); + } + + /** + * The source type. + * + * @return the source type + * @since 4.17.0 + */ + @Contract(pure = true) + @NotNull Class source(); + + /** + * The destination type. + * + * @return the destination type + * @since 4.17.0 + */ + @Contract(pure = true) + @NotNull Class destination(); + + /** + * Perform the actual conversion. + * + * @param key the key used for the data holder + * @param input the source type + * @return a data holder of the destination type + * @since 4.17.0 + */ + @NotNull O convert(final @NotNull Key key, final @NotNull I input); + } + + static final class ConversionCache { + // input -> output -> conversion + private static final ConcurrentMap, ConcurrentMap, RegisteredConversion>> CACHE = new ConcurrentHashMap<>(); + private static final Map, Set> CONVERSIONS = collectConversions(); + + private static Map, Set> collectConversions() { + final Map, Set> collected = new ConcurrentHashMap<>(); + for (final Provider provider : PROVIDERS) { + final @NotNull Key id = requireNonNull(provider.id(), () -> "ID of provider " + provider + " is null"); + for (final Conversion conv : provider.conversions()) { + collected.computeIfAbsent(conv.source(), $ -> ConcurrentHashMap.newKeySet()).add(new RegisteredConversion(id, conv)); + } + } + + for (final Map.Entry, Set> entry : collected.entrySet()) { + entry.setValue(Collections.unmodifiableSet(entry.getValue())); + } + + return new ConcurrentHashMap<>(collected); + } + + static RegisteredConversion compute(final Class src, final Class dst) { + final Deque> sourceTypes = new ArrayDeque<>(); + sourceTypes.add(src); + // walk up the source type hierarchy to find an option + for (Class sourcePtr; (sourcePtr = sourceTypes.poll()) != null;) { + final Set conversions = CONVERSIONS.get(sourcePtr); + if (conversions != null) { + // if we have values for the source type, evaluate each to find the one that matches either the exact destination type, or the nearest subtype + RegisteredConversion nearest = null; + for (final RegisteredConversion potential : conversions) { + final Class potentialDst = potential.conversion.destination(); + + if (dst.equals(potentialDst)) return potential; // exact match + if (!dst.isAssignableFrom(potentialDst)) continue; // out of hierarchy + + // if we are up the hierarchy + if (nearest == null || potentialDst.isAssignableFrom(nearest.conversion.destination())) { + nearest = potential; + } + } + + if (nearest != null) return nearest; // we found a match + } + + addSupertypes(sourcePtr, sourceTypes); + } + + return RegisteredConversion.NONE; + } + + private static void addSupertypes(final Class clazz, final Deque> queue) { + if (clazz.getSuperclass() != null) { + queue.add(clazz.getSuperclass()); + } + + queue.addAll(Arrays.asList(clazz.getInterfaces())); + } + + static @Nullable RegisteredConversion converter(final Class src, final Class dst) { + final RegisteredConversion result = CACHE.computeIfAbsent(src, $ -> new ConcurrentHashMap<>()).computeIfAbsent(dst, $$ -> compute(src, dst)); + if (result == RegisteredConversion.NONE) return null; + + return result; + } + } + + static final class RegisteredConversion { + static final RegisteredConversion NONE = new RegisteredConversion(null, null); + + final Key provider; + final Conversion conversion; + + RegisteredConversion(final Key provider, final Conversion conversion) { + this.provider = provider; + this.conversion = conversion; + } + } +} diff --git a/api/src/main/java/net/kyori/adventure/text/event/HoverEvent.java b/api/src/main/java/net/kyori/adventure/text/event/HoverEvent.java index 02c572db7..e18291355 100644 --- a/api/src/main/java/net/kyori/adventure/text/event/HoverEvent.java +++ b/api/src/main/java/net/kyori/adventure/text/event/HoverEvent.java @@ -23,6 +23,9 @@ */ package net.kyori.adventure.text.event; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; import java.util.Objects; import java.util.UUID; import java.util.function.UnaryOperator; @@ -87,7 +90,7 @@ public final class HoverEvent implements Examinable, HoverEventSource, Sty * @since 4.0.0 */ public static @NotNull HoverEvent showItem(final @NotNull Key item, final @Range(from = 0, to = Integer.MAX_VALUE) int count) { - return showItem(item, count, null); + return showItem(item, count, Collections.emptyMap()); } /** @@ -99,7 +102,7 @@ public final class HoverEvent implements Examinable, HoverEventSource, Sty * @since 4.6.0 */ public static @NotNull HoverEvent showItem(final @NotNull Keyed item, final @Range(from = 0, to = Integer.MAX_VALUE) int count) { - return showItem(item, count, null); + return showItem(item, count, Collections.emptyMap()); } /** @@ -111,8 +114,9 @@ public final class HoverEvent implements Examinable, HoverEventSource, Sty * @return a hover event * @since 4.0.0 */ + @Deprecated public static @NotNull HoverEvent showItem(final @NotNull Key item, final @Range(from = 0, to = Integer.MAX_VALUE) int count, final @Nullable BinaryTagHolder nbt) { - return showItem(ShowItem.of(item, count, nbt)); + return showItem(ShowItem.showItem(item, count, nbt)); } /** @@ -123,9 +127,24 @@ public final class HoverEvent implements Examinable, HoverEventSource, Sty * @param nbt the nbt * @return a hover event * @since 4.6.0 + * @deprecated since Minecraft 1.20.5 and replaced with data components, not scheduled for removal */ + @Deprecated public static @NotNull HoverEvent showItem(final @NotNull Keyed item, final @Range(from = 0, to = Integer.MAX_VALUE) int count, final @Nullable BinaryTagHolder nbt) { - return showItem(ShowItem.of(item, count, nbt)); + return showItem(ShowItem.showItem(item, count, nbt)); + } + + /** + * Creates a hover event that shows an item on hover. + * + * @param item the item + * @param count the count + * @param dataComponents the data components + * @return a hover event + * @since 4.17.0 + */ + public static @NotNull HoverEvent showItem(final @NotNull Keyed item, final @Range(from = 0, to = Integer.MAX_VALUE) int count, final @NotNull Map dataComponents) { + return showItem(ShowItem.showItem(item, count, dataComponents)); } /** @@ -216,7 +235,7 @@ public final class HoverEvent implements Examinable, HoverEventSource, Sty * @param value the achievement value * @return a hover event * @since 4.14.0 - * @deprecated Removed in Vanilla 1.12, but we keep it for backwards compat + * @deprecated Removed in Vanilla 1.12, but we keep it for backwards compatibility */ @Deprecated public static @NotNull HoverEvent showAchievement(final @NotNull String value) { @@ -344,6 +363,7 @@ public static final class ShowItem implements Examinable { private final Key item; private final int count; private final @Nullable BinaryTagHolder nbt; + private final Map dataComponents; /** * Creates. @@ -354,7 +374,7 @@ public static final class ShowItem implements Examinable { * @since 4.14.0 */ public static @NotNull ShowItem showItem(final @NotNull Key item, final @Range(from = 0, to = Integer.MAX_VALUE) int count) { - return showItem(item, count, null); + return showItem(item, count, Collections.emptyMap()); } /** @@ -369,7 +389,7 @@ public static final class ShowItem implements Examinable { @Deprecated @ApiStatus.ScheduledForRemoval(inVersion = "5.0.0") public static @NotNull ShowItem of(final @NotNull Key item, final @Range(from = 0, to = Integer.MAX_VALUE) int count) { - return of(item, count, null); + return showItem(item, count, Collections.emptyMap()); } /** @@ -381,7 +401,7 @@ public static final class ShowItem implements Examinable { * @since 4.14.0 */ public static @NotNull ShowItem showItem(final @NotNull Keyed item, final @Range(from = 0, to = Integer.MAX_VALUE) int count) { - return showItem(item, count, null); + return showItem(item, count, Collections.emptyMap()); } /** @@ -407,9 +427,11 @@ public static final class ShowItem implements Examinable { * @param nbt the nbt * @return a {@code ShowItem} * @since 4.14.0 + * @deprecated since Minecraft 1.20.5 and replaced with data components, not scheduled for removal */ + @Deprecated public static @NotNull ShowItem showItem(final @NotNull Key item, final @Range(from = 0, to = Integer.MAX_VALUE) int count, final @Nullable BinaryTagHolder nbt) { - return new ShowItem(requireNonNull(item, "item"), count, nbt); + return new ShowItem(requireNonNull(item, "item"), count, nbt, Collections.emptyMap()); } /** @@ -425,7 +447,7 @@ public static final class ShowItem implements Examinable { @Deprecated @ApiStatus.ScheduledForRemoval(inVersion = "5.0.0") public static @NotNull ShowItem of(final @NotNull Key item, final @Range(from = 0, to = Integer.MAX_VALUE) int count, final @Nullable BinaryTagHolder nbt) { - return new ShowItem(requireNonNull(item, "item"), count, nbt); + return new ShowItem(requireNonNull(item, "item"), count, nbt, Collections.emptyMap()); } /** @@ -436,9 +458,11 @@ public static final class ShowItem implements Examinable { * @param nbt the nbt * @return a {@code ShowItem} * @since 4.14.0 + * @deprecated since Minecraft 1.20.5 and replaced with data components, not scheduled for removal */ + @Deprecated public static @NotNull ShowItem showItem(final @NotNull Keyed item, final @Range(from = 0, to = Integer.MAX_VALUE) int count, final @Nullable BinaryTagHolder nbt) { - return new ShowItem(requireNonNull(item, "item").key(), count, nbt); + return new ShowItem(requireNonNull(item, "item").key(), count, nbt, Collections.emptyMap()); } /** @@ -454,13 +478,28 @@ public static final class ShowItem implements Examinable { @Deprecated @ApiStatus.ScheduledForRemoval(inVersion = "5.0.0") public static @NotNull ShowItem of(final @NotNull Keyed item, final @Range(from = 0, to = Integer.MAX_VALUE) int count, final @Nullable BinaryTagHolder nbt) { - return new ShowItem(requireNonNull(item, "item").key(), count, nbt); + return new ShowItem(requireNonNull(item, "item").key(), count, nbt, Collections.emptyMap()); + } + + /** + * Creates. + * + * @param item the item + * @param count the count + * @param dataComponents the data components + * @return a {@code ShowItem} + * @since 4.17.0 + * @sinceMinecraft 1.20.5 + */ + public static @NotNull ShowItem showItem(final @NotNull Keyed item, final @Range(from = 0, to = Integer.MAX_VALUE) int count, final @NotNull Map dataComponents) { + return new ShowItem(requireNonNull(item, "item").key(), count, null, dataComponents); } - private ShowItem(final @NotNull Key item, final @Range(from = 0, to = Integer.MAX_VALUE) int count, final @Nullable BinaryTagHolder nbt) { + private ShowItem(final @NotNull Key item, final @Range(from = 0, to = Integer.MAX_VALUE) int count, final @Nullable BinaryTagHolder nbt, final @NotNull Map dataComponents) { this.item = item; this.count = count; this.nbt = nbt; + this.dataComponents = Collections.unmodifiableMap(new HashMap<>(dataComponents)); } /** @@ -482,7 +521,7 @@ private ShowItem(final @NotNull Key item, final @Range(from = 0, to = Integer.MA */ public @NotNull ShowItem item(final @NotNull Key item) { if (requireNonNull(item, "item").equals(this.item)) return this; - return new ShowItem(item, this.count, this.nbt); + return new ShowItem(item, this.count, this.nbt, this.dataComponents); } /** @@ -504,15 +543,19 @@ private ShowItem(final @NotNull Key item, final @Range(from = 0, to = Integer.MA */ public @NotNull ShowItem count(final @Range(from = 0, to = Integer.MAX_VALUE) int count) { if (count == this.count) return this; - return new ShowItem(this.item, count, this.nbt); + return new ShowItem(this.item, count, this.nbt, this.dataComponents); } /** * Gets the nbt. * + *

If there are data components on this item, it will never have NBT data.

+ * * @return the nbt * @since 4.0.0 + * @deprecated since Minecraft 1.20.5 and replaced with data components, not scheduled for removal */ + @Deprecated public @Nullable BinaryTagHolder nbt() { return this.nbt; } @@ -520,13 +563,67 @@ private ShowItem(final @NotNull Key item, final @Range(from = 0, to = Integer.MA /** * Sets the nbt. * + *

This will clear any modern data components set on the item.

+ * * @param nbt the nbt * @return a {@code ShowItem} * @since 4.0.0 + * @deprecated since Minecraft 1.20.5 and replaced with data components, not scheduled for removal */ + @Deprecated public @NotNull ShowItem nbt(final @Nullable BinaryTagHolder nbt) { if (Objects.equals(nbt, this.nbt)) return this; - return new ShowItem(this.item, this.count, nbt); + return new ShowItem(this.item, this.count, nbt, Collections.emptyMap()); + } + + /** + * Get the data components used for this item. + * + *

If there is NBT data on this item, it will never have any data components set.

+ * + * @return an unmodifiable map of data components + * @since 4.17.0 + * @sinceMinecraft 1.20.5 + */ + public @NotNull Map dataComponents() { + return this.dataComponents; + } + + /** + * Set the data components used on this item. + * + *

This will clear any legacy NBT-format data on the item.

+ * + * @param holder the new data components to set + * @return a show item data object that has the provided components + * @since 4.17.0 + * @sinceMinecraft 1.20.5 + */ + public @NotNull ShowItem dataComponents(final @NotNull Map holder) { + if (Objects.equals(this.dataComponents, holder)) return this; + return new ShowItem(this.item, this.count, null, holder.isEmpty() ? Collections.emptyMap() : Collections.unmodifiableMap(new HashMap<>(holder))); + } + + /** + * Return an unmodifiable map of data components coerced to the target type. + * + *

If there is no converter registered with the {@link DataComponentValueConverterRegistry} for the conversion of a value, a {@link IllegalArgumentException} will be thrown.

+ * + * @param targetType the expected target type + * @param the new data component value type + * @return the unmodifiable map + * @since 4.17.0 + */ + public @NotNull Map dataComponentsAs(final @NotNull Class targetType) { + if (this.dataComponents.isEmpty()) { + return Collections.emptyMap(); + } else { + final Map results = new HashMap<>(this.dataComponents.size()); + for (final Map.Entry entry : this.dataComponents.entrySet()) { + results.put(entry.getKey(), DataComponentValueConverterRegistry.convert(targetType, entry.getKey(), entry.getValue())); + } + return Collections.unmodifiableMap(results); + } } @Override @@ -534,7 +631,7 @@ public boolean equals(final @Nullable Object other) { if (this == other) return true; if (other == null || this.getClass() != other.getClass()) return false; final ShowItem that = (ShowItem) other; - return this.item.equals(that.item) && this.count == that.count && Objects.equals(this.nbt, that.nbt); + return this.item.equals(that.item) && this.count == that.count && Objects.equals(this.nbt, that.nbt) && Objects.equals(this.dataComponents, that.dataComponents); } @Override @@ -542,6 +639,7 @@ public int hashCode() { int result = this.item.hashCode(); result = (31 * result) + Integer.hashCode(this.count); result = (31 * result) + Objects.hashCode(this.nbt); + result = (31 * result) + Objects.hashCode(this.dataComponents); return result; } @@ -550,7 +648,8 @@ public int hashCode() { return Stream.of( ExaminableProperty.of("item", this.item), ExaminableProperty.of("count", this.count), - ExaminableProperty.of("nbt", this.nbt) + ExaminableProperty.of("nbt", this.nbt), + ExaminableProperty.of("dataComponents", this.dataComponents) ); } diff --git a/api/src/main/java/net/kyori/adventure/text/event/RemovedDataComponentValueImpl.java b/api/src/main/java/net/kyori/adventure/text/event/RemovedDataComponentValueImpl.java new file mode 100644 index 000000000..440bec0cb --- /dev/null +++ b/api/src/main/java/net/kyori/adventure/text/event/RemovedDataComponentValueImpl.java @@ -0,0 +1,28 @@ +/* + * This file is part of adventure, licensed under the MIT License. + * + * Copyright (c) 2017-2024 KyoriPowered + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package net.kyori.adventure.text.event; + +enum RemovedDataComponentValueImpl implements DataComponentValue.Removed { + REMOVED +} diff --git a/api/src/main/java/net/kyori/adventure/text/flattener/ComponentFlattenerImpl.java b/api/src/main/java/net/kyori/adventure/text/flattener/ComponentFlattenerImpl.java index 92e25b3a3..aadbf7eed 100644 --- a/api/src/main/java/net/kyori/adventure/text/flattener/ComponentFlattenerImpl.java +++ b/api/src/main/java/net/kyori/adventure/text/flattener/ComponentFlattenerImpl.java @@ -23,11 +23,6 @@ */ package net.kyori.adventure.text.flattener; -import java.util.Collections; -import java.util.HashMap; -import java.util.Map; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.ConcurrentMap; import java.util.function.BiConsumer; import java.util.function.Consumer; import java.util.function.Function; @@ -38,6 +33,7 @@ import net.kyori.adventure.text.TextComponent; import net.kyori.adventure.text.TranslatableComponent; import net.kyori.adventure.text.format.Style; +import net.kyori.adventure.util.InheritanceAwareMap; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -66,14 +62,11 @@ final class ComponentFlattenerImpl implements ComponentFlattener { private static final int MAX_DEPTH = 512; - private final Map, Function> flatteners; - private final Map, BiConsumer>> complexFlatteners; - private final ConcurrentMap, Handler> propagatedFlatteners = new ConcurrentHashMap<>(); + private final InheritanceAwareMap flatteners; private final Function unknownHandler; - ComponentFlattenerImpl(final Map, Function> flatteners, final Map, BiConsumer>> complexFlatteners, final @Nullable Function unknownHandler) { - this.flatteners = Collections.unmodifiableMap(new HashMap<>(flatteners)); - this.complexFlatteners = Collections.unmodifiableMap(new HashMap<>(complexFlatteners)); + ComponentFlattenerImpl(final InheritanceAwareMap flatteners, final @Nullable Function unknownHandler) { + this.flatteners = flatteners; this.unknownHandler = unknownHandler; } @@ -96,7 +89,7 @@ private void flatten0(final @NotNull Component input, final @NotNull FlattenerLi listener.pushStyle(inputStyle); try { if (flattener != null) { - flattener.handle(input, listener, depth + 1); + flattener.handle(this, input, listener, depth + 1); } if (!input.children().isEmpty() && listener.shouldContinue()) { @@ -109,34 +102,11 @@ private void flatten0(final @NotNull Component input, final @NotNull FlattenerLi } } - @SuppressWarnings("unchecked") private @Nullable Handler flattener(final T test) { - final Handler flattener = this.propagatedFlatteners.computeIfAbsent(test.getClass(), key -> { - // direct flatteners (just return strings) - final @Nullable Function value = (Function) this.flatteners.get(key); - if (value != null) return (component, listener, depth) -> listener.component(value.apply(component)); - - for (final Map.Entry, Function> entry : this.flatteners.entrySet()) { - if (entry.getKey().isAssignableFrom(key)) { - return (component, listener, depth) -> listener.component(((Function) entry.getValue()).apply(component)); - } - } - - // complex flatteners (these provide extra components) - final @Nullable BiConsumer> complexValue = (BiConsumer>) this.complexFlatteners.get(key); - if (complexValue != null) return (component, listener, depth) -> complexValue.accept(component, c -> this.flatten0(c, listener, depth)); - - for (final Map.Entry, BiConsumer>> entry : this.complexFlatteners.entrySet()) { - if (entry.getKey().isAssignableFrom(key)) { - return (component, listener, depth) -> ((BiConsumer>) entry.getValue()).accept(component, c -> this.flatten0(c, listener, depth)); - } - } + final Handler flattener = this.flatteners.get(test.getClass()); - return Handler.NONE; - }); - - if (flattener == Handler.NONE) { - return this.unknownHandler == null ? null : (component, listener, depth) -> listener.component(this.unknownHandler.apply(component)); + if (flattener == null && this.unknownHandler != null) { + return (self, component, listener, depth) -> listener.component(this.unknownHandler.apply(component)); } else { return flattener; } @@ -144,77 +114,47 @@ private void flatten0(final @NotNull Component input, final @NotNull FlattenerLi @Override public ComponentFlattener.@NotNull Builder toBuilder() { - return new BuilderImpl(this.flatteners, this.complexFlatteners, this.unknownHandler); + return new BuilderImpl(this.flatteners, this.unknownHandler); } // A function that allows nesting other flatten operations @FunctionalInterface interface Handler { - Handler NONE = (input, listener, depth) -> {}; - - void handle(final Component input, final FlattenerListener listener, final int depth); + void handle(final ComponentFlattenerImpl self, final Component input, final FlattenerListener listener, final int depth); } static final class BuilderImpl implements Builder { - private final Map, Function> flatteners; - private final Map, BiConsumer>> complexFlatteners; + private final InheritanceAwareMap.Builder flatteners; private @Nullable Function unknownHandler; BuilderImpl() { - this.flatteners = new HashMap<>(); - this.complexFlatteners = new HashMap<>(); + this.flatteners = InheritanceAwareMap.builder().strict(true); } - BuilderImpl(final Map, Function> flatteners, final Map, BiConsumer>> complexFlatteners, final @Nullable Function unknownHandler) { - this.flatteners = new HashMap<>(flatteners); - this.complexFlatteners = new HashMap<>(complexFlatteners); + BuilderImpl(final InheritanceAwareMap flatteners, final @Nullable Function unknownHandler) { + this.flatteners = InheritanceAwareMap.builder(flatteners).strict(true); this.unknownHandler = unknownHandler; } @Override public @NotNull ComponentFlattener build() { - return new ComponentFlattenerImpl(this.flatteners, this.complexFlatteners, this.unknownHandler); + return new ComponentFlattenerImpl(this.flatteners.build(), this.unknownHandler); } @Override + @SuppressWarnings("unchecked") public ComponentFlattener.@NotNull Builder mapper(final @NotNull Class type, final @NotNull Function converter) { - this.validateNoneInHierarchy(requireNonNull(type, "type")); - this.flatteners.put( - type, - requireNonNull(converter, "converter") - ); - this.complexFlatteners.remove(type); + this.flatteners.put(type, (self, component, listener, depth) -> listener.component(converter.apply((T) component))); return this; } @Override + @SuppressWarnings("unchecked") public ComponentFlattener.@NotNull Builder complexMapper(final @NotNull Class type, final @NotNull BiConsumer> converter) { - this.validateNoneInHierarchy(requireNonNull(type, "type")); - this.complexFlatteners.put( - type, - requireNonNull(converter, "converter") - ); - this.flatteners.remove(type); + this.flatteners.put(type, (self, component, listener, depth) -> converter.accept((T) component, c -> self.flatten0(c, listener, depth))); return this; } - private void validateNoneInHierarchy(final Class beingRegistered) { - for (final Class clazz : this.flatteners.keySet()) { - testHierarchy(clazz, beingRegistered); - } - - for (final Class clazz : this.complexFlatteners.keySet()) { - testHierarchy(clazz, beingRegistered); - } - } - - private static void testHierarchy(final Class existing, final Class beingRegistered) { - if (!existing.equals(beingRegistered) && (existing.isAssignableFrom(beingRegistered) || beingRegistered.isAssignableFrom(existing))) { - throw new IllegalArgumentException("Conflict detected between already registered type " + existing - + " and newly registered type " + beingRegistered + "! Types in a component flattener must not share a common hierarchy!"); - } - } - @Override public ComponentFlattener.@NotNull Builder unknownMapper(final @Nullable Function converter) { this.unknownHandler = converter; diff --git a/api/src/main/java/net/kyori/adventure/util/InheritanceAwareMap.java b/api/src/main/java/net/kyori/adventure/util/InheritanceAwareMap.java new file mode 100644 index 000000000..8bf0cc1da --- /dev/null +++ b/api/src/main/java/net/kyori/adventure/util/InheritanceAwareMap.java @@ -0,0 +1,174 @@ +/* + * This file is part of adventure, licensed under the MIT License. + * + * Copyright (c) 2017-2024 KyoriPowered + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package net.kyori.adventure.util; + +import net.kyori.adventure.builder.AbstractBuilder; +import org.jetbrains.annotations.CheckReturnValue; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** + * A map type that will traverse class hierarchy to find a value for a key. + * + *

These maps are null-hostile, so both keys and values must not be null.

+ * + *

There is a concept of strict mode, where map values have to be strictly non-ambiguous. + * When this enabled (by default it is not), a value will not be added if any subtypes or supertypes are already registered to the map.

+ * + *

Inheritance aware maps are always immutable, so any mutation operations will apply any changes to a new, modified instance.

+ * + * @param the base class type + * @param the value type + * @since 4.17.0 + */ +public interface InheritanceAwareMap { + /** + * Get an empty inheritance aware map. + * + * @param class type upper bound + * @param value type + * @return the map + * @since 4.17.0 + */ + @SuppressWarnings("unchecked") + static @NotNull InheritanceAwareMap empty() { + return InheritanceAwareMapImpl.EMPTY; + } + + /** + * Create a new builder for an inheritance aware map. + * + * @param class type upper bound + * @param value type + * @return a new builder + * @since 4.17.0 + */ + static InheritanceAwareMap.@NotNull Builder builder() { + return new InheritanceAwareMapImpl.BuilderImpl<>(); + } + + /** + * Create a new builder for an inheritance aware map. + * + * @param class type upper bound + * @param value type + * @param existing the existing map to populate the builder with + * @return a new builder + * @since 4.17.0 + */ + static InheritanceAwareMap.@NotNull Builder builder(final InheritanceAwareMap existing) { + return new InheritanceAwareMapImpl.BuilderImpl() + .putAll(existing); + } + + /** + * Check whether this map contains a value (direct or computed) for the provided class. + * + * @param clazz the class type to check + * @return whether such a value is present + * @since 4.17.0 + */ + boolean containsKey(final @NotNull Class clazz); + + /** + * Get the applicable value for the provided class. + * + *

This can be either a direct or inherited value.

+ * + * @param clazz the class type + * @return the value, if any is available + * @since 4.17.0 + */ + @Nullable V get(final @NotNull Class clazz); + + /** + * Get an updated inheritance aware map with the provided key changed. + * + * @param clazz the class type + * @param value the value to update to + * @return the updated map + * @since 4.17.0 + */ + @CheckReturnValue + @NotNull InheritanceAwareMap with(final @NotNull Class clazz, final @NotNull V value); + + /** + * Get an updated inheritance aware map with the provided key removed. + * + * @param clazz the class type to remove a direct value for + * @return the updated map + * @since 4.17.0 + */ + @CheckReturnValue + @NotNull InheritanceAwareMap without(final @NotNull Class clazz); + + /** + * A builder for inheritance-aware maps. + * + * @param the class type + * @param the value type + * @since 4.17.0 + */ + interface Builder extends AbstractBuilder> { + /** + * Set strict mode for this builder. + * + *

If this builder has values from when it was not in strict mode, all previous values will be re-validated for any hierarchy ambiguities.

+ * + * @param strict whether to enable strict mode. + * @return this builder + * @since 4.17.0 + */ + @NotNull Builder strict(final boolean strict); + + /** + * Put another value in this map. + * + * @param clazz the class type + * @param value the value for the provided type and any subtypes + * @return this builder + * @since 4.17.0 + */ + @NotNull Builder put(final @NotNull Class clazz, final @NotNull V value); + + /** + * Remove a value in this map. + * + * @param clazz the class type + * @return this builder + * @since 4.17.0 + */ + @NotNull Builder remove(final @NotNull Class clazz); + + /** + * Put values from an existing inheritance-aware map into this map. + * + * @param map the existing map + * @return this builder + * @since 4.17.0 + */ + @NotNull Builder putAll(final @NotNull InheritanceAwareMap map); + } + +} diff --git a/api/src/main/java/net/kyori/adventure/util/InheritanceAwareMapImpl.java b/api/src/main/java/net/kyori/adventure/util/InheritanceAwareMapImpl.java new file mode 100644 index 000000000..e114532f0 --- /dev/null +++ b/api/src/main/java/net/kyori/adventure/util/InheritanceAwareMapImpl.java @@ -0,0 +1,162 @@ +/* + * This file is part of adventure, licensed under the MIT License. + * + * Copyright (c) 2017-2024 KyoriPowered + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package net.kyori.adventure.util; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import static java.util.Objects.requireNonNull; + +final class InheritanceAwareMapImpl implements InheritanceAwareMap { + private static final Object NONE = new Object(); // null sentinel for CHM + @SuppressWarnings({"rawtypes", "unchecked"}) + static final InheritanceAwareMapImpl EMPTY = new InheritanceAwareMapImpl(false, Collections.emptyMap()); + + private final Map, V> declaredValues; + private final boolean strict; + private transient final ConcurrentMap, Object> cache = new ConcurrentHashMap<>(); + + InheritanceAwareMapImpl(final boolean strict, final Map, V> declaredValues) { + this.strict = strict; + this.declaredValues = declaredValues; + } + + @Override + public boolean containsKey(final @NotNull Class clazz) { + return this.get(clazz) != null; + } + + @Override + @SuppressWarnings("unchecked") + public @Nullable V get(final @NotNull Class clazz) { + final Object ret = this.cache.computeIfAbsent(clazz, c -> { + final @Nullable V value = this.declaredValues.get(c); + if (value != null) return value; + + for (final Map.Entry, V> entry : this.declaredValues.entrySet()) { + if (entry.getKey().isAssignableFrom(c)) { + return entry.getValue(); + } + } + + return NONE; + }); + + return ret == NONE ? null : (V) ret; + } + + @Override + public @NotNull InheritanceAwareMap with(final @NotNull Class clazz, final @NotNull V value) { + if (Objects.equals(this.declaredValues.get(clazz), value)) return this; + if (this.strict) validateNoneInHierarchy(clazz, this.declaredValues); + + final Map, V> newValues = new LinkedHashMap<>(this.declaredValues); + newValues.put(clazz, value); + return new InheritanceAwareMapImpl<>(this.strict, Collections.unmodifiableMap(newValues)); + } + + @Override + public @NotNull InheritanceAwareMap without(final @NotNull Class clazz) { + if (!this.declaredValues.containsKey(clazz)) return this; + + final Map, V> newValues = new LinkedHashMap<>(this.declaredValues); + newValues.remove(clazz); + return new InheritanceAwareMapImpl<>(this.strict, Collections.unmodifiableMap(newValues)); + } + + static final class BuilderImpl implements Builder { + private boolean strict; + private final Map, V> values = new LinkedHashMap<>(); + + @Override + public @NotNull InheritanceAwareMap build() { + return new InheritanceAwareMapImpl<>(this.strict, Collections.unmodifiableMap(new LinkedHashMap<>(this.values))); + } + + @Override + public @NotNull Builder strict(final boolean strict) { + if (strict && !this.strict) { // re-validate contents + for (final Class clazz : this.values.keySet()) { + validateNoneInHierarchy(clazz, this.values); + } + } + this.strict = strict; + return this; + } + + @Override + public @NotNull Builder put(final @NotNull Class clazz, final @NotNull V value) { + if (this.strict) validateNoneInHierarchy(clazz, this.values); + this.values.put( + requireNonNull(clazz, "clazz"), + requireNonNull(value, "value") + ); + return this; + } + + @Override + public @NotNull Builder remove(final @NotNull Class clazz) { + this.values.remove(requireNonNull(clazz, "clazz")); + return this; + } + + @Override + @SuppressWarnings({"rawtypes", "unchecked"}) + public @NotNull Builder putAll(final @NotNull InheritanceAwareMap map) { + final InheritanceAwareMapImpl impl = (InheritanceAwareMapImpl) map; + if (this.strict) { + if (!this.values.isEmpty() || !impl.strict) { // validate all + for (final Map.Entry, V> entry : impl.declaredValues.entrySet()) { + validateNoneInHierarchy(entry.getKey(), this.values); + this.values.put((Class) entry.getKey(), entry.getValue()); + } + return this; + } + } + + // otherwise (simpler) + this.values.putAll((Map) impl.declaredValues); + return this; + } + } + + private static void validateNoneInHierarchy(final Class beingRegistered, final Map, ?> entries) { + for (final Class clazz : entries.keySet()) { + testHierarchy(clazz, beingRegistered); + } + } + + private static void testHierarchy(final Class existing, final Class beingRegistered) { + if (!existing.equals(beingRegistered) && (existing.isAssignableFrom(beingRegistered) || beingRegistered.isAssignableFrom(existing))) { + throw new IllegalArgumentException("Conflict detected between already registered type " + existing + + " and newly registered type " + beingRegistered + "! Types in a strict inheritance-aware map must not share a common hierarchy!"); + } + } +} diff --git a/api/src/main/java/net/kyori/adventure/util/Services.java b/api/src/main/java/net/kyori/adventure/util/Services.java index bb89f6f8b..3e1ff0e21 100644 --- a/api/src/main/java/net/kyori/adventure/util/Services.java +++ b/api/src/main/java/net/kyori/adventure/util/Services.java @@ -23,9 +23,13 @@ */ package net.kyori.adventure.util; +import java.util.Collections; +import java.util.HashSet; import java.util.Iterator; import java.util.Optional; +import java.util.ServiceConfigurationError; import java.util.ServiceLoader; +import java.util.Set; import net.kyori.adventure.internal.properties.AdventureProperties; import org.jetbrains.annotations.NotNull; @@ -121,4 +125,31 @@ public interface Fallback { return Optional.ofNullable(firstFallback); } + + /** + * Locates all providers for a certain service and initializes them. + * + * @param clazz the service interface + * @param

the service interface type + * @return an unmodifiable set of all known providers of the service + * @since 4.17.0 + */ + public static

Set

services(final Class clazz) { + final ServiceLoader loader = Services0.loader(clazz); + final Set

providers = new HashSet<>(); + for (final Iterator it = loader.iterator(); it.hasNext();) { + final P instance; + try { + instance = it.next(); + } catch (final ServiceConfigurationError ex) { + if (SERVICE_LOAD_FAILURES_ARE_FATAL) { + throw new IllegalStateException("Encountered an exception loading a provider for " + clazz + ": ", ex); + } else { + continue; + } + } + providers.add(instance); + } + return Collections.unmodifiableSet(providers); + } } diff --git a/api/src/test/java/net/kyori/adventure/text/event/DataComponentValueConverterRegistryTest.java b/api/src/test/java/net/kyori/adventure/text/event/DataComponentValueConverterRegistryTest.java new file mode 100644 index 000000000..dbb8b399f --- /dev/null +++ b/api/src/test/java/net/kyori/adventure/text/event/DataComponentValueConverterRegistryTest.java @@ -0,0 +1,159 @@ +/* + * This file is part of adventure, licensed under the MIT License. + * + * Copyright (c) 2017-2024 KyoriPowered + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package net.kyori.adventure.text.event; + +import com.google.auto.service.AutoService; +import java.util.Arrays; +import net.kyori.adventure.key.Key; +import net.kyori.adventure.nbt.api.BinaryTagHolder; +import org.jetbrains.annotations.NotNull; +import org.junit.jupiter.api.Test; + +import static net.kyori.adventure.key.Key.key; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class DataComponentValueConverterRegistryTest { + @Test + void testKnownSourceToUnknownDest() { + final IllegalArgumentException iae = assertThrows(IllegalArgumentException.class, () -> { + DataComponentValueConverterRegistry.convert(Unregistered.class, key("test"), new DirectValue(3)); + }); + + assertTrue(iae.getMessage().contains("There is no data holder converter registered")); + } + + @Test + void testUnknownSourceToKnownDest() { + final IllegalArgumentException iae = assertThrows(IllegalArgumentException.class, () -> { + DataComponentValueConverterRegistry.convert(DirectValue.class, key("test"), new Unregistered()); + }); + + assertTrue(iae.getMessage().contains("There is no data holder converter registered")); + + } + + @Test + void testFailedConversionBlamesProvider() { + final IllegalStateException ise = assertThrows(IllegalStateException.class, () -> { + DataComponentValueConverterRegistry.convert(Failing.class, key("test"), BinaryTagHolder.binaryTagHolder("{}")); + }); + + assertTrue(ise.getMessage().contains(TestConverterProvider.ID.asString())); + } + + @Test + void testExactToExact() { + final DirectValue input = new DirectValue(new Object()); + final ItfValueImpl result = DataComponentValueConverterRegistry.convert(ItfValueImpl.class, key("test"), input); + + assertEquals(input.value, result.value); + } + + @Test + void testSubtypeToExact() { + final ItfValue input = new ItfValueImpl(new Object()); + final DirectValue result = DataComponentValueConverterRegistry.convert(DirectValue.class, key("test"), input); + + assertEquals(input.value(), result.value); + } + + @Test + void testSubtypeToSupertype() { + final ItfValue input = new ItfValueImpl(new Object()); + final DataComponentValue result = DataComponentValueConverterRegistry.convert(Intermediate.class, key("test"), input); + + assertInstanceOf(DirectValue.class, result); + assertEquals(input.value(), ((DirectValue) result).value); + } + + @Test + void testExactToSupertype() { + final DirectValue input = new DirectValue(new Object()); + final ItfValue result = DataComponentValueConverterRegistry.convert(ItfValue.class, key("test"), input); + + assertEquals(input.value, result.value()); + } + + @AutoService(DataComponentValueConverterRegistry.Provider.class) + public static final class TestConverterProvider implements DataComponentValueConverterRegistry.Provider { + static final Key ID = key("adventure", "test/converter_registry"); + + @Override + public @NotNull Key id() { + return ID; + } + + @Override + public @NotNull Iterable> conversions() { + // gah j8 + return Arrays.asList( + DataComponentValueConverterRegistry.Conversion.convert(DirectValue.class, ItfValueImpl.class, (key, dir) -> new ItfValueImpl(dir.value)), + DataComponentValueConverterRegistry.Conversion.convert(ItfValue.class, DirectValue.class, (key, itf) -> new DirectValue(itf.value())), + DataComponentValueConverterRegistry.Conversion.convert(BinaryTagHolder.class, Failing.class, (key, itf) -> { + throw new RuntimeException("hah!"); + }) + ); + } + } + + static final class Unregistered implements DataComponentValue { + // i shall not be converted + } + + static final class Failing implements DataComponentValue { + // this is a marker interface to trigger a failure + } + + interface Intermediate extends DataComponentValue { + + } + + static final class DirectValue implements Intermediate { + final Object value; + + DirectValue(final Object value) { + this.value = value; + } + } + + interface ItfValue extends DataComponentValue { + Object value(); + } + + static final class ItfValueImpl implements ItfValue { + final Object value; + + ItfValueImpl(final Object value) { + this.value = value; + } + + @Override + public Object value() { + return this.value; + } + } +} diff --git a/api/src/test/java/net/kyori/adventure/text/event/HoverEventTest.java b/api/src/test/java/net/kyori/adventure/text/event/HoverEventTest.java index e5d429932..19f69b8b8 100644 --- a/api/src/test/java/net/kyori/adventure/text/event/HoverEventTest.java +++ b/api/src/test/java/net/kyori/adventure/text/event/HoverEventTest.java @@ -25,6 +25,7 @@ import com.google.common.collect.ImmutableSet; import com.google.common.testing.EqualsTester; +import java.util.Collections; import java.util.UUID; import java.util.function.UnaryOperator; import net.kyori.adventure.key.Key; @@ -113,8 +114,8 @@ void testEquality() { HoverEvent.hoverEvent(HoverEvent.Action.SHOW_TEXT, Component.empty()) ) .addEqualityGroup( - HoverEvent.showItem(HoverEvent.ShowItem.showItem(Key.key("air"), 1, null)), - HoverEvent.hoverEvent(HoverEvent.Action.SHOW_ITEM, HoverEvent.ShowItem.showItem(Key.key("air"), 1, null)) + HoverEvent.showItem(HoverEvent.ShowItem.showItem(Key.key("air"), 1, Collections.emptyMap())), + HoverEvent.hoverEvent(HoverEvent.Action.SHOW_ITEM, HoverEvent.ShowItem.showItem(Key.key("air"), 1, Collections.emptyMap())) ) .addEqualityGroup( HoverEvent.showEntity(HoverEvent.ShowEntity.showEntity(Key.key("cat"), entity)), diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 32c3c7bf8..b7303f64d 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -2,6 +2,7 @@ version = "1.0" [versions] +autoService = "1.1.1" checkstyle = "10.15.0" errorprone = "2.26.1" examination = "1.3.0" @@ -15,6 +16,8 @@ truth = "1.4.2" [libraries] # shared +autoService = { module = "com.google.auto.service:auto-service", version.ref = "autoService" } +autoService-annotations = { module = "com.google.auto.service:auto-service-annotations", version.ref = "autoService" } examination-api = { module = "net.kyori:examination-api", version.ref = "examination" } examination-string = { module = "net.kyori:examination-string", version.ref = "examination" } option = { module = "net.kyori:option", version = "1.0.0" } @@ -55,8 +58,6 @@ truth-java8 = { module = "com.google.truth.extensions:truth-java8-extension", ve contractValidator = "ca.stellardrift:contract-validator:1.0.1" errorprone = { module = "com.google.errorprone:error_prone_core", version.ref = "errorprone" } stylecheck = "ca.stellardrift:stylecheck:0.2.1" -autoService-annotations = "com.google.auto.service:auto-service-annotations:1.1.1" -autoService-processor = "com.google.auto.service:auto-service:1.1.1" build-errorpronePlugin = "net.ltgt.gradle:gradle-errorprone-plugin:3.1.0" build-indra = { module = "net.kyori:indra-common", version.ref = "indra" } diff --git a/serializer-configurate4/src/main/java/net/kyori/adventure/serializer/configurate4/ConfigurateComponentSerializerImpl.java b/serializer-configurate4/src/main/java/net/kyori/adventure/serializer/configurate4/ConfigurateComponentSerializerImpl.java index 0dafd26a8..052154dfc 100644 --- a/serializer-configurate4/src/main/java/net/kyori/adventure/serializer/configurate4/ConfigurateComponentSerializerImpl.java +++ b/serializer-configurate4/src/main/java/net/kyori/adventure/serializer/configurate4/ConfigurateComponentSerializerImpl.java @@ -107,6 +107,7 @@ private ConfigurateComponentSerializerImpl(final @NotNull Builder builder) { .registerExact(new IndexSerializer<>(TypeToken.get(TextDecoration.class), TextDecoration.NAMES)) .registerExact(HoverEvent.ShowEntity.class, HoverEventShowEntitySerializer.INSTANCE) .registerExact(HoverEvent.ShowItem.class, HoverEventShowItemSerializer.INSTANCE) + .register(ConfigurateDataComponentValue.class, ConfigurateDataComponentValueTypeSerializer.INSTANCE) .build(); } diff --git a/serializer-configurate4/src/main/java/net/kyori/adventure/serializer/configurate4/ConfigurateDataComponentValue.java b/serializer-configurate4/src/main/java/net/kyori/adventure/serializer/configurate4/ConfigurateDataComponentValue.java new file mode 100644 index 000000000..ced228b45 --- /dev/null +++ b/serializer-configurate4/src/main/java/net/kyori/adventure/serializer/configurate4/ConfigurateDataComponentValue.java @@ -0,0 +1,54 @@ +/* + * This file is part of adventure, licensed under the MIT License. + * + * Copyright (c) 2017-2024 KyoriPowered + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package net.kyori.adventure.serializer.configurate4; + +import net.kyori.adventure.text.event.DataComponentValue; +import org.jetbrains.annotations.NotNull; +import org.spongepowered.configurate.ConfigurationNode; + +/** + * A data component value that can integrate with configuration nodes. + * + * @since 4.17.0 + */ +public interface ConfigurateDataComponentValue extends DataComponentValue { + /** + * Create a data component value capturing the value of an existing node. + * + * @param existing the existing node + * @return the captured value + * @since 4.17.0 + */ + static @NotNull ConfigurateDataComponentValue capturingDataComponentValue(final @NotNull ConfigurationNode existing) { + return SnapshottingConfigurateDataComponentValue.create(existing); + } + + /** + * Apply the contained value to the supplied node. + * + * @param node the node to apply this value to + * @since 4.17.0 + */ + void applyTo(final @NotNull ConfigurationNode node); +} diff --git a/serializer-configurate4/src/main/java/net/kyori/adventure/serializer/configurate4/ConfigurateDataComponentValueTypeSerializer.java b/serializer-configurate4/src/main/java/net/kyori/adventure/serializer/configurate4/ConfigurateDataComponentValueTypeSerializer.java new file mode 100644 index 000000000..89d3b9c27 --- /dev/null +++ b/serializer-configurate4/src/main/java/net/kyori/adventure/serializer/configurate4/ConfigurateDataComponentValueTypeSerializer.java @@ -0,0 +1,52 @@ +/* + * This file is part of adventure, licensed under the MIT License. + * + * Copyright (c) 2017-2024 KyoriPowered + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package net.kyori.adventure.serializer.configurate4; + +import java.lang.reflect.Type; +import org.checkerframework.checker.nullness.qual.Nullable; +import org.spongepowered.configurate.ConfigurationNode; +import org.spongepowered.configurate.serialize.SerializationException; +import org.spongepowered.configurate.serialize.TypeSerializer; + +final class ConfigurateDataComponentValueTypeSerializer implements TypeSerializer { + static final TypeSerializer INSTANCE = new ConfigurateDataComponentValueTypeSerializer(); + + private ConfigurateDataComponentValueTypeSerializer() { + } + + @Override + public ConfigurateDataComponentValue deserialize(final Type type, final ConfigurationNode node) throws SerializationException { + return ConfigurateDataComponentValue.capturingDataComponentValue(node); + } + + @Override + public void serialize(final Type type, final @Nullable ConfigurateDataComponentValue obj, final ConfigurationNode node) throws SerializationException { + if (obj == null) { + node.set(null); + return; + } + + obj.applyTo(node); + } +} diff --git a/serializer-configurate4/src/main/java/net/kyori/adventure/serializer/configurate4/HoverEventShowItemSerializer.java b/serializer-configurate4/src/main/java/net/kyori/adventure/serializer/configurate4/HoverEventShowItemSerializer.java index 0dd1b5d98..4b43b7086 100644 --- a/serializer-configurate4/src/main/java/net/kyori/adventure/serializer/configurate4/HoverEventShowItemSerializer.java +++ b/serializer-configurate4/src/main/java/net/kyori/adventure/serializer/configurate4/HoverEventShowItemSerializer.java @@ -23,7 +23,10 @@ */ package net.kyori.adventure.serializer.configurate4; +import io.leangen.geantyref.TypeToken; import java.lang.reflect.Type; +import java.util.HashMap; +import java.util.Map; import net.kyori.adventure.key.Key; import net.kyori.adventure.nbt.api.BinaryTagHolder; import net.kyori.adventure.text.event.HoverEvent; @@ -36,9 +39,13 @@ final class HoverEventShowItemSerializer implements TypeSerializer { static final HoverEventShowItemSerializer INSTANCE = new HoverEventShowItemSerializer(); + private static final TypeToken> COMPONENT_MAP_TYPE = new TypeToken>() { + }; + static final String ID = "id"; static final String COUNT = "count"; static final String TAG = "tag"; + static final String COMPONENTS = "components"; private HoverEventShowItemSerializer() { } @@ -50,9 +57,16 @@ public HoverEvent.ShowItem deserialize(final @NotNull Type type, final @NotNull throw new SerializationException("An id is required to deserialize the show_item hover event"); } final int count = value.node(COUNT).getInt(1); - final String tag = value.node(TAG).getString(); + final ConfigurationNode components = value.node(COMPONENTS); + if (!components.virtual()) { + final Map componentsMap = components.require(COMPONENT_MAP_TYPE); - return HoverEvent.ShowItem.showItem(id, count, tag == null ? null : BinaryTagHolder.binaryTagHolder(tag)); + return HoverEvent.ShowItem.showItem(id, count, new HashMap<>(componentsMap)); + } else { + // legacy (pre-1.20.5) + final String tag = value.node(TAG).getString(); + return HoverEvent.ShowItem.showItem(id, count, tag == null ? null : BinaryTagHolder.binaryTagHolder(tag)); + } } @Override @@ -65,10 +79,15 @@ public void serialize(final @NotNull Type type, final HoverEvent.@Nullable ShowI value.node(ID).set(Key.class, obj.item()); value.node(COUNT).set(obj.count()); - if (obj.nbt() == null) { + if (!obj.dataComponents().isEmpty()) { value.node(TAG).set(null); - } else { + value.node(COMPONENTS).set(COMPONENT_MAP_TYPE, obj.dataComponentsAs(ConfigurateDataComponentValue.class)); + } else if (obj.nbt() != null) { + // legacy (pre-1.20.5) + value.node(COMPONENTS).set(null); value.node(TAG).set(obj.nbt().string()); + } else { + value.node(COMPONENTS).set(null); } } } diff --git a/serializer-configurate4/src/main/java/net/kyori/adventure/serializer/configurate4/SnapshottingConfigurateDataComponentValue.java b/serializer-configurate4/src/main/java/net/kyori/adventure/serializer/configurate4/SnapshottingConfigurateDataComponentValue.java new file mode 100644 index 000000000..3739e6fd9 --- /dev/null +++ b/serializer-configurate4/src/main/java/net/kyori/adventure/serializer/configurate4/SnapshottingConfigurateDataComponentValue.java @@ -0,0 +1,68 @@ +/* + * This file is part of adventure, licensed under the MIT License. + * + * Copyright (c) 2017-2024 KyoriPowered + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package net.kyori.adventure.serializer.configurate4; + +import java.util.stream.Stream; +import net.kyori.examination.ExaminableProperty; +import org.jetbrains.annotations.NotNull; +import org.spongepowered.configurate.AttributedConfigurationNode; +import org.spongepowered.configurate.BasicConfigurationNode; +import org.spongepowered.configurate.CommentedConfigurationNode; +import org.spongepowered.configurate.ConfigurationNode; + +final class SnapshottingConfigurateDataComponentValue implements ConfigurateDataComponentValue { + private final ConfigurationNode ownedNode; + + // capture the value of an existing node without exposing any mutable state + static @NotNull SnapshottingConfigurateDataComponentValue create(final ConfigurationNode existing) { + final ConfigurationNode owned; + if (existing instanceof AttributedConfigurationNode) { + owned = AttributedConfigurationNode.root(((AttributedConfigurationNode) existing).tagName(), existing.options()); + } else if (existing instanceof CommentedConfigurationNode) { + owned = CommentedConfigurationNode.root(existing.options()); + } else { + owned = BasicConfigurationNode.root(existing.options()); + } + + owned.from(existing); + + return new SnapshottingConfigurateDataComponentValue(owned); + } + + private SnapshottingConfigurateDataComponentValue(final ConfigurationNode owned) { + this.ownedNode = owned; + } + + @Override + public void applyTo(final @NotNull ConfigurationNode node) { + node.from(this.ownedNode); + } + + @Override + public @NotNull Stream examinableProperties() { + return Stream.of( + ExaminableProperty.of("ownedNode", this.ownedNode) + ); + } +} diff --git a/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/tag/standard/HoverTag.java b/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/tag/standard/HoverTag.java index afa7e4073..fa2ece7af 100644 --- a/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/tag/standard/HoverTag.java +++ b/text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/tag/standard/HoverTag.java @@ -23,11 +23,14 @@ */ package net.kyori.adventure.text.minimessage.tag.standard; +import java.util.HashMap; +import java.util.Map; import java.util.UUID; import net.kyori.adventure.key.InvalidKeyException; import net.kyori.adventure.key.Key; import net.kyori.adventure.nbt.api.BinaryTagHolder; import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.event.DataComponentValue; import net.kyori.adventure.text.event.HoverEvent; import net.kyori.adventure.text.format.Style; import net.kyori.adventure.text.minimessage.Context; @@ -123,10 +126,27 @@ private ShowItem() { @Override public HoverEvent.@NotNull ShowItem parse(final @NotNull ArgumentQueue args, final @NotNull Context ctx) throws ParsingException { try { + @SuppressWarnings("PatternValidation") final Key key = Key.key(args.popOr("Show item hover needs at least an item ID").value()); final int count = args.hasNext() ? args.pop().asInt().orElseThrow(() -> ctx.newException("The count argument was not a valid integer")) : 1; if (args.hasNext()) { - return HoverEvent.ShowItem.showItem(key, count, BinaryTagHolder.binaryTagHolder(args.pop().value())); + // Compatibility with legacy versions: + // if the value starts with a '{' we assume it's SNBT, and parse it as such to create a legacy holder + // otherwise, we'll parse argument pairs as a map of ResourceLocation -> SNBT value + final String value = args.peek().value(); + if (value.startsWith("{")) { + args.pop(); + return legacyShowItem(key, count, value); + } + + final Map datas = new HashMap<>(); + while (args.hasNext()) { + @SuppressWarnings("PatternValidation") + final Key dataKey = Key.key(args.pop().value()); + final String dataVal = args.popOr("a value was expected for key " + dataKey).value(); + datas.put(dataKey, BinaryTagHolder.binaryTagHolder(dataVal)); + } + return HoverEvent.ShowItem.showItem(key, count, datas); } else { return HoverEvent.ShowItem.showItem(key, count); } @@ -135,18 +155,40 @@ private ShowItem() { } } + @SuppressWarnings("deprecation") + private static HoverEvent.@NotNull ShowItem legacyShowItem(final Key id, final int count, final String value) { + return HoverEvent.ShowItem.showItem(id, count, BinaryTagHolder.binaryTagHolder(value)); + } + @Override public void emit(final HoverEvent.ShowItem event, final TokenEmitter emit) { emit.argument(compactAsString(event.item())); - if (event.count() != 1 || event.nbt() != null) { + if (event.count() != 1 || hasLegacy(event) || !event.dataComponents().isEmpty()) { emit.argument(Integer.toString(event.count())); - if (event.nbt() != null) { - emit.argument(event.nbt().string()); + if (hasLegacy(event)) { + emitLegacyHover(event, emit); + } else { + for (final Map.Entry entry : event.dataComponentsAs(DataComponentValue.TagSerializable.class).entrySet()) { + emit.argument(entry.getKey().asMinimalString()); + emit.argument(entry.getValue().asBinaryTag().string()); + } } } } + + @SuppressWarnings("deprecation") + static boolean hasLegacy(final HoverEvent.ShowItem event) { + return event.nbt() != null; + } + + @SuppressWarnings("deprecation") + static void emitLegacyHover(final HoverEvent.ShowItem event, final TokenEmitter emit) { + if (event.nbt() != null) { + emit.argument(event.nbt().string()); + } + } } static final class ShowEntity implements ActionHandler { diff --git a/text-serializer-gson/build.gradle.kts b/text-serializer-gson/build.gradle.kts index 5d6c1935e..610d9e1c3 100644 --- a/text-serializer-gson/build.gradle.kts +++ b/text-serializer-gson/build.gradle.kts @@ -5,6 +5,8 @@ plugins { dependencies { api(libs.gson) + compileOnlyApi(libs.autoService.annotations) + annotationProcessor(libs.autoService) } applyJarMetadata("net.kyori.adventure.text.serializer.gson") diff --git a/text-serializer-gson/src/main/java/net/kyori/adventure/text/serializer/gson/GsonDataComponentValue.java b/text-serializer-gson/src/main/java/net/kyori/adventure/text/serializer/gson/GsonDataComponentValue.java new file mode 100644 index 000000000..000d6efb8 --- /dev/null +++ b/text-serializer-gson/src/main/java/net/kyori/adventure/text/serializer/gson/GsonDataComponentValue.java @@ -0,0 +1,65 @@ +/* + * This file is part of adventure, licensed under the MIT License. + * + * Copyright (c) 2017-2024 KyoriPowered + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package net.kyori.adventure.text.serializer.gson; + +import com.google.gson.JsonElement; +import com.google.gson.JsonNull; +import net.kyori.adventure.text.event.DataComponentValue; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; + +import static java.util.Objects.requireNonNull; + +/** + * An {@link DataComponentValue} implementation that holds a JsonElement. + * + *

This holder is exposed to allow conversions to/from gson data holders.

+ * + * @since 4.17.0 + */ +@ApiStatus.NonExtendable +public interface GsonDataComponentValue extends DataComponentValue { + /** + * Create a box for item data that can be understood by the gson serializer. + * + * @param data the item data to hold + * @return a newly created item data holder instance + * @since 4.17.0 + */ + static GsonDataComponentValue gsonDatacomponentValue(final @NotNull JsonElement data) { + if (data instanceof JsonNull) { + return GsonDataComponentValueImpl.RemovedGsonComponentValueImpl.INSTANCE; + } else { + return new GsonDataComponentValueImpl(requireNonNull(data, "data")); + } + } + + /** + * The contained element, intended for read-only use. + * + * @return a copy of the contained element + * @since 4.17.0 + */ + @NotNull JsonElement element(); +} diff --git a/text-serializer-gson/src/main/java/net/kyori/adventure/text/serializer/gson/GsonDataComponentValueImpl.java b/text-serializer-gson/src/main/java/net/kyori/adventure/text/serializer/gson/GsonDataComponentValueImpl.java new file mode 100644 index 000000000..67266d3ab --- /dev/null +++ b/text-serializer-gson/src/main/java/net/kyori/adventure/text/serializer/gson/GsonDataComponentValueImpl.java @@ -0,0 +1,80 @@ +/* + * This file is part of adventure, licensed under the MIT License. + * + * Copyright (c) 2017-2024 KyoriPowered + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package net.kyori.adventure.text.serializer.gson; + +import com.google.gson.JsonElement; +import com.google.gson.JsonNull; +import java.util.Objects; +import java.util.stream.Stream; +import net.kyori.adventure.internal.Internals; +import net.kyori.adventure.text.event.DataComponentValue; +import net.kyori.examination.ExaminableProperty; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +class GsonDataComponentValueImpl implements GsonDataComponentValue { + private final JsonElement element; + + GsonDataComponentValueImpl(final @NotNull JsonElement element) { + this.element = element; + } + + @Override + public @NotNull JsonElement element() { + return this.element; + } + + @Override + public @NotNull Stream examinableProperties() { + return Stream.of( + ExaminableProperty.of("element", this.element) + ); + } + + @Override + public String toString() { + return Internals.toString(this); + } + + @Override + public boolean equals(final @Nullable Object other) { + if (this == other) return true; + if (other == null || getClass() != other.getClass()) return false; + final GsonDataComponentValueImpl that = (GsonDataComponentValueImpl) other; + return Objects.equals(this.element, that.element); + } + + @Override + public int hashCode() { + return Objects.hashCode(this.element); + } + + static final class RemovedGsonComponentValueImpl extends GsonDataComponentValueImpl implements DataComponentValue.Removed { + static final RemovedGsonComponentValueImpl INSTANCE = new RemovedGsonComponentValueImpl(); + + private RemovedGsonComponentValueImpl() { + super(JsonNull.INSTANCE); + } + } +} diff --git a/text-serializer-gson/src/main/java/net/kyori/adventure/text/serializer/gson/SerializerFactory.java b/text-serializer-gson/src/main/java/net/kyori/adventure/text/serializer/gson/SerializerFactory.java index 784c8febc..7f8e66c93 100644 --- a/text-serializer-gson/src/main/java/net/kyori/adventure/text/serializer/gson/SerializerFactory.java +++ b/text-serializer-gson/src/main/java/net/kyori/adventure/text/serializer/gson/SerializerFactory.java @@ -80,7 +80,7 @@ public TypeAdapter create(final Gson gson, final TypeToken type) { } else if (HOVER_ACTION_TYPE.isAssignableFrom(rawType)) { return (TypeAdapter) HoverEventActionSerializer.INSTANCE; } else if (SHOW_ITEM_TYPE.isAssignableFrom(rawType)) { - return (TypeAdapter) ShowItemSerializer.create(gson); + return (TypeAdapter) ShowItemSerializer.create(gson, this.features); } else if (SHOW_ENTITY_TYPE.isAssignableFrom(rawType)) { return (TypeAdapter) ShowEntitySerializer.create(gson); } else if (COLOR_WRAPPER_TYPE.isAssignableFrom(rawType)) { diff --git a/text-serializer-gson/src/main/java/net/kyori/adventure/text/serializer/gson/ShowItemSerializer.java b/text-serializer-gson/src/main/java/net/kyori/adventure/text/serializer/gson/ShowItemSerializer.java index 7a37e960e..e42196d1f 100644 --- a/text-serializer-gson/src/main/java/net/kyori/adventure/text/serializer/gson/ShowItemSerializer.java +++ b/text-serializer-gson/src/main/java/net/kyori/adventure/text/serializer/gson/ShowItemSerializer.java @@ -24,39 +24,55 @@ package net.kyori.adventure.text.serializer.gson; import com.google.gson.Gson; +import com.google.gson.JsonElement; import com.google.gson.JsonParseException; import com.google.gson.TypeAdapter; import com.google.gson.stream.JsonReader; import com.google.gson.stream.JsonToken; import com.google.gson.stream.JsonWriter; import java.io.IOException; +import java.util.HashMap; +import java.util.Map; import net.kyori.adventure.key.Key; import net.kyori.adventure.nbt.api.BinaryTagHolder; +import net.kyori.adventure.text.event.DataComponentValue; import net.kyori.adventure.text.event.HoverEvent; +import net.kyori.adventure.text.serializer.json.JSONOptions; +import net.kyori.option.OptionState; +import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; +import static net.kyori.adventure.text.serializer.json.JSONComponentConstants.SHOW_ITEM_COMPONENTS; import static net.kyori.adventure.text.serializer.json.JSONComponentConstants.SHOW_ITEM_COUNT; import static net.kyori.adventure.text.serializer.json.JSONComponentConstants.SHOW_ITEM_ID; -import static net.kyori.adventure.text.serializer.json.JSONComponentConstants.SHOW_ITEM_TAG; final class ShowItemSerializer extends TypeAdapter { - static TypeAdapter create(final Gson gson) { - return new ShowItemSerializer(gson).nullSafe(); - } + @SuppressWarnings("deprecation") + private static final String LEGACY_SHOW_ITEM_TAG = net.kyori.adventure.text.serializer.json.JSONComponentConstants.SHOW_ITEM_TAG; private final Gson gson; + private final boolean emitDefaultQuantity; + private final JSONOptions.ShowItemHoverDataMode itemDataMode; + + static TypeAdapter create(final Gson gson, final OptionState opt) { + return new ShowItemSerializer(gson, opt.value(JSONOptions.EMIT_DEFAULT_ITEM_HOVER_QUANTITY), opt.value(JSONOptions.SHOW_ITEM_HOVER_DATA_MODE)).nullSafe(); + } - private ShowItemSerializer(final Gson gson) { + private ShowItemSerializer(final Gson gson, final boolean emitDefaultQuantity, final JSONOptions.ShowItemHoverDataMode itemDataMode) { this.gson = gson; + this.emitDefaultQuantity = emitDefaultQuantity; + this.itemDataMode = itemDataMode; } @Override + @SuppressWarnings("deprecation") public HoverEvent.ShowItem read(final JsonReader in) throws IOException { in.beginObject(); Key key = null; int count = 1; @Nullable BinaryTagHolder nbt = null; + @Nullable Map dataComponents = null; while (in.hasNext()) { final String fieldName = in.nextName(); @@ -64,7 +80,7 @@ public HoverEvent.ShowItem read(final JsonReader in) throws IOException { key = this.gson.fromJson(in, SerializerFactory.KEY_TYPE); } else if (fieldName.equals(SHOW_ITEM_COUNT)) { count = in.nextInt(); - } else if (fieldName.equals(SHOW_ITEM_TAG)) { + } else if (fieldName.equals(LEGACY_SHOW_ITEM_TAG)) { final JsonToken token = in.peek(); if (token == JsonToken.STRING || token == JsonToken.NUMBER) { nbt = BinaryTagHolder.binaryTagHolder(in.nextString()); @@ -73,8 +89,19 @@ public HoverEvent.ShowItem read(final JsonReader in) throws IOException { } else if (token == JsonToken.NULL) { in.nextNull(); } else { - throw new JsonParseException("Expected " + SHOW_ITEM_TAG + " to be a string"); + throw new JsonParseException("Expected " + LEGACY_SHOW_ITEM_TAG + " to be a string"); + } + } else if (fieldName.equals(SHOW_ITEM_COMPONENTS)) { + in.beginObject(); + while (in.peek() != JsonToken.END_OBJECT) { + final Key id = Key.key(in.nextName()); + final JsonElement tree = this.gson.fromJson(in, JsonElement.class); + if (dataComponents == null) { + dataComponents = new HashMap<>(); + } + dataComponents.put(id, GsonDataComponentValue.gsonDatacomponentValue(tree)); } + in.endObject(); } else { in.skipValue(); } @@ -85,7 +112,11 @@ public HoverEvent.ShowItem read(final JsonReader in) throws IOException { } in.endObject(); - return HoverEvent.ShowItem.showItem(key, count, nbt); + if (dataComponents != null) { + return HoverEvent.ShowItem.showItem(key, count, dataComponents); + } else { + return HoverEvent.ShowItem.showItem(key, count, nbt); + } } @Override @@ -96,17 +127,33 @@ public void write(final JsonWriter out, final HoverEvent.ShowItem value) throws this.gson.toJson(value.item(), SerializerFactory.KEY_TYPE, out); final int count = value.count(); - if (count != 1) { + if (count != 1 || this.emitDefaultQuantity) { out.name(SHOW_ITEM_COUNT); out.value(count); } + final @NotNull Map dataComponents = value.dataComponents(); + if (!dataComponents.isEmpty() && this.itemDataMode != JSONOptions.ShowItemHoverDataMode.EMIT_LEGACY_NBT) { + out.name(SHOW_ITEM_COMPONENTS); + out.beginObject(); + for (final Map.Entry entry : value.dataComponentsAs(GsonDataComponentValue.class).entrySet()) { + out.name(entry.getKey().asString()); + this.gson.toJson(entry.getValue().element(), out); + } + out.endObject(); + } else if (this.itemDataMode != JSONOptions.ShowItemHoverDataMode.EMIT_DATA_COMPONENTS) { + maybeWriteLegacy(out, value); + } + + out.endObject(); + } + + @SuppressWarnings("deprecation") + private static void maybeWriteLegacy(final JsonWriter out, final HoverEvent.ShowItem value) throws IOException { final @Nullable BinaryTagHolder nbt = value.nbt(); if (nbt != null) { - out.name(SHOW_ITEM_TAG); + out.name(LEGACY_SHOW_ITEM_TAG); out.value(nbt.string()); } - - out.endObject(); } } diff --git a/text-serializer-gson/src/main/java/net/kyori/adventure/text/serializer/gson/impl/GsonDataComponentValueConverterProvider.java b/text-serializer-gson/src/main/java/net/kyori/adventure/text/serializer/gson/impl/GsonDataComponentValueConverterProvider.java new file mode 100644 index 000000000..1cc28d6ae --- /dev/null +++ b/text-serializer-gson/src/main/java/net/kyori/adventure/text/serializer/gson/impl/GsonDataComponentValueConverterProvider.java @@ -0,0 +1,63 @@ +/* + * This file is part of adventure, licensed under the MIT License. + * + * Copyright (c) 2017-2024 KyoriPowered + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package net.kyori.adventure.text.serializer.gson.impl; + +import com.google.auto.service.AutoService; +import com.google.gson.JsonNull; +import java.util.Collections; +import net.kyori.adventure.key.Key; +import net.kyori.adventure.text.event.DataComponentValue; +import net.kyori.adventure.text.event.DataComponentValueConverterRegistry; +import net.kyori.adventure.text.serializer.gson.GsonDataComponentValue; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; + +/** + * A provider for Gson's implementations of data component value converters. + * + *

This is public SPI, not API.

+ * + * @since 4.17.0 + */ +@AutoService(DataComponentValueConverterRegistry.Provider.class) +@ApiStatus.Internal +public final class GsonDataComponentValueConverterProvider implements DataComponentValueConverterRegistry.Provider { + private static final Key ID = Key.key("adventure", "serializer/gson"); + + @Override + public @NotNull Key id() { + return ID; + } + + @Override + public @NotNull Iterable> conversions() { + return Collections.singletonList( + DataComponentValueConverterRegistry.Conversion.convert( + DataComponentValue.Removed.class, + GsonDataComponentValue.class, + (k, removed) -> GsonDataComponentValue.gsonDatacomponentValue(JsonNull.INSTANCE) + ) + ); + } +} diff --git a/text-serializer-gson/src/main/java/net/kyori/adventure/text/serializer/gson/JSONComponentSerializerProviderImpl.java b/text-serializer-gson/src/main/java/net/kyori/adventure/text/serializer/gson/impl/JSONComponentSerializerProviderImpl.java similarity index 90% rename from text-serializer-gson/src/main/java/net/kyori/adventure/text/serializer/gson/JSONComponentSerializerProviderImpl.java rename to text-serializer-gson/src/main/java/net/kyori/adventure/text/serializer/gson/impl/JSONComponentSerializerProviderImpl.java index 1087fe2e5..b9c11615b 100644 --- a/text-serializer-gson/src/main/java/net/kyori/adventure/text/serializer/gson/JSONComponentSerializerProviderImpl.java +++ b/text-serializer-gson/src/main/java/net/kyori/adventure/text/serializer/gson/impl/JSONComponentSerializerProviderImpl.java @@ -21,9 +21,11 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ -package net.kyori.adventure.text.serializer.gson; +package net.kyori.adventure.text.serializer.gson.impl; +import com.google.auto.service.AutoService; import java.util.function.Supplier; +import net.kyori.adventure.text.serializer.gson.GsonComponentSerializer; import net.kyori.adventure.text.serializer.json.JSONComponentSerializer; import net.kyori.adventure.util.Services; import org.jetbrains.annotations.ApiStatus; @@ -35,6 +37,7 @@ * @since 4.14.0 */ @ApiStatus.Internal +@AutoService(JSONComponentSerializer.Provider.class) public final class JSONComponentSerializerProviderImpl implements JSONComponentSerializer.Provider, Services.Fallback { @Override public @NotNull JSONComponentSerializer instance() { diff --git a/text-serializer-gson/src/main/java/net/kyori/adventure/text/serializer/gson/impl/package-info.java b/text-serializer-gson/src/main/java/net/kyori/adventure/text/serializer/gson/impl/package-info.java new file mode 100644 index 000000000..b0363ba42 --- /dev/null +++ b/text-serializer-gson/src/main/java/net/kyori/adventure/text/serializer/gson/impl/package-info.java @@ -0,0 +1,7 @@ +/** + * Internal classes for the Gson serializer. + */ +@ApiStatus.Internal +package net.kyori.adventure.text.serializer.gson.impl; + +import org.jetbrains.annotations.ApiStatus; diff --git a/text-serializer-gson/src/main/resources/META-INF/services/net.kyori.adventure.text.serializer.json.JSONComponentSerializer$Provider b/text-serializer-gson/src/main/resources/META-INF/services/net.kyori.adventure.text.serializer.json.JSONComponentSerializer$Provider deleted file mode 100644 index e018fe801..000000000 --- a/text-serializer-gson/src/main/resources/META-INF/services/net.kyori.adventure.text.serializer.json.JSONComponentSerializer$Provider +++ /dev/null @@ -1 +0,0 @@ -net.kyori.adventure.text.serializer.gson.JSONComponentSerializerProviderImpl diff --git a/text-serializer-json/src/main/java/net/kyori/adventure/text/serializer/json/JSONComponentConstants.java b/text-serializer-json/src/main/java/net/kyori/adventure/text/serializer/json/JSONComponentConstants.java index fc3d33ab5..a353820a6 100644 --- a/text-serializer-json/src/main/java/net/kyori/adventure/text/serializer/json/JSONComponentConstants.java +++ b/text-serializer-json/src/main/java/net/kyori/adventure/text/serializer/json/JSONComponentConstants.java @@ -64,7 +64,8 @@ public final class JSONComponentConstants { public static final String SHOW_ENTITY_NAME = "name"; public static final String SHOW_ITEM_ID = "id"; public static final String SHOW_ITEM_COUNT = "count"; - public static final String SHOW_ITEM_TAG = "tag"; + public static final @Deprecated String SHOW_ITEM_TAG = "tag"; + public static final String SHOW_ITEM_COMPONENTS = "components"; private JSONComponentConstants() { throw new IllegalStateException("Cannot instantiate"); diff --git a/text-serializer-json/src/main/java/net/kyori/adventure/text/serializer/json/JSONOptions.java b/text-serializer-json/src/main/java/net/kyori/adventure/text/serializer/json/JSONOptions.java index 0653d195c..05c1e1581 100644 --- a/text-serializer-json/src/main/java/net/kyori/adventure/text/serializer/json/JSONOptions.java +++ b/text-serializer-json/src/main/java/net/kyori/adventure/text/serializer/json/JSONOptions.java @@ -41,6 +41,7 @@ private JSONOptions() { private static final int VERSION_INITIAL = 0; private static final int VERSION_1_16 = 2526; // 20w16a private static final int VERSION_1_20_3 = 3679; // 23w40a + private static final int VERSION_1_20_5 = 3819; // 24w09a /** * Whether to emit RGB text. @@ -82,6 +83,21 @@ private JSONOptions() { * @since 4.15.0 */ public static final Option VALIDATE_STRICT_EVENTS = Option.booleanOption(key("validate/strict_events"), true); + /** + * Whether to emit the default hover event item stack quantity of {@code 1}. + * + *

When enabled, this matches Vanilla as of 1.20.5.

+ * + * @since 4.17.0 + */ + public static final Option EMIT_DEFAULT_ITEM_HOVER_QUANTITY = Option.booleanOption(key("emit/default_item_hover_quantity"), true); + + /** + * How to emit the item data on {@code show_item} hover events. + * + * @since 4.17.0 + */ + public static final Option SHOW_ITEM_HOVER_DATA_MODE = Option.enumOption(key("emit/show_item_hover_data"), ShowItemHoverDataMode.class, ShowItemHoverDataMode.EMIT_EITHER); /** * Versioned by world data version. @@ -93,6 +109,8 @@ private JSONOptions() { .value(EMIT_RGB, false) .value(EMIT_HOVER_SHOW_ENTITY_ID_AS_INT_ARRAY, false) .value(VALIDATE_STRICT_EVENTS, false) + .value(EMIT_DEFAULT_ITEM_HOVER_QUANTITY, false) + .value(SHOW_ITEM_HOVER_DATA_MODE, ShowItemHoverDataMode.EMIT_LEGACY_NBT) ) .version( VERSION_1_16, @@ -105,6 +123,11 @@ private JSONOptions() { .value(EMIT_HOVER_SHOW_ENTITY_ID_AS_INT_ARRAY, true) .value(VALIDATE_STRICT_EVENTS, true) ) + .version( + VERSION_1_20_5, + b -> b.value(EMIT_DEFAULT_ITEM_HOVER_QUANTITY, true) + .value(SHOW_ITEM_HOVER_DATA_MODE, ShowItemHoverDataMode.EMIT_DATA_COMPONENTS) + ) .build(); /** @@ -117,6 +140,7 @@ private JSONOptions() { .value(EMIT_HOVER_SHOW_ENTITY_ID_AS_INT_ARRAY, false) .value(EMIT_COMPACT_TEXT_COMPONENT, false) .value(VALIDATE_STRICT_EVENTS, false) + .value(SHOW_ITEM_HOVER_DATA_MODE, ShowItemHoverDataMode.EMIT_EITHER) .build(); private static String key(final String value) { @@ -170,4 +194,30 @@ public enum HoverEventValueMode { */ BOTH, } + + /** + * Configure how to emit show_item hovers. + * + * @since 4.17.0 + */ + public enum ShowItemHoverDataMode { + /** + * Only emit the pre-1.20.5 item nbt. + * + * @since 4.17.0 + */ + EMIT_LEGACY_NBT, + /** + * Only emit modern data components. + * + * @since 4.17.0 + */ + EMIT_DATA_COMPONENTS, + /** + * Emit whichever of legacy or modern data the item has. + * + * @since 4.17.0 + */ + EMIT_EITHER, + } } diff --git a/text-serializer-json/src/testFixtures/java/net/kyori/adventure/text/serializer/json/ShowItemSerializerTest.java b/text-serializer-json/src/testFixtures/java/net/kyori/adventure/text/serializer/json/ShowItemSerializerTest.java index 44d9a33dc..2a1286655 100644 --- a/text-serializer-json/src/testFixtures/java/net/kyori/adventure/text/serializer/json/ShowItemSerializerTest.java +++ b/text-serializer-json/src/testFixtures/java/net/kyori/adventure/text/serializer/json/ShowItemSerializerTest.java @@ -24,6 +24,7 @@ package net.kyori.adventure.text.serializer.json; import java.io.IOException; +import java.util.Collections; import net.kyori.adventure.key.Key; import net.kyori.adventure.nbt.CompoundBinaryTag; import net.kyori.adventure.nbt.StringBinaryTag; @@ -36,7 +37,12 @@ final class ShowItemSerializerTest extends SerializerTest { @Test void testDeserializeWithPopulatedTag() throws IOException { + final JSONComponentSerializer serializer = JSONComponentSerializer.builder() + .editOptions(opts -> opts.value(JSONOptions.SHOW_ITEM_HOVER_DATA_MODE, JSONOptions.ShowItemHoverDataMode.EMIT_EITHER)) + .build(); + this.testObject( + serializer, Component.text().hoverEvent( HoverEvent.showItem( Key.key("minecraft", "diamond"), @@ -70,7 +76,7 @@ void testDeserializeWithNullTag() { HoverEvent.showItem( Key.key("minecraft", "diamond"), 2, - null + Collections.emptyMap() ) ).build(), json -> { @@ -88,7 +94,11 @@ void testDeserializeWithNullTag() { @Test void testDeserializeWithCountOfOne() throws IOException { + final JSONComponentSerializer serializer = JSONComponentSerializer.builder() + .editOptions(opts -> opts.value(JSONOptions.SHOW_ITEM_HOVER_DATA_MODE, JSONOptions.ShowItemHoverDataMode.EMIT_EITHER)) + .build(); this.testObject( + serializer, Component.text().hoverEvent( HoverEvent.showItem( Key.key("minecraft", "diamond"), @@ -107,6 +117,7 @@ void testDeserializeWithCountOfOne() throws IOException { hover.addProperty(JSONComponentConstants.HOVER_EVENT_ACTION, name(HoverEvent.Action.SHOW_ITEM)); hover.add(JSONComponentConstants.HOVER_EVENT_CONTENTS, object(contents -> { contents.addProperty(JSONComponentConstants.SHOW_ITEM_ID, "minecraft:diamond"); + contents.addProperty(JSONComponentConstants.SHOW_ITEM_COUNT, 1); contents.addProperty(JSONComponentConstants.SHOW_ITEM_TAG, "{display:{Name:\"A test!\"}}"); })); }));