diff --git a/api/build.gradle.kts b/api/build.gradle.kts index d7bd711b3..5609615d2 100644 --- a/api/build.gradle.kts +++ b/api/build.gradle.kts @@ -1,5 +1,6 @@ plugins { `tbp-module` + id("java-test-fixtures") } repositories { @@ -17,6 +18,12 @@ dependencies { // test testImplementation(libs.junit.jupiter) testRuntimeOnly(libs.junit.platform.launcher) + testImplementation(libs.gson) + testImplementation(libs.guava) + testImplementation(libs.adventure.api) + + testFixturesImplementation(libs.junit.jupiter) + testFixturesImplementation(libs.adventure.api) } tasks.test { diff --git a/api/src/main/java/dev/jsinco/brewery/api/brew/Brew.java b/api/src/main/java/dev/jsinco/brewery/api/brew/Brew.java index 5b63e4dd5..9bcee0da3 100644 --- a/api/src/main/java/dev/jsinco/brewery/api/brew/Brew.java +++ b/api/src/main/java/dev/jsinco/brewery/api/brew/Brew.java @@ -1,6 +1,7 @@ package dev.jsinco.brewery.api.brew; import com.google.errorprone.annotations.Immutable; +import dev.jsinco.brewery.api.meta.MetaContainer; import dev.jsinco.brewery.api.recipe.Recipe; import dev.jsinco.brewery.api.recipe.RecipeRegistry; import org.jetbrains.annotations.NotNull; @@ -12,7 +13,7 @@ import java.util.function.Supplier; @Immutable -public interface Brew { +public interface Brew extends MetaContainer { /** * @param registry A registry with all available recipes diff --git a/api/src/main/java/dev/jsinco/brewery/api/meta/BooleanMetaDataType.java b/api/src/main/java/dev/jsinco/brewery/api/meta/BooleanMetaDataType.java new file mode 100644 index 000000000..6cec2c8eb --- /dev/null +++ b/api/src/main/java/dev/jsinco/brewery/api/meta/BooleanMetaDataType.java @@ -0,0 +1,30 @@ +package dev.jsinco.brewery.api.meta; + +/** + * A convenience type for boolean metadata, stored internally as a byte, where 0 is false and everything else is true. + */ +public class BooleanMetaDataType implements MetaDataType { + + public static final BooleanMetaDataType INSTANCE = new BooleanMetaDataType(); + + @Override + public Class getPrimitiveType() { + return Byte.class; + } + + @Override + public Class getComplexType() { + return Boolean.class; + } + + @Override + public Byte toPrimitive(Boolean complex) { + return complex ? (byte) 1 : (byte) 0; + } + + @Override + public Boolean toComplex(Byte primitive) { + return primitive != 0; + } + +} diff --git a/api/src/main/java/dev/jsinco/brewery/api/meta/ListMetaDataType.java b/api/src/main/java/dev/jsinco/brewery/api/meta/ListMetaDataType.java new file mode 100644 index 000000000..205d66c1d --- /dev/null +++ b/api/src/main/java/dev/jsinco/brewery/api/meta/ListMetaDataType.java @@ -0,0 +1,45 @@ +package dev.jsinco.brewery.api.meta; + +import com.google.common.collect.Lists; + +import java.util.List; + +public class ListMetaDataType implements MetaDataType, List> { + + private final MetaDataType elementType; + + private ListMetaDataType(MetaDataType elementType) { + this.elementType = elementType; + } + + public static ListMetaDataType from(MetaDataType type) { + return new ListMetaDataType<>(type); + } + + @Override + @SuppressWarnings("unchecked") + public Class> getPrimitiveType() { + return (Class>) (Object) List.class; + } + + @Override + @SuppressWarnings("unchecked") + public Class> getComplexType() { + return (Class>) (Object) List.class; + } + + @Override + public List

toPrimitive(List complex) { + return Lists.transform(complex, elementType::toPrimitive); + } + + @Override + public List toComplex(List

primitive) { + return Lists.transform(primitive, elementType::toComplex); + } + + public MetaDataType getElementDataType() { + return elementType; + } + +} diff --git a/api/src/main/java/dev/jsinco/brewery/api/meta/MetaContainer.java b/api/src/main/java/dev/jsinco/brewery/api/meta/MetaContainer.java new file mode 100644 index 000000000..9740d8de4 --- /dev/null +++ b/api/src/main/java/dev/jsinco/brewery/api/meta/MetaContainer.java @@ -0,0 +1,60 @@ +package dev.jsinco.brewery.api.meta; + +import net.kyori.adventure.key.Key; + +import java.util.Set; + +public interface MetaContainer> { + + /** + * Creates a new meta container with the provided metadata added. The old container is unchanged. + * @param key The key the value will be stored under + * @param type The type of the value + * @param value The value to store + * @return A new meta container + * @param

The primitive type the value will be stored as when serialized + * @param The type of the value to store + */ + SELF withMeta(Key key, MetaDataType type, C value); + + /** + * Creates a new meta container with the provided metadata removed. The old container is unchanged. + * @param key The key to remove + * @return A new meta container + */ + SELF withoutMeta(Key key); + + /** + * Gets all metadata stored in this container. + * @return All metadata + */ + MetaData meta(); + + /** + * Gets metadata under the provided key. + * @param key The key to look up + * @param type The type of the metadata value + * @return The metadata value, or null if the key is not present + * @param

The primitive type the value is stored as when serialized + * @param The type of the value to retrieve + * @throws IllegalArgumentException If the value is not of the expected type + */ + C meta(Key key, MetaDataType type); + + /** + * Checks if this container has metadata of the specified type under the provided key. + * @param key The key to look up + * @param type The type of the metadata value + * @return True if {@link #meta(Key, MetaDataType)} will return a value + * @param

The primitive type the value is stored as when serialized + * @param The type of the value to retrieve + */ + boolean hasMeta(Key key, MetaDataType type); + + /** + * Gets all keys in this container. + * @return An immutable set of keys + */ + Set metaKeys(); + +} diff --git a/api/src/main/java/dev/jsinco/brewery/api/meta/MetaData.java b/api/src/main/java/dev/jsinco/brewery/api/meta/MetaData.java new file mode 100644 index 000000000..989ff7d74 --- /dev/null +++ b/api/src/main/java/dev/jsinco/brewery/api/meta/MetaData.java @@ -0,0 +1,151 @@ +package dev.jsinco.brewery.api.meta; + +import com.google.errorprone.annotations.Immutable; +import net.kyori.adventure.key.Key; + +import java.util.*; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +/** + * A basic metadata container, and the primitive type for nested metadata containers ({@link MetaDataType#CONTAINER}). + * Not suitable for use as a key in a hash-based collection. + */ +@Immutable +public final class MetaData implements MetaContainer { + + private final Map meta; + + /** + * Creates an empty metadata container. + */ + public MetaData() { + this(Collections.emptyMap()); + } + private MetaData(Map meta) { + this.meta = meta; + } + + @Override + public MetaData withMeta(Key key, MetaDataType type, C value) { + return new MetaData(Stream.concat( + meta.entrySet().stream(), + Stream.of(Map.entry(key, type.toPrimitive(value))) + ).collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue, (oldValue, newValue) -> newValue))); + } + + @Override + public MetaData withoutMeta(Key key) { + return new MetaData(meta.entrySet().stream() + .filter(entry -> !entry.getKey().equals(key)) + .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue))); + } + + @Override + public MetaData meta() { + return this; + } + + @Override + public C meta(Key key, MetaDataType type) { + Object value = meta.get(key); + if (value == null) { + return null; + } + if (!type.getPrimitiveType().isInstance(value)) { + throw new IllegalArgumentException("Meta for " + key + " is not of type " + type.getPrimitiveType().getSimpleName()); + } + if (type instanceof ListMetaDataType listType) { + List list = (List) value; + if (!list.isEmpty() && !listType.getElementDataType().getPrimitiveType().isInstance(list.getFirst())) { + throw new IllegalArgumentException("Meta for " + key + " is not of List with element type " + + listType.getElementDataType().getPrimitiveType().getSimpleName()); + } + } + return type.toComplex(type.getPrimitiveType().cast(value)); + } + + @Override + public boolean hasMeta(Key key, MetaDataType type) { + Object value = meta.get(key); + if (value == null) { + return false; + } + if (!type.getPrimitiveType().isInstance(value)) { + return false; + } + if (type instanceof ListMetaDataType listType) { + List list = (List) value; + return list.isEmpty() || listType.getElementDataType().getPrimitiveType().isInstance(list.getFirst()); + } + return true; + } + + @Override + public Set metaKeys() { + return meta.keySet(); + } + + /** + * Gets the raw metadata mapping. This method is mainly meant to help with serialization, + * prefer the type-safe {@link #meta(Key, MetaDataType)} method instead. + * @return An unmodifiable map of keys to metadata values as their primitive types + */ + public Map primitiveMap() { + return meta; + } + + @Override + public boolean equals(Object o) { + return o instanceof MetaData metaData && areMapsEqual(meta, metaData.meta); + } + + // Version of map.equals that properly checks for array equality + private static boolean areMapsEqual(Map map1, Map map2) { + return map1.size() == map2.size() && map1.entrySet().stream() + .allMatch(entry -> areEqual(entry.getValue(), map2.get(entry.getKey()))); + } + private static boolean areListsEqual(List list1, List list2) { + if (list1.size() != list2.size()) { + return false; + } + for (int i = 0; i < list1.size(); i++) { + if (!areEqual(list1.get(i), list2.get(i))) { + return false; + } + } + return true; + } + private static boolean areEqual(Object obj1, Object obj2) { + if (obj1 instanceof byte[] arr1 && obj2 instanceof byte[] arr2) { + return Arrays.equals(arr1, arr2); + } + if (obj1 instanceof int[] arr1 && obj2 instanceof int[] arr2) { + return Arrays.equals(arr1, arr2); + } + if (obj1 instanceof long[] arr1 && obj2 instanceof long[] arr2) { + return Arrays.equals(arr1, arr2); + } + if (obj1 instanceof List list1 && obj2 instanceof List list2) { + return areListsEqual(list1, list2); + } + return obj1.equals(obj2); + } + + @Override + public String toString() { + return meta.entrySet().stream() + .map(entry -> entry.getKey() + "=" + asString(entry.getValue())) + .collect(Collectors.joining(", ", "MetaData{", "}")); + } + private String asString(Object value) { + return switch (value) { + case byte[] arr -> Arrays.toString(arr); + case int[] arr -> Arrays.toString(arr); + case long[] arr -> Arrays.toString(arr); + case List list -> list.stream().map(this::asString).collect(Collectors.joining(", ")); + default -> value.toString(); + }; + } + +} diff --git a/api/src/main/java/dev/jsinco/brewery/api/meta/MetaDataType.java b/api/src/main/java/dev/jsinco/brewery/api/meta/MetaDataType.java new file mode 100644 index 000000000..f36cbf87f --- /dev/null +++ b/api/src/main/java/dev/jsinco/brewery/api/meta/MetaDataType.java @@ -0,0 +1,104 @@ +package dev.jsinco.brewery.api.meta; + +import net.kyori.adventure.key.Key; + +/** + * Represents a type that a metadata value can have. + *

+ * All static variables of this interface are primitive metadata types. + * Primitive types are stored when the metadata is serialized. + *

+ *

+ * Users can implement this interface to store custom, more complex types. + * See {@link BooleanMetaDataType} for an example. + *

+ * @param

The primitive Java type that is stored when serialized, + * must absolutely be one of the primitive types listed in this interface! + * @param The Java type that is retrieved when using {@link MetaContainer#meta(Key, MetaDataType)} + */ +public interface MetaDataType { + + MetaDataType BYTE = new Primitive<>(Byte.class); + MetaDataType SHORT = new Primitive<>(Short.class); + MetaDataType INTEGER = new Primitive<>(Integer.class); + MetaDataType LONG = new Primitive<>(Long.class); + MetaDataType FLOAT = new Primitive<>(Float.class); + MetaDataType DOUBLE = new Primitive<>(Double.class); + MetaDataType STRING = new Primitive<>(String.class); + + MetaDataType BYTE_ARRAY = new Primitive<>(byte[].class); + MetaDataType INTEGER_ARRAY = new Primitive<>(int[].class); + MetaDataType LONG_ARRAY = new Primitive<>(long[].class); + + /** + * The metadata type for nested metadata. + */ + MetaDataType CONTAINER = new Primitive<>(MetaData.class); + + ListMetaDataType BYTE_LIST = ListMetaDataType.from(MetaDataType.BYTE); + ListMetaDataType SHORT_LIST = ListMetaDataType.from(MetaDataType.SHORT); + ListMetaDataType INTEGER_LIST = ListMetaDataType.from(MetaDataType.INTEGER); + ListMetaDataType LONG_LIST = ListMetaDataType.from(MetaDataType.LONG); + ListMetaDataType FLOAT_LIST = ListMetaDataType.from(MetaDataType.FLOAT); + ListMetaDataType DOUBLE_LIST = ListMetaDataType.from(MetaDataType.DOUBLE); + ListMetaDataType STRING_LIST = ListMetaDataType.from(MetaDataType.STRING); + + ListMetaDataType BYTE_ARRAY_LIST = ListMetaDataType.from(MetaDataType.BYTE_ARRAY); + ListMetaDataType INTEGER_ARRAY_LIST = ListMetaDataType.from(MetaDataType.INTEGER_ARRAY); + ListMetaDataType LONG_ARRAY_LIST = ListMetaDataType.from(MetaDataType.LONG_ARRAY); + + ListMetaDataType CONTAINER_LIST = ListMetaDataType.from(MetaDataType.CONTAINER); + + /** + * @return The class of the primitive Java type + */ + Class

getPrimitiveType(); + + /** + * @return The class of the complex Java type + */ + Class getComplexType(); + + /** + * Reduces a complex object to this type's primitive, which can later be passed to {@link #toComplex(Object)} + * to reconstruct the original object. + * @param complex The complex object + * @return The primitive value + */ + P toPrimitive(C complex); + + /** + * Reconstructs a complex object from its primitive form created from {@link #toPrimitive(Object)}. + * This method may assume the primitive is valid and throw exceptions if this assumption does not hold. + * @param primitive The primitive value + * @return The complex object + */ + C toComplex(P primitive); + + class Primitive

implements MetaDataType { + private final Class

primitiveClass; + + private Primitive(Class

primitiveClass) { + this.primitiveClass = primitiveClass; + } + + @Override + public Class

getPrimitiveType() { + return primitiveClass; + } + @Override + public Class

getComplexType() { + return primitiveClass; + } + + @Override + public P toPrimitive(P complex) { + return complex; + } + @Override + public P toComplex(P primitive) { + return primitive; + } + } + +} diff --git a/api/src/test/java/dev/jsinco/brewery/api/meta/MetaDataTest.java b/api/src/test/java/dev/jsinco/brewery/api/meta/MetaDataTest.java new file mode 100644 index 000000000..5d312ec40 --- /dev/null +++ b/api/src/test/java/dev/jsinco/brewery/api/meta/MetaDataTest.java @@ -0,0 +1,127 @@ +package dev.jsinco.brewery.api.meta; + +import net.kyori.adventure.key.Key; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; + +import java.util.List; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.*; + +public class MetaDataTest { + + @Test + void testAbsentKey() { + Key testKey = Key.key("test", "absent"); + MetaData meta = new MetaData(); + + String retrievedValue = meta.meta(testKey, MetaDataType.STRING); + assertNull(retrievedValue, "Retrieved value should be empty"); + } + + @ParameterizedTest(name = "{index} ==> write and read with type {0} and value {1}") + @MethodSource("dev.jsinco.brewery.api.meta.MetaDataSamples#metaDataSampleProvider") + void testWriteAndRead(MetaDataType type, C testValue) { + Key testKey = Key.key("test", "write_and_read"); + MetaData meta = new MetaData(); + + MetaData updatedMeta = meta.withMeta(testKey, type, testValue); + + C retrievedValue = updatedMeta.meta(testKey, type); + assertEquals(testValue, retrievedValue, "Retrieved value should match the written value"); + } + + @Test + void testWrongDataType() { + Key testKey = Key.key("test", "string"); + MetaData meta = new MetaData(); + + MetaData updatedMeta = meta.withMeta(testKey, MetaDataType.STRING, "value"); + + assertThrows(IllegalArgumentException.class, () -> updatedMeta.meta(testKey, MetaDataType.INTEGER)); + } + + @Test + void testWrongListDataType() { + Key testKey = Key.key("test", "string_list"); + MetaData meta = new MetaData(); + + MetaData updatedMeta = meta.withMeta(testKey, MetaDataType.STRING_LIST, List.of("value")); + + assertThrows(IllegalArgumentException.class, () -> updatedMeta.meta(testKey, MetaDataType.INTEGER_LIST)); + } + + @Test + void testOverwriteKey() { + Key testKey = Key.key("test", "overwrite"); + MetaData meta = new MetaData() + .withMeta(testKey, MetaDataType.STRING, "first") + .withMeta(testKey, MetaDataType.STRING, "second"); + assertEquals("second", meta.meta(testKey, MetaDataType.STRING)); + } + + @Test + void testOverwriteKeyDifferentType() { + Key testKey = Key.key("test", "overwrite"); + MetaData meta = new MetaData() + .withMeta(testKey, MetaDataType.STRING, "first") + .withMeta(testKey, MetaDataType.INTEGER, 2); + assertEquals(2, meta.meta(testKey, MetaDataType.INTEGER)); + } + + @Test + void testWithoutMeta() { + Key testKey = Key.key("test", "string"); + MetaData meta = new MetaData().withMeta(testKey, MetaDataType.STRING, "value"); + + MetaData withoutMeta = meta.withoutMeta(testKey); + + assertNull(withoutMeta.meta(testKey, MetaDataType.STRING)); + } + + @Test + void testHasMetaNothing() { + Key testKey = Key.key("test", "nothing"); + MetaData meta = new MetaData(); + + assertFalse(meta.hasMeta(testKey, MetaDataType.STRING)); + } + + @Test + void testHasMetaSomething() { + Key testKey = Key.key("test", "string"); + MetaData meta = new MetaData().withMeta(testKey, MetaDataType.STRING, "example"); + + assertTrue(meta.hasMeta(testKey, MetaDataType.STRING)); + assertFalse(meta.hasMeta(testKey, MetaDataType.INTEGER)); + assertFalse(meta.hasMeta(testKey, MetaDataType.BYTE_ARRAY)); + assertFalse(meta.hasMeta(testKey, MetaDataType.STRING_LIST)); + } + + @Test + void testMetaKeys() { + Key key1 = Key.key("test", "one"); + Key key2 = Key.key("test", "two"); + Key key3 = Key.key("test", "three"); + MetaData meta = new MetaData() + .withMeta(key1, MetaDataType.STRING, "a") + .withMeta(key2, MetaDataType.STRING, "b") + .withMeta(key3, MetaDataType.STRING, "c"); + + assertEquals(Set.of(key1, key2, key3), meta.metaKeys()); + } + + @Test + void testArrayEqualsJank() { + // array.equals() uses identity comparison, which can cause problems in .equals() calls + Key testKey = Key.key("test", "array_equals"); + MetaData meta1 = new MetaData().withMeta(testKey, MetaDataType.BYTE_ARRAY, new byte[] { 1, 2, 3 }); + MetaData meta2 = new MetaData().withMeta(testKey, MetaDataType.BYTE_ARRAY, new byte[] { 1, 2, 3 }); + assertEquals(meta1, meta2); + assertEquals(meta2, meta1); + } + +} diff --git a/api/src/testFixtures/java/dev/jsinco/brewery/api/meta/MetaDataSamples.java b/api/src/testFixtures/java/dev/jsinco/brewery/api/meta/MetaDataSamples.java new file mode 100644 index 000000000..294e7537d --- /dev/null +++ b/api/src/testFixtures/java/dev/jsinco/brewery/api/meta/MetaDataSamples.java @@ -0,0 +1,124 @@ +package dev.jsinco.brewery.api.meta; + +import net.kyori.adventure.key.Key; +import org.junit.jupiter.params.provider.Arguments; + +import java.nio.charset.StandardCharsets; +import java.util.Collections; +import java.util.List; +import java.util.stream.Stream; + +import static org.junit.jupiter.api.Named.named; + +public class MetaDataSamples { + + /** + * @return A stream of arguments, each containing a MetaDataType and a sample value of that type. + */ + public static Stream metaDataSampleProvider() { + return Stream.of( + // Primitives + Arguments.of(named("Byte", MetaDataType.BYTE), (byte) 3), + Arguments.of(named("Integer", MetaDataType.INTEGER), 68), + Arguments.of(named("String", MetaDataType.STRING), "value"), + Arguments.of(named("ByteArray", MetaDataType.BYTE_ARRAY), "value".getBytes(StandardCharsets.UTF_8)), + Arguments.of(named("Container", MetaDataType.CONTAINER), new MetaData()), + Arguments.of(named("Container", MetaDataType.CONTAINER), sampleMeta("value")), + // List primitives + Arguments.of(named("StringList", MetaDataType.STRING_LIST), Collections.emptyList()), + Arguments.of(named("StringList", MetaDataType.STRING_LIST), List.of("a", "b", "c")), + Arguments.of(named("IntegerList", MetaDataType.INTEGER_LIST), Collections.emptyList()), + Arguments.of(named("IntegerList", MetaDataType.INTEGER_LIST), List.of(-1, 0, 1)), + Arguments.of(named("ByteArrayList", MetaDataType.BYTE_ARRAY_LIST), Collections.emptyList()), + Arguments.of(named("ByteArrayList", MetaDataType.BYTE_ARRAY_LIST), List.of("a".getBytes(StandardCharsets.UTF_8), "b".getBytes(StandardCharsets.UTF_8))), + Arguments.of(named("ContainerList", MetaDataType.CONTAINER_LIST), Collections.emptyList()), + Arguments.of(named("ContainerList", MetaDataType.CONTAINER_LIST), List.of(sampleMeta("value1"), sampleMeta("value2"))), + // Nested list + Arguments.of(named("StringListList", ListMetaDataType.from(MetaDataType.STRING_LIST)), List.of()), + Arguments.of(named("StringListList", ListMetaDataType.from(MetaDataType.STRING_LIST)), List.of(List.of("a", "b", "c"), List.of())), + // Custom + Arguments.of(named("Boolean", BooleanMetaDataType.INSTANCE), true), + Arguments.of(named("ComplexObject", ComplexObjectMetaDataType.INSTANCE), new ComplexObject(5, new SimpleObject(7))), + Arguments.of(named("ComplexObjectList", ListMetaDataType.from(ComplexObjectMetaDataType.INSTANCE)), Collections.emptyList()), + Arguments.of(named("ComplexObjectList", ListMetaDataType.from(ComplexObjectMetaDataType.INSTANCE)), sampleComplexList()) + ); + } + private static MetaData sampleMeta(String sampleValue) { + return new MetaData() + .withMeta(Key.key("test", "string"), MetaDataType.STRING, sampleValue) + .withMeta(Key.key("test", "integer"), MetaDataType.INTEGER, 68); + } + private static List sampleComplexList() { + return List.of( + new ComplexObject(5, new SimpleObject(7)), + new ComplexObject(6, new SimpleObject(8)) + ); + } + + private record SimpleObject(int n) {} + + private static class SimpleObjectMetaDataType implements MetaDataType { + + public static final SimpleObjectMetaDataType INSTANCE = new SimpleObjectMetaDataType(); + private static final Key N = Key.key("test", "n"); + + @Override + public Class getPrimitiveType() { + return MetaData.class; + } + + @Override + public Class getComplexType() { + return SimpleObject.class; + } + + @Override + public MetaData toPrimitive(SimpleObject complex) { + return new MetaData().withMeta(N, MetaDataType.INTEGER, complex.n); + } + + @Override + public SimpleObject toComplex(MetaData primitive) { + return new SimpleObject( + primitive.meta(N, MetaDataType.INTEGER) + ); + } + + } + + private record ComplexObject(int a, SimpleObject o) {} + + private static class ComplexObjectMetaDataType implements MetaDataType { + + public static final ComplexObjectMetaDataType INSTANCE = new ComplexObjectMetaDataType(); + private static final Key A = Key.key("test", "a"); + private static final Key O = Key.key("test", "o"); + + @Override + public Class getPrimitiveType() { + return MetaData.class; + } + + @Override + public Class getComplexType() { + return ComplexObject.class; + } + + @Override + public MetaData toPrimitive(ComplexObject complex) { + return new MetaData() + .withMeta(A, MetaDataType.INTEGER, complex.a) + .withMeta(O, SimpleObjectMetaDataType.INSTANCE, complex.o); + } + + @Override + public ComplexObject toComplex(MetaData primitive) { + return new ComplexObject( + primitive.meta(A, MetaDataType.INTEGER), + primitive.meta(O, SimpleObjectMetaDataType.INSTANCE) + ); + } + + } + +} diff --git a/bukkit/build.gradle.kts b/bukkit/build.gradle.kts index ed25f7be9..970a15850 100644 --- a/bukkit/build.gradle.kts +++ b/bukkit/build.gradle.kts @@ -82,6 +82,7 @@ dependencies { testImplementation(libs.junit.jupiter) testRuntimeOnly(libs.junit.platform.launcher) + testImplementation(testFixtures(project(":api"))) testImplementation(libs.adventure.nbt) testImplementation(libs.mockbukkit) testImplementation(libs.sqlite.jdbc) @@ -225,6 +226,9 @@ bukkit { register("brewery.command.info") { description = "Allows the user to use the /tbp info command." } + register("brewery.command.debug") { + description = "Allows the user to use the /tbp debug command." + } register("brewery.command.seal") { description = "Allows the user to use the /tbp seal command." } @@ -247,6 +251,7 @@ bukkit { "brewery.command.event" to true, "brewery.command.reload" to true, "brewery.command.info" to true, + "brewery.command.debug" to true, "brewery.command.seal" to true, "brewery.command.other" to true, "brewery.command.replicate" to true, diff --git a/bukkit/src/main/java/dev/jsinco/brewery/bukkit/brew/BrewAdapter.java b/bukkit/src/main/java/dev/jsinco/brewery/bukkit/brew/BrewAdapter.java index 5fff2d2dd..abfddd700 100644 --- a/bukkit/src/main/java/dev/jsinco/brewery/bukkit/brew/BrewAdapter.java +++ b/bukkit/src/main/java/dev/jsinco/brewery/bukkit/brew/BrewAdapter.java @@ -6,6 +6,7 @@ import dev.jsinco.brewery.api.brew.BrewingStep; import dev.jsinco.brewery.api.ingredient.Ingredient; import dev.jsinco.brewery.api.ingredient.IngredientManager; +import dev.jsinco.brewery.api.meta.MetaData; import dev.jsinco.brewery.api.recipe.DefaultRecipe; import dev.jsinco.brewery.api.recipe.Recipe; import dev.jsinco.brewery.api.recipe.RecipeResult; @@ -13,6 +14,7 @@ import dev.jsinco.brewery.api.util.Pair; import dev.jsinco.brewery.brew.BrewImpl; import dev.jsinco.brewery.bukkit.TheBrewingProject; +import dev.jsinco.brewery.bukkit.meta.MetaDataPdcType; import dev.jsinco.brewery.bukkit.util.BukkitIngredientUtil; import dev.jsinco.brewery.bukkit.util.ListPersistentDataType; import dev.jsinco.brewery.configuration.Config; @@ -49,6 +51,7 @@ public class BrewAdapter { private static final NamespacedKey BREWING_STEPS = TheBrewingProject.key("steps"); private static final NamespacedKey BREWERY_DATA_VERSION = TheBrewingProject.key("version"); private static final NamespacedKey BREWERY_CIPHERED = TheBrewingProject.key("ciphered"); + private static final NamespacedKey BREWERY_META = TheBrewingProject.key("meta"); public static final NamespacedKey BREWERY_TAG = TheBrewingProject.key("tag"); public static final NamespacedKey BREWERY_SCORE = TheBrewingProject.key("score"); public static final NamespacedKey BREWERY_DISPLAY_NAME = TheBrewingProject.key("display_name"); @@ -82,7 +85,7 @@ public static ItemStack toItem(Brew brew, Brew.State state) { } if (!(state instanceof BrewImpl.State.Seal)) { itemStack.editPersistentDataContainer(pdc -> - applyBrewStepsData(pdc, brew) + applyBrewData(pdc, brew) ); } return itemStack; @@ -145,7 +148,7 @@ private static ItemStack fromDefaultRecipe(Optional> recipe, R return defaultRecipes.getLast().result().newBrewItem(BrewScoreImpl.PLACEHOLDER, brew, state); } - public static void applyBrewStepsData(PersistentDataContainer pdc, Brew brew) { + public static void applyBrewData(PersistentDataContainer pdc, Brew brew) { pdc.set(BREWERY_DATA_VERSION, PersistentDataType.INTEGER, DATA_VERSION); if (Config.config().encryptSensitiveData()) { pdc.set(BREWERY_CIPHERED, PersistentDataType.BOOLEAN, true); @@ -154,6 +157,7 @@ public static void applyBrewStepsData(PersistentDataContainer pdc, Brew brew) { pdc.remove(BREWERY_CIPHERED); pdc.set(BREWING_STEPS, ListPersistentDataType.BREWING_STEP_LIST, brew.getSteps()); } + pdc.set(BREWERY_META, MetaDataPdcType.INSTANCE, brew.meta()); } public static Optional fromItem(ItemStack itemStack) { @@ -162,13 +166,17 @@ public static Optional fromItem(ItemStack itemStack) { if (!Objects.equals(dataVersion, DATA_VERSION)) { return Optional.empty(); } - if (data.has(BREWERY_CIPHERED, PersistentDataType.BOOLEAN)) { - return Optional.ofNullable(data.get(BREWING_STEPS, ListPersistentDataType.BREWING_STEP_CIPHERED_LIST)) - .map(BrewImpl::new); - } else { - return Optional.ofNullable(data.get(BREWING_STEPS, ListPersistentDataType.BREWING_STEP_LIST)) - .map(BrewImpl::new); + List steps = data.has(BREWERY_CIPHERED, PersistentDataType.BOOLEAN) + ? data.get(BREWING_STEPS, ListPersistentDataType.BREWING_STEP_CIPHERED_LIST) + : data.get(BREWING_STEPS, ListPersistentDataType.BREWING_STEP_LIST); + if (steps == null) { + return Optional.empty(); + } + MetaData meta = data.get(BREWERY_META, MetaDataPdcType.INSTANCE); + if (meta == null) { + meta = new MetaData(); } + return Optional.of(new BrewImpl(steps, meta)); } public static void hideTooltips(ItemStack itemStack) { diff --git a/bukkit/src/main/java/dev/jsinco/brewery/bukkit/brew/BukkitDistilleryBrewDataType.java b/bukkit/src/main/java/dev/jsinco/brewery/bukkit/brew/BukkitDistilleryBrewDataType.java index 95a1a0c2b..b476ab6c7 100644 --- a/bukkit/src/main/java/dev/jsinco/brewery/bukkit/brew/BukkitDistilleryBrewDataType.java +++ b/bukkit/src/main/java/dev/jsinco/brewery/bukkit/brew/BukkitDistilleryBrewDataType.java @@ -39,7 +39,7 @@ public List>> find(BreweryLocati while (resultSet.next()) { int pos = resultSet.getInt("pos"); boolean isDistillate = resultSet.getBoolean("is_distillate"); - CompletableFuture brewFuture = BrewImpl.SERIALIZER.deserialize(JsonParser.parseString(resultSet.getString("brew")).getAsJsonArray(), BukkitIngredientManager.INSTANCE); + CompletableFuture brewFuture = BrewImpl.SERIALIZER.deserialize(JsonParser.parseString(resultSet.getString("brew")), BukkitIngredientManager.INSTANCE); output.add(brewFuture.thenApplyAsync(brew -> new Pair<>(brew, new DistilleryContext(searchObject.x(), searchObject.y(), searchObject.z(), searchObject.worldUuid(), pos, isDistillate)) )); diff --git a/bukkit/src/main/java/dev/jsinco/brewery/bukkit/breweries/BukkitCauldronDataType.java b/bukkit/src/main/java/dev/jsinco/brewery/bukkit/breweries/BukkitCauldronDataType.java index f2dda7ee8..b8d92371a 100644 --- a/bukkit/src/main/java/dev/jsinco/brewery/bukkit/breweries/BukkitCauldronDataType.java +++ b/bukkit/src/main/java/dev/jsinco/brewery/bukkit/breweries/BukkitCauldronDataType.java @@ -78,7 +78,7 @@ public List> find(UUID worldUuid, Connection c int x = resultSet.getInt("cauldron_x"); int y = resultSet.getInt("cauldron_y"); int z = resultSet.getInt("cauldron_z"); - CompletableFuture brewFuture = BrewImpl.SERIALIZER.deserialize(JsonParser.parseString(resultSet.getString("brew")).getAsJsonArray(), BukkitIngredientManager.INSTANCE); + CompletableFuture brewFuture = BrewImpl.SERIALIZER.deserialize(JsonParser.parseString(resultSet.getString("brew")), BukkitIngredientManager.INSTANCE); cauldrons.add(brewFuture.thenApplyAsync(brew -> new BukkitCauldron(brew, new BreweryLocation(x, y, z, worldUuid)))); } return cauldrons; diff --git a/bukkit/src/main/java/dev/jsinco/brewery/bukkit/command/BreweryCommand.java b/bukkit/src/main/java/dev/jsinco/brewery/bukkit/command/BreweryCommand.java index f939bd08e..019e86ee6 100644 --- a/bukkit/src/main/java/dev/jsinco/brewery/bukkit/command/BreweryCommand.java +++ b/bukkit/src/main/java/dev/jsinco/brewery/bukkit/command/BreweryCommand.java @@ -42,8 +42,10 @@ public static void register(ReloadableRegistrarEvent commands) { commands.registrar().register(Commands.literal("tbp") .then(CreateCommand.command() .requires(commandSourceStack -> commandSourceStack.getSender().hasPermission("brewery.command.create"))) - .then(InfoCommand.command() + .then(InfoCommand.command("info", false) .requires(commandSourceStack -> commandSourceStack.getSender().hasPermission("brewery.command.info"))) + .then(InfoCommand.command("debug", true) + .requires(commandSourceStack -> commandSourceStack.getSender().hasPermission("brewery.command.debug"))) .then(SealCommand.command() .requires(commandSourceStack -> commandSourceStack.getSender().hasPermission("brewery.command.seal"))) .then(StatusCommand.command() diff --git a/bukkit/src/main/java/dev/jsinco/brewery/bukkit/command/InfoCommand.java b/bukkit/src/main/java/dev/jsinco/brewery/bukkit/command/InfoCommand.java index 5a41e5267..86c07d7f3 100644 --- a/bukkit/src/main/java/dev/jsinco/brewery/bukkit/command/InfoCommand.java +++ b/bukkit/src/main/java/dev/jsinco/brewery/bukkit/command/InfoCommand.java @@ -15,6 +15,9 @@ import io.papermc.paper.command.brigadier.CommandSourceStack; import io.papermc.paper.command.brigadier.Commands; import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.JoinConfiguration; +import net.kyori.adventure.text.event.ClickEvent; +import net.kyori.adventure.text.format.NamedTextColor; import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; import org.bukkit.command.CommandSender; import org.bukkit.entity.Player; @@ -28,46 +31,53 @@ public class InfoCommand { private static final int PLAYER_INVENTORY_SIZE = 41; - public static ArgumentBuilder command() { + public static ArgumentBuilder command(String name, boolean debug) { ArgumentBuilder withIndex = Commands.argument("inventory_slot", IntegerArgumentType.integer(0, PLAYER_INVENTORY_SIZE - 1)) .executes(context -> { Player target = BreweryCommand.getPlayer(context); int slot = context.getArgument("inventory_slot", int.class); PlayerInventory inventory = target.getInventory(); - showInfo(inventory.getItem(slot), context.getSource().getSender()); + showInfo(inventory.getItem(slot), context.getSource().getSender(), debug); return 1; }); ArgumentBuilder withNamedSlot = Commands.argument("equipment_slot", new EnumArgument<>(EquipmentSlot.class)) .executes(context -> { Player target = BreweryCommand.getPlayer(context); PlayerInventory inventory = target.getInventory(); - showInfo(inventory.getItem(context.getArgument("equipment_slot", EquipmentSlot.class)), context.getSource().getSender()); + showInfo(inventory.getItem(context.getArgument("equipment_slot", EquipmentSlot.class)), context.getSource().getSender(), debug); return 1; }); - return Commands.literal("info") + return Commands.literal(name) .then(withNamedSlot) .then(withIndex) .then(BreweryCommand.playerBranch(argument -> { argument.then(withNamedSlot); argument.then(withIndex); - argument.executes(InfoCommand::showHeldItemInfo); + argument.executes(context -> showHeldItemInfo(context, debug)); })) - .executes(InfoCommand::showHeldItemInfo); + .executes(context -> showHeldItemInfo(context, debug)); } - private static int showHeldItemInfo(CommandContext context) throws CommandSyntaxException { + private static int showHeldItemInfo(CommandContext context, boolean debug) throws CommandSyntaxException { Player target = BreweryCommand.getPlayer(context); PlayerInventory inventory = target.getInventory(); - showInfo(inventory.getItemInMainHand(), context.getSource().getSender()); + showInfo(inventory.getItemInMainHand(), context.getSource().getSender(), debug); return 1; } - private static void showInfo(@Nullable ItemStack itemStack, CommandSender sender) { + private static void showInfo(@Nullable ItemStack itemStack, CommandSender sender, boolean debug) { if (itemStack == null) { MessageUtil.message(sender, "tbp.command.info.not-a-brew"); return; } Optional brewOptional = BrewAdapter.fromItem(itemStack); + if (debug) { + brewOptional.ifPresentOrElse( + brew -> sender.sendMessage(debugInfo(brew)), + () -> MessageUtil.message(sender, "tbp.command.info.not-a-brew") + ); + return; + } brewOptional .ifPresent(brew -> MessageUtil.message(sender, "tbp.command.info.message", @@ -86,4 +96,14 @@ private static void showInfo(@Nullable ItemStack itemStack, CommandSender sender MessageUtil.message(sender, "tbp.command.info.not-a-brew"); } } + + private static Component debugInfo(Brew brew) { + String brewStr = brew.toString(); + return Component.join(JoinConfiguration.noSeparators(), + Component.text(brewStr), + Component.text(" (", NamedTextColor.GRAY), + Component.translatable("tbp.command.copy", NamedTextColor.AQUA).clickEvent(ClickEvent.copyToClipboard(brewStr)), + Component.text(")", NamedTextColor.GRAY) + ); + } } diff --git a/bukkit/src/main/java/dev/jsinco/brewery/bukkit/command/ReplicateCommand.java b/bukkit/src/main/java/dev/jsinco/brewery/bukkit/command/ReplicateCommand.java index fd20ac426..ccdf16b3b 100644 --- a/bukkit/src/main/java/dev/jsinco/brewery/bukkit/command/ReplicateCommand.java +++ b/bukkit/src/main/java/dev/jsinco/brewery/bukkit/command/ReplicateCommand.java @@ -49,7 +49,7 @@ private static void givePlayerBrew(Recipe recipe, CommandContext { BrewAdapter.applyBrewTags(pdc, recipe, score.score(), ((BukkitRecipeResult) recipe.getRecipeResult(quality)).getName()); - BrewAdapter.applyBrewStepsData(pdc, brew); + BrewAdapter.applyBrewData(pdc, brew); }); if (!target.getInventory().addItem(brewItem).isEmpty()) { target.getLocation().getWorld().dropItem(target.getLocation(), brewItem); diff --git a/bukkit/src/main/java/dev/jsinco/brewery/bukkit/meta/MetaDataPdcType.java b/bukkit/src/main/java/dev/jsinco/brewery/bukkit/meta/MetaDataPdcType.java new file mode 100644 index 000000000..f5dc6687d --- /dev/null +++ b/bukkit/src/main/java/dev/jsinco/brewery/bukkit/meta/MetaDataPdcType.java @@ -0,0 +1,57 @@ +package dev.jsinco.brewery.bukkit.meta; + +import dev.jsinco.brewery.api.meta.MetaData; +import dev.jsinco.brewery.api.meta.MetaDataType; +import net.kyori.adventure.key.Key; +import org.bukkit.NamespacedKey; +import org.bukkit.persistence.ListPersistentDataType; +import org.bukkit.persistence.PersistentDataAdapterContext; +import org.bukkit.persistence.PersistentDataContainer; +import org.bukkit.persistence.PersistentDataType; +import org.jetbrains.annotations.NotNull; +import org.jspecify.annotations.NonNull; + +import java.util.Map; +import java.util.Objects; + +public class MetaDataPdcType implements PersistentDataType { + + public static final MetaDataPdcType INSTANCE = new MetaDataPdcType(); + public static final ListPersistentDataType LIST = PersistentDataType.LIST.listTypeFrom(INSTANCE); + + @NotNull + @Override + public Class getPrimitiveType() { + return PersistentDataContainer.class; + } + + @NotNull + @Override + public Class getComplexType() { + return MetaData.class; + } + + @Override + @SuppressWarnings("unchecked") // typeof check ensures safety + public @NonNull PersistentDataContainer toPrimitive(@NonNull MetaData complex, @NotNull PersistentDataAdapterContext context) { + PersistentDataContainer pdc = context.newPersistentDataContainer(); + for (Map.Entry entry : complex.primitiveMap().entrySet()) { + NamespacedKey key = new NamespacedKey(entry.getKey().namespace(), entry.getKey().value()); + Object value = entry.getValue(); + pdc.set(key, (PersistentDataType) MetaUtil.pdcTypeOf(value), value); + } + return pdc; + } + + @Override + @SuppressWarnings("unchecked") // typeof check ensures safety + public @NonNull MetaData fromPrimitive(@NonNull PersistentDataContainer primitive, @NotNull PersistentDataAdapterContext context) { + MetaData meta = new MetaData(); + for (NamespacedKey key : primitive.getKeys()) { + Object value = Objects.requireNonNull(primitive.get(key, MetaUtil.findType(primitive, key))); + meta = meta.withMeta(Key.key(key.namespace(), key.value()), (MetaDataType) MetaUtil.metaDataTypeOf(value), value); + } + return meta; + } + +} diff --git a/bukkit/src/main/java/dev/jsinco/brewery/bukkit/meta/MetaUtil.java b/bukkit/src/main/java/dev/jsinco/brewery/bukkit/meta/MetaUtil.java new file mode 100644 index 000000000..013bbed16 --- /dev/null +++ b/bukkit/src/main/java/dev/jsinco/brewery/bukkit/meta/MetaUtil.java @@ -0,0 +1,121 @@ +package dev.jsinco.brewery.bukkit.meta; + +import dev.jsinco.brewery.api.meta.MetaData; +import dev.jsinco.brewery.api.meta.MetaDataType; +import org.bukkit.NamespacedKey; +import org.bukkit.persistence.PersistentDataAdapterContext; +import org.bukkit.persistence.PersistentDataContainer; +import org.bukkit.persistence.PersistentDataType; +import org.jetbrains.annotations.NotNull; + +import java.util.List; + +/** + * Common type utilities for MetaData <-> PDC conversion. + */ +/* internal */ final class MetaUtil { + private MetaUtil() {} + + private static final List> PRIMITIVES = List.of( + PersistentDataType.BYTE, + PersistentDataType.SHORT, + PersistentDataType.INTEGER, + PersistentDataType.LONG, + PersistentDataType.FLOAT, + PersistentDataType.DOUBLE, + PersistentDataType.STRING, + PersistentDataType.BYTE_ARRAY, + PersistentDataType.INTEGER_ARRAY, + PersistentDataType.LONG_ARRAY, + PersistentDataType.TAG_CONTAINER, + UntypedListDataType.INSTANCE + ); + + /* internal */ static PersistentDataType findType(PersistentDataContainer pdc, NamespacedKey key) { + for (PersistentDataType type : PRIMITIVES) { + if (pdc.has(key, type)) { + return type; + } + } + throw new IllegalArgumentException("No type found for " + key); + } + + /* internal */ static PersistentDataType pdcTypeOf(Object value) { + return switch (value) { + case Byte ignored -> PersistentDataType.BYTE; + case Short ignored -> PersistentDataType.SHORT; + case Integer ignored -> PersistentDataType.INTEGER; + case Long ignored -> PersistentDataType.LONG; + case Float ignored -> PersistentDataType.FLOAT; + case Double ignored -> PersistentDataType.DOUBLE; + case String ignored -> PersistentDataType.STRING; + case byte[] ignored -> PersistentDataType.BYTE_ARRAY; + case int[] ignored -> PersistentDataType.INTEGER_ARRAY; + case long[] ignored -> PersistentDataType.LONG_ARRAY; + case MetaData ignored -> MetaDataPdcType.INSTANCE; + case PersistentDataContainer ignored -> PersistentDataType.TAG_CONTAINER; + case List ignored -> UntypedListDataType.INSTANCE; + default -> throw new IllegalArgumentException("No type found for " + value.getClass().getSimpleName()); + }; + } + + /* internal */ static MetaDataType metaDataTypeOf(Object value) { + return switch (value) { + case Byte ignored -> MetaDataType.BYTE; + case Short ignored -> MetaDataType.SHORT; + case Integer ignored -> MetaDataType.INTEGER; + case Long ignored -> MetaDataType.LONG; + case Float ignored -> MetaDataType.FLOAT; + case Double ignored -> MetaDataType.DOUBLE; + case String ignored -> MetaDataType.STRING; + case byte[] ignored -> MetaDataType.BYTE_ARRAY; + case int[] ignored -> MetaDataType.INTEGER_ARRAY; + case long[] ignored -> MetaDataType.LONG_ARRAY; + case MetaData ignored -> MetaDataType.CONTAINER; + case PersistentDataContainer pdc -> PdcMetaDataType.with(pdc.getAdapterContext()); + case List ignored -> UntypedListDataType.INSTANCE; + default -> throw new IllegalArgumentException("No type found for " + value.getClass().getSimpleName()); + }; + } + + private static class UntypedListDataType implements MetaDataType, List>, PersistentDataType, List> { + + public static final UntypedListDataType INSTANCE = new UntypedListDataType(); + + @NotNull + @Override + @SuppressWarnings("unchecked") + public Class> getPrimitiveType() { + return (Class>) (Object) List.class; + } + + @NotNull + @Override + @SuppressWarnings("unchecked") + public Class> getComplexType() { + return (Class>) (Object) List.class; + } + + @NotNull + @Override + public List toPrimitive(@NotNull List complex, @NotNull PersistentDataAdapterContext context) { + return complex; + } + @Override + public List toPrimitive(List complex) { + return complex; + } + + @NotNull + @Override + public List fromPrimitive(@NotNull List primitive, @NotNull PersistentDataAdapterContext context) { + return primitive; + } + @Override + public List toComplex(List primitive) { + return primitive; + } + + } + +} diff --git a/bukkit/src/main/java/dev/jsinco/brewery/bukkit/meta/PdcMetaDataType.java b/bukkit/src/main/java/dev/jsinco/brewery/bukkit/meta/PdcMetaDataType.java new file mode 100644 index 000000000..616dc61a2 --- /dev/null +++ b/bukkit/src/main/java/dev/jsinco/brewery/bukkit/meta/PdcMetaDataType.java @@ -0,0 +1,58 @@ +package dev.jsinco.brewery.bukkit.meta; + +import dev.jsinco.brewery.api.meta.MetaData; +import dev.jsinco.brewery.api.meta.MetaDataType; +import net.kyori.adventure.key.Key; +import org.bukkit.NamespacedKey; +import org.bukkit.persistence.PersistentDataAdapterContext; +import org.bukkit.persistence.PersistentDataContainer; +import org.bukkit.persistence.PersistentDataType; + +import java.util.Map; + +public class PdcMetaDataType implements MetaDataType { + + private final PersistentDataAdapterContext context; + + private PdcMetaDataType(PersistentDataAdapterContext context) { + this.context = context; + } + + public static PdcMetaDataType with(PersistentDataAdapterContext context) { + return new PdcMetaDataType(context); + } + + @Override + public Class getPrimitiveType() { + return MetaData.class; + } + + @Override + public Class getComplexType() { + return PersistentDataContainer.class; + } + + @Override + @SuppressWarnings("unchecked") // typeof check ensures safety + public MetaData toPrimitive(PersistentDataContainer complex) { + MetaData meta = new MetaData(); + for (NamespacedKey key : complex.getKeys()) { + Object value = complex.get(key, MetaUtil.findType(complex, key)); + meta = meta.withMeta(Key.key(key.namespace(), key.value()), (MetaDataType) MetaUtil.metaDataTypeOf(value), value); + } + return meta; + } + + @Override + @SuppressWarnings("unchecked") // typeof check ensures safety + public PersistentDataContainer toComplex(MetaData primitive) { + PersistentDataContainer pdc = context.newPersistentDataContainer(); + for (Map.Entry entry : primitive.primitiveMap().entrySet()) { + NamespacedKey key = new NamespacedKey(entry.getKey().namespace(), entry.getKey().value()); + Object value = entry.getValue(); + pdc.set(key, (PersistentDataType) MetaUtil.pdcTypeOf(value), value); + } + return pdc; + } + +} diff --git a/bukkit/src/test/java/dev/jsinco/brewery/bukkit/brew/BrewAdapterTest.java b/bukkit/src/test/java/dev/jsinco/brewery/bukkit/brew/BrewAdapterTest.java new file mode 100644 index 000000000..4ff6bd341 --- /dev/null +++ b/bukkit/src/test/java/dev/jsinco/brewery/bukkit/brew/BrewAdapterTest.java @@ -0,0 +1,65 @@ +package dev.jsinco.brewery.bukkit.brew; + +import dev.jsinco.brewery.api.brew.Brew; +import dev.jsinco.brewery.api.breweries.BarrelType; +import dev.jsinco.brewery.api.breweries.CauldronType; +import dev.jsinco.brewery.api.meta.MetaDataType; +import dev.jsinco.brewery.api.moment.PassedMoment; +import dev.jsinco.brewery.brew.AgeStepImpl; +import dev.jsinco.brewery.brew.BrewImpl; +import dev.jsinco.brewery.brew.CookStepImpl; +import dev.jsinco.brewery.brew.DistillStepImpl; +import dev.jsinco.brewery.bukkit.TheBrewingProject; +import dev.jsinco.brewery.bukkit.ingredient.SimpleIngredient; +import dev.jsinco.brewery.bukkit.testutil.ItemStackMockPDC; +import dev.jsinco.brewery.bukkit.testutil.TBPServerMock; +import net.kyori.adventure.key.Key; +import org.bukkit.Material; +import org.bukkit.inventory.ItemStack; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockbukkit.mockbukkit.MockBukkit; + +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +public class BrewAdapterTest { + + @BeforeEach + void setUp() { + MockBukkit.mock(new TBPServerMock()); + MockBukkit.load(TheBrewingProject.class); + } + @AfterEach + public void tearDown() { + MockBukkit.unmock(); + } + + @Test + void test_roundTrip() { + Brew originalBrew = new BrewImpl(List.of( + new CookStepImpl( + new PassedMoment(20), + Map.of(SimpleIngredient.from("wheat").get(), 1), + CauldronType.LAVA + ), + new DistillStepImpl( + 3 + ), + new AgeStepImpl( + new PassedMoment(20), + BarrelType.ACACIA + ) + )).withMeta(Key.key("test", "example"), MetaDataType.STRING, "sample text"); + + ItemStack item = new ItemStackMockPDC(Material.POTION); + item.editPersistentDataContainer(pdc -> BrewAdapter.applyBrewData(pdc, originalBrew)); + Brew recreatedBrew = BrewAdapter.fromItem(item).get(); + + assertEquals(originalBrew, recreatedBrew); + } + +} diff --git a/bukkit/src/test/java/dev/jsinco/brewery/bukkit/brew/BrewSerializerTest.java b/bukkit/src/test/java/dev/jsinco/brewery/bukkit/brew/BrewSerializerTest.java new file mode 100644 index 000000000..7b6ff51af --- /dev/null +++ b/bukkit/src/test/java/dev/jsinco/brewery/bukkit/brew/BrewSerializerTest.java @@ -0,0 +1,100 @@ +package dev.jsinco.brewery.bukkit.brew; + +import com.google.gson.JsonElement; +import com.google.gson.JsonParser; +import dev.jsinco.brewery.api.brew.Brew; +import dev.jsinco.brewery.api.breweries.BarrelType; +import dev.jsinco.brewery.api.breweries.CauldronType; +import dev.jsinco.brewery.api.meta.MetaDataType; +import dev.jsinco.brewery.api.moment.PassedMoment; +import dev.jsinco.brewery.brew.AgeStepImpl; +import dev.jsinco.brewery.brew.BrewImpl; +import dev.jsinco.brewery.brew.CookStepImpl; +import dev.jsinco.brewery.brew.DistillStepImpl; +import dev.jsinco.brewery.bukkit.TheBrewingProject; +import dev.jsinco.brewery.bukkit.ingredient.BukkitIngredientManager; +import dev.jsinco.brewery.bukkit.ingredient.SimpleIngredient; +import dev.jsinco.brewery.bukkit.testutil.TBPServerMock; +import net.kyori.adventure.key.Key; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.mockbukkit.mockbukkit.MockBukkit; + +import java.time.Duration; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.stream.Stream; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTimeout; + +public class BrewSerializerTest { + + @BeforeEach + void setup() { + MockBukkit.mock(new TBPServerMock()); + MockBukkit.load(TheBrewingProject.class); + } + @AfterEach + public void tearDown() { + MockBukkit.unmock(); + } + + @ParameterizedTest + @MethodSource("brews") + void roundTrip(Brew brew) { + JsonElement serialized = BrewImpl.SERIALIZER.serialize(brew); + + CompletableFuture future = BrewImpl.SERIALIZER.deserialize(serialized, BukkitIngredientManager.INSTANCE); + Brew deserialized = assertTimeout(Duration.ofSeconds(5), () -> future.get()); + assertEquals(brew, deserialized); + } + + private static Stream brews() { + return Stream.of( + Arguments.of(new BrewImpl( + List.of() + )), + Arguments.of(sampleBrew()), + Arguments.of(sampleBrew() + .withMeta(Key.key("test", "sample"), MetaDataType.STRING, "sample text") + ) + ); + } + + @Test + void version0Conversion() { + String jsonStr = """ + [{"type":"cook","brew_time":20,"cauldron_type":"brewery:lava","ingredients":{"minecraft:wheat":1}},{"type":"distill","runs":3},{"type":"age","age":20,"barrel_type":"brewery:acacia"}]"""; + JsonElement json = JsonParser.parseString(jsonStr); + + CompletableFuture future = BrewImpl.SERIALIZER.deserialize(json, BukkitIngredientManager.INSTANCE); + Brew deserialized = assertTimeout(Duration.ofSeconds(5), () -> future.get()); + assertEquals(sampleBrew(), deserialized); + } + + private static BrewImpl sampleBrew() { + return new BrewImpl( + List.of( + new CookStepImpl( + new PassedMoment(20), + Map.of(SimpleIngredient.from("wheat").get(), 1), + CauldronType.LAVA + ), + new DistillStepImpl( + 3 + ), + new AgeStepImpl( + new PassedMoment(20), + BarrelType.ACACIA + ) + ) + ); + } + +} diff --git a/bukkit/src/test/java/dev/jsinco/brewery/bukkit/meta/MetaDataPdcTypeTest.java b/bukkit/src/test/java/dev/jsinco/brewery/bukkit/meta/MetaDataPdcTypeTest.java new file mode 100644 index 000000000..436451cae --- /dev/null +++ b/bukkit/src/test/java/dev/jsinco/brewery/bukkit/meta/MetaDataPdcTypeTest.java @@ -0,0 +1,49 @@ +package dev.jsinco.brewery.bukkit.meta; + +import dev.jsinco.brewery.api.meta.MetaData; +import dev.jsinco.brewery.api.meta.MetaDataType; +import net.kyori.adventure.key.Key; +import org.bukkit.NamespacedKey; +import org.bukkit.persistence.PersistentDataContainer; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; +import org.mockbukkit.mockbukkit.MockBukkitExtension; +import org.mockbukkit.mockbukkit.persistence.PersistentDataContainerMock; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +@ExtendWith(MockBukkitExtension.class) +public class MetaDataPdcTypeTest { + + @ParameterizedTest(name = "{index} ==> write and read with type {0} and value {1}") + @MethodSource("dev.jsinco.brewery.api.meta.MetaDataSamples#metaDataSampleProvider") + void testWriteAndRead(MetaDataType type, C testValue) { + PersistentDataContainer pdc = new PersistentDataContainerMock(); + MetaData meta = new MetaData() + .withMeta(Key.key("test", "write_and_read_pdc"), type, testValue); + NamespacedKey key = new NamespacedKey("test", "meta"); + + pdc.set(key, MetaDataPdcType.INSTANCE, meta); + MetaData retrievedMeta = pdc.get(key, MetaDataPdcType.INSTANCE); + assertEquals(meta, retrievedMeta); + } + + @Test + void testListTypeIsNotErased() { + PersistentDataContainer pdc = new PersistentDataContainerMock(); + Key innerKey = Key.key("test", "list_erasure"); + MetaData meta = new MetaData() + .withMeta(innerKey, MetaDataType.STRING_LIST, List.of("value")); + NamespacedKey key = new NamespacedKey("test", "meta"); + + pdc.set(key, MetaDataPdcType.INSTANCE, meta); + MetaData retrievedMeta = pdc.get(key, MetaDataPdcType.INSTANCE); + assertThrows(IllegalArgumentException.class, () -> retrievedMeta.meta(innerKey, MetaDataType.INTEGER_LIST)); + } + +} diff --git a/bukkit/src/test/java/dev/jsinco/brewery/bukkit/testutil/ItemStackMockPDC.java b/bukkit/src/test/java/dev/jsinco/brewery/bukkit/testutil/ItemStackMockPDC.java new file mode 100644 index 000000000..ca0a80f60 --- /dev/null +++ b/bukkit/src/test/java/dev/jsinco/brewery/bukkit/testutil/ItemStackMockPDC.java @@ -0,0 +1,19 @@ +package dev.jsinco.brewery.bukkit.testutil; + +import org.bukkit.Material; +import org.bukkit.persistence.PersistentDataContainer; +import org.jetbrains.annotations.NotNull; +import org.mockbukkit.mockbukkit.inventory.ItemStackMock; + +import java.util.function.Consumer; + +public class ItemStackMockPDC extends ItemStackMock { + public ItemStackMockPDC(Material material) { + super(material); + } + + @Override + public boolean editPersistentDataContainer(@NotNull Consumer consumer) { + return editMeta(meta -> consumer.accept(meta.getPersistentDataContainer())); + } +} diff --git a/core/build.gradle.kts b/core/build.gradle.kts index 5d44ed870..25ee6a480 100644 --- a/core/build.gradle.kts +++ b/core/build.gradle.kts @@ -40,6 +40,7 @@ dependencies { testImplementation(libs.junit.jupiter) testRuntimeOnly(libs.junit.platform.launcher) + testImplementation(testFixtures(project(":api"))) testImplementation(libs.gson) testImplementation(libs.joml) testImplementation(libs.guava) diff --git a/core/src/main/java/dev/jsinco/brewery/brew/BarrelBrewDataType.java b/core/src/main/java/dev/jsinco/brewery/brew/BarrelBrewDataType.java index 10ab0875f..15f5a3cc0 100644 --- a/core/src/main/java/dev/jsinco/brewery/brew/BarrelBrewDataType.java +++ b/core/src/main/java/dev/jsinco/brewery/brew/BarrelBrewDataType.java @@ -60,7 +60,7 @@ public void remove(Pair toRemove, Connection connection) th } private CompletableFuture brewFromResultSet(ResultSet resultSet) throws SQLException { - return BrewImpl.SERIALIZER.deserialize(JsonParser.parseString(resultSet.getString("brew")).getAsJsonArray(), getIngredientManager()); + return BrewImpl.SERIALIZER.deserialize(JsonParser.parseString(resultSet.getString("brew")), getIngredientManager()); } protected abstract IngredientManager getIngredientManager(); diff --git a/core/src/main/java/dev/jsinco/brewery/brew/BrewImpl.java b/core/src/main/java/dev/jsinco/brewery/brew/BrewImpl.java index e4f3f14ba..0f8863185 100644 --- a/core/src/main/java/dev/jsinco/brewery/brew/BrewImpl.java +++ b/core/src/main/java/dev/jsinco/brewery/brew/BrewImpl.java @@ -1,17 +1,17 @@ package dev.jsinco.brewery.brew; import dev.jsinco.brewery.api.brew.*; +import dev.jsinco.brewery.api.meta.MetaData; +import dev.jsinco.brewery.api.meta.MetaDataType; import dev.jsinco.brewery.configuration.Config; import dev.jsinco.brewery.api.recipe.Recipe; import dev.jsinco.brewery.api.recipe.RecipeRegistry; import dev.jsinco.brewery.recipes.BrewScoreImpl; import lombok.Getter; +import net.kyori.adventure.key.Key; import org.jetbrains.annotations.NotNull; -import java.util.ArrayList; -import java.util.List; -import java.util.Map; -import java.util.Optional; +import java.util.*; import java.util.function.Function; import java.util.function.Supplier; import java.util.stream.Stream; @@ -20,6 +20,7 @@ public class BrewImpl implements Brew { @Getter private final List steps; + private final MetaData meta; public static final BrewSerializer SERIALIZER = new BrewSerializer(); public BrewImpl(BrewingStep.Cook cook) { @@ -31,27 +32,38 @@ public BrewImpl(BrewingStep.Mix mix) { } public BrewImpl(@NotNull List steps) { + this(steps, new MetaData()); + } + + public BrewImpl(List steps, MetaData meta) { this.steps = steps; + this.meta = meta; } public BrewImpl withStep(BrewingStep step) { - return new BrewImpl(Stream.concat(steps.stream(), Stream.of(step)).toList()); + return new BrewImpl(Stream.concat(steps.stream(), Stream.of(step)).toList(), meta); } public BrewImpl witModifiedLastStep(Function modifier) { BrewingStep newStep = modifier.apply(steps.getLast()); - return new BrewImpl(Stream.concat( - steps.subList(0, steps.size() - 1).stream(), - Stream.of(newStep)).toList() + return new BrewImpl( + Stream.concat( + steps.subList(0, steps.size() - 1).stream(), + Stream.of(newStep) + ).toList(), + meta ); } public BrewImpl withLastStep(Class bClass, Function modifier, Supplier stepSupplier) { if (!steps.isEmpty() && bClass.isInstance(lastStep())) { BrewingStep newStep = modifier.apply(bClass.cast(lastStep())); - return new BrewImpl(Stream.concat( - steps.subList(0, steps.size() - 1).stream(), - Stream.of(newStep)).toList() + return new BrewImpl( + Stream.concat( + steps.subList(0, steps.size() - 1).stream(), + Stream.of(newStep) + ).toList(), + meta ); } return withStep(stepSupplier.get()); @@ -68,6 +80,36 @@ private boolean isCompleted(BrewingStep step) { return !(step instanceof BrewingStep.Age age) || age.time().moment() > Config.config().barrels().agingYearTicks() / 2; } + @Override + public Brew withMeta(Key key, MetaDataType type, C value) { + return new BrewImpl(steps, meta.withMeta(key, type, value)); + } + + @Override + public Brew withoutMeta(Key key) { + return new BrewImpl(steps, meta.withoutMeta(key)); + } + + @Override + public MetaData meta() { + return meta; + } + + @Override + public C meta(Key key, MetaDataType type) { + return meta.meta(key, type); + } + + @Override + public boolean hasMeta(Key key, MetaDataType type) { + return meta.hasMeta(key, type); + } + + @Override + public Set metaKeys() { + return meta.metaKeys(); + } + public Optional> closestRecipe(RecipeRegistry registry) { double bestScore = 0; Recipe bestMatch = null; @@ -136,4 +178,12 @@ public boolean equals(Object other) { return steps.equals(brew.steps); } + @Override + public String toString() { + return "BrewImpl{" + + "steps=" + steps + + ", meta=" + meta + + '}'; + } + } diff --git a/core/src/main/java/dev/jsinco/brewery/brew/BrewSerializer.java b/core/src/main/java/dev/jsinco/brewery/brew/BrewSerializer.java index eae75a375..065fcf417 100644 --- a/core/src/main/java/dev/jsinco/brewery/brew/BrewSerializer.java +++ b/core/src/main/java/dev/jsinco/brewery/brew/BrewSerializer.java @@ -1,10 +1,15 @@ package dev.jsinco.brewery.brew; import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; import dev.jsinco.brewery.api.brew.Brew; import dev.jsinco.brewery.api.brew.BrewingStep; import dev.jsinco.brewery.api.ingredient.IngredientManager; +import dev.jsinco.brewery.api.meta.MetaData; +import dev.jsinco.brewery.meta.MetaSerializer; import dev.jsinco.brewery.util.FutureUtil; +import org.jetbrains.annotations.Nullable; import java.util.List; import java.util.concurrent.CompletableFuture; @@ -12,8 +17,17 @@ public class BrewSerializer { public static final BrewSerializer INSTANCE = new BrewSerializer(); + private static final int VERSION = 1; - public JsonArray serialize(Brew brew) { + public JsonElement serialize(Brew brew) { + JsonObject obj = new JsonObject(); + obj.addProperty("version", VERSION); + obj.add("steps", steps(brew)); + obj.add("meta", MetaSerializer.INSTANCE.serialize(brew.meta())); + return obj; + } + + private static JsonArray steps(Brew brew) { JsonArray array = new JsonArray(); for (BrewingStep step : brew.getSteps()) { array.add(BrewingStepSerializer.INSTANCE.serialize(step)); @@ -21,11 +35,40 @@ public JsonArray serialize(Brew brew) { return array; } - public CompletableFuture deserialize(JsonArray jsonArray, IngredientManager ingredientManager) { + public CompletableFuture deserialize(JsonElement jsonElement, IngredientManager ingredientManager) { + if (jsonElement.isJsonArray()) { + return deserializeVersion0(jsonElement, ingredientManager); + } + JsonObject jsonObject = jsonElement.getAsJsonObject(); + int version = jsonObject.has("version") ? jsonObject.get("version").getAsInt() : 0; + if (version < 1 || version > VERSION) { + throw new RuntimeException("Unsupported version: " + version); + } + return getSteps(jsonObject.getAsJsonArray("steps"), ingredientManager) + .thenApplyAsync(steps -> new BrewImpl( + steps, getMeta(jsonObject.getAsJsonObject("meta")) + )); + } + + private static CompletableFuture deserializeVersion0(JsonElement jsonElement, IngredientManager ingredientManager) { + return getSteps(jsonElement.getAsJsonArray(), ingredientManager) + .thenApplyAsync(BrewImpl::new); + } + + private static CompletableFuture> getSteps(@Nullable JsonArray jsonArray, IngredientManager ingredientManager) { + if (jsonArray == null) { + return CompletableFuture.completedFuture(List.of()); + } List> brewingStepFutures = jsonArray.asList().stream() - .map(jsonElement -> BrewingStepSerializer.INSTANCE.deserialize(jsonElement, ingredientManager)) + .map(element -> BrewingStepSerializer.INSTANCE.deserialize(element, ingredientManager)) .toList(); - return FutureUtil.mergeFutures(brewingStepFutures) - .thenApplyAsync(BrewImpl::new); + return FutureUtil.mergeFutures(brewingStepFutures); + } + + private static MetaData getMeta(@Nullable JsonObject jsonObject) { + if (jsonObject == null) { + return new MetaData(); + } + return MetaSerializer.INSTANCE.deserialize(jsonObject); } } diff --git a/core/src/main/java/dev/jsinco/brewery/meta/MetaSerializer.java b/core/src/main/java/dev/jsinco/brewery/meta/MetaSerializer.java new file mode 100644 index 000000000..c3973ebe8 --- /dev/null +++ b/core/src/main/java/dev/jsinco/brewery/meta/MetaSerializer.java @@ -0,0 +1,224 @@ +package dev.jsinco.brewery.meta; + +import com.google.gson.Gson; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import dev.jsinco.brewery.api.meta.ListMetaDataType; +import dev.jsinco.brewery.api.meta.MetaData; +import dev.jsinco.brewery.api.meta.MetaDataType; +import net.kyori.adventure.key.Key; + +import java.util.List; +import java.util.Map; +import java.util.function.Function; + +public class MetaSerializer { + + /* + * Serializes MetaData to/from the following format: + * + * { + * "": , + * "[": [ + * , // not present if list is empty + * + * ], + * "{": + * } + * + * where is a single character representing the type of the value (see Primitive) + */ + + public static final MetaSerializer INSTANCE = new MetaSerializer(); + + private static final Gson GSON = new Gson(); + private static final char LIST_TYPE_CHARACTER = '['; + private static final char META_TYPE_CHARACTER = '{'; + + public JsonObject serialize(MetaData meta) { + JsonObject json = new JsonObject(); + for (Map.Entry entry : meta.primitiveMap().entrySet()) { + Key metaKey = entry.getKey(); + Object value = entry.getValue(); + switch (value) { + case List list -> json.add(LIST_TYPE_CHARACTER + metaKey.toString(), serializedList(list)); + case MetaData nestedMeta -> json.add(META_TYPE_CHARACTER + metaKey.toString(), serialize(nestedMeta)); + default -> json.add(typeof(value).typeCharacter + metaKey.toString(), GSON.toJsonTree(value)); + } + } + return json; + } + + private JsonArray serializedList(List list) { + if (list.isEmpty()) { + return new JsonArray(); + } + Object first = list.getFirst(); + char typeCharacter = switch (first) { + case List ignored -> LIST_TYPE_CHARACTER; + case MetaData ignored -> META_TYPE_CHARACTER; + default -> typeof(first).typeCharacter; + }; + JsonArray arr = new JsonArray(); + arr.add(typeCharacter); + list.stream() + .map(value -> switch (value) { + case List nestedList -> serializedList(nestedList); + case MetaData meta -> serialize(meta); + default -> GSON.toJsonTree(value); + }) + .forEach(arr::add); + return arr; + } + + public MetaData deserialize(JsonObject json) { + MetaData meta = new MetaData(); + for (Map.Entry entry : json.entrySet()) { + String jsonKey = entry.getKey(); + JsonElement jsonValue = entry.getValue(); + char typeCharacter = jsonKey.charAt(0); + Key metaKey = Key.key(jsonKey.substring(1)); + meta = switch (typeCharacter) { + case LIST_TYPE_CHARACTER -> withList(meta, metaKey, jsonValue.getAsJsonArray()); + case META_TYPE_CHARACTER -> meta.withMeta(metaKey, MetaDataType.CONTAINER, deserialize(jsonValue.getAsJsonObject())); + default -> primitiveFromChar(typeCharacter).metaWithValue(meta, metaKey, jsonValue); + }; + } + return meta; + } + + @SuppressWarnings("unchecked") // Stream>.toList() is a List> + private MetaData withList(MetaData meta, Key metaKey, JsonArray arr) { + if (arr.isEmpty()) { + return meta.withMeta(metaKey, MetaDataType.STRING_LIST, List.of()); + } + char typeCharacter = arr.get(0).getAsString().charAt(0); + arr.remove(0); + return switch (typeCharacter) { + case LIST_TYPE_CHARACTER -> { + List> list = (List>) (Object) arr.asList().stream() + .map(JsonElement::getAsJsonArray) + .map(this::convertTypedArray) + .toList(); + yield meta.withMeta(metaKey, UntypedListDataType.INSTANCE, list); + } + case META_TYPE_CHARACTER -> { + List list = arr.asList().stream() + .map(JsonElement::getAsJsonObject) + .map(this::deserialize) + .toList(); + yield meta.withMeta(metaKey, MetaDataType.CONTAINER_LIST, list); + } + default -> primitiveFromChar(typeCharacter).metaWithListOfValues(meta, metaKey, arr); + }; + } + + private List convertTypedArray(JsonArray arr) { + if (arr.isEmpty()) { + return List.of(); + } + char typeCharacter = arr.get(0).getAsString().charAt(0); + arr.remove(0); + return switch (typeCharacter) { + case LIST_TYPE_CHARACTER -> arr.asList().stream() + .map(JsonElement::getAsJsonArray) + .map(this::convertTypedArray) + .toList(); + case META_TYPE_CHARACTER -> arr.asList().stream() + .map(JsonElement::getAsJsonObject) + .map(this::deserialize) + .toList(); + default -> primitiveFromChar(typeCharacter).asList(arr); + }; + } + + private static final List> PRIMITIVES = List.of( + new Primitive<>('b', MetaDataType.BYTE, MetaDataType.BYTE_LIST, JsonElement::getAsByte), + new Primitive<>('s', MetaDataType.SHORT, MetaDataType.SHORT_LIST, JsonElement::getAsShort), + new Primitive<>('i', MetaDataType.INTEGER, MetaDataType.INTEGER_LIST, JsonElement::getAsInt), + new Primitive<>('l', MetaDataType.LONG, MetaDataType.LONG_LIST, JsonElement::getAsLong), + new Primitive<>('f', MetaDataType.FLOAT, MetaDataType.FLOAT_LIST, JsonElement::getAsFloat), + new Primitive<>('d', MetaDataType.DOUBLE, MetaDataType.DOUBLE_LIST, JsonElement::getAsDouble), + new Primitive<>('S', MetaDataType.STRING, MetaDataType.STRING_LIST, JsonElement::getAsString), + new Primitive<>('B', MetaDataType.BYTE_ARRAY, MetaDataType.BYTE_ARRAY_LIST, e -> GSON.fromJson(e, byte[].class)), + new Primitive<>('I', MetaDataType.INTEGER_ARRAY, MetaDataType.INTEGER_ARRAY_LIST, e -> GSON.fromJson(e, int[].class)), + new Primitive<>('L', MetaDataType.LONG_ARRAY, MetaDataType.LONG_ARRAY_LIST, e -> GSON.fromJson(e, long[].class)) + ); + + private static Primitive primitiveFromChar(char typeCharacter) { + return PRIMITIVES.stream() + .filter(p -> p.typeCharacter == typeCharacter) + .findFirst() + .orElseThrow(); + } + + private static Primitive typeof(Object value) { + return switch (value) { + case Byte ignored -> PRIMITIVES.get(0); + case Short ignored -> PRIMITIVES.get(1); + case Integer ignored -> PRIMITIVES.get(2); + case Long ignored -> PRIMITIVES.get(3); + case Float ignored -> PRIMITIVES.get(4); + case Double ignored -> PRIMITIVES.get(5); + case String ignored -> PRIMITIVES.get(6); + case byte[] ignored -> PRIMITIVES.get(7); + case int[] ignored -> PRIMITIVES.get(8); + case long[] ignored -> PRIMITIVES.get(9); + default -> throw new IllegalArgumentException("No type found for " + value.getClass().getSimpleName()); + }; + } + + private record Primitive

( + char typeCharacter, + MetaDataType type, + ListMetaDataType listType, + Function jsonToPrimitive + ) { + + public MetaData metaWithValue(MetaData meta, Key metaKey, JsonElement value) { + return meta.withMeta(metaKey, type, jsonToPrimitive.apply(value)); + } + + public MetaData metaWithListOfValues(MetaData meta, Key metaKey, JsonArray arr) { + return meta.withMeta(metaKey, listType, asList(arr)); + } + + public List

asList(JsonArray arr) { + return arr.asList().stream() + .map(jsonToPrimitive) + .toList(); + } + + } + + // A data type that reads and writes lists as-is, without any type transformation + private static class UntypedListDataType implements MetaDataType, List> { + + public static final UntypedListDataType INSTANCE = new UntypedListDataType(); + + @Override + @SuppressWarnings("unchecked") + public Class> getPrimitiveType() { + return (Class>) (Object) List.class; + } + + @Override + @SuppressWarnings("unchecked") + public Class> getComplexType() { + return (Class>) (Object) List.class; + } + + @Override + public List toPrimitive(List complex) { + return complex; + } + + @Override + public List toComplex(List primitive) { + return primitive; + } + + } + +} diff --git a/core/src/main/resources/locale/en-US.lang.properties b/core/src/main/resources/locale/en-US.lang.properties index d155b3a0e..6b7e87d3b 100644 --- a/core/src/main/resources/locale/en-US.lang.properties +++ b/core/src/main/resources/locale/en-US.lang.properties @@ -55,6 +55,7 @@ tbp.cauldron.type.lava=lava tbp.cauldron.type.none=none tbp.cauldron.type.snow=snow tbp.cauldron.type.water=water +tbp.command.copy=Click to copy tbp.command.encryption.rotate_key=Successfully migrated to a new 256-bit encryption key! tbp.command.create.missing-mandatory-argument=Missing mandatory argument(s) tbp.command.create.success=Successfully created diff --git a/core/src/test/java/dev/jsinco/brewery/meta/MetaSerializerTest.java b/core/src/test/java/dev/jsinco/brewery/meta/MetaSerializerTest.java new file mode 100644 index 000000000..c1f6b09bc --- /dev/null +++ b/core/src/test/java/dev/jsinco/brewery/meta/MetaSerializerTest.java @@ -0,0 +1,27 @@ +package dev.jsinco.brewery.meta; + +import com.google.gson.JsonObject; +import dev.jsinco.brewery.api.meta.MetaData; +import dev.jsinco.brewery.api.meta.MetaDataType; +import net.kyori.adventure.key.Key; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +public class MetaSerializerTest { + + @ParameterizedTest(name = "{index} ==> round trip with type {0} and value {1}") + @MethodSource("dev.jsinco.brewery.api.meta.MetaDataSamples#metaDataSampleProvider") + void testRoundTrip(MetaDataType type, C testValue) { + Key testKey = Key.key("test", "write_and_read"); + MetaData meta = new MetaData().withMeta(testKey, type, testValue); + + JsonObject json = MetaSerializer.INSTANCE.serialize(meta); + System.out.println(json); + MetaData retrievedMeta = MetaSerializer.INSTANCE.deserialize(json); + + assertEquals(meta, retrievedMeta, "Retrieved meta should match the written meta"); + } + +}