/// <summary>Get every permutation of the given values.</summary> /// <param name="values">The possible values.</param> public IEnumerable <InvariantDictionary <string> > GetPermutations(InvariantDictionary <InvariantHashSet> values) { // no permutations possible if (!values.Any()) { return(new InvariantDictionary <string> [0]); } // recursively find permutations InvariantDictionary <string> curPermutation = new InvariantDictionary <string>(); IEnumerable <InvariantDictionary <string> > GetPermutations(string[] keyQueue) { if (!keyQueue.Any()) { yield return(new InvariantDictionary <string>(curPermutation)); yield break; } string key = keyQueue[0]; foreach (string value in values[key]) { curPermutation[key] = value; foreach (var permutation in GetPermutations(keyQueue.Skip(1).ToArray())) { yield return(permutation); } } } return(GetPermutations(values.Keys.ToArray())); }
/// <summary>Load config values from the content pack.</summary> /// <param name="contentPack">The content pack whose config file to read.</param> /// <param name="config">The config schema.</param> /// <param name="logWarning">The callback to invoke on each validation warning, passed the field name and reason respectively.</param> private void LoadConfigValues(IContentPack contentPack, InvariantDictionary <ConfigField> config, Action <string, string> logWarning) { if (!config.Any()) { return; } // read raw config InvariantDictionary <InvariantHashSet> configValues = new InvariantDictionary <InvariantHashSet>( from entry in (contentPack.ReadJsonFile <InvariantDictionary <string> >(this.Filename) ?? new InvariantDictionary <string>()) let key = entry.Key.Trim() let value = this.ParseCommaDelimitedField(entry.Value) select new KeyValuePair <string, InvariantHashSet>(key, value) ); // remove invalid values foreach (string key in configValues.Keys.ExceptIgnoreCase(config.Keys).ToArray()) { logWarning(key, "no such field supported by this content pack."); configValues.Remove(key); } // inject default values foreach (string key in config.Keys) { ConfigField field = config[key]; if (!configValues.TryGetValue(key, out InvariantHashSet values) || (!field.AllowBlank && !values.Any())) { configValues[key] = field.DefaultValues; } } // parse each field foreach (string key in config.Keys) { // set value ConfigField field = config[key]; field.Value = configValues[key]; // validate allow-multiple if (!field.AllowMultiple && field.Value.Count > 1) { logWarning(key, "field only allows a single value."); field.Value = field.DefaultValues; continue; } // validate allow-values if (field.AllowValues.Any()) { string[] invalidValues = field.Value.ExceptIgnoreCase(field.AllowValues).ToArray(); if (invalidValues.Any()) { logWarning(key, $"found invalid values ({string.Join(", ", invalidValues)}), expected: {string.Join(", ", field.AllowValues)}."); field.Value = field.DefaultValues; } } } }
/********* ** Properties *********/ /// <summary>Parse a raw config schema for a content pack.</summary> /// <param name="rawSchema">The raw config schema.</param> /// <param name="logWarning">The callback to invoke on each validation warning, passed the field name and reason respectively.</param> private InvariantDictionary <ConfigField> LoadConfigSchema(InvariantDictionary <ConfigSchemaFieldConfig> rawSchema, Action <string, string> logWarning) { InvariantDictionary <ConfigField> schema = new InvariantDictionary <ConfigField>(); if (rawSchema == null || !rawSchema.Any()) { return(schema); } foreach (string key in rawSchema.Keys) { ConfigSchemaFieldConfig field = rawSchema[key]; // validate key if (Enum.TryParse(key, true, out ConditionKey conditionKey)) { logWarning(key, $"can't use {conditionKey} as a config field, because it's a reserved condition name."); continue; } // read allowed values InvariantHashSet allowValues = this.ParseCommaDelimitedField(field.AllowValues); if (!allowValues.Any()) { logWarning(key, $"no {nameof(ConfigSchemaFieldConfig.AllowValues)} specified."); continue; } // read default values InvariantHashSet defaultValues = this.ParseCommaDelimitedField(field.Default); { // inject default if (!defaultValues.Any() && !field.AllowBlank) { defaultValues = new InvariantHashSet(allowValues.First()); } // validate values string[] invalidValues = defaultValues.Except(allowValues).ToArray(); if (invalidValues.Any()) { logWarning(key, $"default values '{string.Join(", ", invalidValues)}' are not allowed according to {nameof(ConfigSchemaFieldConfig.AllowBlank)}."); continue; } // validate allow multiple if (!field.AllowMultiple && defaultValues.Count > 1) { logWarning(key, $"can't have multiple default values because {nameof(ConfigSchemaFieldConfig.AllowMultiple)} is false."); continue; } } // add to schema schema[key] = new ConfigField(allowValues, defaultValues, field.AllowBlank, field.AllowMultiple); } return(schema); }
/**** ** Condition parsing ****/ /// <summary>Normalise and parse the given condition values.</summary> /// <param name="raw">The raw condition values to normalise.</param> /// <param name="conditions">The normalised conditions.</param> /// <param name="error">An error message indicating why normalisation failed.</param> public bool TryParseConditions(InvariantDictionary <string> raw, out ConditionDictionary conditions, out string error) { // no conditions if (raw == null || !raw.Any()) { conditions = this.ConditionFactory.BuildEmpty(); error = null; return(true); } // parse conditions conditions = this.ConditionFactory.BuildEmpty(); foreach (KeyValuePair <string, string> pair in raw) { // parse condition key if (!Enum.TryParse(pair.Key, true, out ConditionKey key)) { error = $"'{pair.Key}' isn't a valid condition; must be one of {string.Join(", ", this.ConditionFactory.GetValidConditions())}"; conditions = null; return(false); } // parse values InvariantHashSet values = this.ParseCommaDelimitedField(pair.Value); if (!values.Any()) { error = $"{key} can't be empty"; conditions = null; return(false); } // restrict to allowed values InvariantHashSet validValues = new InvariantHashSet(this.ConditionFactory.GetValidValues(key)); { string[] invalidValues = values.Except(validValues, StringComparer.InvariantCultureIgnoreCase).ToArray(); if (invalidValues.Any()) { error = $"invalid {key} values ({string.Join(", ", invalidValues)}); expected one of {string.Join(", ", validValues)}"; conditions = null; return(false); } } // create condition conditions[key] = new Condition(key, values); } // return parsed conditions error = null; return(true); }
/// <summary>Save the configuration file for a content pack.</summary> /// <param name="contentPack">The content pack.</param> /// <param name="config">The configuration to save.</param> /// <param name="modHelper">The mod helper through which to save the file.</param> public void Save(IContentPack contentPack, InvariantDictionary <ConfigField> config, IModHelper modHelper) { string configPath = Path.Combine(contentPack.DirectoryPath, this.Filename); // save if settings valid if (config.Any()) { InvariantDictionary <string> data = new InvariantDictionary <string>(config.ToDictionary(p => p.Key, p => string.Join(", ", p.Value.Value))); modHelper.WriteJsonFile(configPath, data); } // delete if no settings else if (File.Exists(configPath)) { File.Delete(configPath); } }
/// <summary>Save the configuration file for a content pack.</summary> /// <param name="contentPack">The content pack.</param> /// <param name="config">The configuration to save.</param> /// <param name="modHelper">The mod helper through which to save the file.</param> public void Save(ManagedContentPack contentPack, InvariantDictionary <ConfigField> config, IModHelper modHelper) { // save if settings valid if (config.Any()) { InvariantDictionary <string> data = new InvariantDictionary <string>(config.ToDictionary(p => p.Key, p => string.Join(", ", p.Value.Value))); contentPack.WriteJsonFile(this.Filename, data); } // delete if no settings else { FileInfo file = new FileInfo(Path.Combine(contentPack.GetFullPath(this.Filename))); if (file.Exists) { file.Delete(); } } }
/**** ** Condition parsing ****/ /// <summary>Normalise and parse the given condition values.</summary> /// <param name="raw">The raw condition values to normalise.</param> /// <param name="formatVersion">The format version specified by the content pack.</param> /// <param name="latestFormatVersion">The latest format version.</param> /// <param name="conditions">The normalised conditions.</param> /// <param name="error">An error message indicating why normalisation failed.</param> public bool TryParseConditions(InvariantDictionary <string> raw, ISemanticVersion formatVersion, ISemanticVersion latestFormatVersion, out ConditionDictionary conditions, out string error) { // no conditions if (raw == null || !raw.Any()) { conditions = this.ConditionFactory.BuildEmpty(); error = null; return(true); } // parse conditions conditions = this.ConditionFactory.BuildEmpty(); foreach (KeyValuePair <string, string> pair in raw) { // parse condition key if (!ConditionKey.TryParse(pair.Key, out ConditionKey key)) { error = $"'{pair.Key}' isn't a valid condition; must be one of {string.Join(", ", Enum.GetValues(typeof(ConditionType)))}"; conditions = null; return(false); } // validate types which require an ID if (this.TypesRequireID.Contains(key.Type) && string.IsNullOrWhiteSpace(key.ForID)) { error = $"{key.Type} conditions must specify a separate ID (see readme for usage)"; conditions = null; return(false); } // check compatibility foreach (var versionPair in this.MinimumVersions) { if (formatVersion.IsOlderThan(versionPair.Key) && versionPair.Value.Contains(key.Type)) { error = $"{key} isn't available with format version {formatVersion} (change the {nameof(ContentConfig.Format)} field to {latestFormatVersion} to use newer features)"; conditions = null; return(false); } } // parse values InvariantHashSet values = this.ParseCommaDelimitedField(pair.Value); if (!values.Any()) { error = $"{key} can't be empty"; conditions = null; return(false); } // restrict to allowed values string[] rawValidValues = this.ConditionFactory.GetValidValues(key)?.ToArray(); if (rawValidValues?.Any() == true) { InvariantHashSet validValues = new InvariantHashSet(rawValidValues); { string[] invalidValues = values.Except(validValues, StringComparer.InvariantCultureIgnoreCase).ToArray(); if (invalidValues.Any()) { error = $"invalid {key} values ({string.Join(", ", invalidValues)}); expected one of {string.Join(", ", validValues)}"; conditions = null; return(false); } } } // create condition conditions[key] = new Condition(key, values); } // return parsed conditions error = null; return(true); }
/// <summary>Normalise and parse the given condition values.</summary> /// <param name="raw">The raw condition values to normalise.</param> /// <param name="tokenContext">The tokens available for this content pack.</param> /// <param name="migrator">The migrator which validates and migrates content pack data.</param> /// <param name="conditions">The normalised conditions.</param> /// <param name="error">An error message indicating why normalisation failed.</param> private bool TryParseConditions(InvariantDictionary <string> raw, IContext tokenContext, IMigration migrator, out ConditionDictionary conditions, out string error) { conditions = new ConditionDictionary(); // no conditions if (raw == null || !raw.Any()) { error = null; return(true); } // parse conditions Lexer lexer = new Lexer(); foreach (KeyValuePair <string, string> pair in raw) { // parse condition key ILexToken[] lexTokens = lexer.ParseBits(pair.Key, impliedBraces: true).ToArray(); if (lexTokens.Length != 1 || !(lexTokens[0] is LexTokenToken lexToken) || lexToken.PipedTokens.Any()) { error = $"'{pair.Key}' isn't a valid token name"; conditions = null; return(false); } TokenName name = new TokenName(lexToken.Name, lexToken.InputArg?.Text); // apply migrations if (!migrator.TryMigrate(ref name, out error)) { conditions = null; return(false); } // get token IToken token = tokenContext.GetToken(name, enforceContext: false); if (token == null) { error = $"'{pair.Key}' isn't a valid condition; must be one of {string.Join(", ", tokenContext.GetTokens(enforceContext: false).Select(p => p.Name).OrderBy(p => p))}"; conditions = null; return(false); } // validate subkeys if (!token.CanHaveSubkeys) { if (name.HasSubkey()) { error = $"{name.Key} conditions don't allow subkeys (:)"; conditions = null; return(false); } } else if (token.RequiresSubkeys) { if (!name.HasSubkey()) { error = $"{name.Key} conditions must specify a token subkey (see readme for usage)"; conditions = null; return(false); } } // parse values InvariantHashSet values = this.ParseCommaDelimitedField(pair.Value); if (!values.Any()) { error = $"{name} can't be empty"; conditions = null; return(false); } // validate token keys & values if (!token.TryValidate(name, values, out string customError)) { error = $"invalid {name} condition: {customError}"; conditions = null; return(false); } // create condition conditions[name] = new Condition(name, values); } // return parsed conditions error = null; return(true); }
private void LoadContentPacks(IEnumerable <RawContentPack> contentPacks) { // load content packs ConfigFileHandler configFileHandler = new ConfigFileHandler(this.ConfigFileName, this.ParseCommaDelimitedField, (pack, label, reason) => this.Monitor.Log($"Ignored {pack.Manifest.Name} > {label}: {reason}")); foreach (RawContentPack current in contentPacks) { this.Monitor.VerboseLog($"Loading content pack '{current.Manifest.Name}'..."); try { ContentConfig content = current.Content; // load tokens ModTokenContext tokenContext = this.TokenManager.TrackLocalTokens(current.ManagedPack.Pack); { // load config.json InvariantDictionary <ConfigField> config = configFileHandler.Read(current.ManagedPack, content.ConfigSchema); configFileHandler.Save(current.ManagedPack, config, this.Helper); if (config.Any()) { this.Monitor.VerboseLog($" found config.json with {config.Count} fields..."); } // load config tokens foreach (KeyValuePair <string, ConfigField> pair in config) { ConfigField field = pair.Value; tokenContext.Add(new ImmutableToken(pair.Key, field.Value, allowedValues: field.AllowValues, canHaveMultipleValues: field.AllowMultiple)); } // load dynamic tokens foreach (DynamicTokenConfig entry in content.DynamicTokens ?? new DynamicTokenConfig[0]) { void LogSkip(string reason) => this.Monitor.Log($"Ignored {current.Manifest.Name} > dynamic token '{entry.Name}': {reason}", LogLevel.Warn); // validate token key if (!TokenName.TryParse(entry.Name, out TokenName name)) { LogSkip("the name could not be parsed as a token key."); continue; } if (name.HasSubkey()) { LogSkip("the token name cannot contain a subkey (:)."); continue; } if (name.TryGetConditionType(out ConditionType conflictingType)) { LogSkip($"conflicts with global token '{conflictingType}'."); continue; } if (config.ContainsKey(name.Key)) { LogSkip($"conflicts with player config token '{conflictingType}'."); continue; } // parse values InvariantHashSet values = entry.Value != null?this.ParseCommaDelimitedField(entry.Value) : new InvariantHashSet(); // parse conditions ConditionDictionary conditions; { if (!this.TryParseConditions(entry.When, tokenContext, current.Migrator, out conditions, out string error)) { this.Monitor.Log($"Ignored {current.Manifest.Name} > '{entry.Name}' token: its {nameof(DynamicTokenConfig.When)} field is invalid: {error}.", LogLevel.Warn); continue; } } // add token tokenContext.Add(new DynamicTokenValue(name, values, conditions)); } } // load patches content.Changes = this.SplitPatches(content.Changes).ToArray(); this.NamePatches(current.ManagedPack, content.Changes); foreach (PatchConfig patch in content.Changes) { this.Monitor.VerboseLog($" loading {patch.LogName}..."); this.LoadPatch(current.ManagedPack, patch, tokenContext, current.Migrator, logSkip: reasonPhrase => this.Monitor.Log($"Ignored {patch.LogName}: {reasonPhrase}", LogLevel.Warn)); } } catch (Exception ex) { this.Monitor.Log($"Error loading content pack '{current.Manifest.Name}'. Technical details:\n{ex}", LogLevel.Error); continue; } } }
/********* ** Private methods *********/ /// <summary>Parse a raw config schema for a content pack.</summary> /// <param name="rawSchema">The raw config schema.</param> /// <param name="logWarning">The callback to invoke on each validation warning, passed the field name and reason respectively.</param> /// <param name="formatVersion">The content format version.</param> private InvariantDictionary <ConfigField> LoadConfigSchema(InvariantDictionary <ConfigSchemaFieldConfig?>?rawSchema, Action <string, string> logWarning, ISemanticVersion formatVersion) { InvariantDictionary <ConfigField> schema = new(); if (rawSchema == null || !rawSchema.Any()) { return(schema); } foreach (string rawKey in rawSchema.Keys) { ConfigSchemaFieldConfig?field = rawSchema[rawKey]; if (field is null) { continue; } // validate format if (string.IsNullOrWhiteSpace(rawKey)) { logWarning(rawKey, "the config field name can't be empty."); continue; } if (rawKey.Contains(InternalConstants.PositionalInputArgSeparator) || rawKey.Contains(InternalConstants.NamedInputArgSeparator)) { logWarning(rawKey, $"the name '{rawKey}' can't have input arguments ({InternalConstants.PositionalInputArgSeparator} or {InternalConstants.NamedInputArgSeparator} character)."); continue; } // validate reserved keys if (Enum.TryParse <ConditionType>(rawKey, true, out _)) { logWarning(rawKey, $"can't use {rawKey} as a config field, because it's a reserved condition key."); continue; } // read allowed/default values IInvariantSet allowValues = this.ParseCommaDelimitedField(field.AllowValues); IInvariantSet defaultValues = this.ParseCommaDelimitedField(field.Default); // pre-1.7 behaviour if (formatVersion.IsOlderThan("1.7")) { // allowed values are required if (!allowValues.Any()) { logWarning(rawKey, $"no {nameof(ConfigSchemaFieldConfig.AllowValues)} specified (and format version is less than 1.7)."); continue; } // inject default if needed if (!defaultValues.Any() && !field.AllowBlank) { defaultValues = InvariantSets.FromValue(allowValues.First()); } } // validate allowed values if (!field.AllowBlank && !defaultValues.Any()) { logWarning(rawKey, $"if {nameof(field.AllowBlank)} is false, you must specify {nameof(field.Default)}."); continue; } if (allowValues.Any() && defaultValues.Any()) { IInvariantSet invalidValues = defaultValues.GetWithout(allowValues); if (invalidValues.Any()) { logWarning(rawKey, $"default values '{string.Join(", ", invalidValues)}' are not allowed according to {nameof(ConfigSchemaFieldConfig.AllowValues)}."); continue; } } // validate allow multiple if (!field.AllowMultiple && defaultValues.Count > 1) { logWarning(rawKey, $"can't have multiple default values because {nameof(ConfigSchemaFieldConfig.AllowMultiple)} is false."); continue; } // add to schema schema[rawKey] = new ConfigField( allowValues: allowValues, defaultValues: defaultValues, value: InvariantSets.Empty, allowBlank: field.AllowBlank, allowMultiple: field.AllowMultiple, description: field.Description, section: field.Section ); } return(schema); }
/// <summary>Normalise and parse the given condition values.</summary> /// <param name="raw">The raw condition values to normalise.</param> /// <param name="tokenContext">The tokens available for this content pack.</param> /// <param name="migrator">The migrator which validates and migrates content pack data.</param> /// <param name="conditions">The normalised conditions.</param> /// <param name="error">An error message indicating why normalisation failed.</param> private bool TryParseConditions(InvariantDictionary <string> raw, IContext tokenContext, IMigration migrator, out IList <Condition> conditions, out string error) { conditions = new List <Condition>(); // no conditions if (raw == null || !raw.Any()) { error = null; return(true); } // parse conditions Lexer lexer = new Lexer(); foreach (KeyValuePair <string, string> pair in raw) { // get lexical tokens ILexToken[] lexTokens = lexer.ParseBits(pair.Key, impliedBraces: true).ToArray(); for (int i = 0; i < lexTokens.Length; i++) { if (!migrator.TryMigrate(ref lexTokens[0], out error)) { conditions = null; return(false); } } // parse condition key if (lexTokens.Length != 1 || !(lexTokens[0] is LexTokenToken lexToken)) { error = $"'{pair.Key}' isn't a valid token name"; conditions = null; return(false); } ITokenString input = new TokenString(lexToken.InputArg, tokenContext); // get token IToken token = tokenContext.GetToken(lexToken.Name, enforceContext: false); if (token == null) { error = $"'{pair.Key}' isn't a valid condition; must be one of {string.Join(", ", tokenContext.GetTokens(enforceContext: false).Select(p => p.Name).OrderBy(p => p))}"; conditions = null; return(false); } // validate input if (!token.TryValidateInput(input, out error)) { conditions = null; return(false); } // parse values if (string.IsNullOrWhiteSpace(pair.Value)) { error = $"can't parse condition {pair.Key}: value can't be empty"; conditions = null; return(false); } if (!this.TryParseStringTokens(pair.Value, tokenContext, migrator, out error, out ITokenString values)) { error = $"can't parse condition {pair.Key}: {error}"; return(false); } // validate token keys & values if (!values.IsMutable && !token.TryValidateValues(input, values.SplitValues(), tokenContext, out string customError)) { error = $"invalid {lexToken.Name} condition: {customError}"; conditions = null; return(false); } // create condition conditions.Add(new Condition(name: token.Name, input: input, values: values)); } // return parsed conditions error = null; return(true); }
private void LoadContentPacks(IEnumerable <RawContentPack> contentPacks) { // load content packs ConfigFileHandler configFileHandler = new ConfigFileHandler(this.ConfigFileName, this.ParseCommaDelimitedField, (pack, label, reason) => this.Monitor.Log($"Ignored {pack.Manifest.Name} > {label}: {reason}", LogLevel.Warn)); foreach (RawContentPack current in contentPacks) { this.Monitor.VerboseLog($"Loading content pack '{current.Manifest.Name}'..."); try { ContentConfig content = current.Content; // load tokens ModTokenContext modContext = this.TokenManager.TrackLocalTokens(current.ManagedPack.Pack); { // load config.json InvariantDictionary <ConfigField> config = configFileHandler.Read(current.ManagedPack, content.ConfigSchema, current.Content.Format); configFileHandler.Save(current.ManagedPack, config, this.Helper); if (config.Any()) { this.Monitor.VerboseLog($" found config.json with {config.Count} fields..."); } // load config tokens foreach (KeyValuePair <string, ConfigField> pair in config) { ConfigField field = pair.Value; modContext.Add(new ImmutableToken(pair.Key, field.Value, scope: current.Manifest.UniqueID, allowedValues: field.AllowValues, canHaveMultipleValues: field.AllowMultiple)); } // load dynamic tokens foreach (DynamicTokenConfig entry in content.DynamicTokens ?? new DynamicTokenConfig[0]) { void LogSkip(string reason) => this.Monitor.Log($"Ignored {current.Manifest.Name} > dynamic token '{entry.Name}': {reason}", LogLevel.Warn); // validate token key if (string.IsNullOrWhiteSpace(entry.Name)) { LogSkip("the token name can't be empty."); continue; } if (entry.Name.Contains(InternalConstants.InputArgSeparator)) { LogSkip($"the token name can't have an input argument ({InternalConstants.InputArgSeparator} character)."); continue; } if (Enum.TryParse <ConditionType>(entry.Name, true, out _)) { LogSkip("the token name is already used by a global token."); continue; } if (config.ContainsKey(entry.Name)) { LogSkip("the token name is already used by a config token."); continue; } // parse values ITokenString values; if (!string.IsNullOrWhiteSpace(entry.Value)) { if (!this.TryParseStringTokens(entry.Value, modContext, current.Migrator, out string valueError, out values)) { LogSkip($"the token value is invalid: {valueError}"); continue; } } else { values = new LiteralString(""); } // parse conditions IList <Condition> conditions; { if (!this.TryParseConditions(entry.When, modContext, current.Migrator, out conditions, out string conditionError)) { this.Monitor.Log($"Ignored {current.Manifest.Name} > '{entry.Name}' token: its {nameof(DynamicTokenConfig.When)} field is invalid: {conditionError}.", LogLevel.Warn); continue; } } // add token modContext.Add(new DynamicTokenValue(entry.Name, values, conditions)); } } // load patches IContext patchTokenContext = new SinglePatchContext(current.Manifest.UniqueID, parentContext: modContext); // make patch tokens available to patches content.Changes = this.SplitPatches(content.Changes).ToArray(); this.NamePatches(current.ManagedPack, content.Changes); foreach (PatchConfig patch in content.Changes) { this.Monitor.VerboseLog($" loading {patch.LogName}..."); this.LoadPatch(current.ManagedPack, patch, patchTokenContext, current.Migrator, logSkip: reasonPhrase => this.Monitor.Log($"Ignored {patch.LogName}: {reasonPhrase}", LogLevel.Warn)); } } catch (Exception ex) { this.Monitor.Log($"Error loading content pack '{current.Manifest.Name}'. Technical details:\n{ex}", LogLevel.Error); continue; } } }
/// <summary>Load one patch from a content pack's <c>content.json</c> file.</summary> /// <param name="pack">The content pack being loaded.</param> /// <param name="entry">The change to load.</param> /// <param name="config">The content pack's config values.</param> /// <param name="logSkip">The callback to invoke with the error reason if loading it fails.</param> private bool LoadPatch(IContentPack pack, PatchConfig entry, InvariantDictionary <ConfigField> config, Action <string> logSkip) { bool TrackSkip(string reason, bool warn = true) { this.PatchManager.AddPermanentlyDisabled(new DisabledPatch(entry.LogName, entry.Action, entry.Target, pack, reason)); if (warn) { logSkip(reason); } return(false); } try { // normalise patch fields if (entry.When == null) { entry.When = new InvariantDictionary <string>(); } // parse action if (!Enum.TryParse(entry.Action, true, out PatchType action)) { return(TrackSkip(string.IsNullOrWhiteSpace(entry.Action) ? $"must set the {nameof(PatchConfig.Action)} field." : $"invalid {nameof(PatchConfig.Action)} value '{entry.Action}', expected one of: {string.Join(", ", Enum.GetNames(typeof(PatchType)))}." )); } // parse target asset TokenString assetName; { if (string.IsNullOrWhiteSpace(entry.Target)) { return(TrackSkip($"must set the {nameof(PatchConfig.Target)} field.")); } if (!this.TryParseTokenString(entry.Target, config, out string error, out TokenStringBuilder builder)) { return(TrackSkip($"the {nameof(PatchConfig.Target)} is invalid: {error}")); } assetName = builder.Build(); } // parse 'enabled' bool enabled = true; { if (entry.Enabled != null && !this.TryParseBoolean(entry.Enabled, config, out string error, out enabled)) { return(TrackSkip($"invalid {nameof(PatchConfig.Enabled)} value '{entry.Enabled}': {error}")); } } // apply config foreach (string key in config.Keys) { if (entry.When.TryGetValue(key, out string values)) { InvariantHashSet expected = this.PatchManager.ParseCommaDelimitedField(values); if (!expected.Intersect(config[key].Value).Any()) { return(TrackSkip($"disabled: config field '{key}' must have one of '{string.Join(", ", expected)}', but found '{string.Join(", ", config[key].Value)}'.", warn: false)); } entry.When.Remove(key); } } // parse conditions ConditionDictionary conditions; { if (!this.PatchManager.TryParseConditions(entry.When, out conditions, out string error)) { return(TrackSkip($"the {nameof(PatchConfig.When)} field is invalid: {error}.")); } } // get patch instance IPatch patch; switch (action) { // load asset case PatchType.Load: { // init patch if (!this.TryPrepareLocalAsset(pack, entry.FromFile, config, conditions, out string error, out TokenString fromAsset, checkOnly: !enabled)) { return(TrackSkip(error)); } patch = new LoadPatch(entry.LogName, this.AssetLoader, pack, assetName, conditions, fromAsset, this.Helper.Content.NormaliseAssetName); // detect conflicting loaders if (enabled) { InvariantDictionary <IPatch> conflicts = this.PatchManager.GetConflictingLoaders(patch); if (conflicts.Any()) { IEnumerable <string> conflictNames = (from conflict in conflicts orderby conflict.Key select $"'{conflict.Value.LogName}' already loads {conflict.Key}"); return(TrackSkip( $"{nameof(entry.Target)} '{patch.TokenableAssetName.Raw}' conflicts with other load patches ({string.Join(", ", conflictNames)}). Each file can only be loaded by one patch, unless their conditions can never overlap.")); } } } break; // edit data case PatchType.EditData: { // validate if (entry.Entries == null && entry.Fields == null) { return(TrackSkip($"either {nameof(PatchConfig.Entries)} or {nameof(PatchConfig.Fields)} must be specified for a '{action}' change.")); } if (entry.Entries != null && entry.Entries.Any(p => string.IsNullOrWhiteSpace(p.Value))) { return(TrackSkip($"the {nameof(PatchConfig.Entries)} can't contain empty values.")); } if (entry.Fields != null && entry.Fields.Any(p => p.Value == null || p.Value.Any(n => n.Value == null))) { return(TrackSkip($"the {nameof(PatchConfig.Fields)} can't contain empty values.")); } // save patch = new EditDataPatch(entry.LogName, this.AssetLoader, pack, assetName, conditions, entry.Entries, entry.Fields, this.Monitor, this.Helper.Content.NormaliseAssetName); } break; // edit image case PatchType.EditImage: { // read patch mode PatchMode patchMode = PatchMode.Replace; if (!string.IsNullOrWhiteSpace(entry.PatchMode) && !Enum.TryParse(entry.PatchMode, true, out patchMode)) { return(TrackSkip($"the {nameof(PatchConfig.PatchMode)} is invalid. Expected one of these values: [{string.Join(", ", Enum.GetNames(typeof(PatchMode)))}].")); } // save if (!this.TryPrepareLocalAsset(pack, entry.FromFile, config, conditions, out string error, out TokenString fromAsset, checkOnly: !enabled)) { return(TrackSkip(error)); } patch = new EditImagePatch(entry.LogName, this.AssetLoader, pack, assetName, conditions, fromAsset, entry.FromArea, entry.ToArea, patchMode, this.Monitor, this.Helper.Content.NormaliseAssetName); } break; default: return(TrackSkip($"unsupported patch type '{action}'.")); } // only apply patch when its tokens are available HashSet <ConditionKey> tokensUsed = new HashSet <ConditionKey>(patch.GetTokensUsed()); foreach (ConditionKey key in tokensUsed) { if (!patch.Conditions.ContainsKey(key)) { patch.Conditions.Add(key, patch.Conditions.GetValidValues(key)); } } // skip if not enabled // note: we process the patch even if it's disabled, so any errors are caught by the modder instead of only failing after the patch is enabled. if (!enabled) { return(TrackSkip($"{nameof(PatchConfig.Enabled)} is false.", warn: false)); } // save patch this.PatchManager.Add(patch); return(true); } catch (Exception ex) { return(TrackSkip($"error reading info. Technical details:\n{ex}")); } }
private void LoadContentPacks() { ConfigFileHandler configFileHandler = new ConfigFileHandler(this.ConfigFileName, this.PatchManager.ParseCommaDelimitedField, (pack, label, reason) => this.Monitor.Log($"Ignored {pack.Manifest.Name} > {label}: {reason}")); foreach (IContentPack pack in this.Helper.GetContentPacks()) { this.VerboseLog($"Loading content pack '{pack.Manifest.Name}'..."); try { // read changes file ContentConfig content = pack.ReadJsonFile <ContentConfig>(this.PatchFileName); if (content == null) { this.Monitor.Log($"Ignored content pack '{pack.Manifest.Name}' because it has no {this.PatchFileName} file.", LogLevel.Error); continue; } if (content.Format == null || content.Changes == null) { this.Monitor.Log($"Ignored content pack '{pack.Manifest.Name}' because it doesn't specify the required {nameof(ContentConfig.Format)} or {nameof(ContentConfig.Changes)} fields.", LogLevel.Error); continue; } // validate version if (!this.SupportedFormatVersions.Contains(content.Format.ToString())) { this.Monitor.Log($"Ignored content pack '{pack.Manifest.Name}' because it uses unsupported format {content.Format} (supported version: {string.Join(", ", this.SupportedFormatVersions)}).", LogLevel.Error); continue; } // load config.json InvariantDictionary <ConfigField> config = configFileHandler.Read(pack, content.ConfigSchema); configFileHandler.Save(pack, config, this.Helper); if (config.Any()) { this.VerboseLog($" found config.json with {config.Count} fields..."); } // validate features if (content.Format.IsOlderThan("1.3")) { if (config.Any()) { this.Monitor.Log($"Loading content pack '{pack.Manifest.Name}' failed. It specifies format version {content.Format}, but uses the {nameof(ContentConfig.ConfigSchema)} field added in 1.3.", LogLevel.Error); continue; } if (content.Changes.Any(p => p.FromFile != null && p.FromFile.Contains("{{"))) { this.Monitor.Log($"Loading content pack '{pack.Manifest.Name}' failed. It specifies format version {content.Format}, but uses the {{{{token}}}} feature added in 1.3.", LogLevel.Error); continue; } if (content.Changes.Any(p => p.When != null && p.When.Any())) { this.Monitor.Log($"Loading content pack '{pack.Manifest.Name}' failed. It specifies format version {content.Format}, but uses the condition feature ({nameof(ContentConfig.Changes)}.{nameof(PatchConfig.When)} field) added in 1.3.", LogLevel.Error); continue; } } // load patches this.NamePatches(pack, content.Changes); foreach (PatchConfig patch in content.Changes) { this.VerboseLog($" loading {patch.LogName}..."); this.LoadPatch(pack, patch, config, logSkip: reasonPhrase => this.Monitor.Log($"Ignored {patch.LogName}: {reasonPhrase}", LogLevel.Warn)); } } catch (Exception ex) { this.Monitor.Log($"Error loading content pack '{pack.Manifest.Name}'. Technical details:\n{ex}", LogLevel.Error); } } }
/**** ** Condition parsing ****/ /// <summary>Normalise and parse the given condition values.</summary> /// <param name="raw">The raw condition values to normalise.</param> /// <param name="formatVersion">The format version specified by the content pack.</param> /// <param name="latestFormatVersion">The latest format version.</param> /// <param name="conditions">The normalised conditions.</param> /// <param name="error">An error message indicating why normalisation failed.</param> public bool TryParseConditions(InvariantDictionary <string> raw, ISemanticVersion formatVersion, ISemanticVersion latestFormatVersion, out ConditionDictionary conditions, out string error) { // no conditions if (raw == null || !raw.Any()) { conditions = this.ConditionFactory.BuildEmpty(); error = null; return(true); } // parse conditions conditions = this.ConditionFactory.BuildEmpty(); foreach (KeyValuePair <string, string> pair in raw) { // parse condition key if (!Enum.TryParse(pair.Key, true, out ConditionKey key)) { error = $"'{pair.Key}' isn't a valid condition; must be one of {string.Join(", ", this.ConditionFactory.GetValidConditions())}"; conditions = null; return(false); } // check compatibility if (formatVersion.IsOlderThan("1.4")) { if (key == ConditionKey.DayEvent || key == ConditionKey.HasFlag || key == ConditionKey.HasSeenEvent || key == ConditionKey.Spouse) { error = $"{key} isn't available with format version {formatVersion} (change the {nameof(ContentConfig.Format)} field to {latestFormatVersion} to use newer features)"; conditions = null; return(false); } } // parse values InvariantHashSet values = this.ParseCommaDelimitedField(pair.Value); if (!values.Any()) { error = $"{key} can't be empty"; conditions = null; return(false); } // restrict to allowed values string[] rawValidValues = this.ConditionFactory.GetValidValues(key)?.ToArray(); if (rawValidValues?.Any() == true) { InvariantHashSet validValues = new InvariantHashSet(rawValidValues); { string[] invalidValues = values.Except(validValues, StringComparer.InvariantCultureIgnoreCase).ToArray(); if (invalidValues.Any()) { error = $"invalid {key} values ({string.Join(", ", invalidValues)}); expected one of {string.Join(", ", validValues)}"; conditions = null; return(false); } } } // create condition conditions[key] = new Condition(key, values); } // return parsed conditions error = null; return(true); }
/// <summary>Normalise and parse the given condition values.</summary> /// <param name="raw">The raw condition values to normalise.</param> /// <param name="tokenContext">The tokens available for this content pack.</param> /// <param name="formatVersion">The format version specified by the content pack.</param> /// <param name="latestFormatVersion">The latest format version.</param> /// <param name="minumumTokenVersions">The minimum format versions for newer condition types.</param> /// <param name="conditions">The normalised conditions.</param> /// <param name="error">An error message indicating why normalisation failed.</param> private bool TryParseConditions(InvariantDictionary <string> raw, IContext tokenContext, ISemanticVersion formatVersion, ISemanticVersion latestFormatVersion, InvariantDictionary <ISemanticVersion> minumumTokenVersions, out ConditionDictionary conditions, out string error) { conditions = new ConditionDictionary(); // no conditions if (raw == null || !raw.Any()) { error = null; return(true); } // parse conditions foreach (KeyValuePair <string, string> pair in raw) { // parse condition key if (!TokenName.TryParse(pair.Key, out TokenName name)) { error = $"'{pair.Key}' isn't a valid token name"; conditions = null; return(false); } // get token IToken token = tokenContext.GetToken(name, enforceContext: false); if (token == null) { error = $"'{pair.Key}' isn't a valid condition; must be one of {string.Join(", ", tokenContext.GetTokens(enforceContext: false).Select(p => p.Name).OrderBy(p => p))}"; conditions = null; return(false); } // validate subkeys if (!token.CanHaveSubkeys) { if (name.HasSubkey()) { error = $"{name.Key} conditions don't allow subkeys (:)"; conditions = null; return(false); } } else if (token.RequiresSubkeys) { if (!name.HasSubkey()) { error = $"{name.Key} conditions must specify a token subkey (see readme for usage)"; conditions = null; return(false); } } // check compatibility if (minumumTokenVersions.TryGetValue(name.Key, out ISemanticVersion minVersion) && minVersion.IsNewerThan(formatVersion)) { error = $"{name} isn't available with format version {formatVersion} (change the {nameof(ContentConfig.Format)} field to {latestFormatVersion} to use newer features)"; conditions = null; return(false); } // parse values InvariantHashSet values = this.ParseCommaDelimitedField(pair.Value); if (!values.Any()) { error = $"{name} can't be empty"; conditions = null; return(false); } // restrict to allowed values InvariantHashSet rawValidValues = token.GetAllowedValues(name); if (rawValidValues?.Any() == true) { InvariantHashSet validValues = new InvariantHashSet(rawValidValues); { string[] invalidValues = values.Except(validValues, StringComparer.InvariantCultureIgnoreCase).ToArray(); if (invalidValues.Any()) { error = $"invalid {name} values ({string.Join(", ", invalidValues)}); expected one of {string.Join(", ", validValues)}"; conditions = null; return(false); } } } // perform custom validation if (!token.TryCustomValidation(values, out string customError)) { error = $"invalid {name} values: {customError}"; conditions = null; return(false); } // create condition conditions[name] = new Condition(name, values); } // return parsed conditions error = null; return(true); }
/********* ** Private methods *********/ /// <summary>Parse a raw config schema for a content pack.</summary> /// <param name="rawSchema">The raw config schema.</param> /// <param name="logWarning">The callback to invoke on each validation warning, passed the field name and reason respectively.</param> private InvariantDictionary <ConfigField> LoadConfigSchema(InvariantDictionary <ConfigSchemaFieldConfig> rawSchema, Action <string, string> logWarning) { InvariantDictionary <ConfigField> schema = new InvariantDictionary <ConfigField>(); if (rawSchema == null || !rawSchema.Any()) { return(schema); } foreach (string rawKey in rawSchema.Keys) { ConfigSchemaFieldConfig field = rawSchema[rawKey]; // validate format if (!TokenName.TryParse(rawKey, out TokenName name)) { logWarning(rawKey, $"the name '{rawKey}' is not in a valid format."); continue; } if (name.HasSubkey()) { logWarning(rawKey, $"the name '{rawKey}' can't have a subkey (:)."); continue; } // validate reserved keys if (name.TryGetConditionType(out ConditionType _)) { logWarning(rawKey, $"can't use {name.Key} as a config field, because it's a reserved condition key."); continue; } // read allowed values InvariantHashSet allowValues = this.ParseCommaDelimitedField(field.AllowValues); if (!allowValues.Any()) { logWarning(rawKey, $"no {nameof(ConfigSchemaFieldConfig.AllowValues)} specified."); continue; } // read default values InvariantHashSet defaultValues = this.ParseCommaDelimitedField(field.Default); { // inject default if (!defaultValues.Any() && !field.AllowBlank) { defaultValues = new InvariantHashSet(allowValues.First()); } // validate values string[] invalidValues = defaultValues.ExceptIgnoreCase(allowValues).ToArray(); if (invalidValues.Any()) { logWarning(rawKey, $"default values '{string.Join(", ", invalidValues)}' are not allowed according to {nameof(ConfigSchemaFieldConfig.AllowBlank)}."); continue; } // validate allow multiple if (!field.AllowMultiple && defaultValues.Count > 1) { logWarning(rawKey, $"can't have multiple default values because {nameof(ConfigSchemaFieldConfig.AllowMultiple)} is false."); continue; } } // add to schema schema[rawKey] = new ConfigField(allowValues, defaultValues, field.AllowBlank, field.AllowMultiple); } return(schema); }