From 0cd83156930cf320578e09fafa17069bedd8230f Mon Sep 17 00:00:00 2001 From: Anthony Hilyard Date: Tue, 25 Jan 2022 09:18:04 -0800 Subject: Rewrote new config system, added support for Config Menus for Forge mod. --- .../iceberg/config/IcebergConfigSpec.java | 598 +++++++++++++++++++++ 1 file changed, 598 insertions(+) create mode 100644 src/main/java/com/anthonyhilyard/iceberg/config/IcebergConfigSpec.java (limited to 'src/main/java/com/anthonyhilyard/iceberg/config/IcebergConfigSpec.java') diff --git a/src/main/java/com/anthonyhilyard/iceberg/config/IcebergConfigSpec.java b/src/main/java/com/anthonyhilyard/iceberg/config/IcebergConfigSpec.java new file mode 100644 index 0000000..01a5e40 --- /dev/null +++ b/src/main/java/com/anthonyhilyard/iceberg/config/IcebergConfigSpec.java @@ -0,0 +1,598 @@ +package com.anthonyhilyard.iceberg.config; + +import java.io.File; +import java.lang.reflect.Field; +import java.util.Collections; +import java.util.HashMap; +import java.util.Iterator; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.function.Consumer; +import java.util.function.Function; +import java.util.function.Predicate; +import java.util.function.Supplier; + +import javax.annotation.Nullable; + +import net.minecraftforge.common.ForgeConfigSpec; +import net.minecraftforge.common.ForgeConfigSpec.ConfigValue; +import net.minecraftforge.common.ForgeConfigSpec.ValueSpec; +import net.minecraftforge.fml.Logging; +import net.minecraftforge.fml.config.IConfigSpec; +import net.minecraftforge.fml.unsafe.UnsafeHacks; + +import org.apache.commons.lang3.tuple.Pair; +import org.apache.logging.log4j.LogManager; + +import com.anthonyhilyard.iceberg.Loader; +import com.electronwill.nightconfig.core.AbstractCommentedConfig; +import com.electronwill.nightconfig.core.CommentedConfig; +import com.electronwill.nightconfig.core.Config; +import com.electronwill.nightconfig.core.ConfigFormat; +import com.electronwill.nightconfig.core.ConfigSpec.CorrectionAction; +import com.electronwill.nightconfig.core.ConfigSpec.CorrectionListener; +import com.electronwill.nightconfig.core.InMemoryFormat; +import com.electronwill.nightconfig.core.UnmodifiableConfig; +import com.electronwill.nightconfig.core.file.FileConfig; +import com.electronwill.nightconfig.core.file.FileWatcher; +import com.electronwill.nightconfig.core.utils.UnmodifiableConfigWrapper; +import com.google.common.base.Joiner; +import com.google.common.base.Preconditions; +import com.google.common.base.Splitter; +import com.google.common.collect.Lists; + +/* + * Basically an improved ForgeConfigSpec that supports subconfigs. + */ +public class IcebergConfigSpec extends UnmodifiableConfigWrapper implements IConfigSpec +{ + private Map, String> levelComments = new HashMap<>(); + + private UnmodifiableConfig values; + private Config childConfig; + + private boolean isCorrecting = false; + + private IcebergConfigSpec(UnmodifiableConfig storage, UnmodifiableConfig values, Map, String> levelComments) + { + super(storage); + this.values = values; + this.levelComments = levelComments; + + // Update the filewatcher's default instance to have a more sensible exception handler. + try + { + Field exceptionHandlerField = FileWatcher.class.getDeclaredField("exceptionHandler"); + UnsafeHacks.setField(exceptionHandlerField, FileWatcher.defaultInstance(), (Consumer)e -> { + LogManager.getLogger().warn(Logging.CORE, "An error occurred while reloading config:", e); + }); + } + catch (Exception e) {} + } + + public void setConfig(CommentedConfig config) + { + this.childConfig = config; + if (config != null && !isCorrect(config)) + { + String configName = config instanceof FileConfig fileConfig ? fileConfig.getNioPath().toString() : config.toString(); + Loader.LOGGER.warn("Configuration file {} is not correct. Correcting", configName); + correct(config, + (action, path, incorrectValue, correctedValue) -> + Loader.LOGGER.warn("Incorrect key {} was corrected from {} to its default, {}. {}", DOT_JOINER.join( path ), incorrectValue, correctedValue, incorrectValue == correctedValue ? "This seems to be an error." : ""), + (action, path, incorrectValue, correctedValue) -> + Loader.LOGGER.debug("The comment on key {} does not match the spec. This may create a backup.", DOT_JOINER.join( path ))); + + if (config instanceof FileConfig fileConfig) + { + fileConfig.save(); + } + } + this.afterReload(); + } + + @Override + @SuppressWarnings("unchecked") + public T getRaw(List path) + { + T value = super.getRaw(path); + if (value != null) + { + return value; + } + // Try to "recursively" get the value if needed. + List subPath = path.subList(0, path.size() - 1); + Object test = super.getRaw(subPath); + if (test instanceof ValueSpec valueSpec && valueSpec.getDefault() instanceof MutableSubconfig subconfig) + { + // Okay, this is a dynamic subconfig. That means that only values defined in the default have + // an actual value spec. + value = subconfig.getRaw(path.get(path.size() - 1)); + + // Value will be null here for non-default entries. In that case, just return a default value spec. + if (value == null) + { + value = (T) subconfig.defaultValueSpec(); + } + return value; + } + return null; + } + + @Override + public void acceptConfig(final CommentedConfig data) + { + setConfig(data); + } + + public boolean isCorrecting() + { + return isCorrecting; + } + + public boolean isLoaded() + { + return childConfig != null; + } + + public UnmodifiableConfig getSpec() + { + return this.config; + } + + public UnmodifiableConfig getValues() + { + return this.values; + } + + public void afterReload() + { + this.resetCaches(getValues().valueMap().values()); + } + + private void resetCaches(final Iterable configValues) + { + configValues.forEach(value -> + { + if (value instanceof ConfigValue configValue) + { + configValue.clearCache(); + } + else if (value instanceof Config innerConfig) + { + this.resetCaches(innerConfig.valueMap().values()); + } + }); + } + + public void save() + { + Preconditions.checkNotNull(childConfig, "Cannot save config value without assigned Config object present!"); + if (childConfig instanceof FileConfig fileConfig) + { + fileConfig.save(); + } + } + + public synchronized boolean isCorrect(CommentedConfig config) + { + LinkedList parentPath = new LinkedList<>(); + + // Forge's config watcher isn't properly atomic, so sometimes this method can give false negatives leading + // to the entire config file reverting to defaults. To prevent this, we'll check for an invalid state and + // skip the correction process when that happens. + if (config.valueMap().isEmpty() && config instanceof FileConfig fileConfig) + { + File configFile = fileConfig.getFile(); + + // Sleep for 10 ms to give it a chance to catch up. This shouldn't cause any issues since + // this method only runs when the file changes and at startup. + try { Thread.sleep(10); } catch (Exception e) { } + + // The file isn't actually empty, so this is an invalid state. Skip this correction phase. + if (configFile.length() > 0) + { + return true; + } + } + + return correct(this.config, config, parentPath, Collections.unmodifiableList( parentPath ), (a, b, c, d) -> {}, null, true) == 0; + } + + public synchronized int correct(CommentedConfig config) + { + return correct(config, (action, path, incorrectValue, correctedValue) -> {}, null); + } + + public synchronized int correct(CommentedConfig config, CorrectionListener listener) + { + return correct(config, listener, null); + } + + public synchronized int correct(CommentedConfig config, CorrectionListener listener, CorrectionListener commentListener) + { + LinkedList parentPath = new LinkedList<>(); + int ret = -1; + try + { + isCorrecting = true; + ret = correct(this.config, config, parentPath, Collections.unmodifiableList(parentPath), listener, commentListener, false); + } + finally + { + isCorrecting = false; + } + return ret; + } + + private synchronized int correct(UnmodifiableConfig spec, CommentedConfig config, LinkedList parentPath, List parentPathUnmodifiable, CorrectionListener listener, CorrectionListener commentListener, boolean dryRun) + { + int count = 0; + + Map specMap = spec.valueMap(); + Map configMap = config.valueMap(); + + for (Map.Entry specEntry : specMap.entrySet()) + { + final String key = specEntry.getKey(); + Object specValue = specEntry.getValue(); + final Object configValue = configMap.get(key); + final CorrectionAction action = configValue == null ? CorrectionAction.ADD : CorrectionAction.REPLACE; + + parentPath.addLast(key); + + String subConfigComment = null; + + // If this value is a config, use that as the spec value to support subconfigs. + if (specValue instanceof ValueSpec valueSpec && valueSpec.getDefault() instanceof UnmodifiableConfig) + { + subConfigComment = valueSpec.getComment(); + specValue = valueSpec.getDefault(); + } + + if (specValue instanceof UnmodifiableConfig) + { + if (configValue instanceof Config) + { + count += correct((UnmodifiableConfig)specValue, configValue instanceof CommentedConfig commentedConfig ? commentedConfig : CommentedConfig.copy((Config)configValue), parentPath, parentPathUnmodifiable, listener, commentListener, dryRun); + if (count > 0 && dryRun) + { + return count; + } + } + else if (dryRun) + { + return 1; + } + else + { + CommentedConfig newValue = config.createSubConfig(); + configMap.put(key, newValue); + listener.onCorrect(action, parentPathUnmodifiable, configValue, newValue); + count++; + count += correct((UnmodifiableConfig)specValue, newValue, parentPath, parentPathUnmodifiable, listener, commentListener, dryRun); + } + + String newComment = subConfigComment == null ? levelComments.get(parentPath) : subConfigComment; + String oldComment = config.getComment(key); + if (!stringsMatchIgnoringNewlines(oldComment, newComment)) + { + if (commentListener != null) + { + commentListener.onCorrect(action, parentPathUnmodifiable, oldComment, newComment); + } + + if (dryRun) + { + return 1; + } + + config.setComment(key, newComment); + } + } + else + { + ValueSpec valueSpec = (ValueSpec)specValue; + if (!valueSpec.test(configValue)) + { + if (dryRun) + { + return 1; + } + + Object newValue = valueSpec.correct(configValue); + configMap.put(key, newValue); + listener.onCorrect(action, parentPathUnmodifiable, configValue, newValue); + count++; + } + String oldComment = config.getComment(key); + if (!stringsMatchIgnoringNewlines(oldComment, valueSpec.getComment())) + { + if (commentListener != null) + { + commentListener.onCorrect(action, parentPathUnmodifiable, oldComment, valueSpec.getComment()); + } + + if (dryRun) + { + return 1; + } + + config.setComment(key, valueSpec.getComment()); + } + } + + parentPath.removeLast(); + } + + // Now remove any config values that are not explicitly set in the spec. + for (Iterator> iterator = configMap.entrySet().iterator(); iterator.hasNext();) + { + Map.Entry entry = iterator.next(); + + // If the spec is a dynamic subconfig, don't bother checking the spec since that's the point. + if (!(spec instanceof MutableSubconfig) && !specMap.containsKey(entry.getKey())) + { + if (dryRun) + { + return 1; + } + + iterator.remove(); + parentPath.addLast(entry.getKey()); + listener.onCorrect(CorrectionAction.REMOVE, parentPathUnmodifiable, entry.getValue(), null); + parentPath.removeLast(); + count++; + } + } + return count; + } + + private boolean stringsMatchIgnoringNewlines(@Nullable Object obj1, @Nullable Object obj2) + { + if (obj1 instanceof String && obj2 instanceof String) + { + String string1 = (String) obj1; + String string2 = (String) obj2; + + if (string1.length() > 0 && string2.length() > 0) + { + return string1.replaceAll("\r\n", "\n").equals(string2.replaceAll("\r\n", "\n")); + } + } + + // Fallback for when we're not given Strings, or one of them is empty + return Objects.equals(obj1, obj2); + } + + public static ValueSpec createValueSpec(String comment, String langKey, boolean worldRestart, Class clazz, Supplier defaultSupplier, Predicate validator) + { + Objects.requireNonNull(defaultSupplier, "Default supplier can not be null!"); + Objects.requireNonNull(validator, "Validator can not be null!"); + + // Instantiate the new ValueSpec instance, then use reflection to set the required fields. + ValueSpec result = UnsafeHacks.newInstance(ValueSpec.class); + try + { + Field commentField = ValueSpec.class.getDeclaredField("comment"); + Field langKeyField = ValueSpec.class.getDeclaredField("langKey"); + Field rangeField = ValueSpec.class.getDeclaredField("range"); + Field worldRestartField = ValueSpec.class.getDeclaredField("worldRestart"); + Field clazzField = ValueSpec.class.getDeclaredField("clazz"); + Field supplierField = ValueSpec.class.getDeclaredField("supplier"); + Field validatorField = ValueSpec.class.getDeclaredField("validator"); + UnsafeHacks.setField(commentField, result, comment); + UnsafeHacks.setField(langKeyField, result, langKey); + UnsafeHacks.setField(rangeField, result, null); + UnsafeHacks.setField(worldRestartField, result, worldRestart); + UnsafeHacks.setField(clazzField, result, clazz); + UnsafeHacks.setField(supplierField, result, defaultSupplier); + UnsafeHacks.setField(validatorField, result, validator); + } + catch (Exception e) { } + + return result; + } + + public static class Builder extends ForgeConfigSpec.Builder + { + @Override + public Builder comment(String comment) + { + return (Builder) super.comment(comment); + } + + @Override + public Builder comment(String... comment) + { + return (Builder) super.comment(comment); + } + + @Override + public Builder translation(String translationKey) + { + return (Builder) super.translation(translationKey); + } + + @Override + public Builder worldRestart() + { + return (Builder) super.worldRestart(); + } + + @Override + public Builder push(String path) + { + return (Builder) super.push(path); + } + + @Override + public Builder push(List path) + { + return (Builder) super.push(path); + } + + @Override + public Builder pop() + { + return (Builder) super.pop(); + } + + @Override + public Builder pop(int count) + { + return (Builder) super.pop(count); + } + + public ConfigValue defineSubconfig(String path, UnmodifiableConfig defaultValue, Predicate keyValidator, Predicate valueValidator) + { + return defineSubconfig(split(path), defaultValue, keyValidator, valueValidator); + } + + public ConfigValue defineSubconfig(List path, UnmodifiableConfig defaultValue, Predicate keyValidator, Predicate valueValidator) + { + return defineSubconfig(path, () -> defaultValue, keyValidator, valueValidator); + } + + public ConfigValue defineSubconfig(String path, Supplier defaultSupplier, Predicate keyValidator, Predicate valueValidator) + { + return defineSubconfig(split(path), defaultSupplier, keyValidator, valueValidator); + } + + public ConfigValue defineSubconfig(List path, Supplier defaultSupplier, Predicate keyValidator, Predicate valueValidator) + { + final UnmodifiableConfig defaultConfig = defaultSupplier.get(); + return define(path, () -> MutableSubconfig.copy(defaultConfig, keyValidator, valueValidator), o -> o != null); + } + + private IcebergConfigSpec finishBuild() + { + IcebergConfigSpec result = null; + + try + { + Field valuesField = ForgeConfigSpec.Builder.class.getDeclaredField("values"); + Field storageField = ForgeConfigSpec.Builder.class.getDeclaredField("storage"); + Field levelCommentsField = ForgeConfigSpec.Builder.class.getDeclaredField("levelComments"); + + List> values = UnsafeHacks.>>getField(valuesField, this); + Config storage = UnsafeHacks.getField(storageField, this); + Map, String> levelComments = UnsafeHacks., String>>getField(levelCommentsField, this); + + Config valueCfg = Config.of(Config.getDefaultMapCreator(true, true), InMemoryFormat.withSupport(ConfigValue.class::isAssignableFrom)); + values.forEach(v -> valueCfg.set(v.getPath(), v)); + + final IcebergConfigSpec ret = new IcebergConfigSpec(storage, valueCfg, levelComments); + values.forEach(v -> { + try + { + Field specField = ConfigValue.class.getDeclaredField("spec"); + UnsafeHacks.setField(specField, v, ret); + } + catch (Exception e) { } + }); + result = ret; + } + catch (Exception e) { } + return result; + } + + Pair finish(Function consumer) + { + T o = consumer.apply(this); + return Pair.of(o, this.finishBuild()); + } + + @Deprecated + @Override + public Pair configure(Function consumer) + { + throw new UnsupportedOperationException("Configure method not supported. Use IcebergConfig instead."); + } + + @Deprecated + @Override + public ForgeConfigSpec build() + { + throw new UnsupportedOperationException("Build method not supported. Use IcebergConfig instead."); + } + } + + /** + * This class is specifically meant for dynamic subconfigs--subconfigs where both keys and values are mutable. + */ + final public static class MutableSubconfig extends AbstractCommentedConfig + { + private final ConfigFormat configFormat; + private final Predicate keyValidator; + private final Predicate valueValidator; + private static ValueSpec defaultValueSpec = null; + + /** + * Creates a Subconfig by copying a config and with the specified format. + * + * @param toCopy the config to copy + * @param configFormat the config's format + */ + MutableSubconfig(UnmodifiableConfig toCopy, ConfigFormat configFormat, boolean concurrent, Predicate keyValidator, Predicate valueValidator) + { + super(toCopy, concurrent); + this.configFormat = configFormat; + this.keyValidator = keyValidator; + this.valueValidator = valueValidator; + } + + // Returns a value spec to use for each entry in this subconfig. + public ValueSpec defaultValueSpec() + { + if (defaultValueSpec == null) + { + defaultValueSpec = createValueSpec(null, null, false, Object.class, () -> null, valueValidator); + } + return defaultValueSpec; + } + + @Override + public ConfigFormat configFormat() + { + return configFormat; + } + + public Predicate keyValidator() + { + return keyValidator; + } + + public Predicate valueValidator() + { + return valueValidator; + } + + /** + * Creates a new Subconfig with the content of the given config. The returned config will have + * the same format as the copied config. + * + * @param config the config to copy + * @return a copy of the config + */ + public static MutableSubconfig copy(UnmodifiableConfig config, Predicate keyValidator, Predicate valueValidator) + { + return new MutableSubconfig(config, config.configFormat(), false, keyValidator, valueValidator); + } + + @Override + public CommentedConfig createSubConfig() { throw new UnsupportedOperationException("Can't make a subconfig of a dynamic subconfig!"); } + + @Override + public AbstractCommentedConfig clone() { throw new UnsupportedOperationException("Can't clone a dynamic subconfig!"); } + } + + private static final Joiner DOT_JOINER = Joiner.on("."); + private static final Splitter DOT_SPLITTER = Splitter.on("."); + private static List split(String path) + { + return Lists.newArrayList(DOT_SPLITTER.split(path)); + } +} -- cgit