diff --git a/docs/Home.md b/docs/Home.md
new file mode 100644
index 0000000..bea7634
--- /dev/null
+++ b/docs/Home.md
@@ -0,0 +1,2 @@
+# HollowCube Common
+
diff --git a/modules/build.gradle.kts b/modules/build.gradle.kts
index 76de787..41ccdfe 100644
--- a/modules/build.gradle.kts
+++ b/modules/build.gradle.kts
@@ -5,7 +5,7 @@ subprojects {
apply(plugin = "java")
apply(plugin = "net.ltgt.errorprone")
- group = "net.hollowcube.libmmo"
+ group = "net.hollowcube.common"
repositories {
mavenCentral()
diff --git a/modules/common/README.md b/modules/common/README.md
new file mode 100644
index 0000000..df8b896
--- /dev/null
+++ b/modules/common/README.md
@@ -0,0 +1,11 @@
+## common
+
+Common libraries for other modules in the project.
+
+Included by all other projects, so this must be kept small with minimal dependencies.
+
+```
+data: Data parsing abstraction
+registry: Resource registry for various data driven modules
+util: Common utilities
+```
diff --git a/modules/common/build.gradle.kts b/modules/common/build.gradle.kts
new file mode 100644
index 0000000..1473429
--- /dev/null
+++ b/modules/common/build.gradle.kts
@@ -0,0 +1,17 @@
+plugins {
+ `java-library`
+}
+
+dependencies {
+ api("com.github.minestommmo:DataFixerUpper:cf58e926a6")
+ api("net.kyori:adventure-text-minimessage:4.11.0")
+
+ implementation("org.tinylog:tinylog-impl:2.4.1")
+
+ implementation("io.github.cdimascio:dotenv-java:2.2.4")
+
+ // Optional components
+ compileOnly("org.mongodb:mongodb-driver-sync:4.7.0")
+
+
+}
diff --git a/modules/common/src/main/java/net/hollowcube/Env.java b/modules/common/src/main/java/net/hollowcube/Env.java
new file mode 100644
index 0000000..cd9bf1f
--- /dev/null
+++ b/modules/common/src/main/java/net/hollowcube/Env.java
@@ -0,0 +1,28 @@
+package net.hollowcube;
+
+import org.jetbrains.annotations.NotNull;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.function.Supplier;
+
+public class Env {
+ /**
+ * Strict mode is enabled in production, but may be disabled during tests.
+ *
+ * It should be used to check cases which are fine during development but are a fatal problem in production. For
+ * example, if a registry is empty for any reason in production the server should not be allowed to start.
+ */
+ public static final Boolean STRICT_MODE = Boolean.valueOf(System.getProperty("starlight.strict", "false"));
+
+
+ private static final Logger STRICT_LOGGER = LoggerFactory.getLogger("STRICT");
+
+ public static void strictValidation(@NotNull String message, @NotNull Supplier predicate) {
+ if (STRICT_MODE && predicate.get()) {
+ STRICT_LOGGER.error(message);
+ System.exit(1);
+ }
+ }
+
+}
diff --git a/modules/common/src/main/java/net/hollowcube/config/ConfigProvider.java b/modules/common/src/main/java/net/hollowcube/config/ConfigProvider.java
new file mode 100644
index 0000000..cd363eb
--- /dev/null
+++ b/modules/common/src/main/java/net/hollowcube/config/ConfigProvider.java
@@ -0,0 +1,20 @@
+package net.hollowcube.config;
+
+import com.mojang.serialization.Codec;
+import net.minestom.server.utils.validate.Check;
+import org.jetbrains.annotations.NotNull;
+import net.hollowcube.dfu.EnvVarOps;
+
+import java.util.Locale;
+
+public final class ConfigProvider {
+
+ public static @NotNull T load(@NotNull String prefix, @NotNull Codec codec) {
+ var result = EnvVarOps.DOTENV.withDecoder(codec)
+ .apply(prefix.toUpperCase(Locale.ROOT))
+ .result()
+ .orElse(null);
+ Check.notNull(result, "Config unable to load");
+ return result.getFirst();
+ }
+}
diff --git a/modules/common/src/main/java/net/hollowcube/data/NumberSource.java b/modules/common/src/main/java/net/hollowcube/data/NumberSource.java
new file mode 100644
index 0000000..4a125af
--- /dev/null
+++ b/modules/common/src/main/java/net/hollowcube/data/NumberSource.java
@@ -0,0 +1,24 @@
+package net.hollowcube.data;
+
+import org.jetbrains.annotations.NotNull;
+
+import java.util.concurrent.ThreadLocalRandom;
+
+/**
+ * A source of numbers, implementations decide where the numbers come from
+ */
+@FunctionalInterface
+public interface NumberSource {
+
+ static @NotNull NumberSource constant(double value) {
+ return () -> value;
+ }
+
+ static @NotNull NumberSource threadLocalRandom() {
+ return () -> ThreadLocalRandom.current().nextDouble();
+ }
+
+
+ double random();
+
+}
diff --git a/modules/common/src/main/java/net/hollowcube/data/number/ConstantNumberProvider.java b/modules/common/src/main/java/net/hollowcube/data/number/ConstantNumberProvider.java
new file mode 100644
index 0000000..398321f
--- /dev/null
+++ b/modules/common/src/main/java/net/hollowcube/data/number/ConstantNumberProvider.java
@@ -0,0 +1,42 @@
+package net.hollowcube.data.number;
+
+import com.google.auto.service.AutoService;
+import com.mojang.serialization.Codec;
+import com.mojang.serialization.codecs.RecordCodecBuilder;
+import net.hollowcube.data.NumberSource;
+import net.minestom.server.utils.NamespaceID;
+import org.jetbrains.annotations.NotNull;
+import net.hollowcube.dfu.ExtraCodecs;
+
+record ConstantNumberProvider(
+ @NotNull Number value
+) implements NumberProvider {
+
+ public static Codec CODEC = RecordCodecBuilder.create(i -> i.group(
+ ExtraCodecs.NUMBER.fieldOf("value").forGetter(ConstantNumberProvider::value)
+ ).apply(i, ConstantNumberProvider::new));
+
+
+ @Override
+ public long nextLong(@NotNull NumberSource numbers) {
+ return value().longValue();
+ }
+
+ @Override
+ public double nextDouble(@NotNull NumberSource numbers) {
+ return value().doubleValue();
+ }
+
+
+ @AutoService(NumberProvider.Factory.class)
+ public static final class Factory extends NumberProvider.Factory {
+ public Factory() {
+ super(
+ NamespaceID.from("starlight:constant"),
+ ConstantNumberProvider.class,
+ ConstantNumberProvider.CODEC
+ );
+ }
+ }
+
+}
diff --git a/modules/common/src/main/java/net/hollowcube/data/number/NumberProvider.java b/modules/common/src/main/java/net/hollowcube/data/number/NumberProvider.java
new file mode 100644
index 0000000..294e09c
--- /dev/null
+++ b/modules/common/src/main/java/net/hollowcube/data/number/NumberProvider.java
@@ -0,0 +1,79 @@
+package net.hollowcube.data.number;
+
+import com.mojang.datafixers.util.Either;
+import com.mojang.serialization.Codec;
+import net.hollowcube.registry.Registry;
+import net.hollowcube.registry.ResourceFactory;
+import net.minestom.server.utils.NamespaceID;
+import org.jetbrains.annotations.NotNull;
+import net.hollowcube.data.NumberSource;
+import net.hollowcube.dfu.DFUUtil;
+import net.hollowcube.dfu.ExtraCodecs;
+
+/**
+ * A source of numbers, see Number
+ * Providers.
+ *
+ * Implementing a new number source requires a few steps:
+ *
+ * - Create a class inheriting from {@link NumberProvider}
+ * - Write a {@link Codec} for the class
+ * - Create an inner factory class inheriting from {@link NumberProvider.Factory}, filling in the required constructor params
+ * - Annotate the factory class with {@link com.google.auto.service.AutoService} for {@link NumberProvider.Factory}
+ *
+ *
+ * Would like to look into a simplification of this process & removal of the Factory class. I would prefer if it was something like the following
+ *
{@code
+ * // This, where the annotation generates an entry for the superclass
+ * @BasicRegistryItem("minecraft:constant")
+ * public record ConstantNumberProvider(
+ * Number value
+ * ) implements NumberProvider {
+ *
+ * Codec CODEC = ...;
+ *
+ * // Or alternatively something like this, where it finds the descriptor element
+ * Descriptor DESCRIPTOR = new Descriptor("minecraft:constant", CODEC){}
+ *
+ * }
+ * }
+ *
+ * @see ConstantNumberProvider
+ */
+public interface NumberProvider {
+
+ static @NotNull NumberProvider constant(@NotNull Number value) {
+ return new ConstantNumberProvider(value);
+ }
+
+ Codec CODEC = Codec.either(
+ Factory.CODEC.dispatch(Factory::from, Factory::codec),
+ // Handle the case of providing a single value value. If serialized back it will
+ // come out as a full {"type": "constant", "value": ...}
+ ExtraCodecs.NUMBER.xmap(ConstantNumberProvider::new, ConstantNumberProvider::value)
+ ).xmap(DFUUtil::value, Either::left);
+
+
+ // Impl
+
+ long nextLong(@NotNull NumberSource numbers);
+
+ double nextDouble(@NotNull NumberSource numbers);
+
+
+ abstract class Factory extends ResourceFactory {
+ static Registry REGISTRY = Registry.service("number_providers", NumberProvider.Factory.class);
+ static Registry.Index, NumberProvider.Factory> TYPE_REGISTRY = REGISTRY.index(NumberProvider.Factory::type);
+
+ public static final Codec CODEC = Codec.STRING.xmap(ns -> REGISTRY.get(ns), Factory::name);
+
+ public Factory(NamespaceID namespace, Class extends NumberProvider> type, Codec extends NumberProvider> codec) {
+ super(namespace, type, codec);
+ }
+
+ static @NotNull Factory from(@NotNull NumberProvider provider) {
+ return TYPE_REGISTRY.get(provider.getClass());
+ }
+ }
+
+}
diff --git a/modules/common/src/main/java/net/hollowcube/dfu/DFUUtil.java b/modules/common/src/main/java/net/hollowcube/dfu/DFUUtil.java
new file mode 100644
index 0000000..424f8d3
--- /dev/null
+++ b/modules/common/src/main/java/net/hollowcube/dfu/DFUUtil.java
@@ -0,0 +1,25 @@
+package net.hollowcube.dfu;
+
+import com.mojang.datafixers.util.Either;
+import com.mojang.datafixers.util.Pair;
+import org.jetbrains.annotations.NotNull;
+
+import java.util.List;
+import java.util.Map;
+import java.util.function.Function;
+import java.util.stream.Collectors;
+
+public final class DFUUtil {
+
+ public static Map pairListToMap(List> pairList) {
+ return pairList.stream().collect(Collectors.toMap(Pair::getFirst, Pair::getSecond));
+ }
+
+ public static List> mapToPairList(Map map) {
+ return map.entrySet().stream().map(entry -> new Pair<>(entry.getKey(), entry.getValue())).toList();
+ }
+
+ public static @NotNull T value(@NotNull Either extends T, ? extends T> either) {
+ return either.map(Function.identity(), Function.identity());
+ }
+}
diff --git a/modules/common/src/main/java/net/hollowcube/dfu/EnvVarOps.java b/modules/common/src/main/java/net/hollowcube/dfu/EnvVarOps.java
new file mode 100644
index 0000000..c74221b
--- /dev/null
+++ b/modules/common/src/main/java/net/hollowcube/dfu/EnvVarOps.java
@@ -0,0 +1,224 @@
+package net.hollowcube.dfu;
+
+import com.mojang.datafixers.util.Pair;
+import com.mojang.serialization.DataResult;
+import com.mojang.serialization.DynamicOps;
+import com.mojang.serialization.MapLike;
+import io.github.cdimascio.dotenv.Dotenv;
+import io.github.cdimascio.dotenv.DotenvEntry;
+import kotlin.NotImplementedError;
+import net.minestom.server.utils.validate.Check;
+import org.jetbrains.annotations.Contract;
+import org.jetbrains.annotations.NotNull;
+
+import java.util.*;
+import java.util.function.BiConsumer;
+import java.util.function.Consumer;
+import java.util.stream.Stream;
+
+public abstract class EnvVarOps implements DynamicOps {
+ public static final EnvVarOps DOTENV = new EnvVarOps() {
+ private final Dotenv dotenv = Dotenv.configure().ignoreIfMissing().load();
+
+ @Override
+ protected Collection envKeys() {
+ return dotenv.entries().stream().map(DotenvEntry::getKey).toList();
+ }
+
+ @Override
+ protected String get(String path) {
+ if (path.isEmpty())
+ return "";
+ return dotenv.get(path);
+ }
+ };
+
+ @Override
+ public String empty() {
+ return "";
+ }
+
+ @Override
+ public DataResult getNumberValue(String input) {
+ String value = get(input);
+
+ // This is really primitive number parsing, but I guess it will work.
+ try {
+ if (value.contains(".")) {
+ return DataResult.success(Double.parseDouble(value));
+ } else {
+ return DataResult.success(Long.parseLong(value));
+ }
+ } catch (NumberFormatException e) {
+ return DataResult.error(e.getMessage());
+ }
+ }
+
+ @Override
+ public DataResult getBooleanValue(String input) {
+ String value = get(input).toLowerCase(Locale.ROOT);
+ // Do not use Boolean.parseBoolean because it treats invalid as false.
+ if (value.equals("true"))
+ return DataResult.success(true);
+ if (value.equals("false"))
+ return DataResult.success(false);
+ return DataResult.error("Not a boolean: " + input);
+ }
+
+ @Override
+ public DataResult getStringValue(String input) {
+ return DataResult.success(get(input));
+ }
+
+ @Override
+ public DataResult> getMap(String input) {
+ // All the available keys starting with the input prefix
+ Set possibleKeys = new HashSet<>();
+ for (String path : envKeys()) {
+ if (path.toUpperCase(Locale.ROOT).startsWith(input)) {
+ possibleKeys.add(path);
+ }
+ }
+
+ return DataResult.success(new MapLike<>() {
+ @Override
+ public String get(String key) {
+ // Create the new path by appending to the original query path
+ String newPath = key.toUpperCase(Locale.ROOT);
+ if (!input.isEmpty()) {
+ newPath = input + "_" + newPath;
+ }
+
+ // The map contains any path starting with this one, so we need to iterate over it.
+ for (var entryKey : possibleKeys) {
+ if (entryKey.startsWith(newPath)) {
+ // Return the new path. entryKey contains the entire path
+ return newPath;
+ }
+ }
+
+ return null;
+ }
+
+ @Override
+ public Stream> entries() {
+ return notImplemented("getMap -> entries");
+ }
+ });
+ }
+
+ @Override
+ public DataResult>> getList(String input) {
+ // Children contains every path which starts with input and is followed by a single number segment
+ Map children = new HashMap<>(); // (map to ignore duplicate keys)
+ for (String path : envKeys()) {
+ if (!path.toUpperCase(Locale.ROOT).startsWith(input))
+ continue;
+ String[] rest = path.substring(input.length()).split("_", -1);
+ // First entry must be "" because we split on _00_REST
+ if (rest.length < 2 || !rest[0].isEmpty())
+ continue;
+ // Try to parse number segment
+ int index = Integer.parseInt(rest[1]);
+ children.put(index, input + "_" + index);
+ }
+
+ // Sort on index
+ String[] sortedChildren = new String[children.size()];
+ for (var child : children.entrySet()) {
+ // If the child is not within the sorted range then there was an order problem in the original (eg skipping a number)
+ if (child.getKey() < 0 || child.getKey() >= children.size())
+ return DataResult.error(String.format("invalid index of %s: %s", input, child));
+ sortedChildren[child.getKey()] = child.getValue();
+ }
+
+ return DataResult.success(c -> {
+ for (var child : sortedChildren) {
+ Check.notNull(child, "missing list element");
+ c.accept(child);
+ }
+ });
+ }
+
+ @Override
+ public String createList(Stream input) {
+ return "ERR@createList";
+ }
+
+ // NOT IMPLEMENTED BELOW
+ // The rest of DynamicOps is not implemented. The reasons are:
+ // - Serialization is not supported by EnvVarOps.
+ // - I have not found a use for the function. If we find one it will be implemented.
+
+ @Override
+ public U convertTo(DynamicOps outOps, String input) {
+ //todo
+ throw new NotImplementedError("convertTo");
+ }
+
+ @Override
+ public String createNumeric(Number i) {
+ return notImplemented("createNumeric");
+ }
+
+ @Override
+ public String createBoolean(boolean value) {
+ return notImplemented("createBoolean");
+ }
+
+ @Override
+ public String createString(String value) {
+ return notImplemented("createString");
+ }
+
+ @Override
+ public DataResult mergeToList(String list, String value) {
+ throw new NotImplementedError("mergeToList");
+ }
+
+ @Override
+ public DataResult mergeToMap(String map, String key, String value) {
+ return notImplemented("mergeToMap@3");
+ }
+
+ @Override
+ public DataResult mergeToMap(String map, MapLike values) {
+ throw new NotImplementedError("mergeToMap@2");
+ }
+
+ @Override
+ public DataResult>> getMapValues(String input) {
+ throw new NotImplementedError("getMapValues");
+ }
+
+ @Override
+ public DataResult>> getMapEntries(String input) {
+ throw new NotImplementedError("getMapEntries");
+ }
+
+ @Override
+ public String createMap(Stream> map) {
+ return notImplemented("createMap");
+ }
+
+ @Override
+ public DataResult> getStream(String input) {
+ return notImplemented("getStream");
+ }
+
+ @Override
+ public String remove(String input, String key) {
+ return notImplemented("remove");
+ }
+
+
+ protected abstract Collection envKeys();
+
+ protected abstract String get(String path);
+
+ @Contract("_ -> fail")
+ @SuppressWarnings("TypeParameterUnusedInFormals")
+ private T notImplemented(@NotNull String name) {
+ throw new NotImplementedError("EnvVarOps#" + name);
+ }
+}
diff --git a/modules/common/src/main/java/net/hollowcube/dfu/ExtraCodecs.java b/modules/common/src/main/java/net/hollowcube/dfu/ExtraCodecs.java
new file mode 100644
index 0000000..1e42a46
--- /dev/null
+++ b/modules/common/src/main/java/net/hollowcube/dfu/ExtraCodecs.java
@@ -0,0 +1,77 @@
+package net.hollowcube.dfu;
+
+import com.mojang.datafixers.util.Pair;
+import com.mojang.serialization.Codec;
+import com.mojang.serialization.DataResult;
+import com.mojang.serialization.DynamicOps;
+import com.mojang.serialization.MapCodec;
+import com.mojang.serialization.codecs.PrimitiveCodec;
+import net.minestom.server.instance.block.Block;
+import net.minestom.server.item.Material;
+import net.minestom.server.potion.PotionEffect;
+import net.minestom.server.utils.NamespaceID;
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+
+import java.util.Locale;
+import java.util.function.Supplier;
+
+public final class ExtraCodecs {
+ private ExtraCodecs() {}
+
+ public static final PrimitiveCodec NUMBER = new PrimitiveCodec<>() {
+ @Override
+ public DataResult read(DynamicOps ops, T input) {
+ return ops.getNumberValue(input);
+ }
+
+ @Override
+ public T write(DynamicOps ops, Number value) {
+ return ops.createNumeric(value);
+ }
+ };
+
+ public static final Codec NAMESPACE_ID = Codec.STRING.xmap(NamespaceID::from, NamespaceID::asString);
+
+ public static final Codec MATERIAL = Codec.STRING.xmap(Material::fromNamespaceId, Material::name);
+
+ public static final Codec POTION_EFFECT = Codec.STRING.xmap(PotionEffect::fromNamespaceId, PotionEffect::name);
+
+ public static final Codec BLOCK = Codec.STRING.xmap(Block::fromNamespaceId, Block::name);
+
+ public static @NotNull MapCodec string(@NotNull String name, @Nullable String defaultValue) {
+ return Codec.STRING.optionalFieldOf(name, defaultValue);
+ }
+
+ public static > @NotNull Codec forEnum(Class type) {
+ return Codec.STRING.xmap(s -> Enum.valueOf(type, s.toUpperCase(Locale.ROOT)), e -> e.name().toLowerCase(Locale.ROOT));
+ }
+
+ public static @NotNull Codec lazy(Supplier> init) {
+ return new LazyCodec<>(init);
+ }
+
+
+ public static class LazyCodec implements Codec {
+ private final Supplier> init;
+ private Codec value = null;
+
+ LazyCodec(Supplier> init) {
+ this.init = init;
+ }
+
+ @Override
+ public DataResult> decode(DynamicOps ops, T1 input) {
+ if (value == null) value = init.get();
+ return value.decode(ops, input);
+ }
+
+ @Override
+ public DataResult encode(T input, DynamicOps ops, T1 prefix) {
+ if (value == null) value = init.get();
+ return value.encode(input, ops, prefix);
+ }
+ }
+
+
+}
diff --git a/modules/common/src/main/java/net/hollowcube/lang/LanguageProvider.java b/modules/common/src/main/java/net/hollowcube/lang/LanguageProvider.java
new file mode 100644
index 0000000..11c39f8
--- /dev/null
+++ b/modules/common/src/main/java/net/hollowcube/lang/LanguageProvider.java
@@ -0,0 +1,72 @@
+package net.hollowcube.lang;
+
+import net.kyori.adventure.text.Component;
+import net.kyori.adventure.text.TextReplacementConfig;
+import net.kyori.adventure.text.TranslatableComponent;
+import org.jetbrains.annotations.NotNull;
+import net.hollowcube.util.ComponentUtil;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.List;
+import java.util.Properties;
+import java.util.regex.Pattern;
+
+/**
+ * Naive component translation system.
+ *
+ * Should be replaced with adventure translation or something else in the future.
+ */
+public class LanguageProvider {
+ private static final Properties properties = new Properties();
+
+ static {
+ try (InputStream is = LanguageProvider.class.getResourceAsStream("/lang/en_US.properties")) {
+ if (is != null) {
+ properties.load(is);
+ }
+ } catch (IOException e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ private static final Pattern ARG_PATTERN = Pattern.compile("\\{[0-9]+}");
+
+ /**
+ * Translates a component (if possible, see below).
+ *
+ * If the component is a {@link TranslatableComponent}, it will attempt to be translated. Any arguments in the
+ * component will also be templated into the translation using the {@link java.text.MessageFormat} syntax of `{0}`,
+ * `{1}`, etc. Translations are parsed using MiniMessage, and may contain styling as such.
+ *
+ * Translations are always (for now) loaded from `/lang/en_US.properties` within the classpath. This system is
+ * temporary, and will be replaced with either a proxy translation system or using the Adventure translation system.
+ * The problem with the adventure translation system is that it does not support MiniMessage in translation strings
+ * as far as I can tell.
+ *
+ * @param component The component to translate
+ * @return The component, or a component holding just the translation key if not found
+ */
+ public static @NotNull Component get(@NotNull Component component) {
+ if (!(component instanceof TranslatableComponent translatable)) {
+ return component;
+ }
+ String value = properties.getProperty(translatable.key());
+ if (value == null) return Component.text(translatable.key());
+ Component translated = ComponentUtil.fromStringSafe(value);
+ List args = translatable.args();
+ if (args.size() != 0) {
+ //todo this seems like it could be wildly slow...
+ translated = translated.replaceText(TextReplacementConfig.builder()
+ .match(ARG_PATTERN)
+ .replacement((result, builder) -> {
+ var group = result.group();
+ int index = Integer.parseInt(group.substring(1, group.length() - 1));
+ return index < args.size() ?
+ args.get(index) :
+ Component.text("$$" + index);
+ }).build());
+ }
+ return translated;
+ }
+}
diff --git a/modules/common/src/main/java/net/hollowcube/logging/CustomJsonWriter.java b/modules/common/src/main/java/net/hollowcube/logging/CustomJsonWriter.java
new file mode 100644
index 0000000..209bccf
--- /dev/null
+++ b/modules/common/src/main/java/net/hollowcube/logging/CustomJsonWriter.java
@@ -0,0 +1,155 @@
+package net.hollowcube.logging;
+
+import com.google.auto.service.AutoService;
+import org.tinylog.core.LogEntry;
+import org.tinylog.core.LogEntryValue;
+import org.tinylog.pattern.FormatPatternParser;
+import org.tinylog.pattern.Token;
+import org.tinylog.writers.AbstractFileBasedWriter;
+import org.tinylog.writers.Writer;
+import org.tinylog.writers.raw.ByteArrayWriter;
+
+import java.io.IOException;
+import java.nio.charset.Charset;
+import java.util.*;
+import java.util.Map.Entry;
+
+/**
+ * Copy of {@link org.tinylog.writers.JsonWriter} with some modifications to join the config with the log
+ */
+@AutoService(Writer.class)
+public final class CustomJsonWriter extends AbstractFileBasedWriter {
+
+ private static final String NEW_LINE = System.getProperty("line.separator");
+ private static final String FIELD_PREFIX = "field.";
+
+ private final Charset charset;
+ private final ByteArrayWriter writer;
+
+ private StringBuilder builder;
+ private final Map jsonProperties;
+
+ public CustomJsonWriter() throws IOException {
+ this(Collections.emptyMap());
+ }
+
+ public CustomJsonWriter(final Map properties) throws IOException {
+ super(properties);
+
+ String fileName = getFileName();
+ boolean append = getBooleanValue("append");
+ boolean buffered = getBooleanValue("buffered");
+ boolean writingThread = getBooleanValue("writingthread");
+
+ charset = getCharset();
+ writer = createByteArrayWriter(fileName, append, buffered, !writingThread, false, charset);
+
+ jsonProperties = createTokens(properties);
+
+ if (writingThread) {
+ builder = new StringBuilder();
+ }
+ }
+
+ @Override
+ public void write(final LogEntry logEntry) throws IOException {
+ StringBuilder builder;
+ if (this.builder == null) {
+ builder = new StringBuilder();
+ } else {
+ builder = this.builder;
+ builder.setLength(0);
+ }
+
+ addJsonObject(logEntry, builder);
+
+ byte[] data = builder.toString().getBytes(charset);
+ writer.write(data, 0, data.length);
+ }
+
+ @Override
+ public void flush() throws IOException {
+ writer.flush();
+ }
+
+ @Override
+ public void close() throws IOException {
+ writer.flush();
+ writer.close();
+ }
+
+ @Override
+ public Collection getRequiredLogEntryValues() {
+ Collection values = EnumSet.noneOf(LogEntryValue.class);
+ values.add(LogEntryValue.CONTEXT);
+ for (Token token : jsonProperties.values()) {
+ values.addAll(token.getRequiredLogEntryValues());
+ }
+ return values;
+ }
+
+ private void addJsonObject(final LogEntry logEntry, final StringBuilder builder) {
+ builder.append("{");
+
+ Map entries = new HashMap<>();
+ if (logEntry.getContext() != null)
+ entries.putAll(logEntry.getContext());
+ entries.putAll(jsonProperties);
+
+ Object[] tokenEntries = entries.values().toArray(new Object[0]);
+ String[] fields = entries.keySet().toArray(new String[0]);
+
+ for (int i = 0; i < tokenEntries.length; i++) {
+ builder.append("\"").append(fields[i]).append("\":\"");
+ int start = builder.length();
+
+ Object entry = tokenEntries[i];
+ if (entry instanceof Token token) {
+ token.render(logEntry, builder);
+ } else {
+ builder.append(entry.toString());
+ }
+
+ escapeCharacter("\\", "\\\\", builder, start);
+ escapeCharacter("\"", "\\\"", builder, start);
+ escapeCharacter(NEW_LINE, "\\n", builder, start);
+ escapeCharacter("\t", "\\t", builder, start);
+ escapeCharacter("\b", "\\b", builder, start);
+ escapeCharacter("\f", "\\f", builder, start);
+ escapeCharacter("\n", "\\n", builder, start);
+ escapeCharacter("\r", "\\r", builder, start);
+
+ builder.append("\"");
+
+ if (i + 1 < entries.size()) {
+ builder.append(",");
+ }
+ }
+ builder.append("}").append(NEW_LINE);
+ }
+
+ private void escapeCharacter(final String character, final String escapeWith, final StringBuilder stringBuilder,
+ final int startIndex) {
+ for (
+ int index = stringBuilder.indexOf(character, startIndex);
+ index != -1;
+ index = stringBuilder.indexOf(character, index + escapeWith.length())
+ ) {
+ stringBuilder.replace(index, index + character.length(), escapeWith);
+ }
+ }
+
+ private static Map createTokens(final Map properties) {
+ FormatPatternParser parser = new FormatPatternParser(properties.get("exception"));
+
+ Map tokens = new HashMap<>();
+ for (Entry entry : properties.entrySet()) {
+ if (entry.getKey().toLowerCase(Locale.ROOT).startsWith(FIELD_PREFIX)) {
+ tokens.put(entry.getKey().substring(FIELD_PREFIX.length()), parser.parse(entry.getValue()));
+ }
+ }
+ return tokens;
+ }
+
+}
+
diff --git a/modules/common/src/main/java/net/hollowcube/logging/Logger.java b/modules/common/src/main/java/net/hollowcube/logging/Logger.java
new file mode 100644
index 0000000..692bd32
--- /dev/null
+++ b/modules/common/src/main/java/net/hollowcube/logging/Logger.java
@@ -0,0 +1,33 @@
+package net.hollowcube.logging;
+
+import org.jetbrains.annotations.NotNull;
+
+import java.util.Map;
+
+/**
+ * SLF4J wrapper with a sane API for MDC.
+ *
+ * Context variables are important for searching within log tools such as Loki (grafana) and the default usage of MDC is
+ * extremely verbose and error-prone.
+ *
+ * Create using {@link LoggerFactory}.
+ */
+public interface Logger {
+
+ void debug(@NotNull String message);
+
+ void debug(@NotNull String message, @NotNull Map context);
+
+ void info(@NotNull String message);
+
+ void info(@NotNull String message, @NotNull Map context);
+
+ void warn(@NotNull String message);
+
+ void warn(@NotNull String message, @NotNull Map context);
+
+ void error(@NotNull String message);
+
+ void error(@NotNull String message, @NotNull Map context);
+
+}
diff --git a/modules/common/src/main/java/net/hollowcube/logging/LoggerFactory.java b/modules/common/src/main/java/net/hollowcube/logging/LoggerFactory.java
new file mode 100644
index 0000000..53d3683
--- /dev/null
+++ b/modules/common/src/main/java/net/hollowcube/logging/LoggerFactory.java
@@ -0,0 +1,12 @@
+package net.hollowcube.logging;
+
+import org.jetbrains.annotations.NotNull;
+
+public final class LoggerFactory {
+ private LoggerFactory() {}
+
+ public static @NotNull Logger getLogger(@NotNull Class> clazz) {
+ return new LoggerImpl(org.slf4j.LoggerFactory.getLogger(clazz));
+ }
+
+}
diff --git a/modules/common/src/main/java/net/hollowcube/logging/LoggerImpl.java b/modules/common/src/main/java/net/hollowcube/logging/LoggerImpl.java
new file mode 100644
index 0000000..d75d987
--- /dev/null
+++ b/modules/common/src/main/java/net/hollowcube/logging/LoggerImpl.java
@@ -0,0 +1,98 @@
+package net.hollowcube.logging;
+
+import org.jetbrains.annotations.NotNull;
+import org.slf4j.MDC;
+
+import java.util.Map;
+
+record LoggerImpl(org.slf4j.Logger delegate) implements Logger {
+
+ @Override
+ public void debug(@NotNull String message) {
+ debug(message, Map.of());
+ }
+
+ @Override
+ public void debug(@NotNull String message, @NotNull Map context) {
+ if (!delegate.isDebugEnabled()) return;
+
+ for (var entry : context.entrySet()) {
+ put(entry.getKey(), entry.getValue());
+ }
+
+ delegate.debug(message);
+
+ for (var key : context.keySet()) {
+ remove(key);
+ }
+ }
+
+
+ @Override
+ public void info(@NotNull String message) {
+ info(message, Map.of());
+ }
+
+ @Override
+ public void info(@NotNull String message, @NotNull Map context) {
+ if (!delegate.isInfoEnabled()) return;
+
+ for (var entry : context.entrySet()) {
+ put(entry.getKey(), entry.getValue());
+ }
+
+ delegate.info(message);
+
+ for (var key : context.keySet()) {
+ remove(key);
+ }
+ }
+
+ @Override
+ public void warn(@NotNull String message) {
+ warn(message, Map.of());
+ }
+
+ @Override
+ public void warn(@NotNull String message, @NotNull Map context) {
+ if (!delegate.isWarnEnabled()) return;
+
+ for (var entry : context.entrySet()) {
+ put(entry.getKey(), entry.getValue());
+ }
+
+ delegate.warn(message);
+
+ for (var key : context.keySet()) {
+ remove(key);
+ }
+ }
+
+ @Override
+ public void error(@NotNull String message) {
+ error(message, Map.of());
+ }
+
+ @Override
+ public void error(@NotNull String message, @NotNull Map context) {
+ if (!delegate.isErrorEnabled()) return;
+
+ for (var entry : context.entrySet()) {
+ put(entry.getKey(), entry.getValue());
+ }
+
+ delegate.error(message);
+
+ for (var key : context.keySet()) {
+ remove(key);
+ }
+ }
+
+ private void put(String key, Object value) {
+ MDC.put(key, value == null ? null : value.toString());
+ }
+
+ private void remove(String key) {
+ MDC.remove(key);
+ }
+}
diff --git a/modules/common/src/main/java/net/hollowcube/mongo/BsonOps.java b/modules/common/src/main/java/net/hollowcube/mongo/BsonOps.java
new file mode 100644
index 0000000..0a53a46
--- /dev/null
+++ b/modules/common/src/main/java/net/hollowcube/mongo/BsonOps.java
@@ -0,0 +1,294 @@
+package net.hollowcube.mongo;
+
+import com.mojang.datafixers.util.Pair;
+import com.mojang.serialization.DataResult;
+import com.mojang.serialization.DynamicOps;
+import com.mojang.serialization.MapLike;
+import org.bson.*;
+import org.jetbrains.annotations.Nullable;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.function.BiConsumer;
+import java.util.function.Consumer;
+import java.util.stream.Stream;
+
+/**
+ * DFU {@link DynamicOps} implementation for BSON values.
+ *
+ * The official BSON library _does_ support direct reflection based object mapping, however, since codecs are being used
+ * in many other places, it makes some sense to use them here too. It generalizes storage implementations to simply use
+ * the codec for a type.
+ *
+ * todo its possible this will be problematic because it is not using the real bson types (eg bson date),
+ * but I dont expect this to be a huge issue.
+ */
+public class BsonOps implements DynamicOps {
+
+ public static final BsonOps INSTANCE = new BsonOps();
+
+ private BsonOps() {}
+
+ @Override
+ public BsonValue empty() {
+ return BsonNull.VALUE;
+ }
+
+ @Override
+ public U convertTo(DynamicOps outOps, BsonValue input) {
+ if (input instanceof BsonDocument) {
+ return convertMap(outOps, input);
+ }
+ if (input instanceof BsonArray) {
+ return convertList(outOps, input);
+ }
+ if (input instanceof BsonNull) {
+ return outOps.empty();
+ }
+ //todo other bson types
+ return null;
+ }
+
+ @Override
+ public DataResult getNumberValue(BsonValue input) {
+ if (input instanceof BsonNumber number) {
+ return DataResult.success(new BsonNumberWrapper(number));
+ }
+ if (input instanceof BsonBoolean bool) {
+ return DataResult.success(bool.getValue() ? 1 : 0);
+ }
+ return DataResult.error("Not a number: " + input);
+ }
+
+ @Override
+ public BsonValue createNumeric(Number i) {
+ if (i instanceof Byte || i instanceof Short || i instanceof Integer) {
+ return new BsonInt32(i.intValue());
+ }
+ if (i instanceof Long) {
+ return new BsonInt64(i.longValue());
+ }
+ if (i instanceof Float || i instanceof Double) {
+ return new BsonDouble(i.doubleValue());
+ }
+ throw new IllegalArgumentException("Unknown number type: " + i.getClass().getSimpleName() + " (" + i + ")");
+ }
+
+ @Override
+ public DataResult getStringValue(BsonValue input) {
+ if (input instanceof BsonString string) {
+ return DataResult.success(string.getValue());
+ }
+ return DataResult.error("Not a string: " + input);
+ }
+
+ @Override
+ public BsonValue createString(String value) {
+ return new BsonString(value);
+ }
+
+ @Override
+ public DataResult mergeToList(BsonValue list, BsonValue value) {
+ if (!(list instanceof BsonArray) && list != empty()) {
+ return DataResult.error("mergeToList called with not a list: " + list, list);
+ }
+
+ final BsonArray result = new BsonArray();
+ if (list != empty()) {
+ result.addAll(list.asArray());
+ }
+ result.add(value);
+ return DataResult.success(result);
+ }
+
+ @Override
+ public DataResult mergeToList(BsonValue list, List values) {
+ if (!(list instanceof BsonArray) && list != empty()) {
+ return DataResult.error("mergeToList called with not a list: " + list, list);
+ }
+
+ final BsonArray result = new BsonArray();
+ if (list != empty()) {
+ result.addAll(list.asArray());
+ }
+ result.addAll(values);
+ return DataResult.success(result);
+ }
+
+ @Override
+ public DataResult mergeToMap(BsonValue map, BsonValue key, BsonValue value) {
+ if (!(map instanceof BsonDocument) && map != empty()) {
+ return DataResult.error("mergeToMap called with not a map: " + map, map);
+ }
+ if (!(key instanceof BsonString keyString)) {
+ return DataResult.error("key is not a string: " + key, map);
+ }
+
+ final BsonDocument output = new BsonDocument();
+ if (map != empty()) {
+ output.putAll(map.asDocument());
+ }
+ output.put(keyString.getValue(), value);
+
+ return DataResult.success(output);
+ }
+
+ @Override
+ public DataResult mergeToMap(BsonValue map, MapLike values) {
+ if (!(map instanceof BsonDocument) && map != empty()) {
+ return DataResult.error("mergeToMap called with not a map: " + map, map);
+ }
+
+ final BsonDocument output = new BsonDocument();
+ if (map != empty()) {
+ output.putAll(map.asDocument());
+ }
+
+ final List missed = new ArrayList<>();
+
+ values.entries().forEach(entry -> {
+ final BsonValue key = entry.getFirst();
+ if (!(key instanceof BsonString keyString)) {
+ missed.add(key);
+ return;
+ }
+ output.put(keyString.getValue(), entry.getSecond());
+ });
+
+ if (!missed.isEmpty()) {
+ return DataResult.error("some keys are not strings: " + missed, output);
+ }
+
+ return DataResult.success(output);
+ }
+
+ @Override
+ public DataResult>> getMapValues(BsonValue input) {
+ if (!(input instanceof BsonDocument document)) {
+ return DataResult.error("Not a bson document: " + input);
+ }
+ return DataResult.success(document.entrySet().stream().map(entry -> Pair.of(
+ new BsonString(entry.getKey()),
+ entry.getValue() instanceof BsonNull ? null : entry.getValue())
+ ));
+ }
+
+ @Override
+ public DataResult>> getMapEntries(BsonValue input) {
+ if (!(input instanceof BsonDocument document)) {
+ return DataResult.error("Not a bson document: " + input);
+ }
+ return DataResult.success(c -> {
+ for (final Map.Entry entry : document.entrySet()) {
+ c.accept(createString(entry.getKey()), entry.getValue() instanceof BsonNull ? null : entry.getValue());
+ }
+ });
+ }
+
+ @Override
+ public DataResult> getMap(BsonValue input) {
+ if (!(input instanceof BsonDocument document)) {
+ return DataResult.error("Not a bson document: " + input);
+ }
+ return DataResult.success(new MapLike<>() {
+ @Override
+ public @Nullable BsonValue get(final BsonValue key) {
+ final BsonValue value = document.get(key.asString().getValue());
+ return value instanceof BsonNull ? null : value;
+ }
+
+ @Override
+ public @Nullable BsonValue get(final String key) {
+ final BsonValue value = document.get(key);
+ return value instanceof BsonNull ? null : value;
+ }
+
+ @Override
+ public Stream> entries() {
+ return document.entrySet().stream().map(e -> Pair.of(new BsonString(e.getKey()), e.getValue()));
+ }
+
+ @Override
+ public String toString() {
+ return "MapLike[" + document + "]";
+ }
+ });
+ }
+
+ @Override
+ public BsonValue createMap(Stream> map) {
+ final BsonDocument result = new BsonDocument();
+ map.forEach(p -> result.put(p.getFirst().asString().getValue(), p.getSecond()));
+ return result;
+ }
+
+ @Override
+ public DataResult> getStream(BsonValue input) {
+ if (input instanceof BsonArray array) {
+ return DataResult.success(array.stream().map(e -> e instanceof BsonNull ? null : e));
+ }
+ return DataResult.error("Not a bson array: " + input);
+ }
+
+ @Override
+ public DataResult>> getList(BsonValue input) {
+ if (input instanceof BsonArray array) {
+ return DataResult.success(c -> {
+ for (final BsonValue value : array) {
+ c.accept(value);
+ }
+ });
+ }
+ return DataResult.error("Not a bson array: " + input);
+ }
+
+ @Override
+ public BsonValue createList(Stream input) {
+ final BsonArray result = new BsonArray();
+ input.forEach(result::add);
+ return result;
+ }
+
+ @Override
+ public BsonValue remove(BsonValue input, String key) {
+ if (input instanceof BsonDocument document) {
+ final BsonDocument result = new BsonDocument();
+ document.entrySet().stream()
+ .filter(entry -> !Objects.equals(entry.getKey(), key))
+ .forEach(entry -> result.put(entry.getKey(), entry.getValue()));
+ return result;
+ }
+ return input;
+ }
+
+
+ private static final class BsonNumberWrapper extends Number {
+ private final BsonNumber delegate;
+
+ public BsonNumberWrapper(BsonNumber delegate) {
+ this.delegate = delegate;
+ }
+
+ @Override
+ public int intValue() {
+ return delegate.intValue();
+ }
+
+ @Override
+ public long longValue() {
+ return delegate.longValue();
+ }
+
+ @Override
+ public float floatValue() {
+ return (float) delegate.doubleValue();
+ }
+
+ @Override
+ public double doubleValue() {
+ return delegate.doubleValue();
+ }
+ }
+}
diff --git a/modules/common/src/main/java/net/hollowcube/mongo/MongoConfig.java b/modules/common/src/main/java/net/hollowcube/mongo/MongoConfig.java
new file mode 100644
index 0000000..abf2a40
--- /dev/null
+++ b/modules/common/src/main/java/net/hollowcube/mongo/MongoConfig.java
@@ -0,0 +1,17 @@
+package net.hollowcube.mongo;
+
+import com.mojang.serialization.Codec;
+import com.mojang.serialization.codecs.RecordCodecBuilder;
+import org.jetbrains.annotations.NotNull;
+
+public record MongoConfig(
+ @NotNull String uri,
+ boolean useTransactions
+) {
+
+ public static final Codec CODEC = RecordCodecBuilder.create(i -> i.group(
+ Codec.STRING.fieldOf("uri").forGetter(MongoConfig::uri),
+ Codec.BOOL.optionalFieldOf("use_transactions", false).forGetter(MongoConfig::useTransactions)
+ ).apply(i, MongoConfig::new));
+
+}
diff --git a/modules/common/src/main/java/net/hollowcube/mongo/package-info.java b/modules/common/src/main/java/net/hollowcube/mongo/package-info.java
new file mode 100644
index 0000000..f359a09
--- /dev/null
+++ b/modules/common/src/main/java/net/hollowcube/mongo/package-info.java
@@ -0,0 +1,7 @@
+/**
+ * MongoDB utilities to work with the other systems present.
+ *
+ * `common` does not have a runtime dependency on mongodb, so this package will not bring along any extra baggage.
+ * However, any package using this module must bring along mongodb-driver-sync.
+ */
+package net.hollowcube.mongo;
\ No newline at end of file
diff --git a/modules/common/src/main/java/net/hollowcube/registry/MapRegistry.java b/modules/common/src/main/java/net/hollowcube/registry/MapRegistry.java
new file mode 100644
index 0000000..c28c2ee
--- /dev/null
+++ b/modules/common/src/main/java/net/hollowcube/registry/MapRegistry.java
@@ -0,0 +1,53 @@
+package net.hollowcube.registry;
+
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+import org.jetbrains.annotations.UnknownNullability;
+
+import java.util.Collection;
+import java.util.Map;
+import java.util.function.Function;
+import java.util.stream.Collectors;
+
+class MapRegistry implements Registry {
+ private final Map delegate;
+
+ public MapRegistry(Map resources) {
+ this.delegate = resources;
+ }
+
+ @Override
+ public @Nullable T getRaw(String namespace) {
+ return delegate.get(namespace);
+ }
+
+ @Override
+ public @NotNull Collection values() {
+ return delegate.values();
+ }
+
+ @Override
+ public int size() {
+ return delegate.size();
+ }
+
+ @Override
+ public @NotNull Index index(Function mapper) {
+ Map index = values().stream().collect(Collectors.toMap(mapper, i -> i));
+ return new MapIndex<>(index);
+ }
+
+
+ static class MapIndex implements Index {
+ private final Map delegate;
+
+ MapIndex(Map delegate) {
+ this.delegate = delegate;
+ }
+
+ @Override
+ public @UnknownNullability T get(K key) {
+ return delegate.get(key);
+ }
+ }
+}
diff --git a/modules/common/src/main/java/net/hollowcube/registry/MissingEntryException.java b/modules/common/src/main/java/net/hollowcube/registry/MissingEntryException.java
new file mode 100644
index 0000000..d8ac537
--- /dev/null
+++ b/modules/common/src/main/java/net/hollowcube/registry/MissingEntryException.java
@@ -0,0 +1,22 @@
+package net.hollowcube.registry;
+
+import org.jetbrains.annotations.NotNull;
+
+public class MissingEntryException extends RuntimeException{
+ private final @NotNull Registry> registry;
+ private final @NotNull String key;
+
+ public MissingEntryException(@NotNull Registry> registry, @NotNull String key) {
+ super("Missing registry entry: " + key + " in " + registry + "!");
+ this.registry = registry;
+ this.key = key;
+ }
+
+ public @NotNull Registry> registry() {
+ return registry;
+ }
+
+ public @NotNull String key() {
+ return key;
+ }
+}
diff --git a/modules/common/src/main/java/net/hollowcube/registry/Registry.java b/modules/common/src/main/java/net/hollowcube/registry/Registry.java
new file mode 100644
index 0000000..20a28e9
--- /dev/null
+++ b/modules/common/src/main/java/net/hollowcube/registry/Registry.java
@@ -0,0 +1,200 @@
+package net.hollowcube.registry;
+
+import com.google.gson.JsonArray;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonObject;
+import com.mojang.serialization.Codec;
+import com.mojang.serialization.JsonOps;
+import net.hollowcube.Env;
+import net.minestom.server.utils.NamespaceID;
+import net.minestom.server.utils.collection.ObjectArray;
+import net.minestom.server.utils.validate.Check;
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+import org.jetbrains.annotations.UnknownNullability;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import net.hollowcube.dfu.DFUUtil;
+
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.ServiceLoader;
+import java.util.function.Function;
+import java.util.function.Supplier;
+
+/**
+ * A (publically) immutable list of {@link Resource}s, indexed by {@link NamespaceID}.
+ *
+ * Registries can be created in a variety of ways with varying degrees of comlepexity. Ores, item components and item
+ * entries serve as examples with varying complexity and should be used as an example.
+ *
+ * The most simple option is {@link #codec(String, Codec)}. This will create a registry from a single data file with the
+ * name {@code ${name}.json} which must contain an array of objects, each conforming to the given {@link Codec}. Ores
+ * are an example using this pattern.
+ *
+ * Another very common pattern is used in NumberProviders. This case can be used when a field may contain a variety of
+ * implementations, identified by their key. First, an interface should be created for the resource, as well as a
+ * Factory inner abstract class which implements ResourceFactory. A {@link #service(String, java.lang.Class)} registry
+ * can be created to load all implementations of the factory. From there, registry dispatch may be used on the codec to
+ * create a codec for all implementations of the interface. Finally, each implementation must implement a Factory class
+ * and register it with {@link com.google.auto.service.AutoService}. See NumberProvider for more detail.
+ *
+ * Finally, if some complexity is needed during the loading process of static resources,
+ * {@link #manual(String, Function)} can be used to individually process each resource. The loading mechanism is the
+ * same as {@link #codec(String, Codec)}. Item registry entries (ItemRegistry.Entry) are an example using this pattern.
+ *
+ * @param The type of resource stored in the registry.
+ */
+public interface Registry {
+ //todo registry should implement Codec i think
+
+ // Factory
+
+ static Registry manual(@NotNull String name, Supplier> supplier) {
+ Map registry = new HashMap<>();
+ for (T element : supplier.get()) {
+ registry.put(element.name(), element);
+ }
+
+ // Registries may not be empty in strict mode
+ Env.strictValidation(
+ "Empty registry for manual resource: " + name,
+ registry::isEmpty
+ );
+
+ return new MapRegistry<>(registry);
+ }
+
+ static Registry service(@NotNull String name, Class type) {
+ Map registry = new HashMap<>();
+ for (T elem : ServiceLoader.load(type)) {
+ registry.put(elem.name(), elem);
+ }
+
+ // Registries may not be empty in strict mode
+ Env.strictValidation(
+ "Empty registry for manual resource: " + name,
+ registry::isEmpty
+ );
+
+ return new MapRegistry<>(registry);
+ }
+
+ static Registry manual(@NotNull String name, @NotNull Function mapper) {
+ Map registry = new HashMap<>();
+
+ JsonArray content = Resource.loadJsonArray(name + ".json");
+ for (JsonElement elem : content) {
+ Check.stateCondition(!elem.isJsonObject(), "Registry items must be json objects");
+
+ try {
+ T element = mapper.apply(elem.getAsJsonObject());
+ registry.put(element.name(), element);
+ } catch (Throwable e) {
+ Logger logger = LoggerFactory.getLogger(Registry.class);
+ logger.error("Failed to load registry item in {}: {}", name, elem, e);
+
+ Env.strictValidation("Registry item failure", () -> true);
+ }
+ }
+
+ // Registries may not be empty in strict mode
+ Env.strictValidation(
+ "Empty registry for resource: " + name,
+ registry::isEmpty
+ );
+
+ return new MapRegistry<>(registry);
+ }
+
+ /**
+ * Create a registry from a codec directly. The registry item must have a "namespace" field for the ID.
+ *
+ * @param codec The codec to deserialize with
+ * @param name The name of the data file to load from (.json is appended)
+ */
+ static Registry codec(@NotNull String name, @NotNull Codec codec) {
+ JsonArray content = Resource.loadJsonArray(name + ".json");
+
+ // Create a modified encoder that converts to a map. See the description of ItemRegistry.Entry.CODEC
+ // for more notes on the technique used here.
+ Codec