Files
DeltaZern/paper-server/src/main/java/io/papermc/paper/adventure/PaperAdventure.java
T
2025-06-25 21:16:18 +02:00

506 lines
22 KiB
Java

package io.papermc.paper.adventure;
import com.mojang.brigadier.exceptions.CommandSyntaxException;
import com.mojang.serialization.JavaOps;
import io.netty.util.AttributeKey;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.BiConsumer;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.StreamSupport;
import net.kyori.adventure.bossbar.BossBar;
import net.kyori.adventure.inventory.Book;
import net.kyori.adventure.key.Key;
import net.kyori.adventure.nbt.api.BinaryTagHolder;
import net.kyori.adventure.sound.Sound;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.TranslatableComponent;
import net.kyori.adventure.text.TranslationArgument;
import net.kyori.adventure.text.event.DataComponentValue;
import net.kyori.adventure.text.event.DataComponentValueConverterRegistry;
import net.kyori.adventure.text.flattener.ComponentFlattener;
import net.kyori.adventure.text.format.Style;
import net.kyori.adventure.text.format.TextColor;
import net.kyori.adventure.text.serializer.ComponentSerializer;
import net.kyori.adventure.text.serializer.ansi.ANSIComponentSerializer;
import net.kyori.adventure.text.serializer.gson.GsonComponentSerializer;
import net.kyori.adventure.text.serializer.plain.PlainComponentSerializer;
import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer;
import net.kyori.adventure.translation.GlobalTranslator;
import net.kyori.adventure.util.Codec;
import net.minecraft.ChatFormatting;
import net.minecraft.commands.CommandSourceStack;
import net.minecraft.core.Holder;
import net.minecraft.core.component.DataComponentPatch;
import net.minecraft.core.component.DataComponentType;
import net.minecraft.core.component.DataComponents;
import net.minecraft.core.registries.BuiltInRegistries;
import net.minecraft.locale.Language;
import net.minecraft.nbt.CompoundTag;
import net.minecraft.nbt.NbtOps;
import net.minecraft.nbt.Tag;
import net.minecraft.nbt.TagParser;
import net.minecraft.network.chat.ComponentUtils;
import net.minecraft.network.protocol.Packet;
import net.minecraft.network.protocol.game.ClientboundSoundEntityPacket;
import net.minecraft.network.protocol.game.ClientboundSoundPacket;
import net.minecraft.resources.RegistryOps;
import net.minecraft.resources.ResourceKey;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.server.network.Filterable;
import net.minecraft.sounds.SoundEvent;
import net.minecraft.sounds.SoundSource;
import net.minecraft.world.BossEvent;
import net.minecraft.world.entity.Entity;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.component.WrittenBookContent;
import org.bukkit.command.CommandSender;
import org.bukkit.craftbukkit.CraftRegistry;
import org.bukkit.craftbukkit.command.VanillaCommandWrapper;
import org.bukkit.craftbukkit.entity.CraftEntity;
import org.intellij.lang.annotations.Subst;
import org.jetbrains.annotations.Contract;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import static java.util.Objects.requireNonNull;
public final class PaperAdventure {
private static final Pattern LOCALIZATION_PATTERN = Pattern.compile("%(?:(\\d+)\\$)?s");
public static final ComponentFlattener FLATTENER = ComponentFlattener.basic().toBuilder()
.nestingLimit(30) // todo: should this be configurable? a system property or config value?
.complexMapper(TranslatableComponent.class, (translatable, consumer) -> {
final Language language = Language.getInstance();
final @Nullable String fallback = translatable.fallback();
if (!language.has(translatable.key()) && (fallback == null || !language.has(fallback))) {
if (GlobalTranslator.translator().canTranslate(translatable.key(), Locale.US)) {
consumer.accept(GlobalTranslator.render(translatable, Locale.US));
return;
}
}
final @NotNull String translated = Language.getInstance().getOrDefault(translatable.key(), fallback != null ? fallback : translatable.key());
final Matcher matcher = LOCALIZATION_PATTERN.matcher(translated);
final List<TranslationArgument> args = translatable.arguments();
int argPosition = 0;
int lastIdx = 0;
while (matcher.find()) {
// append prior
if (lastIdx < matcher.start()) {
consumer.accept(Component.text(translated.substring(lastIdx, matcher.start())));
}
lastIdx = matcher.end();
final @Nullable String argIdx = matcher.group(1);
// calculate argument position
if (argIdx != null) {
try {
final int idx = Integer.parseInt(argIdx) - 1;
if (idx < args.size()) {
consumer.accept(args.get(idx).asComponent());
}
} catch (final NumberFormatException ex) {
// ignore, drop the format placeholder
}
} else {
final int idx = argPosition++;
if (idx < args.size()) {
consumer.accept(args.get(idx).asComponent());
}
}
}
// append tail
if (lastIdx < translated.length()) {
consumer.accept(Component.text(translated.substring(lastIdx)));
}
})
.build();
public static final AttributeKey<Locale> LOCALE_ATTRIBUTE = AttributeKey.valueOf("adventure:locale"); // init after FLATTENER because classloading triggered here might create a logger
@Deprecated
public static final PlainComponentSerializer PLAIN = PlainComponentSerializer.builder().flattener(FLATTENER).build();
public static final ANSIComponentSerializer ANSI_SERIALIZER = ANSIComponentSerializer.builder().flattener(FLATTENER).build();
public static final Codec<Tag, String, CommandSyntaxException, RuntimeException> NBT_CODEC = new Codec<>() {
@Override
public @NotNull Tag decode(final @NotNull String encoded) throws CommandSyntaxException {
return TagParser.parseCompoundFully(encoded);
}
@Override
public @NotNull String encode(final @NotNull Tag decoded) {
return decoded.toString();
}
};
public static final ComponentSerializer<Component, Component, net.minecraft.network.chat.Component> WRAPPER_AWARE_SERIALIZER = new WrapperAwareSerializer(() -> CraftRegistry.getMinecraftRegistry().createSerializationContext(JavaOps.INSTANCE));
private PaperAdventure() {
}
// Key
public static Key asAdventure(final ResourceLocation key) {
return Key.key(key.getNamespace(), key.getPath());
}
public static ResourceLocation asVanilla(final Key key) {
return ResourceLocation.fromNamespaceAndPath(key.namespace(), key.value());
}
public static <T> ResourceKey<T> asVanilla(
final ResourceKey<? extends net.minecraft.core.Registry<T>> registry,
final Key key
) {
return ResourceKey.create(registry, asVanilla(key));
}
public static Key asAdventureKey(final ResourceKey<?> key) {
return asAdventure(key.location());
}
public static @Nullable ResourceLocation asVanillaNullable(final Key key) {
if (key == null) {
return null;
}
return asVanilla(key);
}
public static Holder<SoundEvent> resolveSound(final Key key) {
ResourceLocation id = asVanilla(key);
Optional<Holder.Reference<SoundEvent>> vanilla = BuiltInRegistries.SOUND_EVENT.get(id);
if (vanilla.isPresent()) {
return vanilla.get();
}
// sound is not known so not in the registry but might be used by the client with a resource pack
return Holder.direct(SoundEvent.createVariableRangeEvent(id));
}
// Component
public static @NotNull Component asAdventure(@Nullable final net.minecraft.network.chat.Component component) {
return component == null ? Component.empty() : WRAPPER_AWARE_SERIALIZER.deserialize(component);
}
public static ArrayList<Component> asAdventure(final List<? extends net.minecraft.network.chat.Component> vanillas) {
final ArrayList<Component> adventures = new ArrayList<>(vanillas.size());
for (final net.minecraft.network.chat.Component vanilla : vanillas) {
adventures.add(asAdventure(vanilla));
}
return adventures;
}
public static ArrayList<Component> asAdventureFromJson(final List<String> jsonStrings) {
final ArrayList<Component> adventures = new ArrayList<>(jsonStrings.size());
for (final String json : jsonStrings) {
adventures.add(GsonComponentSerializer.gson().deserialize(json));
}
return adventures;
}
public static List<String> asJson(final List<? extends Component> adventures) {
final List<String> jsons = new ArrayList<>(adventures.size());
for (final Component component : adventures) {
jsons.add(GsonComponentSerializer.gson().serialize(component));
}
return jsons;
}
public static net.minecraft.network.chat.@NotNull Component asVanillaNullToEmpty(final @Nullable Component component) {
if (component == null) return net.minecraft.network.chat.CommonComponents.EMPTY;
return asVanilla(component);
}
@Contract("null -> null; !null -> !null")
public static net.minecraft.network.chat.Component asVanilla(final @Nullable Component component) {
if (component == null) return null;
if (true) return new AdventureComponent(component);
return WRAPPER_AWARE_SERIALIZER.serialize(component);
}
public static List<net.minecraft.network.chat.Component> asVanilla(final List<? extends Component> adventures) {
final List<net.minecraft.network.chat.Component> vanillas = new ArrayList<>(adventures.size());
for (final Component adventure : adventures) {
vanillas.add(asVanilla(adventure));
}
return vanillas;
}
public static String asJsonString(final Component component, final Locale locale) {
return GsonComponentSerializer.gson().serialize(translated(component, locale));
}
public static boolean hasAnyTranslations() {
return StreamSupport.stream(GlobalTranslator.translator().sources().spliterator(), false)
.anyMatch(t -> t.hasAnyTranslations().toBooleanOrElse(true));
}
private static final Map<Locale, com.mojang.serialization.Codec<Component>> LOCALIZED_CODECS = new ConcurrentHashMap<>();
public static com.mojang.serialization.Codec<Component> localizedCodec(final @Nullable Locale l) {
if (l == null) {
return AdventureCodecs.COMPONENT_CODEC;
}
return LOCALIZED_CODECS.computeIfAbsent(l, locale -> AdventureCodecs.COMPONENT_CODEC.xmap(
component -> component, // decode
component -> translated(component, locale) // encode
));
}
public static String asPlain(final Component component, final Locale locale) {
return PlainTextComponentSerializer.plainText().serialize(translated(component, locale));
}
private static Component translated(final Component component, final Locale locale) {
//noinspection ConstantValue
return GlobalTranslator.render(
component,
// play it safe
locale != null
? locale
: Locale.US
);
}
public static Component resolveWithContext(final @NotNull Component component, final @Nullable CommandSender context, final @Nullable org.bukkit.entity.Entity scoreboardSubject, final boolean bypassPermissions) throws IOException {
final CommandSourceStack css = context != null ? VanillaCommandWrapper.getListener(context) : null;
Boolean previous = null;
if (css != null && bypassPermissions) {
previous = css.bypassSelectorPermissions;
css.bypassSelectorPermissions = true;
}
try {
return asAdventure(ComponentUtils.updateForEntity(css, asVanilla(component), scoreboardSubject == null ? null : ((CraftEntity) scoreboardSubject).getHandle(), 0));
} catch (final CommandSyntaxException e) {
throw new IOException(e);
} finally {
if (css != null && previous != null) {
css.bypassSelectorPermissions = previous;
}
}
}
// BossBar
public static BossEvent.BossBarColor asVanilla(final BossBar.Color color) {
return switch (color) {
case PINK -> BossEvent.BossBarColor.PINK;
case BLUE -> BossEvent.BossBarColor.BLUE;
case RED -> BossEvent.BossBarColor.RED;
case GREEN -> BossEvent.BossBarColor.GREEN;
case YELLOW -> BossEvent.BossBarColor.YELLOW;
case PURPLE -> BossEvent.BossBarColor.PURPLE;
case WHITE -> BossEvent.BossBarColor.WHITE;
};
}
public static BossBar.Color asAdventure(final BossEvent.BossBarColor color) {
return switch (color) {
case PINK -> BossBar.Color.PINK;
case BLUE -> BossBar.Color.BLUE;
case RED -> BossBar.Color.RED;
case GREEN -> BossBar.Color.GREEN;
case YELLOW -> BossBar.Color.YELLOW;
case PURPLE -> BossBar.Color.PURPLE;
case WHITE -> BossBar.Color.WHITE;
};
}
public static BossEvent.BossBarOverlay asVanilla(final BossBar.Overlay overlay) {
return switch (overlay) {
case PROGRESS -> BossEvent.BossBarOverlay.PROGRESS;
case NOTCHED_6 -> BossEvent.BossBarOverlay.NOTCHED_6;
case NOTCHED_10 -> BossEvent.BossBarOverlay.NOTCHED_10;
case NOTCHED_12 -> BossEvent.BossBarOverlay.NOTCHED_12;
case NOTCHED_20 -> BossEvent.BossBarOverlay.NOTCHED_20;
};
}
public static BossBar.Overlay asAdventure(final BossEvent.BossBarOverlay overlay) {
return switch (overlay) {
case PROGRESS -> BossBar.Overlay.PROGRESS;
case NOTCHED_6 -> BossBar.Overlay.NOTCHED_6;
case NOTCHED_10 -> BossBar.Overlay.NOTCHED_10;
case NOTCHED_12 -> BossBar.Overlay.NOTCHED_12;
case NOTCHED_20 -> BossBar.Overlay.NOTCHED_20;
};
}
public static void setFlag(final BossBar bar, final BossBar.Flag flag, final boolean value) {
if (value) {
bar.addFlag(flag);
} else {
bar.removeFlag(flag);
}
}
// Book
public static ItemStack asItemStack(final Book book, final Locale locale) {
final ItemStack item = new ItemStack(net.minecraft.world.item.Items.WRITTEN_BOOK, 1);
item.set(DataComponents.WRITTEN_BOOK_CONTENT, new WrittenBookContent(
Filterable.passThrough(validateField(asPlain(book.title(), locale), WrittenBookContent.TITLE_MAX_LENGTH, "title")),
asPlain(book.author(), locale),
0,
book.pages().stream().map(c -> Filterable.passThrough(PaperAdventure.asVanilla(c))).toList(), // TODO should we validate legnth?
false
));
return item;
}
private static String validateField(final String content, final int length, final String name) {
final int actual = content.length();
if (actual > length) {
throw new IllegalArgumentException("Field '" + name + "' has a maximum length of " + length + " but was passed '" + content + "', which was " + actual + " characters long.");
}
return content;
}
// Sounds
public static SoundSource asVanilla(final Sound.Source source) {
return switch (source) {
case MASTER -> SoundSource.MASTER;
case MUSIC -> SoundSource.MUSIC;
case RECORD -> SoundSource.RECORDS;
case WEATHER -> SoundSource.WEATHER;
case BLOCK -> SoundSource.BLOCKS;
case HOSTILE -> SoundSource.HOSTILE;
case NEUTRAL -> SoundSource.NEUTRAL;
case PLAYER -> SoundSource.PLAYERS;
case AMBIENT -> SoundSource.AMBIENT;
case VOICE -> SoundSource.VOICE;
case UI -> SoundSource.UI;
};
}
public static @Nullable SoundSource asVanillaNullable(final Sound.@Nullable Source source) {
if (source == null) {
return null;
}
return asVanilla(source);
}
public static Packet<?> asSoundPacket(final Sound sound, final double x, final double y, final double z, final long seed, @Nullable BiConsumer<Packet<?>, Float> packetConsumer) {
final ResourceLocation name = asVanilla(sound.name());
final Optional<SoundEvent> soundEvent = BuiltInRegistries.SOUND_EVENT.getOptional(name);
final SoundSource source = asVanilla(sound.source());
final Holder<SoundEvent> soundEventHolder = soundEvent.map(BuiltInRegistries.SOUND_EVENT::wrapAsHolder).orElseGet(() -> Holder.direct(SoundEvent.createVariableRangeEvent(name)));
final Packet<?> packet = new ClientboundSoundPacket(soundEventHolder, source, x, y, z, sound.volume(), sound.pitch(), seed);
if (packetConsumer != null) {
packetConsumer.accept(packet, soundEventHolder.value().getRange(sound.volume()));
}
return packet;
}
public static Packet<?> asSoundPacket(final Sound sound, final Entity emitter, final long seed, @Nullable BiConsumer<Packet<?>, Float> packetConsumer) {
final ResourceLocation name = asVanilla(sound.name());
final Optional<SoundEvent> soundEvent = BuiltInRegistries.SOUND_EVENT.getOptional(name);
final SoundSource source = asVanilla(sound.source());
final Holder<SoundEvent> soundEventHolder = soundEvent.map(BuiltInRegistries.SOUND_EVENT::wrapAsHolder).orElseGet(() -> Holder.direct(SoundEvent.createVariableRangeEvent(name)));
final Packet<?> packet = new ClientboundSoundEntityPacket(soundEventHolder, source, emitter, sound.volume(), sound.pitch(), seed);
if (packetConsumer != null) {
packetConsumer.accept(packet, soundEventHolder.value().getRange(sound.volume()));
}
return packet;
}
// NBT
@SuppressWarnings({"rawtypes", "unchecked"})
public static Map<Key, ? extends DataComponentValue> asAdventure(
final DataComponentPatch patch
) {
if (patch.isEmpty()) {
return Collections.emptyMap();
}
final Map<Key, DataComponentValue> map = new HashMap<>();
for (final Map.Entry<DataComponentType<?>, Optional<?>> entry : patch.entrySet()) {
if (entry.getKey().isTransient()) continue;
@Subst("key:value") final String typeKey = requireNonNull(BuiltInRegistries.DATA_COMPONENT_TYPE.getKey(entry.getKey())).toString();
if (entry.getValue().isEmpty()) {
map.put(Key.key(typeKey), DataComponentValue.removed());
} else {
map.put(Key.key(typeKey), new DataComponentValueImpl(entry.getKey().codec(), entry.getValue().get()));
}
}
return map;
}
@SuppressWarnings({"rawtypes", "unchecked"})
public static DataComponentPatch asVanilla(final Map<? extends Key, ? extends DataComponentValue> map) {
if (map.isEmpty()) {
return DataComponentPatch.EMPTY;
}
final DataComponentPatch.Builder builder = DataComponentPatch.builder();
map.forEach((key, dataComponentValue) -> {
final DataComponentType<?> type = requireNonNull(BuiltInRegistries.DATA_COMPONENT_TYPE.getValue(asVanilla(key)));
if (dataComponentValue instanceof DataComponentValue.Removed) {
builder.remove(type);
return;
}
final DataComponentValueImpl<?> converted = DataComponentValueConverterRegistry.convert(DataComponentValueImpl.class, key, dataComponentValue);
builder.set((DataComponentType) type, (Object) converted.value());
});
return builder.build();
}
public record DataComponentValueImpl<T>(com.mojang.serialization.Codec<T> codec, T value) implements DataComponentValue.TagSerializable {
@Override
public @NotNull BinaryTagHolder asBinaryTag() {
return BinaryTagHolder.encode(this.codec.encodeStart(CraftRegistry.getMinecraftRegistry().createSerializationContext(NbtOps.INSTANCE), this.value).getOrThrow(IllegalArgumentException::new), NBT_CODEC);
}
}
public static @Nullable BinaryTagHolder asBinaryTagHolder(final @Nullable CompoundTag tag) {
if (tag == null) {
return null;
}
return BinaryTagHolder.encode(tag, NBT_CODEC);
}
// Colors
public static @NotNull TextColor asAdventure(final ChatFormatting formatting) {
final Integer color = formatting.getColor();
if (color == null) {
throw new IllegalArgumentException("Not a valid color");
}
return TextColor.color(color);
}
public static @Nullable ChatFormatting asVanilla(final TextColor color) {
return ChatFormatting.getByHexValue(color.value());
}
// Style
public static net.minecraft.network.chat.Style asVanilla(final Style style) {
final RegistryOps<Object> ops = CraftRegistry.getMinecraftRegistry().createSerializationContext(JavaOps.INSTANCE);
final Object encoded = AdventureCodecs.STYLE_MAP_CODEC.codec()
.encodeStart(ops, style).getOrThrow(IllegalStateException::new);
return net.minecraft.network.chat.Style.Serializer.CODEC
.parse(ops, encoded).getOrThrow(IllegalStateException::new);
}
public static Style asAdventure(final net.minecraft.network.chat.Style style) {
final RegistryOps<Object> ops = CraftRegistry.getMinecraftRegistry().createSerializationContext(JavaOps.INSTANCE);
final Object encoded = net.minecraft.network.chat.Style.Serializer.CODEC
.encodeStart(ops, style).getOrThrow(IllegalStateException::new);
return AdventureCodecs.STYLE_MAP_CODEC.codec()
.parse(ops, encoded).getOrThrow(IllegalStateException::new);
}
}