package io.papermc.paper.adventure; import com.mojang.datafixers.util.Pair; import com.mojang.serialization.Codec; import com.mojang.serialization.DataResult; import com.mojang.serialization.DynamicOps; import com.mojang.serialization.JavaOps; import com.mojang.serialization.JsonOps; import io.papermc.paper.util.MethodParameterSource; import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; import java.net.URI; import java.util.List; import java.util.Optional; import java.util.UUID; import java.util.function.Function; import java.util.stream.Stream; import net.kyori.adventure.key.Key; import net.kyori.adventure.nbt.api.BinaryTagHolder; import net.kyori.adventure.text.BlockNBTComponent; import net.kyori.adventure.text.Component; import net.kyori.adventure.text.event.ClickEvent; import net.kyori.adventure.text.event.HoverEvent; import net.kyori.adventure.text.format.NamedTextColor; import net.kyori.adventure.text.format.ShadowColor; import net.kyori.adventure.text.format.Style; import net.kyori.adventure.text.format.TextColor; import net.kyori.adventure.text.format.TextDecoration; import net.minecraft.core.component.DataComponents; import net.minecraft.core.registries.BuiltInRegistries; import net.minecraft.nbt.ByteTag; import net.minecraft.nbt.CompoundTag; import net.minecraft.nbt.IntTag; import net.minecraft.nbt.ListTag; import net.minecraft.nbt.NbtOps; import net.minecraft.nbt.Tag; import net.minecraft.network.chat.ComponentSerialization; import net.minecraft.resources.ResourceLocation; import net.minecraft.world.item.ItemStack; import net.minecraft.world.item.Items; import org.bukkit.support.RegistryHelper; import org.bukkit.support.environment.VanillaFeature; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.EnumSource; import org.junit.jupiter.params.provider.MethodSource; import org.junitpioneer.jupiter.cartesian.CartesianTest; import static io.papermc.paper.adventure.AdventureCodecs.CLICK_EVENT_CODEC; import static io.papermc.paper.adventure.AdventureCodecs.COMPONENT_CODEC; import static io.papermc.paper.adventure.AdventureCodecs.HOVER_EVENT_CODEC; import static io.papermc.paper.adventure.AdventureCodecs.KEY_CODEC; import static io.papermc.paper.adventure.AdventureCodecs.STYLE_MAP_CODEC; import static io.papermc.paper.adventure.AdventureCodecs.TEXT_COLOR_CODEC; import static java.util.Objects.requireNonNull; import static net.kyori.adventure.key.Key.key; import static net.kyori.adventure.text.Component.blockNBT; import static net.kyori.adventure.text.Component.entityNBT; import static net.kyori.adventure.text.Component.keybind; import static net.kyori.adventure.text.Component.score; import static net.kyori.adventure.text.Component.selector; import static net.kyori.adventure.text.Component.storageNBT; import static net.kyori.adventure.text.Component.text; import static net.kyori.adventure.text.Component.translatable; import static net.kyori.adventure.text.TranslationArgument.numeric; import static net.kyori.adventure.text.event.ClickEvent.openUrl; import static net.kyori.adventure.text.event.ClickEvent.suggestCommand; import static net.kyori.adventure.text.event.HoverEvent.showEntity; import static net.kyori.adventure.text.format.Style.style; import static net.kyori.adventure.text.format.TextColor.color; import static net.kyori.adventure.text.minimessage.MiniMessage.miniMessage; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; @VanillaFeature class AdventureCodecsTest { static final String PARAMETERIZED_NAME = "[{index}] {displayName}: {arguments}"; @Test void testTextColor() { final TextColor color = color(0x1d38df); final Tag result = TEXT_COLOR_CODEC.encodeStart(NbtOps.INSTANCE, color).result().orElseThrow(); assertEquals("\"" + color.asHexString() + "\"", result.toString()); final net.minecraft.network.chat.TextColor nms = net.minecraft.network.chat.TextColor.CODEC.decode(NbtOps.INSTANCE, result).result().orElseThrow().getFirst(); assertEquals(color.value(), nms.getValue()); } @Test void testNamedTextColor() { final NamedTextColor color = NamedTextColor.BLUE; final Tag result = TEXT_COLOR_CODEC.encodeStart(NbtOps.INSTANCE, color).result().orElseThrow(); assertEquals("\"" + NamedTextColor.NAMES.keyOrThrow(color) + "\"", result.toString()); final net.minecraft.network.chat.TextColor nms = net.minecraft.network.chat.TextColor.CODEC.decode(NbtOps.INSTANCE, result).result().orElseThrow().getFirst(); assertEquals(color.value(), nms.getValue()); } @Test void testKey() { final Key key = key("hello", "there"); final Tag result = KEY_CODEC.encodeStart(NbtOps.INSTANCE, key).result().orElseThrow(); assertEquals("\"" + key.asString() + "\"", result.toString()); final ResourceLocation location = ResourceLocation.CODEC.decode(NbtOps.INSTANCE, result).result().orElseThrow().getFirst(); assertEquals(key.asString(), location.toString()); } @ParameterizedTest(name = PARAMETERIZED_NAME) @EnumSource(value = ClickEvent.Action.class, mode = EnumSource.Mode.EXCLUDE, names = {"OPEN_FILE", "SHOW_DIALOG", "CUSTOM"}) void testClickEvent(final ClickEvent.Action action) { final ClickEvent event = switch (action) { case OPEN_URL -> openUrl("https://google.com"); case RUN_COMMAND -> ClickEvent.runCommand("/say hello"); case SUGGEST_COMMAND -> suggestCommand("/suggest hello"); case CHANGE_PAGE -> ClickEvent.changePage(2); case COPY_TO_CLIPBOARD -> ClickEvent.copyToClipboard("clipboard content"); case CUSTOM -> ClickEvent.custom(key("test"), BinaryTagHolder.binaryTagHolder("3")); case SHOW_DIALOG, OPEN_FILE -> throw new IllegalArgumentException(); }; final Tag result = CLICK_EVENT_CODEC.encodeStart(NbtOps.INSTANCE, event).result().orElseThrow(() -> new RuntimeException("Failed to encode ClickEvent: " + event)); final net.minecraft.network.chat.ClickEvent nms = net.minecraft.network.chat.ClickEvent.CODEC.decode(NbtOps.INSTANCE, result).result().orElseThrow().getFirst(); assertEquals(event.action().toString(), nms.action().getSerializedName()); switch (nms) { case net.minecraft.network.chat.ClickEvent.OpenUrl(URI uri) -> assertEquals(((ClickEvent.Payload.Text) event.payload()).value(), uri.toString()); case net.minecraft.network.chat.ClickEvent.SuggestCommand(String command) -> assertEquals(((ClickEvent.Payload.Text) event.payload()).value(), command); case net.minecraft.network.chat.ClickEvent.RunCommand(String command) -> assertEquals(((ClickEvent.Payload.Text) event.payload()).value(), command); case net.minecraft.network.chat.ClickEvent.CopyToClipboard(String value) -> assertEquals(((ClickEvent.Payload.Text) event.payload()).value(), value); case net.minecraft.network.chat.ClickEvent.ChangePage(int page) -> assertEquals(((ClickEvent.Payload.Int) event.payload()).integer(), page); case net.minecraft.network.chat.ClickEvent.Custom(ResourceLocation id, Optional payload) -> { assertEquals(((ClickEvent.Payload.Custom) event.payload()).key().toString(), id.toString()); assertEquals(((ClickEvent.Payload.Custom) event.payload()).nbt(), payload.orElseThrow().asString()); } default -> throw new AssertionError("Unexpected ClickEvent type: " + nms.getClass()); } } @Test void testShowTextHoverEvent() { final HoverEvent hoverEvent = HoverEvent.hoverEvent(HoverEvent.Action.SHOW_TEXT, text("hello")); final Tag result = HOVER_EVENT_CODEC.encodeStart(NbtOps.INSTANCE, hoverEvent).result().orElseThrow(); final net.minecraft.network.chat.HoverEvent.ShowText nms = (net.minecraft.network.chat.HoverEvent.ShowText) net.minecraft.network.chat.HoverEvent.CODEC.decode(NbtOps.INSTANCE, result).result().orElseThrow().getFirst(); assertEquals(hoverEvent.action().toString(), nms.action().getSerializedName()); assertEquals("hello", nms.value().getString()); } @Test void testShowItemHoverEvent() { final ItemStack stack = new ItemStack(Items.PUMPKIN, 3); stack.set(DataComponents.CUSTOM_NAME, net.minecraft.network.chat.Component.literal("NAME")); final HoverEvent hoverEvent = HoverEvent.showItem(key("minecraft:pumpkin"), 3, PaperAdventure.asAdventure(stack.getComponentsPatch())); final Tag result = HOVER_EVENT_CODEC.encodeStart(NbtOps.INSTANCE, hoverEvent).result().orElseThrow(); final DataResult> dataResult = net.minecraft.network.chat.HoverEvent.CODEC.decode(NbtOps.INSTANCE, result); assertTrue(dataResult.result().isPresent(), () -> dataResult + " result is not present"); final net.minecraft.network.chat.HoverEvent.ShowItem nms = (net.minecraft.network.chat.HoverEvent.ShowItem) dataResult.result().orElseThrow().getFirst(); assertEquals(hoverEvent.action().toString(), nms.action().getSerializedName()); final ItemStack item = nms.item(); assertNotNull(item); assertEquals(hoverEvent.value().count(), item.getCount()); assertEquals(hoverEvent.value().item().asString(), item.getItem().toString()); assertEquals(stack.getComponentsPatch(), item.getComponentsPatch()); } @Test void testShowEntityHoverEvent() { UUID uuid = UUID.randomUUID(); final HoverEvent hoverEvent = showEntity(key("minecraft:wolf"), uuid, text("NAME")); final Tag result = HOVER_EVENT_CODEC.encodeStart(NbtOps.INSTANCE, hoverEvent).result().orElseThrow(); final DataResult> dataResult = net.minecraft.network.chat.HoverEvent.CODEC.decode(NbtOps.INSTANCE, result); assertTrue(dataResult.result().isPresent(), () -> dataResult + " result is not present"); final net.minecraft.network.chat.HoverEvent.ShowEntity nms = (net.minecraft.network.chat.HoverEvent.ShowEntity) dataResult.result().orElseThrow().getFirst(); assertEquals(hoverEvent.action().toString(), nms.action().getSerializedName()); final net.minecraft.network.chat.HoverEvent.EntityTooltipInfo value = nms.entity(); assertNotNull(value); assertEquals(hoverEvent.value().type().asString(), BuiltInRegistries.ENTITY_TYPE.getKey(value.type).toString()); assertEquals(hoverEvent.value().id(), value.uuid); assertEquals("NAME", value.name.orElseThrow().getString()); } @Test void testSimpleStyle() { final Style style = style().decorate(TextDecoration.BOLD).color(NamedTextColor.RED).build(); final Tag result = STYLE_MAP_CODEC.codec().encodeStart(NbtOps.INSTANCE, style).result().orElseThrow(); final DataResult> dataResult = net.minecraft.network.chat.Style.Serializer.CODEC.decode(NbtOps.INSTANCE, result); assertTrue(dataResult.result().isPresent(), () -> dataResult + " result is not present"); final net.minecraft.network.chat.Style nms = dataResult.result().get().getFirst(); assertTrue(nms.isBold()); assertEquals(requireNonNull(style.color()).value(), requireNonNull(nms.getColor()).getValue()); } @CartesianTest(name = PARAMETERIZED_NAME) void testDirectRoundTripStyle( @MethodParameterSource("dynamicOps") final DynamicOps dynamicOps, @MethodParameterSource("testStyles") final Style style ) { testDirectRoundTrip(dynamicOps, STYLE_MAP_CODEC.codec(), style); } @CartesianTest(name = PARAMETERIZED_NAME) void testMinecraftRoundTripStyle( @MethodParameterSource("dynamicOps") final DynamicOps dynamicOps, @MethodParameterSource("testStyles") final Style style ) { testMinecraftRoundTrip(dynamicOps, STYLE_MAP_CODEC.codec(), net.minecraft.network.chat.Style.Serializer.CODEC, style); } @CartesianTest(name = PARAMETERIZED_NAME) void testDirectRoundTripComponent( @MethodParameterSource("dynamicOps") final DynamicOps dynamicOps, @TestComponents final Component component ) { testDirectRoundTrip(dynamicOps, COMPONENT_CODEC, component); } @CartesianTest(name = PARAMETERIZED_NAME) void testMinecraftRoundTripComponent( @MethodParameterSource("dynamicOps") final DynamicOps dynamicOps, @TestComponents final Component component ) { testMinecraftRoundTrip(dynamicOps, COMPONENT_CODEC, ComponentSerialization.CODEC, component); } static List> dynamicOps() { return Stream.of( NbtOps.INSTANCE, JavaOps.INSTANCE, JsonOps.INSTANCE ) .map(ops -> RegistryHelper.getRegistry().createSerializationContext(ops)) .toList(); } @ParameterizedTest(name = PARAMETERIZED_NAME) @MethodSource({"invalidData"}) void invalidThrows(final Tag input) { assertThrows(RuntimeException.class, () -> { require( COMPONENT_CODEC.decode(NbtOps.INSTANCE, input), msg -> "Failed to decode " + input + ": " + msg ); }); } static void testDirectRoundTrip(final DynamicOps ops, final Codec codec, final A adventure) { final O encoded = require( codec.encodeStart(ops, adventure), msg -> "Failed to encode " + adventure + ": " + msg ); final Pair roundTripResult = require( codec.decode(ops, encoded), msg -> "Failed to decode " + encoded + ": " + msg ); assertEquals(adventure, roundTripResult.getFirst()); } static void testMinecraftRoundTrip(final DynamicOps ops, final Codec adventureCodec, final Codec minecraftCodec, final A adventure) { final O encoded = require( adventureCodec.encodeStart(ops, adventure), msg -> "Failed to encode " + adventure + ": " + msg ); final M minecraftResult = require( minecraftCodec.decode(ops, encoded), msg -> "Failed to decode to Minecraft: " + encoded + "; " + msg ).getFirst(); final O minecraftReEncoded = require( minecraftCodec.encodeStart(ops, minecraftResult), msg -> "Failed to re-encode Minecraft: " + minecraftResult + "; " + msg ); final Pair roundTripResult = require( adventureCodec.decode(ops, minecraftReEncoded), msg -> "Failed to decode " + minecraftReEncoded + ": " + msg ); assertEquals(adventure, roundTripResult.getFirst()); } static R require(final DataResult result, final Function errorMessage) { return result.getOrThrow(s -> new RuntimeException(errorMessage.apply(s))); } static List invalidData() { return List.of( IntTag.valueOf(-1), ByteTag.ZERO, new CompoundTag(), new ListTag() ); } static List