diff --git a/src/main/java/org/mvplugins/multiverse/core/PlaceholderExpansionHook.java b/src/main/java/org/mvplugins/multiverse/core/PlaceholderExpansionHook.java index 3491e7157..8b1de9362 100644 --- a/src/main/java/org/mvplugins/multiverse/core/PlaceholderExpansionHook.java +++ b/src/main/java/org/mvplugins/multiverse/core/PlaceholderExpansionHook.java @@ -151,6 +151,19 @@ public boolean persist() { case "generator" -> world.getGenerator(); case "hunger" -> String.valueOf(world.isHunger()); case "isloaded" -> String.valueOf(world.isLoaded()); + case "key" -> String.valueOf(world.getKey()); + case "meta" -> { + if (placeholderParams.isEmpty()) { + warning("No meta key specified."); + yield null; + } + String metaKey = String.join("_", placeholderParams); + yield world.getMeta(metaKey) + .getOrElse(() -> { + warning("Meta key '" + metaKey + "' not found for world '" + world.getName() + "'."); + return null; + }); + } case "monstersspawn" -> String.valueOf(world.getEntitySpawnConfig() .getSpawnCategoryConfig(SpawnCategory.MONSTER) .isSpawn()); diff --git a/src/main/java/org/mvplugins/multiverse/core/command/MVCommandCompletions.java b/src/main/java/org/mvplugins/multiverse/core/command/MVCommandCompletions.java index 2fbeddf9a..ae4ffcfb0 100644 --- a/src/main/java/org/mvplugins/multiverse/core/command/MVCommandCompletions.java +++ b/src/main/java/org/mvplugins/multiverse/core/command/MVCommandCompletions.java @@ -103,6 +103,7 @@ public class MVCommandCompletions extends PaperCommandCompletions { registerStaticCompletion("mvconfigs", config.getStringPropertyHandle().getAllPropertyNames()); registerAsyncCompletion("mvconfigvalues", this::suggestMVConfigValues); registerAsyncCompletion("mvworlds", this::suggestMVWorlds); + registerAsyncCompletion("mvworldmetakey", this::suggestMVWorldMetaKey); registerAsyncCompletion("mvworldpropsname", this::suggestMVWorldPropsName); registerAsyncCompletion("mvworldpropsvalue", this::suggestMVWorldPropsValue); registerCompletion("playersarray", this::suggestPlayersArray); // getting online players cannot be async @@ -308,6 +309,13 @@ private Collection suggestMVWorlds(BukkitCommandCompletionContext contex return Collections.emptyList(); } + private Collection suggestMVWorldMetaKey(BukkitCommandCompletionContext context) { + return Try.of(() -> context.getContextValue(MultiverseWorld.class)) + .map(MultiverseWorld::getAllMeta) + .map(Map::keySet) + .getOrElse(Collections.emptySet()); + } + private Collection suggestMVWorldPropsName(BukkitCommandCompletionContext context) { return Try.of(() -> { MultiverseWorld world = context.getContextValue(MultiverseWorldValue.class).value(); diff --git a/src/main/java/org/mvplugins/multiverse/core/commands/MetaCommand.java b/src/main/java/org/mvplugins/multiverse/core/commands/MetaCommand.java new file mode 100644 index 000000000..5757baabd --- /dev/null +++ b/src/main/java/org/mvplugins/multiverse/core/commands/MetaCommand.java @@ -0,0 +1,121 @@ +package org.mvplugins.multiverse.core.commands; + +import co.aikar.commands.annotation.CommandCompletion; +import co.aikar.commands.annotation.CommandPermission; +import co.aikar.commands.annotation.Description; +import co.aikar.commands.annotation.Flags; +import co.aikar.commands.annotation.Optional; +import co.aikar.commands.annotation.Single; +import co.aikar.commands.annotation.Subcommand; +import co.aikar.commands.annotation.Syntax; +import jakarta.inject.Inject; +import org.bukkit.ChatColor; +import org.jetbrains.annotations.NotNull; +import org.jvnet.hk2.annotations.Service; +import org.mvplugins.multiverse.core.command.MVCommandIssuer; +import org.mvplugins.multiverse.core.command.flag.ParsedCommandFlags; +import org.mvplugins.multiverse.core.command.flags.PageFilterFlags; +import org.mvplugins.multiverse.core.display.ContentDisplay; +import org.mvplugins.multiverse.core.display.filters.DefaultContentFilter; +import org.mvplugins.multiverse.core.display.handlers.PagedSendHandler; +import org.mvplugins.multiverse.core.display.parsers.MapContentProvider; +import org.mvplugins.multiverse.core.locale.MVCorei18n; +import org.mvplugins.multiverse.core.locale.message.Message; +import org.mvplugins.multiverse.core.locale.message.MessageReplacement.Replace; +import org.mvplugins.multiverse.core.world.MultiverseWorld; +import org.mvplugins.multiverse.core.world.WorldManager; + +import java.util.Locale; + +import static org.mvplugins.multiverse.core.locale.message.MessageReplacement.replace; + +@Service +@Subcommand("meta") +final class MetaCommand extends CoreCommand { + + private final WorldManager worldManager; + private final PageFilterFlags flags; + + @Inject + MetaCommand(@NotNull WorldManager worldManager, @NotNull PageFilterFlags flags) { + this.worldManager = worldManager; + this.flags = flags; + } + + @Subcommand("info") + @CommandPermission("multiverse.core.meta.info") + @CommandCompletion("@mvworlds:scope=both|@flags:resolveUntil=arg1,groupName=" + PageFilterFlags.NAME + " " + + "@flags:groupName=" + PageFilterFlags.NAME) + @Syntax("[world] [--page ] [--filter ]") + @Description("{@@mv-core.meta.info.description}") + void infoCommand( + MVCommandIssuer issuer, + + @Flags("resolve=issuerAware,maxArgForAware=0") + @Syntax("[world]") + @Description("{@@mv-core.meta.info.world.description}") + MultiverseWorld world, + + @Optional + @Syntax("[--page ] [--filter ]") + String[] flagArray + ) { + ParsedCommandFlags parsedFlags = flags.parse(flagArray); + ContentDisplay.create() + .addContent(MapContentProvider.forContent(world.getAllMeta()) + .withKeyColor(ChatColor.AQUA) + .withValueColor(ChatColor.WHITE)) + .withSendHandler(PagedSendHandler.create() + .withHeader(Message.of(MVCorei18n.META_INFO_HEADER, replace("{world}").with(world.getName()))) + .noContentMessage(Message.of(MVCorei18n.META_INFO_NOCONTENT, replace("{world}").with(world.getName()))) + .withTargetPage(parsedFlags.flagValue(flags.page, 1)) + .withFilter(parsedFlags.flagValue(flags.filter, DefaultContentFilter.get()))) + .send(issuer); + } + + @Subcommand("modify") + @CommandPermission("multiverse.core.meta.modify") + @CommandCompletion("@mvworlds:scope=both set|remove @mvworldmetakey @empty") + @Syntax("[world] [value]") + void modifyCommand( + MVCommandIssuer issuer, + + @Flags("resolve=issuerAware,maxArgForAware=4") + @Syntax("[world]") + MultiverseWorld world, + + @Syntax("") + String action, + + @Syntax("") + String key, + + @Optional + @Single + @Syntax("[value]") + String value + ) { + switch (action.toLowerCase(Locale.ROOT)) { + case "set" -> world.setMeta(key, value) + .onSuccess(ignore -> issuer.sendInfo(MVCorei18n.META_MODIFY_SET_SUCCESS, + replace("{key}").with(key), + replace("{value}").with(value), + Replace.WORLD.with(world.getName()))) + .onFailure(throwable -> issuer.sendError(MVCorei18n.META_MODIFY_SET_FAILURE, + replace("{key}").with(key), + replace("{value}").with(value), + Replace.WORLD.with(world.getName()), + Replace.ERROR.with(throwable))); + case "remove" -> world.removeMeta(key) + .onSuccess(ignore -> issuer.sendInfo(MVCorei18n.META_MODIFY_REMOVE_SUCCESS, + replace("{key}").with(key), + Replace.WORLD.with(world.getName()))) + .onFailure(throwable -> issuer.sendError(MVCorei18n.META_MODIFY_REMOVE_FAILURE, + replace("{key}").with(key), + Replace.WORLD.with(world.getName()), + Replace.ERROR.with(throwable))); + default -> issuer.sendError(MVCorei18n.META_MODIFY_INVALIDACTION, replace("{action}").with(action)); + } + worldManager.saveWorldsConfig(); + } +} diff --git a/src/main/java/org/mvplugins/multiverse/core/config/handle/BaseConfigurationHandle.java b/src/main/java/org/mvplugins/multiverse/core/config/handle/BaseConfigurationHandle.java index 2dbd45a87..9119ef846 100644 --- a/src/main/java/org/mvplugins/multiverse/core/config/handle/BaseConfigurationHandle.java +++ b/src/main/java/org/mvplugins/multiverse/core/config/handle/BaseConfigurationHandle.java @@ -3,11 +3,9 @@ import java.util.HashMap; import java.util.List; import java.util.Map; -import java.util.Objects; import java.util.logging.Logger; import com.dumptruckman.minecraft.util.Logging; -import io.vavr.control.Option; import io.vavr.control.Try; import org.bukkit.Bukkit; import org.bukkit.command.CommandSender; @@ -19,6 +17,7 @@ import org.mvplugins.multiverse.core.config.migration.ConfigMigrator; import org.mvplugins.multiverse.core.config.node.ListValueNode; +import org.mvplugins.multiverse.core.config.node.MapValueNode; import org.mvplugins.multiverse.core.config.node.NodeGroup; import org.mvplugins.multiverse.core.config.node.ValueNode; @@ -93,17 +92,13 @@ protected void setUpNodes() { } protected T deserializeNodeFromConfig(ValueNode node) { - if (node.getSerializer() == null) { - return Option.of(config.getObject(node.getPath(), node.getType())).getOrElse(node::getDefaultValue); - } return Try.of(() -> { var value = config.get(node.getPath()); - if (value == null) { - return node.getDefaultValue(); - } - return node.getSerializer().deserialize(value, node.getType()); - }).flatMap(value -> node.validate(value).map(ignore -> value)) - .onFailure(e -> Logging.warning("Failed to deserialize node %s: %s", node.getPath(), e.getMessage())) + return value == null ? node.getDefaultValue() : node.deserialize(value); + }) + .flatMap(value -> node.validate(value).map(ignore -> value)) + .onFailure(e -> Logging.warning("Failed to deserialize node %s: %s", + node.getPath(), e.getMessage())) .getOrElse(node::getDefaultValue); } @@ -124,12 +119,7 @@ protected void serializeNodeToConfig(ValueNode node) { if (value == null) { value = node.getDefaultValue(); } - if (node.getSerializer() != null) { - var serialized = node.getSerializer().serialize(value, node.getType()); - config.set(node.getPath(), serialized); - } else { - config.set(node.getPath(), value); - } + config.set(node.getPath(), node.serialize(value)); } /** @@ -163,6 +153,15 @@ public Try set(@NotNull ValueNode node, T value) { return set(Bukkit.getConsoleSender(), node, value); } + public Try set(@NotNull MapValueNode node, K key, V value) { + return node.validateEntry(key, value).map(ignore -> { + Map map = get(node); + map.put(key, value); + // node.onSetEntryValue(null, key, null, value); + return null; + }); + } + /** * Sets the value of a node, if the validator is not null, it will be tested first. * @@ -221,6 +220,18 @@ public Try remove(@NotNull ListValueNode node, I itemValue) { }); } + public Try remove(@NotNull MapValueNode node, K key) { + return node.validateKey(key).map(ignore -> { + Map map = get(node); + V value = map.remove(key); + if (value == null) { + throw new IllegalArgumentException("Cannot remove entry as it is already not in the map!"); + } + // node.onSetEntryValue(key, value, null); + return null; + }); + } + /** * Sets the default value of a node. * diff --git a/src/main/java/org/mvplugins/multiverse/core/config/handle/FileConfigurationHandle.java b/src/main/java/org/mvplugins/multiverse/core/config/handle/FileConfigurationHandle.java index c5c82054e..8e8c47b07 100644 --- a/src/main/java/org/mvplugins/multiverse/core/config/handle/FileConfigurationHandle.java +++ b/src/main/java/org/mvplugins/multiverse/core/config/handle/FileConfigurationHandle.java @@ -112,10 +112,5 @@ protected Builder(@NotNull Path configPath, @NotNull NodeGroup nodes) { * @return The configuration handle. */ public abstract @NotNull FileConfigurationHandle build(); - - @SuppressWarnings("unchecked") - protected B self() { - return (B) this; - } } } diff --git a/src/main/java/org/mvplugins/multiverse/core/config/node/ConfigNode.java b/src/main/java/org/mvplugins/multiverse/core/config/node/ConfigNode.java index 1e56ede25..caab48f1d 100644 --- a/src/main/java/org/mvplugins/multiverse/core/config/node/ConfigNode.java +++ b/src/main/java/org/mvplugins/multiverse/core/config/node/ConfigNode.java @@ -180,6 +180,24 @@ protected ConfigNode( return serializer; } + /** + * {@inheritDoc} + */ + @Override + public T deserialize(@Nullable Object object) { + return serializer == null + ? (type.isInstance(object)) ? type.cast(object) : getDefaultValue() + : serializer.deserialize(object, type); + } + + /** + * {@inheritDoc} + */ + @Override + public Object serialize(T value) { + return serializer == null ? value : serializer.serialize(value, type); + } + /** * {@inheritDoc} */ diff --git a/src/main/java/org/mvplugins/multiverse/core/config/node/ListConfigNode.java b/src/main/java/org/mvplugins/multiverse/core/config/node/ListConfigNode.java index c68468bde..0a730e4aa 100644 --- a/src/main/java/org/mvplugins/multiverse/core/config/node/ListConfigNode.java +++ b/src/main/java/org/mvplugins/multiverse/core/config/node/ListConfigNode.java @@ -283,7 +283,7 @@ protected Builder(@NotNull String path, @NotNull Class itemType) { //noinspection unchecked super(path, (Class>) (Object) List.class); this.itemType = itemType; - this.defaultValue = () -> (List) new ArrayList<>(); + this.defaultValue = ArrayList::new; } /** diff --git a/src/main/java/org/mvplugins/multiverse/core/config/node/MapConfigNode.java b/src/main/java/org/mvplugins/multiverse/core/config/node/MapConfigNode.java new file mode 100644 index 000000000..88c7e0b18 --- /dev/null +++ b/src/main/java/org/mvplugins/multiverse/core/config/node/MapConfigNode.java @@ -0,0 +1,603 @@ +package org.mvplugins.multiverse.core.config.node; + +import com.dumptruckman.minecraft.util.Logging; +import io.vavr.control.Try; +import org.bukkit.command.CommandSender; +import org.bukkit.configuration.ConfigurationSection; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.mvplugins.multiverse.core.config.node.functions.DefaultStringParserProvider; +import org.mvplugins.multiverse.core.config.node.functions.DefaultSuggesterProvider; +import org.mvplugins.multiverse.core.config.node.functions.NodeChangeCallback; +import org.mvplugins.multiverse.core.config.node.functions.NodeStringParser; +import org.mvplugins.multiverse.core.config.node.functions.NodeSuggester; +import org.mvplugins.multiverse.core.config.node.functions.NodeValueCallback; +import org.mvplugins.multiverse.core.config.node.functions.SenderNodeStringParser; +import org.mvplugins.multiverse.core.config.node.functions.SenderNodeSuggester; +import org.mvplugins.multiverse.core.config.node.serializer.DefaultSerializerProvider; +import org.mvplugins.multiverse.core.config.node.serializer.NodeSerializer; +import org.mvplugins.multiverse.core.exceptions.MultiverseException; +import org.mvplugins.multiverse.core.utils.REPatterns; + +import java.util.Collection; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; +import java.util.function.Function; +import java.util.function.Supplier; + +/** + * A config node that contains key-value mappings. + * + * @param The key type. + * @param The value type. + * + * @since 5.7 + */ +@ApiStatus.AvailableSince("5.7") +public class MapConfigNode extends ConfigNode> implements MapValueNode { + + /** + * Creates a new builder for a {@link MapConfigNode}. + * + * @param path The path of the node. + * @param keyType The map key type. + * @param valueType The map value type. + * @param The key type. + * @param The value type. + * @return The new builder. + * + * @since 5.7 + */ + @ApiStatus.AvailableSince("5.7") + public static @NotNull Builder mapBuilder( + @NotNull String path, + @NotNull Class keyType, + @NotNull Class valueType) { + return new Builder<>(path, keyType, valueType); + } + + protected final @NotNull Class keyType; + protected final @NotNull Class valueType; + protected final @Nullable NodeSuggester keySuggester; + protected final @Nullable NodeSuggester valueSuggester; + protected final @Nullable NodeStringParser keyStringParser; + protected final @Nullable NodeStringParser valueStringParser; + protected final @Nullable NodeSerializer keySerializer; + protected final @Nullable NodeSerializer valueSerializer; + protected final @Nullable Function> keyValidator; + protected final @Nullable Function> valueValidator; + + protected MapConfigNode( + @NotNull String path, + @NotNull String[] comments, + @Nullable String name, + @NotNull Class> type, + @NotNull String[] aliases, + @Nullable Supplier> defaultValueSupplier, + @Nullable NodeSuggester suggester, + @Nullable NodeStringParser> stringParser, + @Nullable NodeSerializer> serializer, + @Nullable Function, Try> validator, + @Nullable NodeValueCallback> onLoad, + @Nullable NodeChangeCallback> onLoadAndChange, + @Nullable NodeChangeCallback> onChange, + @NotNull Class keyType, + @NotNull Class valueType, + @Nullable NodeSuggester keySuggester, + @Nullable NodeSuggester valueSuggester, + @Nullable NodeStringParser keyStringParser, + @Nullable NodeStringParser valueStringParser, + @Nullable NodeSerializer keySerializer, + @Nullable NodeSerializer valueSerializer, + @Nullable Function> keyValidator, + @Nullable Function> valueValidator) { + super(path, comments, name, type, aliases, defaultValueSupplier, suggester, stringParser, serializer, + validator, onLoad, onLoadAndChange, onChange); + this.keyType = keyType; + this.valueType = valueType; + this.keySuggester = keySuggester != null + ? keySuggester + : DefaultSuggesterProvider.getDefaultSuggester(keyType); + this.valueSuggester = valueSuggester != null + ? valueSuggester + : DefaultSuggesterProvider.getDefaultSuggester(valueType); + this.keyStringParser = keyStringParser != null + ? keyStringParser + : DefaultStringParserProvider.getDefaultStringParser(keyType); + this.valueStringParser = valueStringParser != null + ? valueStringParser + : DefaultStringParserProvider.getDefaultStringParser(valueType); + this.keySerializer = keySerializer != null + ? keySerializer + : DefaultSerializerProvider.getDefaultSerializer(keyType); + this.valueSerializer = valueSerializer != null + ? valueSerializer + : DefaultSerializerProvider.getDefaultSerializer(valueType); + this.keyValidator = keyValidator != null + ? keyValidator + : defaultYamlKeyValidator(); + this.valueValidator = valueValidator; + + setDefaults(); + } + + private Function> defaultYamlKeyValidator() { + return key -> Try.of(() -> { + if (!REPatterns.YAML_KEY.matcher(String.valueOf(serializeKey(key))).matches()) { + throw new MultiverseException("Invalid yaml key: '" + key + "'. Keys can only " + + "contain alphanumeric characters, underscores and hyphens."); + } + return null; + }); + } + + private void setDefaults() { + if ((this.keySuggester != null || this.valueSuggester != null) && this.suggester == null) { + setDefaultSuggester(); + } + if (this.keyStringParser != null && this.valueStringParser != null && this.stringParser == null) { + setDefaultStringParser(); + } + if ((this.keyValidator != null || this.valueValidator != null) && this.validator == null) { + setDefaultValidator(); + } + if ((this.keySerializer != null || this.valueSerializer != null) && this.serializer == null) { + setDefaultSerializer(); + } + if (this.defaultValue == null) { + this.defaultValue = LinkedHashMap::new; + } + } + + private void setDefaultSuggester() { + if (this.keySuggester instanceof SenderNodeSuggester || this.valueSuggester instanceof SenderNodeSuggester) { + this.suggester = (SenderNodeSuggester) this::suggestEntries; + } else { + this.suggester = input -> suggestEntries(null, input); + } + } + + private @NotNull Collection suggestEntries(@Nullable CommandSender sender, @Nullable String input) { + String content = input == null ? "" : input; + int lastComma = content.lastIndexOf(','); + String prefix = lastComma == -1 ? "" : content.substring(0, lastComma + 1); + String currentEntry = lastComma == -1 ? content : content.substring(lastComma + 1); + + String[] keyValue = REPatterns.EQUALS.split(currentEntry, 2); + if (keyValue.length == 2 && valueSuggester != null) { + String keyPart = keyValue[0]; + String valuePart = keyValue[1]; + return suggestFrom(valueSuggester, sender, valuePart).stream() + .map(suggestedValue -> prefix + keyPart + "=" + suggestedValue) + .toList(); + } + + if (keySuggester == null) { + return Collections.emptyList(); + } + + return suggestFrom(keySuggester, sender, currentEntry).stream() + .map(suggestedKey -> prefix + suggestedKey + "=") + .toList(); + } + + private @NotNull Collection suggestFrom( + @NotNull NodeSuggester suggester, + @Nullable CommandSender sender, + @Nullable String input) { + if (sender != null && suggester instanceof SenderNodeSuggester senderSuggester) { + return senderSuggester.suggest(sender, input); + } + return suggester.suggest(input); + } + + private void setDefaultStringParser() { + this.stringParser = (input, type) -> Try.of(() -> { + Map parsed = new LinkedHashMap<>(); + if (input == null || input.isBlank()) { + return parsed; + } + for (String entry : REPatterns.SEMICOLON.split(input)) { + String[] keyValue = REPatterns.EQUALS.split(entry, 2); + if (keyValue.length != 2) { + throw new IllegalArgumentException("Invalid map entry '" + entry + "'. Expected format key=value"); + } + NodeStringParser keyParser = Objects.requireNonNull(keyStringParser, "keyStringParser"); + NodeStringParser valueParser = Objects.requireNonNull(valueStringParser, "valueStringParser"); + K parsedKey = keyParser.parse(keyValue[0].trim(), keyType).get(); + V parsedValue = valueParser.parse(keyValue[1].trim(), valueType).get(); + parsed.put(parsedKey, parsedValue); + } + return parsed; + }); + } + + private void setDefaultValidator() { + this.validator = value -> Try.of(() -> { + if (value == null) { + return null; + } + for (Map.Entry entry : value.entrySet()) { + if (keyValidator != null) { + keyValidator.apply(entry.getKey()).get(); + } + if (valueValidator != null) { + valueValidator.apply(entry.getValue()).get(); + } + } + return null; + }); + } + + private void setDefaultSerializer() { + this.serializer = new NodeSerializer<>() { + @Override + public Map deserialize(Object object, Class> type) { + if (object == null) { + return new LinkedHashMap<>(); + } + + if (object instanceof Map rawMap) { + Map result = new LinkedHashMap<>(); + rawMap.forEach((key, value) -> { + Map.Entry entry = deserializeEntry(key, value); + result.put(entry.getKey(), entry.getValue()); + }); + return result; + } + + if (object instanceof ConfigurationSection section) { + Map result = new LinkedHashMap<>(); + for (String key : section.getKeys(false)) { + Object rawValue = section.get(key); + Map.Entry entry = deserializeEntry(key, rawValue); + result.put(entry.getKey(), entry.getValue()); + } + return result; + } + + throw new IllegalArgumentException("Invalid map object '" + object + "'. Expected map or section."); + } + + @Override + public Object serialize(Map object, Class> type) { + Map serialized = new LinkedHashMap<>(); + if (object == null) { + return serialized; + } + object.forEach((key, value) -> { + Map.Entry entry = serializeEntry(key, value); + serialized.put(entry.getKey(), entry.getValue()); + }); + return serialized; + } + }; + } + + /** + * {@inheritDoc} + */ + @Override + public @NotNull Try validateKey(@Nullable K key) { + return keyValidator == null ? Try.success(null) : keyValidator.apply(key); + } + + /** + * {@inheritDoc} + */ + @Override + public @NotNull Try validateValue(@Nullable V value) { + return valueValidator == null ? Try.success(null) : valueValidator.apply(value); + } + + /** + * {@inheritDoc} + */ + @Override + public @NotNull Try validateEntry(@Nullable K key, @Nullable V value) { + return validateKey(key).flatMap(ignored -> validateValue(value)); + } + + /** + * {@inheritDoc} + */ + @Override + public @NotNull Map.Entry deserializeEntry(@Nullable Object key, @Nullable Object value) { + K deserializedKey = keySerializer == null + ? keyType.isInstance(key) ? keyType.cast(key) : null + : keySerializer.deserialize(key, keyType); + V deserializedValue = valueSerializer == null + ? keyType.isInstance(value) ? valueType.cast(value) : null + : valueSerializer.deserialize(value, valueType); + return new SimpleEntry<>(deserializedKey, deserializedValue); + } + + /** + * {@inheritDoc} + */ + @Override + public @NotNull Map.Entry serializeEntry(@Nullable K key, @Nullable V value) { + return new SimpleEntry<>(serializeKey(key), serializeValue(value)); + } + + private @Nullable Object serializeKey(@Nullable K key) { + return keySerializer == null ? key : keySerializer.serialize(key, keyType); + } + + private @Nullable Object serializeValue(@Nullable V value) { + return valueSerializer == null ? value : valueSerializer.serialize(value, valueType); + } + + private record SimpleEntry(K key, V value) implements Map.Entry { + @Override + public K getKey() { + return key; + } + + @Override + public V getValue() { + return value; + } + + @Override + public V setValue(V value) { + throw new UnsupportedOperationException(); + } + } + + /** + * Builder for {@link MapConfigNode} instances. + * + *

This builder extends {@link ConfigNode.Builder} with additional configuration specific to map entries: + * entry-level suggesters, string parsers, serializers and validators for both keys and values. When any + * entry-level component is omitted the builder provides a sensible default (for example default parsers, + * suggesters, serializers or an empty map default value). + * + * @param The map key type. + * @param The map value type. + * + * @since 5.7 + */ + @ApiStatus.AvailableSince("5.7") + public static class Builder extends ConfigNode.Builder, Builder> { + + protected final @NotNull Class keyType; + protected final @NotNull Class valueType; + protected @Nullable NodeSuggester keySuggester; + protected @Nullable NodeSuggester valueSuggester; + protected @Nullable NodeStringParser keyStringParser; + protected @Nullable NodeStringParser valueStringParser; + protected @Nullable NodeSerializer keySerializer; + protected @Nullable NodeSerializer valueSerializer; + protected @Nullable Function> keyValidator; + protected @Nullable Function> valueValidator; + + /** + * Creates a new builder for a map config node. + * + * @param path The configuration path for the node. + * @param keyType The runtime type of the map keys. + * @param valueType The runtime type of the map values. + * + * @since 5.7 + */ + @ApiStatus.AvailableSince("5.7") + protected Builder(@NotNull String path, @NotNull Class keyType, @NotNull Class valueType) { + //noinspection unchecked + super(path, (Class>) (Object) Map.class); + this.keyType = keyType; + this.valueType = valueType; + this.defaultValue = LinkedHashMap::new; + } + + /** + * Sets a suggester for map keys. The suggester is used when computing tab-completion suggestions for the + * key part of an entry. + * + * @param keySuggester The suggester to use for keys. + * @return This builder for chaining. + * + * @since 5.7 + */ + @ApiStatus.AvailableSince("5.7") + public @NotNull Builder keySuggester(@NotNull NodeSuggester keySuggester) { + this.keySuggester = keySuggester; + return self(); + } + + /** + * Sets a sender-aware suggester for map keys. Use this when suggestions depend on the command sender + * (permissions, location, etc.). + * + * @param keySuggester The sender-aware suggester. + * @return This builder for chaining. + * + * @since 5.7 + */ + @ApiStatus.AvailableSince("5.7") + public @NotNull Builder keySuggester(@NotNull SenderNodeSuggester keySuggester) { + this.keySuggester = keySuggester; + return self(); + } + + /** + * Sets a suggester for map values. The suggester is used when computing tab-completion suggestions for the + * value part of an entry. + * + * @param valueSuggester The suggester to use for values. + * @return This builder for chaining. + * + * @since 5.7 + */ + @ApiStatus.AvailableSince("5.7") + public @NotNull Builder valueSuggester(@NotNull NodeSuggester valueSuggester) { + this.valueSuggester = valueSuggester; + return self(); + } + + /** + * Sets a sender-aware suggester for map values. Use this when value suggestions depend on the + * command sender. + * + * @param valueSuggester The sender-aware suggester. + * @return This builder for chaining. + * + * @since 5.7 + */ + @ApiStatus.AvailableSince("5.7") + public @NotNull Builder valueSuggester(@NotNull SenderNodeSuggester valueSuggester) { + this.valueSuggester = valueSuggester; + return self(); + } + + /** + * Sets the string parser for keys. The parser is used when parsing user-provided key strings into key + * instances (e.g. when parsing a map from a single-line string representation). + * + * @param keyStringParser The parser to use for keys. + * @return This builder for chaining. + * + * @since 5.7 + */ + @ApiStatus.AvailableSince("5.7") + public @NotNull Builder keyStringParser(@NotNull NodeStringParser keyStringParser) { + this.keyStringParser = keyStringParser; + return self(); + } + + /** + * Sets a sender-aware string parser for keys. Use this when parsing depends on the sender context. + * + * @param keyStringParser The sender-aware key parser. + * @return This builder for chaining. + * + * @since 5.7 + */ + @ApiStatus.AvailableSince("5.7") + public @NotNull Builder keyStringParser(@NotNull SenderNodeStringParser keyStringParser) { + this.keyStringParser = keyStringParser; + return self(); + } + + /** + * Sets the string parser for values. The parser converts the textual part after '=' in an entry into the + * value type. + * + * @param valueStringParser The parser to use for values. + * @return This builder for chaining. + * + * @since 5.7 + */ + @ApiStatus.AvailableSince("5.7") + public @NotNull Builder valueStringParser(@NotNull NodeStringParser valueStringParser) { + this.valueStringParser = valueStringParser; + return self(); + } + + /** + * Sets a sender-aware string parser for values. + * + * @param valueStringParser The sender-aware value parser. + * @return This builder for chaining. + * + * @since 5.7 + */ + @ApiStatus.AvailableSince("5.7") + public @NotNull Builder valueStringParser(@NotNull SenderNodeStringParser valueStringParser) { + this.valueStringParser = valueStringParser; + return self(); + } + + /** + * Sets the serializer for keys. Serializers convert keys to/from persisted forms stored in the configuration + * backend. + * + * @param keySerializer The key serializer. + * @return This builder for chaining. + * + * @since 5.7 + */ + @ApiStatus.AvailableSince("5.7") + public @NotNull Builder keySerializer(@NotNull NodeSerializer keySerializer) { + this.keySerializer = keySerializer; + return self(); + } + + /** + * Sets the serializer for values. + * + * @param valueSerializer The value serializer. + * @return This builder for chaining. + * + * @since 5.7 + */ + @ApiStatus.AvailableSince("5.7") + public @NotNull Builder valueSerializer(@NotNull NodeSerializer valueSerializer) { + this.valueSerializer = valueSerializer; + return self(); + } + + /** + * Sets a validator for keys. The validator should return a successful {@link Try} for valid keys or a failed + * {@link Try} describing the validation error for invalid keys. + * + * @param keyValidator The key validator. + * @return This builder for chaining. + * + * @since 5.7 + */ + @ApiStatus.AvailableSince("5.7") + public @NotNull Builder keyValidator(@NotNull Function> keyValidator) { + this.keyValidator = keyValidator; + return self(); + } + + /** + * Sets a validator for values. + * + * @param valueValidator The value validator. + * @return This builder for chaining. + * + * @since 5.7 + */ + @ApiStatus.AvailableSince("5.7") + public @NotNull Builder valueValidator(@NotNull Function> valueValidator) { + this.valueValidator = valueValidator; + return self(); + } + + /** + * {@inheritDoc} + */ + @Override + public @NotNull MapConfigNode build() { + return new MapConfigNode<>( + path, + comments.toArray(new String[0]), + name, + type, + aliases, + defaultValue, + suggester, + stringParser, + serializer, + validator, + onLoad, + onLoadAndChange, + onChange, + keyType, + valueType, + keySuggester, + valueSuggester, + keyStringParser, + valueStringParser, + keySerializer, + valueSerializer, + keyValidator, + valueValidator); + } + } +} diff --git a/src/main/java/org/mvplugins/multiverse/core/config/node/MapValueNode.java b/src/main/java/org/mvplugins/multiverse/core/config/node/MapValueNode.java new file mode 100644 index 000000000..b0d318aaa --- /dev/null +++ b/src/main/java/org/mvplugins/multiverse/core/config/node/MapValueNode.java @@ -0,0 +1,93 @@ +package org.mvplugins.multiverse.core.config.node; + +import io.vavr.control.Try; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.Map; + +/** + * A {@link ValueNode} specialization that stores a {@link Map} of key-value pairs. + * + * @param The key type. + * @param The value type. + * + * @since 5.7 + */ +@ApiStatus.AvailableSince("5.7") +public interface MapValueNode extends ValueNode> { + + /** + * Validates a single map key. + *
+ * Implementations should return a successful {@link Try} when the key is acceptable, or a failed + * {@link Try} when the key is invalid. + * + * @param key The key to validate, or {@code null} if the key is absent. + * @return A successful {@link Try} if the key is valid, or a failed {@link Try} if it is not. + * + * @since 5.7 + */ + @ApiStatus.AvailableSince("5.7") + @NotNull Try validateKey(@Nullable K key); + + /** + * Validates a single map value. + *
+ * Implementations should return a successful {@link Try} when the value is acceptable, or a failed + * {@link Try} when the value is invalid. + * + * @param value The value to validate, or {@code null} if the value is absent. + * @return A successful {@link Try} if the value is valid, or a failed {@link Try} if it is not. + * + * @since 5.7 + */ + @ApiStatus.AvailableSince("5.7") + @NotNull Try validateValue(@Nullable V value); + + /** + * Validates a key-value entry. + *
+ * This is typically implemented by composing {@link #validateKey(Object)} and {@link #validateValue(Object)}. + * If either part fails, the entry is considered invalid. + * + * @param key The entry key, or {@code null} if the key is absent. + * @param value The entry value, or {@code null} if the value is absent. + * @return A successful {@link Try} if both parts are valid, or a failed {@link Try} if either part is invalid. + * + * @since 5.7 + */ + @ApiStatus.AvailableSince("5.7") + @NotNull Try validateEntry(@Nullable K key, @Nullable V value); + + /** + * Deserializes a raw key-value pair into a map entry. + *
+ * This method converts a single persisted entry, not the full map. If either component cannot be converted, + * implementations may return {@code null} for that side of the entry. + * + * @param key The raw key object, or {@code null} if no key is present. + * @param value The raw value object, or {@code null} if no value is present. + * @return A map entry containing the deserialized key and value. + * + * @since 5.7 + */ + @ApiStatus.AvailableSince("5.7") + @NotNull Map.Entry deserializeEntry(@Nullable Object key, @Nullable Object value); + + /** + * Serializes a map entry into raw configuration objects. + *
+ * This method converts a single entry to a persisted representation, not the full map. The returned entry + * contains the serialized key and serialized value. + * + * @param key The entry key, or {@code null} if the key is absent. + * @param value The entry value, or {@code null} if the value is absent. + * @return A map entry containing the serialized key and value. + * + * @since 5.7 + */ + @ApiStatus.AvailableSince("5.7") + @NotNull Map.Entry serializeEntry(@Nullable K key, @Nullable V value); +} diff --git a/src/main/java/org/mvplugins/multiverse/core/config/node/ValueNode.java b/src/main/java/org/mvplugins/multiverse/core/config/node/ValueNode.java index 8101796a1..278aed711 100644 --- a/src/main/java/org/mvplugins/multiverse/core/config/node/ValueNode.java +++ b/src/main/java/org/mvplugins/multiverse/core/config/node/ValueNode.java @@ -34,7 +34,7 @@ public interface ValueNode extends Node { * Gets the aliases of this node. Serves as shorter or legacy alternatives the {@link #getName()} and must be * unique within a node group. * - * @return The aliases of this node. + * @return The aliases of this node, or an empty array if the node has no aliases. * * @since 5.1 */ @@ -44,27 +44,36 @@ public interface ValueNode extends Node { } /** - * Gets the default value with type {@link T} of the node. + * Gets the default value for this node. * - * @return The default value of the node. + *

The returned value is used when no explicit value is present or when deserialization falls back to the + * node's default. Implementations may return {@code null} to indicate that no default value is available. + * + * @return The default value of the node, or {@code null} if none is configured. */ @Nullable T getDefaultValue(); /** - * Suggests possible string values for this node. Generated based on the current user input. + * Suggests possible string values for this node. + * + *

The returned values are based on the current partial user input and should be suitable for command-line + * tab completion or other interactive hints. * - * @param input The current partial user input + * @param input The current partial user input, or {@code null} if no input has been typed yet. * @return A collection of possible string values. */ @NotNull Collection suggest(@Nullable String input); /** - * Suggests possible string values for this node. Use contextural information from the sender such as - * sender name, permissions, or player location for better suggestions. + * Suggests possible string values for this node using sender context. * - * @param sender The sender context. - * @param input The input string. - * @return A collection of possible string values + *

Implementations may use the sender's name, permissions, or player location to tailor the returned + * suggestions. If no sender-specific logic is available, implementations should fall back to + * {@link #suggest(String)}. + * + * @param sender The sender context. + * @param input The current partial user input, or {@code null} if no input has been typed yet. + * @return A collection of possible string values. * * @since 5.1 */ @@ -72,20 +81,25 @@ public interface ValueNode extends Node { @NotNull Collection suggest(@NotNull CommandSender sender, @Nullable String input); /** - * Parses the given string into a value of type {@link T}. Used for property set by user input. + * Parses the given string into a value of type {@link T}. * - * @param input The string to parse. - * @return The parsed value, or given exception if parsing failed. + *

This is typically used for values entered by a user or read from configuration text. + * + * @param input The string to parse, or {@code null} if the source value is absent. + * @return The parsed value, or a failed {@link Try} containing the parsing error. */ @NotNull Try parseFromString(@Nullable String input); /** * Parses the given string into a value of type {@link T} with context from the sender. - * Used for property set by user input. * - * @param sender The sender context. - * @param input The string to parse. - * @return The parsed value, or given exception if parsing failed. + *

Sender-aware implementations may use permissions or other sender-specific information when converting the + * input. If no sender-aware parser is available, implementations should fall back to + * {@link #parseFromString(String)}. + * + * @param sender The sender context. + * @param input The string to parse, or {@code null} if the source value is absent. + * @return The parsed value, or a failed {@link Try} containing the parsing error. * * @since 5.1 */ @@ -97,23 +111,59 @@ public interface ValueNode extends Node { /** * Gets the serializer for this node. * - * @return The serializer for this node. + *

This method exists for backward compatibility while the preferred API is + * {@link #serialize(Object)} and {@link #deserialize(Object)}. + * + * @return The serializer for this node, or {@code null} if none is configured. + * + * @deprecated Use {@link #serialize(Object)} and {@link #deserialize(Object)} instead. */ + @Deprecated(forRemoval = true, since = "5.7") + @ApiStatus.ScheduledForRemoval(inVersion = "6.0") @Nullable NodeSerializer getSerializer(); + /** + * Deserializes a raw configuration object into a value of type {@link T}. + * + *

The input is typically a value read from YAML or another configuration source. Implementations should + * return a sensible fallback, such as {@link #getDefaultValue()}, when the object cannot be converted. + * + * @param object The raw object to deserialize. + * @return The deserialized value, or {@code null} if the object cannot be converted and no fallback exists. + * + * @since 5.7 + */ + @ApiStatus.AvailableSince("5.7") + @Nullable T deserialize(@Nullable Object object); + + /** + * Serializes a value of type {@link T} into a configuration-friendly object. + * + *

The returned object should be suitable for persistence in a configuration file. + * + * @param value The value to serialize. + * @return The serialized representation, or {@code null} if the value cannot be serialized. + * + * @since 5.7 + */ + @ApiStatus.AvailableSince("5.7") + @Nullable Object serialize(@Nullable T value); + /** * Validates the value of this node. * * @param value The value to validate. - * @return An empty {@link Try} if the value is valid, or a {@link Try} containing an exception if the value is - * invalid. + * @return A successful {@link Try} if the value is valid, or a failed {@link Try} containing the validation + * exception if the value is invalid. */ Try validate(@Nullable T value); /** * Called when the value of this node is loaded by {@link BaseConfigurationHandle#load()}. * - * @param value The loaded value. + *

This callback is invoked after the value has been deserialized and validated during a configuration load. + * + * @param value The loaded value, or {@code null} if the configured value could not be resolved. * * @since 5.4 */ @@ -123,9 +173,12 @@ public interface ValueNode extends Node { /** * Called when the value of this node is loaded or changed. * - * @param sender The sender who triggered the change. If triggered by loading or no target sender is specified, - * it will be the console sender. - * @param oldValue The old value, will always be null on load. + *

This callback is shared by both initial load and subsequent updates. When invoked during a load, the + * {@code oldValue} parameter will be {@code null} and the sender will be the console sender. + * + * @param sender The sender who triggered the change. If triggered by loading or no target sender is specified, + * it will be the console sender. + * @param oldValue The previous value, or {@code null} when called during load. * @param newValue The new value. * * @since 5.4 @@ -136,9 +189,11 @@ public interface ValueNode extends Node { /** * Called when the value of this node is changed by {@link BaseConfigurationHandle#set(ValueNode, Object)}. * - * @param sender The sender who changed the value, or console sender if no target sender specified. - * @param oldValue The old value. - * @param newValue The new value. + *

This callback is only invoked for explicit updates after the value already exists. + * + * @param sender The sender who changed the value, or the console sender if no target sender was specified. + * @param oldValue The old value. + * @param newValue The new value. * * @since 5.4 */ @@ -148,12 +203,15 @@ public interface ValueNode extends Node { /** * Called when the value of this node is set. * + *

This is the legacy, senderless form of {@link #onLoadAndChange(CommandSender, Object, Object)}. + * * @param oldValue The old value. * @param newValue The new value. * * @deprecated Use {@link #onLoadAndChange(CommandSender, Object, Object)} instead. */ @Deprecated(since = "5.4", forRemoval = true) + @ApiStatus.ScheduledForRemoval(inVersion = "6.0") default void onSetValue(@Nullable T oldValue, @Nullable T newValue) { onLoadAndChange(Bukkit.getConsoleSender(), oldValue, newValue); } diff --git a/src/main/java/org/mvplugins/multiverse/core/locale/MVCorei18n.java b/src/main/java/org/mvplugins/multiverse/core/locale/MVCorei18n.java index d31b9755b..6948d8f7b 100644 --- a/src/main/java/org/mvplugins/multiverse/core/locale/MVCorei18n.java +++ b/src/main/java/org/mvplugins/multiverse/core/locale/MVCorei18n.java @@ -146,6 +146,19 @@ public enum MVCorei18n implements MessageKeyProvider { LOAD_LOADING, LOAD_SUCCESS, + // /mv meta info + META_INFO_DESCRIPTION, + META_INFO_WORLD, + META_INFO_HEADER, + META_INFO_NOCONTENT, + + // /mv meta modify + META_MODIFY_SET_SUCCESS, + META_MODIFY_SET_FAILURE, + META_MODIFY_REMOVE_SUCCESS, + META_MODIFY_REMOVE_FAILURE, + META_MODIFY_INVALIDACTION, + // /mv modify MODIFY_DESCRIPTION, MODIFY_WORLD_DESCRIPTION, diff --git a/src/main/java/org/mvplugins/multiverse/core/world/MultiverseWorld.java b/src/main/java/org/mvplugins/multiverse/core/world/MultiverseWorld.java index c2ed1c84b..81f7fe6f6 100644 --- a/src/main/java/org/mvplugins/multiverse/core/world/MultiverseWorld.java +++ b/src/main/java/org/mvplugins/multiverse/core/world/MultiverseWorld.java @@ -1,7 +1,9 @@ package org.mvplugins.multiverse.core.world; import java.io.File; +import java.util.Collections; import java.util.List; +import java.util.Map; import com.google.common.base.Strings; import io.vavr.control.Option; @@ -17,6 +19,7 @@ import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; +import org.jetbrains.annotations.UnmodifiableView; import org.mvplugins.multiverse.core.config.CoreConfig; import org.mvplugins.multiverse.core.config.handle.StringPropertyHandle; import org.mvplugins.multiverse.core.utils.text.ChatTextFormatter; @@ -495,6 +498,59 @@ public Try setKeepSpawnInMemory(boolean keepSpawnInMemory) { return worldConfig.setKeepSpawnInMemory(keepSpawnInMemory); } + /** + * Get all meta key-value pairs for this world. This is an unmodifiable view of the internal map, so changes to + * the world meta will be reflected in this map, but attempts to modify this map will throw an exception. + * + * @return An unmodifiable view of all meta key-value pairs for this world. + * + * @since 5.7 + */ + @ApiStatus.AvailableSince("5.7") + public @UnmodifiableView Map getAllMeta() { + return Collections.unmodifiableMap(worldConfig.getMeta()); + } + + /** + * Gets the meta value for the given key if it exists. + * + * @param key The meta key to look up, may be {@code null} in which case an empty result is returned. + * @return An {@link Option} containing the meta value if present, otherwise an empty {@link Option}. + * + * @since 5.7 + */ + @ApiStatus.AvailableSince("5.7") + public @NotNull Option getMeta(@Nullable String key) { + return Option.of(worldConfig.getMeta().get(key)); + } + + /** + * Sets a meta key-value pair for this world. Existing value for the key will be replaced. + * + * @param key The meta key to set, must not be {@code null}. + * @param value The meta value to set. + * @return Result of setting the property. + * + * @since 5.7 + */ + @ApiStatus.AvailableSince("5.7") + public @NotNull Try setMeta(@NotNull String key, @Nullable String value) { + return worldConfig.setMeta(key, value); + } + + /** + * Removes the meta entry associated with the given key. + * + * @param key The meta key to remove. + * @return Result of removing the property. + * + * @since 5.7 + */ + @ApiStatus.AvailableSince("5.7") + public @NotNull Try removeMeta(@NotNull String key) { + return worldConfig.removeMeta(key); + } + /** * Gets the player limit for this world after which players without an override * permission node will not be allowed in. A value of -1 or less signifies no limit diff --git a/src/main/java/org/mvplugins/multiverse/core/world/WorldConfig.java b/src/main/java/org/mvplugins/multiverse/core/world/WorldConfig.java index 57bf7aba3..e67ae6b54 100644 --- a/src/main/java/org/mvplugins/multiverse/core/world/WorldConfig.java +++ b/src/main/java/org/mvplugins/multiverse/core/world/WorldConfig.java @@ -2,6 +2,7 @@ import java.util.List; import java.util.Locale; +import java.util.Map; import java.util.concurrent.atomic.AtomicReference; import com.dumptruckman.minecraft.util.Logging; @@ -303,6 +304,18 @@ Try setKeepSpawnInMemory(boolean keepSpawnInMemory) { return configHandle.set(configNodes.keepSpawnInMemory, keepSpawnInMemory); } + Map getMeta() { + return configHandle.get(configNodes.meta); + } + + Try setMeta(String key, String value) { + return configHandle.set(configNodes.meta, key, value); + } + + Try removeMeta(String key) { + return configHandle.remove(configNodes.meta, key); + } + int getPlayerLimit() { return configHandle.get(configNodes.playerLimit); } diff --git a/src/main/java/org/mvplugins/multiverse/core/world/WorldConfigNodes.java b/src/main/java/org/mvplugins/multiverse/core/world/WorldConfigNodes.java index be6f29ba4..6318c942d 100644 --- a/src/main/java/org/mvplugins/multiverse/core/world/WorldConfigNodes.java +++ b/src/main/java/org/mvplugins/multiverse/core/world/WorldConfigNodes.java @@ -18,6 +18,7 @@ import org.mvplugins.multiverse.core.MultiverseCore; import org.mvplugins.multiverse.core.config.CoreConfig; +import org.mvplugins.multiverse.core.config.node.MapConfigNode; import org.mvplugins.multiverse.core.config.node.serializer.NodeSerializer; import org.mvplugins.multiverse.core.event.world.MVWorldPropertyChangedEvent; import org.mvplugins.multiverse.core.config.node.ConfigNode; @@ -208,6 +209,10 @@ public Object serialize(Material object, Class type) { }); })); + final MapConfigNode meta = (MapConfigNode) node(MapConfigNode + .mapBuilder("meta", String.class, String.class) + .hidden()); + final ConfigNode playerLimit = node(ConfigNode.builder("player-limit", Integer.class) .defaultValue(-1)); diff --git a/src/main/resources/multiverse-core_en.properties b/src/main/resources/multiverse-core_en.properties index a998eaa8e..e20aebb12 100644 --- a/src/main/resources/multiverse-core_en.properties +++ b/src/main/resources/multiverse-core_en.properties @@ -127,6 +127,24 @@ mv-core.load.world.description=Name of world you want to load. mv-core.load.loading=Loading world '{world}'... mv-core.load.success=&aWorld '{world}' loaded! +# /mv meta info +mv-core.meta.info.description=Shows all metadata for a world. +mv-core.meta.info.world.description=The world to show metadata for. +mv-core.meta.info.header=&a&l---- World Metadata: &f&l{world}&a&l ---- +mv-core.meta.info.nocontent=&cNo metadata found for world '{world}'. You can set metadata with '&6/mv meta modify {world} set &c' command! + +# /mv meta modify +mv-core.meta.modify.description=Modifies a metadata of a given world. +mv-core.meta.modify.world.description=Name of world you want to modify metadata of. +mv-core.meta.modify.action.description=Action to perform on the metadata. Can be 'set', 'remove', or 'reset'. +mv-core.meta.modify.key.description=Metadata key to modify. +mv-core.meta.modify.value.description=Value to set the metadata to. Only used when action is 'set'. +mv-core.meta.modify.set.success=&aSuccessfully set metadata key '&9{key}&a' to '&9{value}&a' for world &9{world}&a. +mv-core.meta.modify.set.failure=&cFailed to set metadata key '&9{key}&c' to '&9{value}&c' for world &9{world}&c.\n&c{error} +mv-core.meta.modify.remove.success=&aSuccessfully removed metadata key '&9{key}&a' for world &9{world}&a. +mv-core.meta.modify.remove.failure=&cFailed to remove metadata key '&9{key}&c' for world &9{world}&c.\n&c{error} +mv-core.meta.modify.invalidaction=&cInvalid action '&6{action}&c'! Valid actions are 'set' and 'remove' only. + # /mv modify mv-core.modify.description=Modifies a world property of a given world. mv-core.modify.world.description=Name of world you want to modify. diff --git a/src/test/java/org/mvplugins/multiverse/core/config/node/MapConfigNodeTest.kt b/src/test/java/org/mvplugins/multiverse/core/config/node/MapConfigNodeTest.kt new file mode 100644 index 000000000..3d82631a3 --- /dev/null +++ b/src/test/java/org/mvplugins/multiverse/core/config/node/MapConfigNodeTest.kt @@ -0,0 +1,54 @@ +package org.mvplugins.multiverse.core.config.node + +import io.vavr.control.Try +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class MapConfigNodeTest { + + @Test + fun `MapConfigNode parses from string with defaults`() { + val node: MapConfigNode = + MapConfigNode.mapBuilder("my-map", String::class.java, Int::class.javaObjectType).build() + val expected: Map = mapOf("key1" to 1, "key2" to 2) + + assertEquals( + expected, + node.parseFromString("key1=1;key2=2").get() + ) + assertEquals(emptyMap(), node.parseFromString(" ").get()) + } + + @Test + fun `MapConfigNode default serializer converts values by key and value type`() { + val node: MapConfigNode = + MapConfigNode.mapBuilder("my-map", String::class.java, Int::class.javaObjectType).build() + val serializer = node.serializer + val expected: Map = mapOf("alpha" to 4, "beta" to 8) + val rawMap: Map = mapOf("alpha" to "4", "beta" to 8) + + val deserialized = serializer!!.deserialize(rawMap, node.type) + assertEquals(expected, deserialized) + + val serialized = serializer.serialize(expected, node.type) + assertEquals(expected, serialized) + } + + @Test + fun `MapConfigNode key and value validators are used for node validation`() { + val node: MapConfigNode = + MapConfigNode.mapBuilder("my-map", String::class.java, Int::class.javaObjectType) + .keyValidator { key -> + if (key.startsWith("k")) Try.success(null) else Try.failure(IllegalArgumentException("invalid key")) + } + .valueValidator { value -> + if (value >= 0) Try.success(null) else Try.failure(IllegalArgumentException("invalid value")) + } + .build() + + assertTrue(node.validate(mapOf("key" to 1)).isSuccess) + assertTrue(node.validate(mapOf("bad" to 1)).isFailure) + assertTrue(node.validate(mapOf("key" to -1)).isFailure) + } +} diff --git a/src/test/java/org/mvplugins/multiverse/core/inject/InjectionTest.kt b/src/test/java/org/mvplugins/multiverse/core/inject/InjectionTest.kt index de936e63d..8432a969b 100644 --- a/src/test/java/org/mvplugins/multiverse/core/inject/InjectionTest.kt +++ b/src/test/java/org/mvplugins/multiverse/core/inject/InjectionTest.kt @@ -77,7 +77,7 @@ class InjectionTest : TestWithMockBukkit() { @Test fun `Commands are available as services`() { val commands = serviceLocator.getAllActiveServices(CoreCommand::class.java) - assertEquals(57, commands.size) + assertEquals(58, commands.size) } @Test diff --git a/src/test/java/org/mvplugins/multiverse/core/world/WorldConfigTest.kt b/src/test/java/org/mvplugins/multiverse/core/world/WorldConfigTest.kt index 21329f628..f1b213449 100644 --- a/src/test/java/org/mvplugins/multiverse/core/world/WorldConfigTest.kt +++ b/src/test/java/org/mvplugins/multiverse/core/world/WorldConfigTest.kt @@ -29,7 +29,7 @@ class WorldConfigTest : TestWithMockBukkit() { assertNotNull(worldConfig) worldNetherConfig = worldConfigManager.getWorldConfig(key("world_nether")).orNull.takeIf { it != null } ?: run { throw IllegalStateException("WorldConfig for world is not available") } - assertNotNull(worldNetherConfig); + assertNotNull(worldNetherConfig) } @Test @@ -90,6 +90,74 @@ class WorldConfigTest : TestWithMockBukkit() { assertEquals(Material.JUNGLE_WOOD, worldConfig.stringPropertyHandle.getProperty("entryfee-currency").get()) } + @Test + fun `Getting meta returns empty map initially`() { + assertEquals(emptyMap(), worldConfig.meta) + } + + @Test + fun `Setting meta with setMeta adds individual key-value pairs`() { + assertTrue(worldConfig.setMeta("custom-key", "custom-value").isSuccess) + val meta = worldConfig.meta + assertEquals("custom-value", meta["custom-key"]) + } + + @Test + fun `Setting multiple meta values accumulates in the map`() { + assertTrue(worldConfig.setMeta("key1", "value1").isSuccess) + assertTrue(worldConfig.setMeta("key2", "value2").isSuccess) + assertTrue(worldConfig.setMeta("key3", "value3").isSuccess) + + val meta = worldConfig.meta + assertEquals("value1", meta["key1"]) + assertEquals("value2", meta["key2"]) + assertEquals("value3", meta["key3"]) + assertEquals(3, meta.size) + } + + @Test + fun `Updating existing meta key overwrites the value`() { + assertTrue(worldConfig.setMeta("key1", "original-value").isSuccess) + assertTrue(worldConfig.setMeta("key1", "updated-value").isSuccess) + + val meta = worldConfig.meta + assertEquals("updated-value", meta["key1"]) + assertEquals(1, meta.size) + } + + @Test + fun `Removing meta key with removeMeta deletes the key from map`() { + assertTrue(worldConfig.setMeta("key1", "value1").isSuccess) + assertTrue(worldConfig.setMeta("key2", "value2").isSuccess) + + assertTrue(worldConfig.removeMeta("key1").isSuccess) + + val meta = worldConfig.meta + assertNull(meta["key1"]) + assertEquals("value2", meta["key2"]) + assertEquals(1, meta.size) + } + + @Test + fun `Removing non-existing meta key returns failure`() { + assertTrue(worldConfig.removeMeta("non-existent-key").isFailure) + assertEquals(emptyMap(), worldConfig.meta) + } + + @Test + fun `Meta is independent across different world configs`() { + assertTrue(worldConfig.setMeta("world-key", "world-value").isSuccess) + assertTrue(worldNetherConfig.setMeta("nether-key", "nether-value").isSuccess) + + val worldMeta = worldConfig.meta + val netherMeta = worldNetherConfig.meta + + assertEquals("world-value", worldMeta["world-key"]) + assertNull(worldMeta["nether-key"]) + assertEquals("nether-value", netherMeta["nether-key"]) + assertNull(netherMeta["world-key"]) + } + @Test fun `Updating a non-existing property with setProperty returns false`() { assertTrue(worldConfig.stringPropertyHandle.setProperty("invalid-property", false).isFailure) diff --git a/src/test/resources/worlds/default_worlds.yml b/src/test/resources/worlds/default_worlds.yml index c73e7578f..fba34fb42 100644 --- a/src/test/resources/worlds/default_worlds.yml +++ b/src/test/resources/worlds/default_worlds.yml @@ -19,6 +19,7 @@ world: hidden: false hunger: true keep-spawn-in-memory: true + meta: {} player-limit: -1 portal-form: all pvp: true @@ -100,6 +101,7 @@ world_nether: hidden: false hunger: true keep-spawn-in-memory: true + meta: {} player-limit: -1 portal-form: all pvp: true diff --git a/src/test/resources/worlds/delete_worlds.yml b/src/test/resources/worlds/delete_worlds.yml index ee8e24d5b..cc095b7ba 100644 --- a/src/test/resources/worlds/delete_worlds.yml +++ b/src/test/resources/worlds/delete_worlds.yml @@ -19,6 +19,7 @@ world_nether: hidden: false hunger: true keep-spawn-in-memory: true + meta: {} player-limit: -1 portal-form: all pvp: true diff --git a/src/test/resources/worlds/edgecase_worlds.yml b/src/test/resources/worlds/edgecase_worlds.yml index d96489c38..2a03d3074 100644 --- a/src/test/resources/worlds/edgecase_worlds.yml +++ b/src/test/resources/worlds/edgecase_worlds.yml @@ -19,6 +19,7 @@ world: hidden: false hunger: true keep-spawn-in-memory: true + meta: {} player-limit: -1 portal-form: all pvp: true @@ -102,6 +103,7 @@ world_nether: hidden: false hunger: true keep-spawn-in-memory: true + meta: {} player-limit: -1 portal-form: all pvp: true diff --git a/src/test/resources/worlds/migrated_worlds.yml b/src/test/resources/worlds/migrated_worlds.yml index 67988d5da..2b1f39baa 100644 --- a/src/test/resources/worlds/migrated_worlds.yml +++ b/src/test/resources/worlds/migrated_worlds.yml @@ -19,6 +19,7 @@ world_the_end: hidden: false hunger: true keep-spawn-in-memory: true + meta: {} player-limit: -1 portal-form: all pvp: true @@ -100,6 +101,7 @@ world[dot]a[dot]b: hidden: false hunger: true keep-spawn-in-memory: true + meta: {} player-limit: -1 portal-form: all pvp: true @@ -182,6 +184,7 @@ world[dot]a[dot]c: hidden: false hunger: true keep-spawn-in-memory: true + meta: {} player-limit: -1 portal-form: all pvp: true diff --git a/src/test/resources/worlds/newworld_worlds.yml b/src/test/resources/worlds/newworld_worlds.yml index 9cff97e8d..c7a4090e4 100644 --- a/src/test/resources/worlds/newworld_worlds.yml +++ b/src/test/resources/worlds/newworld_worlds.yml @@ -19,6 +19,7 @@ world: hidden: false hunger: true keep-spawn-in-memory: true + meta: {} player-limit: -1 portal-form: all pvp: true @@ -100,6 +101,7 @@ world_nether: hidden: false hunger: true keep-spawn-in-memory: true + meta: {} player-limit: -1 portal-form: all pvp: true @@ -181,6 +183,7 @@ minecraft:new[dot]world: hidden: false hunger: true keep-spawn-in-memory: true + meta: {} player-limit: -1 portal-form: all pvp: true diff --git a/src/test/resources/worlds/properties_worlds.yml b/src/test/resources/worlds/properties_worlds.yml index c390531b2..efc958555 100644 --- a/src/test/resources/worlds/properties_worlds.yml +++ b/src/test/resources/worlds/properties_worlds.yml @@ -19,6 +19,7 @@ world: hidden: false hunger: true keep-spawn-in-memory: true + meta: {} player-limit: -1 portal-form: ALL pvp: true @@ -100,6 +101,7 @@ world_nether: hidden: false hunger: true keep-spawn-in-memory: true + meta: {} player-limit: -1 portal-form: ALL pvp: true diff --git a/src/test/resources/worlds/updated_worlds.yml b/src/test/resources/worlds/updated_worlds.yml index 5a893d469..e9ca33eb7 100644 --- a/src/test/resources/worlds/updated_worlds.yml +++ b/src/test/resources/worlds/updated_worlds.yml @@ -19,6 +19,7 @@ world: hidden: false hunger: true keep-spawn-in-memory: true + meta: {} player-limit: -1 portal-form: all pvp: true @@ -102,6 +103,7 @@ world_nether: hidden: false hunger: true keep-spawn-in-memory: true + meta: {} player-limit: -1 portal-form: all pvp: true