diff --git a/README.md b/README.md index 7a1de26..7e3c011 100644 --- a/README.md +++ b/README.md @@ -392,9 +392,69 @@ YamlConfigurationProperties properties = ConfigLib.BUKKIT_DEFAULT_PROPERTIES.toB .build(); ``` -To get access to this object, you have to import `configlib-paper` instead +If you are using **Paper** software, you can use `PAPER_DEFAULT_PROPERTIES` which includes both Bukkit `ConfigurationSerializable` support (e.g. `ItemStack`) and Adventure support pre-configured: + +```java +YamlConfigurationProperties properties = ConfigLib.PAPER_DEFAULT_PROPERTIES.toBuilder() + // ...further configure the builder... + .build(); +``` + +To get access to these objects, you have to import `configlib-paper` instead of `configlib-yaml` as described in the [Import](#import) section. +#### Support for Adventure library types + +The `configlib-adventure` module provides serializers for Adventure library types +commonly used in PaperMC and Velocity plugins. It supports: + +* `Component` - Text components with customizable format (MiniMessage, legacy, JSON) +* `Key` - Namespaced keys (e.g., `minecraft:stone`) +* `Sound` - Sound effects with pitch, volume, and source + +To use Adventure types in your configuration, use the helper method that registers all Adventure serializers: + +```java +YamlConfigurationProperties.Builder builder = YamlConfigurationProperties.newBuilder(); +AdventureConfigLib.addDefaults(builder); +YamlConfigurationProperties properties = builder.build(); +``` + +##### Component Formats + +The `AdventureComponentSerializer` supports multiple text formats through the `AdventureComponentFormat` enum: + +| Format | Description | Example | +|---------------------|----------------------------------------------------|------------------------------------| +| `MINI_MESSAGE` | MiniMessage tags | `Hello World` | +| `LEGACY_AMPERSAND` | Legacy colors with `&` | `&cHello &lWorld` | +| `LEGACY_SECTION` | Legacy colors with `§` | `§cHello §lWorld` | +| `MINECRAFT_JSON` | Minecraft JSON format | `{"text":"Hello","color":"red"}` | +| `TRANSLATION_KEY` | Translation keys | `block.minecraft.stone` | + +You can customize the serialization and deserialization format order: + +```java +List serializeOrder = List.of(AdventureComponentFormat.MINI_MESSAGE); +List deserializeOrder = List.of( + AdventureComponentFormat.MINI_MESSAGE, + AdventureComponentFormat.LEGACY_AMPERSAND +); +AdventureConfigLib.addDefaults(builder, serializeOrder, deserializeOrder); +``` + +##### Sound Serialization + +Sounds are serialized in a compact string format: ` [pitch] [volume] [source]`. +Sound id can be anything even it is not in vanilla Minecraft to support custom sound from texture packs. +```yaml +# Full format +joinSound: "minecraft:entity.experience_orb.pickup 1.0 1.0 MASTER" + +# Minimal format (defaults: pitch=1.0, volume=1.0, source=MASTER) +leaveSound: "minecraft:entity.experience_orb.pickup" +``` + ### Comments The configuration elements of a configuration type can be annotated with @@ -1141,6 +1201,10 @@ This project contains three classes of modules: * The `configlib-yaml` module contains the classes that can save configuration instances as YAML files and instantiate new instances from such files. This module does not contain anything Minecraft related, either. +* The `configlib-adventure` module provides serializers for Adventure library types + like `Component`, `Key`, and `Sound`. This module is useful for PaperMC and + Velocity plugins that use the Adventure text API. See the + [Adventure support section](#support-for-adventure-library-types) for details. * The `configlib-paper`, `configlib-velocity`, and `configlib-waterfall` modules contain basic plugins that are used to conveniently load this library. These three modules shade the `-core` module, the `-yaml` module, and the YAML diff --git a/buildSrc/src/main/kotlin/libs-config.gradle.kts b/buildSrc/src/main/kotlin/libs-config.gradle.kts index b77efb5..074f26b 100644 --- a/buildSrc/src/main/kotlin/libs-config.gradle.kts +++ b/buildSrc/src/main/kotlin/libs-config.gradle.kts @@ -7,6 +7,3 @@ dependencies { testImplementation(testFixtures(project(":configlib-core"))) } -tasks.compileJava { - dependsOn(project(":configlib-core").tasks.check) -} \ No newline at end of file diff --git a/configlib-adventure/build.gradle.kts b/configlib-adventure/build.gradle.kts new file mode 100644 index 0000000..6c21fef --- /dev/null +++ b/configlib-adventure/build.gradle.kts @@ -0,0 +1,22 @@ +plugins { + `core-config` + `libs-config` +} + +val adventureVersion = "4.26.1" + +dependencies { + api(project(":configlib-core")) + compileOnly("net.kyori:adventure-api:$adventureVersion") + compileOnly("net.kyori:adventure-text-minimessage:$adventureVersion") + compileOnly("net.kyori:adventure-text-serializer-legacy:$adventureVersion") + compileOnly("net.kyori:adventure-text-serializer-gson:$adventureVersion") + compileOnly("net.kyori:adventure-text-serializer-plain:${adventureVersion}") + + testImplementation(project(":configlib-yaml")) + testImplementation("net.kyori:adventure-api:$adventureVersion") + testImplementation("net.kyori:adventure-text-minimessage:$adventureVersion") + testImplementation("net.kyori:adventure-text-serializer-legacy:$adventureVersion") + testImplementation("net.kyori:adventure-text-serializer-gson:$adventureVersion") + testImplementation("net.kyori:adventure-text-serializer-plain:${adventureVersion}") +} diff --git a/configlib-adventure/src/main/java/de/exlll/configlib/AdventureComponentFormat.java b/configlib-adventure/src/main/java/de/exlll/configlib/AdventureComponentFormat.java new file mode 100644 index 0000000..e42fd0f --- /dev/null +++ b/configlib-adventure/src/main/java/de/exlll/configlib/AdventureComponentFormat.java @@ -0,0 +1,64 @@ +package de.exlll.configlib; + +import net.kyori.adventure.text.serializer.legacy.LegacyComponentSerializer; + +import java.util.function.Predicate; +import java.util.regex.Pattern; + +/** + * Represents the different text formats supported for Adventure Component + * serialization. + */ +public enum AdventureComponentFormat { + /** + * MiniMessage format with tags like {@code } or {@code }. + */ + MINI_MESSAGE(Patterns.MINI_MESSAGE_PATTERN.asPredicate()), + /** + * Translation key format for translatable components. + */ + TRANSLATION_KEY(input -> true), // translation keys can be any format + /** + * Legacy format using ampersand ({@code &}) as the color code prefix. + */ + LEGACY_AMPERSAND(input -> + input.indexOf(LegacyComponentSerializer.AMPERSAND_CHAR) != -1), + /** + * Legacy format using section symbol ({@code §}) as the color code prefix. + */ + LEGACY_SECTION(input -> + input.indexOf(LegacyComponentSerializer.SECTION_CHAR) != -1), + /** + * Minecraft JSON format for components. + */ + MINECRAFT_JSON(input -> { + input = input.trim(); + return input.startsWith("{") && input.endsWith("}"); + }); + + // Hack to avoid compiler error while singleton pattern initialization + private static class Patterns { + // Pattern to detect any in a string + static final Pattern MINI_MESSAGE_PATTERN = + Pattern.compile("<[a-zA-Z0-9_:-]+(?::[^<>]+)?>"); + } + + private final Predicate inputPredicate; + + AdventureComponentFormat(Predicate inputPredicate) { + this.inputPredicate = inputPredicate; + } + + /** + * Checks if the given input string matches this format. + * + * @param input the input string to check + * @return true if the input matches this format, false otherwise + */ + public boolean matches(String input) { + if (input == null) { + return false; + } + return inputPredicate.test(input); + } +} diff --git a/configlib-adventure/src/main/java/de/exlll/configlib/AdventureComponentSerializer.java b/configlib-adventure/src/main/java/de/exlll/configlib/AdventureComponentSerializer.java new file mode 100644 index 0000000..30a13ec --- /dev/null +++ b/configlib-adventure/src/main/java/de/exlll/configlib/AdventureComponentSerializer.java @@ -0,0 +1,105 @@ +package de.exlll.configlib; + +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.TranslatableComponent; +import net.kyori.adventure.text.minimessage.MiniMessage; +import net.kyori.adventure.text.serializer.gson.GsonComponentSerializer; +import net.kyori.adventure.text.serializer.legacy.LegacyComponentSerializer; +import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer; + +import java.util.Arrays; +import java.util.List; + +/** + * Serializer for Adventure {@link Component} objects. + * Supports multiple formats including MiniMessage, legacy, and JSON. + */ +public final class AdventureComponentSerializer implements Serializer { + private final AdventureComponentFormat serializeFormat; + private final List deserializeOrder; + + /** + * Creates a new ComponentSerializer with separate format orders for + * serialization and deserialization. + * + * @param serializeFormat the format to use when serializing + * @param deserializeOrder the order of formats to try when deserializing + */ + public AdventureComponentSerializer(AdventureComponentFormat serializeFormat, + List deserializeOrder) { + this.serializeFormat = serializeFormat; + this.deserializeOrder = List.copyOf(deserializeOrder); + } + + /** + * Creates a new ComponentSerializer using the same format order for + * both serialization and deserialization. + * + * @param serializeFormat the format to use for serialization + * @param deserializeFormats the formats to use for deserialization, in order of + * preference + */ + public AdventureComponentSerializer(AdventureComponentFormat serializeFormat, + AdventureComponentFormat... deserializeFormats) { + this(serializeFormat, deserializeFormats.length == 0 + ? List.of(serializeFormat) + : Arrays.asList(deserializeFormats)); + } + + @Override + public String serialize(Component element) { + if (element == null) { + return null; + } + + return serialize(element, serializeFormat); + } + + @Override + public Component deserialize(String element) { + if (element == null) { + return null; + } + + for (AdventureComponentFormat format : deserializeOrder) { + if (!format.matches(element)) { + continue; + } + + return deserialize(element, format); + } + + // Fallback to MiniMessage + return MiniMessage.miniMessage().deserialize(element); + } + + private String serialize(Component component, AdventureComponentFormat format) { + return switch (format) { + case MINI_MESSAGE -> MiniMessage.miniMessage().serialize(component); + case LEGACY_AMPERSAND -> + LegacyComponentSerializer.legacyAmpersand().serialize(component); + case LEGACY_SECTION -> + LegacyComponentSerializer.legacySection().serialize(component); + case MINECRAFT_JSON -> GsonComponentSerializer.gson().serialize(component); + case TRANSLATION_KEY -> + component instanceof TranslatableComponent translatableComponent + ? translatableComponent.key() + : PlainTextComponentSerializer.plainText().serialize(component); + }; + } + + private Component deserialize(String string, AdventureComponentFormat format) { + return switch (format) { + case MINI_MESSAGE -> + MiniMessage.miniMessage().deserialize(string); + case LEGACY_AMPERSAND -> + LegacyComponentSerializer.legacyAmpersand().deserialize(string); + case LEGACY_SECTION -> + LegacyComponentSerializer.legacySection().deserialize(string); + case MINECRAFT_JSON -> + GsonComponentSerializer.gson().deserialize(string); + case TRANSLATION_KEY -> + Component.translatable(string); + }; + } +} diff --git a/configlib-adventure/src/main/java/de/exlll/configlib/AdventureConfigLib.java b/configlib-adventure/src/main/java/de/exlll/configlib/AdventureConfigLib.java new file mode 100644 index 0000000..4788583 --- /dev/null +++ b/configlib-adventure/src/main/java/de/exlll/configlib/AdventureConfigLib.java @@ -0,0 +1,58 @@ +package de.exlll.configlib; + +import net.kyori.adventure.key.Key; +import net.kyori.adventure.sound.Sound; +import net.kyori.adventure.text.Component; + +import java.util.List; + +/** + * Utility class providing default serializers for Adventure library types. + */ +public final class AdventureConfigLib { + // Use MiniMessage as the default format since MiniMessage covered all + // component type + private static final List DEFAULT_FORMAT_ORDER = List.of( + AdventureComponentFormat.MINI_MESSAGE + ); + + private AdventureConfigLib() { + } + + /** + * Adds default Adventure serializers to the configuration builder. + * + * @param builder the configuration properties builder + * @param the builder type + * @return the builder with default serializers added + */ + public static > + ConfigurationProperties.Builder addDefaults( + ConfigurationProperties.Builder builder) { + return addDefaults(builder, DEFAULT_FORMAT_ORDER.get(0), DEFAULT_FORMAT_ORDER); + } + + /** + * Adds default Adventure serializers to the configuration builder with custom + * format orders. + * + * @param builder the configuration properties builder + * @param serializeFormat the format to use when serializing + * components + * @param deserializeOrder the order of formats to try when deserializing + * components + * @param the builder type + * @return the builder with default serializers added + */ + public static > + ConfigurationProperties.Builder addDefaults( + ConfigurationProperties.Builder builder, + AdventureComponentFormat serializeFormat, + List deserializeOrder) { + builder.addSerializer(Component.class, + new AdventureComponentSerializer(serializeFormat, deserializeOrder)); + builder.addSerializer(Key.class, new AdventureKeySerializer()); + builder.addSerializer(Sound.class, new AdventureSoundSerializer()); + return builder; + } +} diff --git a/configlib-adventure/src/main/java/de/exlll/configlib/AdventureKeySerializer.java b/configlib-adventure/src/main/java/de/exlll/configlib/AdventureKeySerializer.java new file mode 100644 index 0000000..5e0eaa2 --- /dev/null +++ b/configlib-adventure/src/main/java/de/exlll/configlib/AdventureKeySerializer.java @@ -0,0 +1,51 @@ +package de.exlll.configlib; + +import net.kyori.adventure.key.Key; + +import java.util.OptionalInt; + +/** + * Serializer for Adventure {@link Key} objects. + */ +public final class AdventureKeySerializer implements Serializer { + + private final String defaultNamespace; + + /** + * Creates a new KeySerializer with the specified default namespace. + * + * @param defaultNamespace the default namespace to use when deserializing keys + * without a namespace + * @throws IllegalArgumentException if the namespace is invalid + */ + public AdventureKeySerializer(String defaultNamespace) { + this.defaultNamespace = defaultNamespace; + OptionalInt result = Key.checkNamespace(defaultNamespace); + if (result.isPresent()) { + throw new IllegalArgumentException( + "Invalid namespace at index " + result.getAsInt() + ": " + + defaultNamespace); + } + } + + /** + * Creates a new KeySerializer using Adventure's default namespace (minecraft). + */ + public AdventureKeySerializer() { + this.defaultNamespace = null; // Use Adventure's default namespace + } + + @Override + public String serialize(Key element) { + return element.asString(); + } + + @Override + public Key deserialize(String element) { + if (this.defaultNamespace == null) { + return Key.key(element); + } + + return Key.key(this.defaultNamespace, element); + } +} diff --git a/configlib-adventure/src/main/java/de/exlll/configlib/AdventureSoundSerializer.java b/configlib-adventure/src/main/java/de/exlll/configlib/AdventureSoundSerializer.java new file mode 100644 index 0000000..67c9fdc --- /dev/null +++ b/configlib-adventure/src/main/java/de/exlll/configlib/AdventureSoundSerializer.java @@ -0,0 +1,107 @@ +package de.exlll.configlib; + +import net.kyori.adventure.key.Key; +import net.kyori.adventure.sound.Sound; + +import java.util.Objects; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Serializer for {@link Sound} objects. + *

+ * String format: {@code [pitch] [volume] [source]} + *

+ * Example: {@code "minecraft:entity.player.levelup 1.0 1.0 MASTER"} + */ +public final class AdventureSoundSerializer implements Serializer { + /** + * The delimiter used to separate sound components in the serialized string. + */ + public static final String DELIMITER = " "; + + private static final Pattern SOUND_PATTERN = Pattern.compile(buildRegex()); + + private final Sound.Source defaultSource; + + /** + * Creates a new SoundSerializer with the default source set to + * {@link Sound.Source#MASTER}. + */ + public AdventureSoundSerializer() { + this.defaultSource = Sound.Source.MASTER; + } + + /** + * Creates a new SoundSerializer with the specified default source. + * + * @param defaultSource the default sound source to use when deserializing + */ + public AdventureSoundSerializer(Sound.Source defaultSource) { + Objects.requireNonNull(defaultSource, "defaultSource must not be null"); + this.defaultSource = defaultSource; + } + + @Override + public String serialize(Sound element) { + StringBuilder builder = new StringBuilder(element.name().asString()); + if (element.source() != defaultSource) { + builder.append(DELIMITER).append(formatFloatSimple(element.pitch())); + builder.append(DELIMITER).append(formatFloatSimple(element.volume())); + builder.append(DELIMITER).append(element.source().name()); + } else if (element.volume() != 1f) { + builder.append(DELIMITER).append(formatFloatSimple(element.pitch())); + builder.append(DELIMITER).append(formatFloatSimple(element.volume())); + } else if (element.pitch() != 1f) { + builder.append(DELIMITER).append(formatFloatSimple(element.pitch())); + } + + return builder.toString(); + } + + @Override + public Sound deserialize(String element) { + Matcher matcher = SOUND_PATTERN.matcher(element); + if (!matcher.matches()) { + throw new IllegalArgumentException("Invalid sound format: " + element); + } + + String keyString = matcher.group("key"); + Key key = Key.key(keyString); + + float pitch = matcher.group("pitch") != null + ? Float.parseFloat(matcher.group("pitch")) + : 1.0f; + + float volume = matcher.group("volume") != null + ? Float.parseFloat(matcher.group("volume")) + : 1.0f; + + Sound.Source source = defaultSource; + if (matcher.group("source") != null) { + String sourceName = matcher.group("source"); + try { + source = Sound.Source.valueOf(sourceName.toUpperCase()); + } catch (IllegalArgumentException e) { + throw new ConfigurationException("Invalid sound source '" + sourceName + "' in: " + element); + } + } + + return Sound.sound(key, source, volume, pitch); + } + + // If the float is a whole number, remove the decimal part for simplicity + private static String formatFloatSimple(float value) { + String s = String.valueOf(value); + return s.endsWith(".0") ? s.substring(0, s.length() - 2) : s; + } + + private static String buildRegex() { + String deliminator = Pattern.quote(DELIMITER); + return "^(?[a-zA-Z0-9:._-]+)" + + "(?:" + deliminator + "+(?\\d+(?:\\.\\d+)?))?" + + "(?:" + deliminator + "+(?\\d+(?:\\.\\d+)?))?" + + "(?:" + deliminator + "+(?[a-zA-Z_]+))?" + + deliminator + "*$"; + } +} diff --git a/configlib-adventure/src/test/java/de/exlll/configlib/AdventureComponentTests.java b/configlib-adventure/src/test/java/de/exlll/configlib/AdventureComponentTests.java new file mode 100644 index 0000000..1d70fe5 --- /dev/null +++ b/configlib-adventure/src/test/java/de/exlll/configlib/AdventureComponentTests.java @@ -0,0 +1,158 @@ +package de.exlll.configlib; + +import com.google.common.jimfs.Jimfs; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.TranslatableComponent; +import net.kyori.adventure.text.format.NamedTextColor; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.nio.file.FileSystem; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; + +import static de.exlll.configlib.TestUtils.createPlatformSpecificFilePath; +import static org.junit.jupiter.api.Assertions.*; + +class AdventureComponentTests { + private FileSystem fs; + private Path yamlFile; + + @BeforeEach + void setUp() throws IOException { + fs = Jimfs.newFileSystem(); + yamlFile = fs.getPath(createPlatformSpecificFilePath("/tmp/config.yml")); + Files.createDirectories(yamlFile.getParent()); + } + + @AfterEach + void tearDown() throws IOException { + if (fs != null) { + fs.close(); + } + } + + @Test + void testTranslatableComponentRoundTrip() { + AdventureComponentSerializer serializer = new AdventureComponentSerializer( + AdventureComponentFormat.TRANSLATION_KEY); + + TranslatableComponent original = Component.translatable("item.minecraft.diamond_sword"); + TranslatableComponent originalWithDefaultMessage = Component.translatable( + "item.minecraft.diamond_sword", + "Default Message"); + + String serialized = serializer.serialize(original); + + String serializedWithDefaultMessage = serializer.serialize(originalWithDefaultMessage); + + // Deserialize and validate returned TranslatableComponent + TranslatableComponent deserialized = assertInstanceOf(TranslatableComponent.class, + serializer.deserialize(serialized)); + TranslatableComponent deserializedWithDefaultMessage = assertInstanceOf(TranslatableComponent.class, + serializer.deserialize(serializedWithDefaultMessage)); + + // Basic round-trip equality check + assertEquals(original, deserialized, + "Deserialized component does not match the original"); + + // The default message should not be preserved in this format + assertNotEquals(originalWithDefaultMessage, deserializedWithDefaultMessage, + "Deserialized component should not match the original with default message"); + assertEquals(originalWithDefaultMessage.key(), deserializedWithDefaultMessage.key()); + } + + @Test + void testMultipleFormats() { + List baseFormats = new ArrayList<>(List.of( + AdventureComponentFormat.MINI_MESSAGE, + AdventureComponentFormat.MINECRAFT_JSON, + AdventureComponentFormat.LEGACY_AMPERSAND, + AdventureComponentFormat.LEGACY_SECTION)); + + List> allPermutations = generatePermutations(baseFormats); + + for (List deserializeOrder : allPermutations) { + runTestWithOrder(deserializeOrder); + } + } + + private void runTestWithOrder(List deserializeOrder) { + YamlConfigurationProperties.Builder builder = YamlConfigurationProperties + .newBuilder(); + // Set a fixed serialize order, we are testing deserialization. + AdventureConfigLib.addDefaults(builder, + AdventureComponentFormat.MINI_MESSAGE, deserializeOrder); + YamlConfigurationProperties properties = builder.build(); + + YamlConfigurationStore store = new YamlConfigurationStore<>( + MixedConfiguration.class, properties); + + // Manually create the YAML content to ensure specific formats are present + // We use double quotes to ensure characters like & and § are preserved and + // parsed as strings + String yamlContent = """ + json: '{"text":"strict_json","color":"yellow"}' + miniMessage: 'strict_mini' + legacyAmpersand: '&bstrict_ampersand' + legacySection: '§dstrict_section' + """; + + try { + Files.writeString(yamlFile, yamlContent); + } catch (IOException e) { + throw new RuntimeException(e); + } + + MixedConfiguration loaded = store.load(yamlFile); + + assertEquals( + Component.text("strict_json", NamedTextColor.YELLOW), + loaded.json, + "JSON mismatch with order: " + deserializeOrder); + assertEquals( + Component.text("strict_mini", NamedTextColor.GREEN), + loaded.miniMessage, + "MiniMessage mismatch with order: " + deserializeOrder); + assertEquals( + Component.text("strict_ampersand", NamedTextColor.AQUA), + loaded.legacyAmpersand, + "Legacy Ampersand mismatch with order: " + deserializeOrder); + assertEquals( + Component.text("strict_section", NamedTextColor.LIGHT_PURPLE), + loaded.legacySection, + "Legacy Section mismatch with order: " + deserializeOrder); + } + + @Configuration + static class MixedConfiguration { + Component json; + Component miniMessage; + Component legacyAmpersand; + Component legacySection; + } + + private List> generatePermutations(List original) { + if (original.isEmpty()) { + List> result = new ArrayList<>(); + result.add(new ArrayList<>()); + return result; + } + E firstElement = original.get(0); + List> returnValue = new ArrayList<>(); + List> permutations = generatePermutations( + original.subList(1, original.size())); + for (List smallerPermutated : permutations) { + for (int index = 0; index <= smallerPermutated.size(); index++) { + List temp = new ArrayList<>(smallerPermutated); + temp.add(index, firstElement); + returnValue.add(temp); + } + } + return returnValue; + } +} diff --git a/configlib-adventure/src/test/java/de/exlll/configlib/AdventureConfigurationTests.java b/configlib-adventure/src/test/java/de/exlll/configlib/AdventureConfigurationTests.java new file mode 100644 index 0000000..976e46a --- /dev/null +++ b/configlib-adventure/src/test/java/de/exlll/configlib/AdventureConfigurationTests.java @@ -0,0 +1,132 @@ +package de.exlll.configlib; + +import com.google.common.jimfs.Jimfs; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.minimessage.MiniMessage; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.nio.file.FileSystem; +import java.nio.file.Files; +import java.nio.file.Path; + +import static de.exlll.configlib.TestUtils.createPlatformSpecificFilePath; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +class AdventureConfigurationTests { + private FileSystem fs; + private Path yamlFile; + + @BeforeEach + void setUp() throws IOException { + fs = Jimfs.newFileSystem(); + yamlFile = fs.getPath(createPlatformSpecificFilePath("/tmp/config.yml")); + Files.createDirectories(yamlFile.getParent()); + } + + @AfterEach + void tearDown() throws IOException { + if (fs != null) { + fs.close(); + } + } + + // Test to make sure data is consistent after serialization and deserialization + @Test + void testSerializationAndDeserializationDefaults() { + YamlConfigurationProperties.Builder builder = YamlConfigurationProperties + .newBuilder(); + AdventureConfigLib.addDefaults(builder); + YamlConfigurationProperties properties = builder.build(); + + YamlConfigurationStore store = new YamlConfigurationStore<>( + AdventureTestConfiguration.class, properties); + + AdventureTestConfiguration original = new AdventureTestConfiguration(); + store.save(original, yamlFile); + + AdventureTestConfiguration loaded = store.load(yamlFile); + + assertConfigsEqual(original, loaded); + } + + private void assertConfigsEqual(AdventureTestConfiguration expected, + AdventureTestConfiguration actual) { + assertNotNull(actual); + assertEquals(expected.simpleText, + actual.simpleText, + "Simple Text mismatch"); + assertEquals(expected.coloredText, + actual.coloredText, + "Colored Text mismatch"); + assertEquals(expected.decoratedText, + actual.decoratedText, + "Decorated Text mismatch"); + assertEquals(expected.clickLink, + actual.clickLink, + "Click Link mismatch"); + assertEquals(expected.hoverText, + actual.hoverText, + "Hover Text mismatch"); + + assertEquals(serialize(expected.gradientText), + serialize(actual.gradientText), + "Gradient Text mismatch"); + assertEquals(serialize(expected.rainbowText), + serialize(actual.rainbowText), + "Rainbow Text mismatch"); + assertEquals(serialize(expected.formattedText), + serialize(actual.formattedText), + "Formatted Text mismatch"); + assertEquals(serialize(expected.clickText), + serialize(actual.clickText), + "Click Text mismatch"); + assertEquals(serialize(expected.hoverTextComplex), + serialize(actual.hoverTextComplex), + "Hover Text Complex mismatch"); + assertEquals(serialize(expected.keybindText), + serialize(actual.keybindText), + "Keybind Text (MM) mismatch"); + assertEquals(serialize(expected.translatableText), + serialize(actual.translatableText), + "Translatable Text (MM) mismatch"); + + assertEquals(expected.translatable, + actual.translatable, + "Translatable mismatch"); + assertEquals(expected.keybind, + actual.keybind, + "Keybind mismatch"); + + assertEquals(expected.simpleKey, + actual.simpleKey, + "Key mismatch"); + + assertEquals(expected.simpleSound.name(), + actual.simpleSound.name(), + "Sound Name mismatch"); + assertEquals(expected.simpleSound.source(), + actual.simpleSound.source(), + "Sound Source mismatch"); + assertEquals(expected.simpleSound.volume(), + actual.simpleSound.volume(), + "Sound Volume mismatch"); + assertEquals(expected.simpleSound.pitch(), + actual.simpleSound.pitch(), + "Sound Pitch mismatch"); + + assertEquals(expected.componentList, + actual.componentList, + "List mismatch"); + assertEquals(expected.componentMap, + actual.componentMap, + "Map mismatch"); + } + + private String serialize(Component component) { + return MiniMessage.miniMessage().serialize(component); + } +} diff --git a/configlib-adventure/src/test/java/de/exlll/configlib/AdventureKeySerializerTest.java b/configlib-adventure/src/test/java/de/exlll/configlib/AdventureKeySerializerTest.java new file mode 100644 index 0000000..35e4752 --- /dev/null +++ b/configlib-adventure/src/test/java/de/exlll/configlib/AdventureKeySerializerTest.java @@ -0,0 +1,49 @@ +package de.exlll.configlib; + +import net.kyori.adventure.key.InvalidKeyException; +import net.kyori.adventure.key.Key; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class AdventureKeySerializerTest { + + @Test + void testSerialize() { + AdventureKeySerializer serializer = new AdventureKeySerializer(); + Key key = Key.key("namespace", "value"); + assertEquals("namespace:value", serializer.serialize(key)); + } + + @Test + void testDeserializeWithColon() { + AdventureKeySerializer serializer = new AdventureKeySerializer(); + Key key = serializer.deserialize("namespace:value"); + assertEquals(Key.key("namespace", "value"), key); + } + + @Test + void testDeserializeWithoutColonAndNoDefaultNamespace() { + AdventureKeySerializer serializer = new AdventureKeySerializer(); + // Adventure default namespace is "minecraft" + Key key = serializer.deserialize("value"); + assertEquals(Key.key("minecraft", "value"), key); + } + + @Test + void testDeserializeWithoutColonAndDefaultNamespace() { + AdventureKeySerializer serializer = new AdventureKeySerializer("default"); + Key key = serializer.deserialize("value"); + assertEquals(Key.key("default", "value"), key); + } + + @Test + void testDeserializeWithColonAndDefaultNamespace() { + AdventureKeySerializer serializer = new AdventureKeySerializer("default"); + // Key.key("default", "namespace:value") will throw InvalidKeyException because + // ':' is not allowed in value + assertThrows(InvalidKeyException.class, () -> + serializer.deserialize("namespace:value")); + } +} diff --git a/configlib-adventure/src/test/java/de/exlll/configlib/AdventureSoundSerializerTest.java b/configlib-adventure/src/test/java/de/exlll/configlib/AdventureSoundSerializerTest.java new file mode 100644 index 0000000..f1f53c0 --- /dev/null +++ b/configlib-adventure/src/test/java/de/exlll/configlib/AdventureSoundSerializerTest.java @@ -0,0 +1,111 @@ +package de.exlll.configlib; + +import net.kyori.adventure.key.Key; +import net.kyori.adventure.sound.Sound; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class AdventureSoundSerializerTest { + private final AdventureSoundSerializer serializer = new AdventureSoundSerializer(); + + @Test + void testDeserializeBasic() { + String input = "minecraft:entity.player.levelup"; + Sound sound = serializer.deserialize(input); + + assertEquals(Key.key("minecraft", "entity.player.levelup"), sound.name()); + assertEquals(1.0f, sound.pitch()); + assertEquals(1.0f, sound.volume()); + assertEquals(Sound.Source.MASTER, sound.source()); + } + + @Test + void testDeserializeWithPitch() { + String input = "minecraft:test" + AdventureSoundSerializer.DELIMITER + "1.5"; + Sound sound = serializer.deserialize(input); + + assertEquals(Key.key("minecraft", "test"), sound.name()); + assertEquals(1.5f, sound.pitch()); + assertEquals(1.0f, sound.volume()); + assertEquals(Sound.Source.MASTER, sound.source()); + } + + @Test + void testDeserializeWithPitchAndVolume() { + String input = "minecraft:test" + AdventureSoundSerializer.DELIMITER + "0.5" + + AdventureSoundSerializer.DELIMITER + "2.0"; + Sound sound = serializer.deserialize(input); + + assertEquals(Key.key("minecraft", "test"), sound.name()); + assertEquals(0.5f, sound.pitch()); + assertEquals(2.0f, sound.volume()); + assertEquals(Sound.Source.MASTER, sound.source()); + } + + @Test + void testDeserializeFull() { + String input = "minecraft:test" + AdventureSoundSerializer.DELIMITER + "0.8" + + AdventureSoundSerializer.DELIMITER + "0.5" + + AdventureSoundSerializer.DELIMITER + "MUSIC"; + Sound sound = serializer.deserialize(input); + + assertEquals(Key.key("minecraft", "test"), sound.name()); + assertEquals(0.8f, sound.pitch()); + assertEquals(0.5f, sound.volume()); + assertEquals(Sound.Source.MUSIC, sound.source()); + } + + @Test + void testDeserializeComplexKey() { + String input = "minecraft:complex.key.name" + AdventureSoundSerializer.DELIMITER + "1.2"; + Sound sound = serializer.deserialize(input); + + assertEquals(Key.key("minecraft", "complex.key.name"), sound.name()); + assertEquals(1.2f, sound.pitch()); + } + + @Test + void testDeserializeAmbiguousKeyLookingLikeFloat() { + String input = "custom" + AdventureSoundSerializer.DELIMITER + "1.5"; + Sound sound = serializer.deserialize(input); + assertEquals(Key.key("minecraft", "custom"), sound.name()); + assertEquals(1.5f, sound.pitch()); + } + + @Test + void testDeserializeAmbiguousKeyLookingLikeSource() { + String input = "custom" + AdventureSoundSerializer.DELIMITER + "MUSIC"; + Sound sound = serializer.deserialize(input); + assertEquals(Key.key("minecraft", "custom"), sound.name()); + assertEquals(Sound.Source.MUSIC, sound.source()); + } + + @Test + void testRoundTrip() { + Sound original = Sound.sound( + Key.key("test:sound"), Sound.Source.AMBIENT, 0.5f, 1.2f); + String serialized = serializer.serialize(original); + Sound deserialized = serializer.deserialize(serialized); + + assertEquals(original.name(), deserialized.name()); + assertEquals(original.source(), deserialized.source()); + assertEquals(original.volume(), deserialized.volume()); + assertEquals(original.pitch(), deserialized.pitch()); + } + + @Test + void testDeserializeInvalidSourceThrowsConfigurationException() { + String input = "minecraft:test" + AdventureSoundSerializer.DELIMITER + "1.0" + + AdventureSoundSerializer.DELIMITER + "1.0" + + AdventureSoundSerializer.DELIMITER + "INVALID_SOURCE"; + + ConfigurationException exception = org.junit.jupiter.api.Assertions.assertThrows( + ConfigurationException.class, + () -> serializer.deserialize(input)); + + org.junit.jupiter.api.Assertions.assertTrue( + exception.getMessage().contains("Invalid sound source 'INVALID_SOURCE'"), + "Exception message should mention the invalid source"); + } +} diff --git a/configlib-adventure/src/test/java/de/exlll/configlib/AdventureTestConfiguration.java b/configlib-adventure/src/test/java/de/exlll/configlib/AdventureTestConfiguration.java new file mode 100644 index 0000000..e25b1d7 --- /dev/null +++ b/configlib-adventure/src/test/java/de/exlll/configlib/AdventureTestConfiguration.java @@ -0,0 +1,74 @@ +package de.exlll.configlib; + +import net.kyori.adventure.key.Key; +import net.kyori.adventure.sound.Sound; +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.TextDecoration; +import net.kyori.adventure.text.minimessage.MiniMessage; + +import java.util.List; +import java.util.Map; + +@Configuration +public class AdventureTestConfiguration { + Component simpleText = Component.text("Hello World"); + + Component coloredText = Component.text("Red Text", NamedTextColor.RED); + + Component decoratedText = Component.text("Bold Italic", + NamedTextColor.BLUE, TextDecoration.BOLD, TextDecoration.ITALIC); + + Component clickLink = Component.text("Click Me") + .clickEvent(ClickEvent.openUrl("https://example.com")); + + Component hoverText = Component.text("Hover Me") + .hoverEvent(HoverEvent.showText( + Component.text("Hover Content", NamedTextColor.GREEN))); + + Component gradientText = MiniMessage.miniMessage() + .deserialize("Gradient Text"); + + Component rainbowText = MiniMessage.miniMessage() + .deserialize("Rainbow"); + + Component formattedText = MiniMessage.miniMessage() + .deserialize("Bold " + + "Italic " + + "Underlined " + + "Strike " + + "Obfuscated"); + + Component clickText = MiniMessage.miniMessage() + .deserialize("Click Command"); + + // Note: Hover events with complex content inside might be sensitive to + // serialization round trips + Component hoverTextComplex = MiniMessage.miniMessage() + .deserialize("Red Hover'>Hover Text"); + Component keybindText = MiniMessage.miniMessage() + .deserialize(""); + Component translatableText = MiniMessage.miniMessage() + .deserialize(""); + + Key simpleKey = Key.key("namespace", "value"); + + Sound simpleSound = Sound.sound( + Key.key("minecraft:entity.player.levelup"), + Sound.Source.MASTER, + 1f, + 1f); + + List componentList = List.of( + Component.text("Item 1"), + Component.text("Item 2", NamedTextColor.GOLD)); + + Map componentMap = Map.of("welcome", Component.text("Welcome!"), + "goodbye", Component.text("Goodbye!", NamedTextColor.RED)); + + // Testing specific component types if possible via MiniMessage or construction + Component translatable = Component.translatable("item.minecraft.diamond_sword"); + Component keybind = Component.keybind("key.jump"); +} diff --git a/configlib-core/src/test/java/de/exlll/configlib/SerializersTest.java b/configlib-core/src/test/java/de/exlll/configlib/SerializersTest.java index ac5e284..54e08cf 100644 --- a/configlib-core/src/test/java/de/exlll/configlib/SerializersTest.java +++ b/configlib-core/src/test/java/de/exlll/configlib/SerializersTest.java @@ -1216,8 +1216,10 @@ void deserializeThrowsForObjectsIfObjectToStringDeserializationTypeNotAdded() { assertThrowsConfigurationException( () -> serializer.deserialize(new File(TMP_CONFIG_PATH)), - buildExceptionMessage("File", "/tmp/config.yml", "OBJECT_TO_STRING") + buildExceptionMessage("File", TMP_CONFIG_PATH, + "OBJECT_TO_STRING") ); + assertThrowsConfigurationException( () -> serializer.deserialize(URI.create("https://example.com")), buildExceptionMessage("URI", "https://example.com", "OBJECT_TO_STRING") @@ -1275,7 +1277,7 @@ void deserializeObjectToStringIfDeserializationTypeAdded() { assertThat( serializer.deserialize(new File(TMP_CONFIG_PATH)), - is("/tmp/config.yml") + is(TMP_CONFIG_PATH) ); assertThat( serializer.deserialize(URI.create("https://example.com")), diff --git a/configlib-core/src/testFixtures/java/de/exlll/configlib/TestUtils.java b/configlib-core/src/testFixtures/java/de/exlll/configlib/TestUtils.java index edb1f6d..d54a829 100644 --- a/configlib-core/src/testFixtures/java/de/exlll/configlib/TestUtils.java +++ b/configlib-core/src/testFixtures/java/de/exlll/configlib/TestUtils.java @@ -350,13 +350,16 @@ on different platforms like Windows. Currently, Jimfs(1.3.0) lacks support path declarations to fulfill the non-unix system's needs. */ public static String createPlatformSpecificFilePath(String path) { - final String platform = System.getProperty("os.name"); - - if (!platform.contains("Windows")) return path; + if (!isWindows()) return path; return String.format("C:%s", path.replace("/", File.separator)); } + public static boolean isWindows() { + final String platform = System.getProperty("os.name"); + return platform.contains("Windows"); + } + public static List createListOfPlatformSpecificFilePaths(String... paths) { return Stream.of(paths).map(TestUtils::createPlatformSpecificFilePath).toList(); } diff --git a/configlib-paper/build.gradle.kts b/configlib-paper/build.gradle.kts index b7e75b7..77e3121 100644 --- a/configlib-paper/build.gradle.kts +++ b/configlib-paper/build.gradle.kts @@ -5,4 +5,9 @@ plugins { dependencies { compileOnly("io.papermc.paper:paper-api:1.20.2-R0.1-SNAPSHOT") + api(project(":configlib-adventure")) +} + +tasks.compileJava { + dependsOn(project(":configlib-adventure").tasks.check) } diff --git a/configlib-paper/src/main/java/de/exlll/configlib/ConfigLib.java b/configlib-paper/src/main/java/de/exlll/configlib/ConfigLib.java index 41d110e..87a712c 100644 --- a/configlib-paper/src/main/java/de/exlll/configlib/ConfigLib.java +++ b/configlib-paper/src/main/java/de/exlll/configlib/ConfigLib.java @@ -18,14 +18,35 @@ public final class ConfigLib extends JavaPlugin { public static final YamlConfigurationProperties BUKKIT_DEFAULT_PROPERTIES = initializeBukkitDefaultProperties(); + /** + * A {@code YamlConfigurationProperties} object designed for the Paper platform. It provides + * native support for the Adventure library and serializer classes for types like + * {@link ItemStack} and other {@link ConfigurationSerializable} implementations. + *

+ * You can configure these properties further by creating a new builder using the + * {@code toBuilder()} method of this object. + */ + public static final YamlConfigurationProperties PAPER_DEFAULT_PROPERTIES = + initializePaperDefaultProperties(); + private static YamlConfigurationProperties initializeBukkitDefaultProperties() { - return YamlConfigurationProperties - .newBuilder() + return builder().build(); + } + + private static YamlConfigurationProperties initializePaperDefaultProperties() { + return AdventureConfigLib.addDefaults(builder()) + .getThis() + .build(); + } + + private static YamlConfigurationProperties.Builder builder() { + var builder = YamlConfigurationProperties.newBuilder(); + return builder .addSerializerByCondition( type -> type instanceof Class cls && ConfigurationSerializable.class.isAssignableFrom(cls), - BukkitConfigurationSerializableSerializer.DEFAULT - ) - .build(); + BukkitConfigurationSerializableSerializer.DEFAULT); } + + } diff --git a/configlib-velocity/build.gradle.kts b/configlib-velocity/build.gradle.kts index 1a19522..0f4e6bc 100644 --- a/configlib-velocity/build.gradle.kts +++ b/configlib-velocity/build.gradle.kts @@ -6,4 +6,9 @@ plugins { dependencies { compileOnly("com.velocitypowered:velocity-api:3.2.0-SNAPSHOT") annotationProcessor("com.velocitypowered:velocity-api:3.2.0-SNAPSHOT") + api(project(":configlib-adventure")) +} + +tasks.compileJava { + dependsOn(project(":configlib-adventure").tasks.check) } diff --git a/configlib-velocity/src/main/java/de/exlll/configlib/ConfigLib.java b/configlib-velocity/src/main/java/de/exlll/configlib/ConfigLib.java index b15a1c0..07adae8 100644 --- a/configlib-velocity/src/main/java/de/exlll/configlib/ConfigLib.java +++ b/configlib-velocity/src/main/java/de/exlll/configlib/ConfigLib.java @@ -3,7 +3,7 @@ import com.velocitypowered.api.plugin.Plugin; /** - * An empty plugin class that loads this library and its dependencies. + * A velocity plugin that loads this library and support for Adventure types. */ @Plugin( id = "configlib", @@ -13,4 +13,13 @@ description = "A library for working with YAML configurations.", authors = {"Exlll"} ) -public final class ConfigLib {} +public final class ConfigLib { + /** + * A {@code YamlConfigurationProperties} object design for Velocity software, it provides native support for Adventure library. + *

+ * You can configure these properties further by creating a new builder using the + * {@code toBuilder()} method of this object. + */ + public static final YamlConfigurationProperties VELOCITY_DEFAULT_PROPERTIES = AdventureConfigLib + .addDefaults(YamlConfigurationProperties.newBuilder()).getThis().build(); +} diff --git a/settings.gradle.kts b/settings.gradle.kts index bfb48b0..b77f1d2 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -4,3 +4,4 @@ include("configlib-yaml") include("configlib-paper") include("configlib-waterfall") include("configlib-velocity") +include("configlib-adventure")