From 045b5f773870f5c0ff657f276c9fff918457b006 Mon Sep 17 00:00:00 2001 From: mushymato Date: Tue, 20 Jan 2026 23:32:38 -0500 Subject: [PATCH] Support includes for dynamic tokens --- Common/Utilities/IInvariantSet.cs | 2 +- Common/Utilities/InvariantSet.cs | 5 +- .../Framework/AggregateContextual.cs | 6 + .../Framework/ConfigModels/ContentConfig.cs | 23 ++ .../ConfigModels/DynamicTokenConfig.cs | 7 +- ContentPatcher/Framework/ModTokenContext.cs | 58 +++-- ContentPatcher/Framework/PatchLoader.cs | 25 +- .../Framework/Patches/IncludePatch.cs | 25 +- ContentPatcher/Framework/ScreenManager.cs | 215 +++++++++++++----- ContentPatcher/Framework/TokenParser.cs | 18 ++ .../Tokens/DynamicTokenContextual.cs | 94 ++++++++ .../Framework/Tokens/DynamicTokenValue.cs | 49 +--- 12 files changed, 372 insertions(+), 155 deletions(-) create mode 100644 ContentPatcher/Framework/Tokens/DynamicTokenContextual.cs diff --git a/Common/Utilities/IInvariantSet.cs b/Common/Utilities/IInvariantSet.cs index e2a3645e1..dc1556ea7 100644 --- a/Common/Utilities/IInvariantSet.cs +++ b/Common/Utilities/IInvariantSet.cs @@ -14,7 +14,7 @@ internal interface IInvariantSet : IReadOnlySet /// Get an invariant set with the given values added to this set's values. /// The values to add. - IInvariantSet GetWith(ICollection other); + IInvariantSet GetWith(IEnumerable other); /// Get an invariant set with the given value removed from this set's values. /// The value to remove. diff --git a/Common/Utilities/InvariantSet.cs b/Common/Utilities/InvariantSet.cs index 2974b3e01..65a9e0f0f 100644 --- a/Common/Utilities/InvariantSet.cs +++ b/Common/Utilities/InvariantSet.cs @@ -2,6 +2,7 @@ using System.Collections; using System.Collections.Generic; using System.Diagnostics.Contracts; +using System.Linq; namespace Pathoschild.Stardew.Common.Utilities; @@ -139,9 +140,9 @@ public IInvariantSet GetWith(string other) } /// - public IInvariantSet GetWith(ICollection other) + public IInvariantSet GetWith(IEnumerable other) { - if (other.Count == 0) + if (!other.Any()) return this; if (this.Count == 0) diff --git a/ContentPatcher/Framework/AggregateContextual.cs b/ContentPatcher/Framework/AggregateContextual.cs index 336b14a71..e5e421a66 100644 --- a/ContentPatcher/Framework/AggregateContextual.cs +++ b/ContentPatcher/Framework/AggregateContextual.cs @@ -132,6 +132,12 @@ public IInvariantSet GetTokensUsed() ); } + /// Get the token names used by this entity as mutable invariant set. + public MutableInvariantSet GetMutableTokensUsed() + { + return [.. this.ValuesImpl.SelectMany(p => p.GetTokensUsed())]; + } + /// public IContextualState GetDiagnosticState() { diff --git a/ContentPatcher/Framework/ConfigModels/ContentConfig.cs b/ContentPatcher/Framework/ConfigModels/ContentConfig.cs index 6601d1df4..9c3abbdbf 100644 --- a/ContentPatcher/Framework/ConfigModels/ContentConfig.cs +++ b/ContentPatcher/Framework/ConfigModels/ContentConfig.cs @@ -1,4 +1,7 @@ +using System.Collections; +using System.Collections.Generic; using System.Linq; +using System.Reflection; using Pathoschild.Stardew.Common; using Pathoschild.Stardew.Common.Utilities; using StardewModdingAPI; @@ -49,4 +52,24 @@ public ContentConfig(ISemanticVersion? format, DynamicTokenConfig?[]? dynamicTok this.Changes = changes?.WhereNotNull().ToArray() ?? []; this.ConfigSchema = configSchema ?? new InvariantDictionary(); } + + + /// Get the content fields which aren't allowed for a secondary file which were set. + /// The content to validate. + public IEnumerable GetInvalidFieldsForSubFile(params string[] allowedFields) + { + foreach (PropertyInfo property in typeof(ContentConfig).GetProperties()) + { + if (allowedFields.Contains(property.Name)) + continue; + + object? value = property.GetValue(this); + bool hasValue = value is IEnumerable list + ? list.Cast().Any() + : value != null; + + if (hasValue) + yield return property.Name; + } + } } diff --git a/ContentPatcher/Framework/ConfigModels/DynamicTokenConfig.cs b/ContentPatcher/Framework/ConfigModels/DynamicTokenConfig.cs index 79de426de..450e6244c 100644 --- a/ContentPatcher/Framework/ConfigModels/DynamicTokenConfig.cs +++ b/ContentPatcher/Framework/ConfigModels/DynamicTokenConfig.cs @@ -1,4 +1,5 @@ using Pathoschild.Stardew.Common.Utilities; +using StardewModdingAPI.Utilities; namespace ContentPatcher.Framework.ConfigModels; @@ -14,6 +15,9 @@ internal class DynamicTokenConfig /// The value to set. public string? Value { get; } + /// If set, include tokens from a dynamic token include file. + public string? IncludeFromFile { get; } + /// The criteria to apply. See the README for valid values. public InvariantDictionary When { get; } @@ -25,10 +29,11 @@ internal class DynamicTokenConfig /// The name of the token to set. /// The value to set. /// The criteria to apply. See the README for valid values. - public DynamicTokenConfig(string name, string value, InvariantDictionary? when) + public DynamicTokenConfig(string name, string value, string? includeFromFile, InvariantDictionary? when) { this.Name = name; this.Value = value; + this.IncludeFromFile = PathUtilities.NormalizePath(includeFromFile); this.When = when ?? new(); } } diff --git a/ContentPatcher/Framework/ModTokenContext.cs b/ContentPatcher/Framework/ModTokenContext.cs index 103e94903..da695ec3d 100644 --- a/ContentPatcher/Framework/ModTokenContext.cs +++ b/ContentPatcher/Framework/ModTokenContext.cs @@ -31,9 +31,12 @@ internal class ModTokenContext : IContext /// The dynamic tokens stored in the . private readonly InvariantDictionary DynamicTokens = new(); - /// The possible values for the . - /// These must be stored in registration order, since each token value may affect the value of subsequent tokens. - private readonly List DynamicTokenValues = []; + /// + /// The contextual entities for the that can receive context updates in . + /// They are either associated with a particular , or serves only as parent to subsequent values. + /// + /// These must be stored in registration order, since each contextual value may affect the value of subsequent tokens. + private readonly List DynamicTokenContextualList = []; /// The alias token names defined for the content pack. private readonly InvariantDictionary AliasTokenNames = new(); @@ -104,7 +107,8 @@ public void RemoveLocalToken(string name) /// The token name. /// The token value to set. /// The conditions that must match to set this value. - public void AddDynamicToken(string name, IManagedTokenString rawValue, Condition[] conditions) + /// The parent contextual entity of this dynamic token value. + public void AddDynamicToken(string name, IManagedTokenString rawValue, Condition[] conditions, DynamicTokenContextual? parent) { // validate if (this.ParentContext.Contains(name, enforceContext: false)) @@ -121,7 +125,7 @@ public void AddDynamicToken(string name, IManagedTokenString rawValue, Condition } // create token value handler - var tokenValue = new DynamicTokenValue(managed, rawValue, conditions); + var tokenValue = new DynamicTokenValue(managed, rawValue, conditions, parent); IInvariantSet tokensUsed = tokenValue.GetTokensUsed().GetWithout(name); // save value info @@ -132,8 +136,29 @@ public void AddDynamicToken(string name, IManagedTokenString rawValue, Condition managed.ValueProvider.SetValue(tokenValue.Value); managed.ValueProvider.SetReady(true); } - this.DynamicTokenValues.Add(tokenValue); + this.AddDynamicTokenContextual(tokenValue, tokensUsed); + } + + /// Add a dynamic token value to the context. + /// The include name. + /// The conditions to be updated in this context. + /// The parent contextual entity of this dynamic token value. + /// + public DynamicTokenContextual AddDynamicTokenInclude(string name, Condition[] conditions, DynamicTokenContextual? parent) + { + var tokenGroup = new DynamicTokenContextual(name, conditions, parent); + this.AddDynamicTokenContextual(tokenGroup, tokenGroup.GetTokensUsed().GetWithout(name)); + return tokenGroup; + } + + /// Add a dynamic token contextual entity. + /// Token contextual entity to add + /// Tokens this contextual entity depends on + private void AddDynamicTokenContextual(DynamicTokenContextual tokenCtx, IInvariantSet tokensUsed) + { + this.DynamicTokenContextualList.Add(tokenCtx); + string name = tokenCtx.Name; // track token dependencies if (tokensUsed.Any()) { @@ -152,7 +177,6 @@ public void AddDynamicToken(string name, IManagedTokenString rawValue, Condition { if (!this.DynamicTokenDependents.TryGetValue(dependency, out MutableInvariantSet? dependents)) this.DynamicTokenDependents[dependency] = dependents = []; - dependents.Add(name); } @@ -260,18 +284,22 @@ public void UpdateContext(IInvariantSet globalChangedTokens) } } - foreach (DynamicTokenValue tokenValue in this.DynamicTokenValues) + foreach (DynamicTokenContextual tokenCtx in this.DynamicTokenContextualList) { - if (!resetDynamicTokens && !updateDynamicTokens!.Contains(tokenValue.Name)) + if (!resetDynamicTokens && !updateDynamicTokens!.Contains(tokenCtx.Name)) + { continue; - - tokenValue.UpdateContext(this); - if (tokenValue.IsReady && tokenValue.Conditions.All(p => p.IsMatch)) + } + tokenCtx.UpdateContext(this); + if (tokenCtx is DynamicTokenValue tokenValue) { - ManualValueProvider valueProvider = tokenValue.ParentToken.ValueProvider; + if (tokenValue.IsReady && tokenValue.AllConditionsMatch()) + { + ManualValueProvider valueProvider = tokenValue.ParentToken.ValueProvider; - valueProvider.SetValue(tokenValue.Value); - valueProvider.SetReady(true); + valueProvider.SetValue(tokenValue.Value); + valueProvider.SetReady(true); + } } } } diff --git a/ContentPatcher/Framework/PatchLoader.cs b/ContentPatcher/Framework/PatchLoader.cs index fb874adfb..7b962a12c 100644 --- a/ContentPatcher/Framework/PatchLoader.cs +++ b/ContentPatcher/Framework/PatchLoader.cs @@ -144,27 +144,26 @@ public void UnloadPatchesLoadedBy(RawContentPack pack) /// Handles low-level parsing and validation for tokens. /// The path to the value from the root content file. /// The normalized conditions. - /// The immutable mod IDs always required by these conditions (if they're and immutable). + /// Mutable set of mod IDs always required by these conditions (if they're and immutable). /// An error message indicating why normalization failed. - public bool TryParseConditions(IDictionary? raw, TokenParser tokenParser, LogPathBuilder path, out Condition[] conditions, out IInvariantSet immutableRequiredModIds, [NotNullWhen(false)] out string? error) + public bool TryParseConditions(IDictionary? raw, TokenParser tokenParser, LogPathBuilder path, out Condition[] conditions, ref MutableInvariantSet? requiredModIds, [NotNullWhen(false)] out string? error) { // no conditions if (raw == null || !raw.Any()) { - immutableRequiredModIds = InvariantSets.Empty; conditions = []; error = null; return true; } // parse conditions - MutableInvariantSet requiredModIds = []; + requiredModIds ??= []; InvariantDictionary parsed = new(); foreach ((string key, string? value) in raw.OrderBy(p => this.GetConditionParseOrder(p.Key, p.Value))) { if (!this.TryParseCondition(key, value, tokenParser, path.With(key), out Condition? condition, ref requiredModIds, out error)) { - immutableRequiredModIds = InvariantSets.Empty; + requiredModIds = null; conditions = []; return false; } @@ -172,12 +171,26 @@ public bool TryParseConditions(IDictionary? raw, TokenParser to parsed[key] = condition; } - immutableRequiredModIds = requiredModIds.Lock(); conditions = parsed.Values.ToArray(); error = null; return true; } + /// Normalize and parse the given condition values. + /// The raw condition values to normalize. + /// Handles low-level parsing and validation for tokens. + /// The path to the value from the root content file. + /// The normalized conditions. + /// The immutable mod IDs always required by these conditions (if they're and immutable). + /// An error message indicating why normalization failed. + public bool TryParseConditions(IDictionary? raw, TokenParser tokenParser, LogPathBuilder path, out Condition[] conditions, out IInvariantSet immutableRequiredModIDs, [NotNullWhen(false)] out string? error) + { + MutableInvariantSet? requiredModIDs = null; + bool result = this.TryParseConditions(raw, tokenParser, path, out conditions, ref requiredModIDs, out error); + immutableRequiredModIDs = requiredModIDs?.Lock() ?? InvariantSet.Empty; + return result; + } + /// Normalize and parse the given update rate. /// The raw update rate to normalize. /// The normalized update rate. diff --git a/ContentPatcher/Framework/Patches/IncludePatch.cs b/ContentPatcher/Framework/Patches/IncludePatch.cs index 13247c53a..84124f541 100644 --- a/ContentPatcher/Framework/Patches/IncludePatch.cs +++ b/ContentPatcher/Framework/Patches/IncludePatch.cs @@ -1,8 +1,6 @@ using System; -using System.Collections; using System.Collections.Generic; using System.Linq; -using System.Reflection; using ContentPatcher.Framework.Conditions; using ContentPatcher.Framework.ConfigModels; using ContentPatcher.Framework.Tokens; @@ -144,10 +142,10 @@ public void AttemptLoad() } // validate fields - string[] invalidFields = this.GetInvalidFields(content).ToArray(); + var invalidFields = content.GetInvalidFieldsForSubFile(nameof(ContentConfig.Changes)); if (invalidFields.Any()) { - this.WarnForPatch($"file contains fields which aren't allowed for a secondary file ({string.Join(", ", invalidFields.OrderByHuman())})."); + this.WarnForPatch($"file contains fields which aren't allowed for a content include file ({string.Join(", ", invalidFields.OrderByHuman())})."); return; } @@ -206,25 +204,6 @@ private bool IsSameFilePath(string? left, string? right) return left.EqualsIgnoreCase(right); } - /// Get the content fields which aren't allowed for a secondary file which were set. - /// The content to validate. - private IEnumerable GetInvalidFields(ContentConfig content) - { - foreach (PropertyInfo property in typeof(ContentConfig).GetProperties()) - { - if (property.Name == nameof(ContentConfig.Changes)) - continue; - - object? value = property.GetValue(content); - bool hasValue = value is IEnumerable list - ? list.Cast().Any() - : value != null; - - if (hasValue) - yield return property.Name; - } - } - /// Get the root log path for included patches. /// The file path being loaded. private LogPathBuilder GetIncludedLogPath(string filePath) diff --git a/ContentPatcher/Framework/ScreenManager.cs b/ContentPatcher/Framework/ScreenManager.cs index 0ff49f0bd..445a3ee7a 100644 --- a/ContentPatcher/Framework/ScreenManager.cs +++ b/ContentPatcher/Framework/ScreenManager.cs @@ -14,6 +14,7 @@ using StardewModdingAPI.Enums; using StardewModdingAPI.Events; using StardewValley; +using StardewValley.Extensions; namespace ContentPatcher.Framework; @@ -248,72 +249,10 @@ private void LoadContentPacks(IEnumerable contentPacks, IInva foreach (KeyValuePair pair in config) this.AddConfigToken(pair.Key, pair.Value, modContext, current); - // load dynamic tokens IDictionary dynamicTokenCountByName = new InvariantDictionary(); foreach (DynamicTokenConfig entry in content.DynamicTokens) { - void LogSkip(string reason) => this.Monitor.Log($"Ignored {current.Manifest.Name} > dynamic token '{entry.Name}': {reason}", LogLevel.Warn); - - // get path - LogPathBuilder localPath = current.LogPath.With(nameof(content.DynamicTokens)); - { - string label = string.IsNullOrWhiteSpace(entry.Name) - ? "unnamed" - : entry.Name; - - dynamicTokenCountByName.TryAdd(label, -1); - int discriminator = ++dynamicTokenCountByName[label]; - localPath = localPath.With($"{entry.Name} {discriminator}"); - } - - // validate token name - if (string.IsNullOrWhiteSpace(entry.Name)) - { - LogSkip("the token name can't be empty."); - continue; - } - if (entry.Name.Contains(InternalConstants.PositionalInputArgSeparator)) - { - LogSkip($"the token name can't have positional input arguments ({InternalConstants.PositionalInputArgSeparator} character)."); - continue; - } - if (Enum.TryParse(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 conditions - Condition[] conditions; - IInvariantSet immutableRequiredModIds; - { - if (!this.PatchLoader.TryParseConditions(entry.When, tokenParser, localPath.With(nameof(entry.When)), out conditions, out immutableRequiredModIds, out string? conditionError)) - { - this.Monitor.Log($"Ignored {current.Manifest.Name} > '{entry.Name}' token: its {nameof(DynamicTokenConfig.When)} field is invalid: {conditionError}.", LogLevel.Warn); - continue; - } - } - - // parse values - IManagedTokenString? values; - if (!string.IsNullOrWhiteSpace(entry.Value)) - { - if (!tokenParser.TryParseString(entry.Value, immutableRequiredModIds, localPath.With(nameof(entry.Value)), out string? valueError, out values)) - { - LogSkip($"the token value is invalid: {valueError}"); - continue; - } - } - else - values = new LiteralString("", localPath.With(nameof(entry.Value))); - - // add token - modContext.AddDynamicToken(entry.Name, values, conditions); + this.AddDynamicToken(current, config, modContext, tokenParser, dynamicTokenCountByName, entry); } } @@ -368,6 +307,156 @@ private void LoadContentPacks(IEnumerable contentPacks, IInva this.CustomLocationManager.EnforceUniqueNames(); } + /// Register a dynamic token for a content pack, possibly one with includes. + /// Content pack + /// Registered config tokens + /// Token context + /// Token parser + /// Counter for number of times a specific dynamic token has been referenced + /// Dynamic token info + /// Parent dynamic token contextual entity + /// Parent required mod IDs + /// + private bool AddDynamicToken(LoadedContentPack current, InvariantDictionary config, ModTokenContext modContext, TokenParser tokenParser, IDictionary dynamicTokenCountByName, DynamicTokenConfig entry, DynamicTokenContextual? parent = null, MutableInvariantSet? requiredModIDs = null) + { + void LogSkip(string reason) => this.Monitor.Log($"Ignored {current.Manifest.Name} > dynamic token '{entry.Name}': {reason}", LogLevel.Warn); + + // validate token + if (!string.IsNullOrWhiteSpace(entry.IncludeFromFile)) + { + if (!current.ContentPack.HasFile(entry.IncludeFromFile)) + { + LogSkip($"the token include from file '{entry.IncludeFromFile}' does not exist."); + return false; + } + if (dynamicTokenCountByName.ContainsKey(entry.IncludeFromFile)) + { + LogSkip($"the token include from file '{entry.IncludeFromFile}' has already been included once."); + return false; + } + if (entry.IncludeFromFile.StartsWith(DynamicTokenContextual.DynamicTokenIncludePrefix)) + { + LogSkip($"the token include from file cannot start with '{DynamicTokenContextual.DynamicTokenIncludePrefix}'."); + return false; + } + var includeContent = current.ContentPack.ModContent.Load(entry.IncludeFromFile); + if (!includeContent.DynamicTokens.Any()) + { + LogSkip($"the file '{entry.IncludeFromFile}' doesn't have anything in the {nameof(includeContent.Changes)} field. Is the file formatted correctly?."); + return false; + } + { + var invalidFields = includeContent.GetInvalidFieldsForSubFile(nameof(ContentConfig.DynamicTokens)); + if (invalidFields.Any()) + { + LogSkip($"the file '{entry.IncludeFromFile}' contains fields which aren't allowed for a dynamic token include file ({string.Join(", ", invalidFields.OrderByHuman())})."); + return false; + } + } + + // Case 1: Dynamic token include group + + LogPathBuilder localPath = MakeDynamicTokenLocalPath(current, dynamicTokenCountByName, entry, entry.IncludeFromFile); + // parse conditions + Condition[] conditions; + { + if (!this.PatchLoader.TryParseConditions(entry.When, tokenParser, localPath.With(nameof(entry.When)), out conditions, ref requiredModIDs, out string? conditionError)) + { + LogSkip($"its {nameof(DynamicTokenConfig.When)} field is invalid: {conditionError}."); + return false; + } + } + // add token group if it has conditions + DynamicTokenContextual? tokenInclude = null; + if (parent != null || conditions.Any()) + { + tokenInclude = modContext.AddDynamicTokenInclude(entry.IncludeFromFile, conditions, parent); + } + // add tokens + foreach (DynamicTokenConfig subEntry in includeContent.DynamicTokens) + { + this.AddDynamicToken(current, config, modContext, tokenParser, dynamicTokenCountByName, subEntry, tokenInclude, requiredModIDs); + } + return true; + + } + else if (!string.IsNullOrWhiteSpace(entry.Name)) + { + if (entry.Name.Contains(InternalConstants.PositionalInputArgSeparator)) + { + LogSkip($"the token name can't have positional input arguments ({InternalConstants.PositionalInputArgSeparator} character)."); + return false; + } + if (Enum.TryParse(entry.Name, true, out _)) + { + LogSkip("the token name is already used by a global token."); + return false; + } + if (config.ContainsKey(entry.Name)) + { + LogSkip("the token name is already used by a config token."); + return false; + } + if (entry.Name.StartsWith(DynamicTokenContextual.DynamicTokenIncludePrefix)) + { + LogSkip($"the token name cannot start with '{DynamicTokenContextual.DynamicTokenIncludePrefix}'."); + return false; + } + + // CASE 2: Concrete dynamic token + + LogPathBuilder localPath = MakeDynamicTokenLocalPath(current, dynamicTokenCountByName, entry, entry.Name); + // parse conditions + MutableInvariantSet? thisRequiredModIDs = requiredModIDs != null ? new(requiredModIDs) : null; + Condition[] conditions; + { + if (!this.PatchLoader.TryParseConditions(entry.When, tokenParser, localPath.With(nameof(entry.When)), out conditions, ref thisRequiredModIDs, out string? conditionError)) + { + LogSkip($"its {nameof(DynamicTokenConfig.When)} field is invalid: {conditionError}."); + return false; + } + } + IInvariantSet assumeModIds = thisRequiredModIDs?.Lock() ?? InvariantSets.Empty; + + // parse values + IManagedTokenString? values; + if (!string.IsNullOrWhiteSpace(entry.Value)) + { + if (!tokenParser.TryParseString(entry.Value, assumeModIds, localPath.With(nameof(entry.Value)), out string? valueError, out values)) + { + LogSkip($"the token value is invalid: {valueError}"); + return false; + } + } + else + values = new LiteralString("", localPath.With(nameof(entry.Value))); + + // add token + modContext.AddDynamicToken(entry.Name, values, conditions, parent); + return true; + } + else + { + LogSkip("the token entry must have either 'Name' or 'IncludeFromFile'."); + return false; + } + } + + /// Create dynamic token specific log path builder. + /// Current content pack + /// Counter for number of times a specific dynamic token has been referenced + /// Dynamic token config entry + /// Label to use in log + /// + private static LogPathBuilder MakeDynamicTokenLocalPath(LoadedContentPack current, IDictionary dynamicTokenCountByName, DynamicTokenConfig entry, string logPathLabel) + { + LogPathBuilder localPath = current.LogPath.With(nameof(current.Content.DynamicTokens)); + dynamicTokenCountByName.TryAdd(logPathLabel, -1); + int discriminator = ++dynamicTokenCountByName[logPathLabel]; + localPath = localPath.With($"{logPathLabel} {discriminator}"); + return localPath; + } + /// Reapply a content pack when its configuration changes. /// The content pack to reapply. private void ApplyNewConfig(LoadedContentPack contentPack) diff --git a/ContentPatcher/Framework/TokenParser.cs b/ContentPatcher/Framework/TokenParser.cs index 38915ee4f..aad7e7c92 100644 --- a/ContentPatcher/Framework/TokenParser.cs +++ b/ContentPatcher/Framework/TokenParser.cs @@ -107,6 +107,24 @@ public IInputArguments CreateInputArgs(ITokenString? inputStr) : InputArguments.Empty; } + /// Fetch iterator of required but not loaded mods. + /// Mod IDs required + /// Iterator of not installed mod ID + /// True if there are any required but not loaded Mod IDs + public bool TryGetRequiredNotInstalledModIDs(ISet? requiredModIDs, [NotNullWhen(true)] out IEnumerable? notInstalledRequiredModIDs) + { + notInstalledRequiredModIDs = null; + if (requiredModIDs != null) + { + notInstalledRequiredModIDs = requiredModIDs.Where(modId => !this.InstalledMods.Contains(modId)); + if (notInstalledRequiredModIDs.Any()) + return true; + else + notInstalledRequiredModIDs = null; + } + return false; + } + /// Parse a string which can contain tokens or be null, and validate that it's valid. /// The raw string which may contain tokens. /// Mod IDs to assume are installed for purposes of token validation. diff --git a/ContentPatcher/Framework/Tokens/DynamicTokenContextual.cs b/ContentPatcher/Framework/Tokens/DynamicTokenContextual.cs new file mode 100644 index 000000000..cde30fa12 --- /dev/null +++ b/ContentPatcher/Framework/Tokens/DynamicTokenContextual.cs @@ -0,0 +1,94 @@ +using System.Linq; +using ContentPatcher.Framework.Conditions; +using Pathoschild.Stardew.Common.Utilities; +using StardewValley.Extensions; + +namespace ContentPatcher.Framework.Tokens; + +/// A holder for dynamic token conditions. +internal class DynamicTokenContextual : IContextual +{ + /// Namespace used for dynamic token includes + internal const string DynamicTokenIncludePrefix = ""; + + /********* + ** Fields + *********/ + /// Backing field for name + private readonly string NameImpl = string.Empty; + + /// The underlying contextual values. + protected readonly AggregateContextual Contextuals = new(); + + /********* + ** Accessors + *********/ + /// The conditions that must match to set this value. + public Condition[] Conditions { get; } + + /// The name of the token whose value to set. + public virtual string Name => this.NameImpl; + + /// A parent contextual whose conditions must all match as well + public DynamicTokenContextual? Parent { get; } + + /// + public bool IsMutable => (this.Parent?.IsMutable ?? false) || this.Contextuals.IsMutable; + + /// + public bool IsReady => (this.Parent?.IsReady ?? true) && this.Contextuals.IsReady; + + /********* + ** Protected methods + *********/ + /// Create a new DynamicTokenContextual + /// list of conditions + /// parent contextual + protected DynamicTokenContextual(Condition[] conditions, DynamicTokenContextual? parent) + { + this.Parent = parent; + this.Conditions = conditions; + this.Contextuals.Add(this.Conditions); + } + /********* + ** Public methods + *********/ + /// Create a new DynamicTokenContextual that is named + /// name of this context + /// list of conditions + /// parent contextual + public DynamicTokenContextual(string name, Condition[] conditions, DynamicTokenContextual? parent) : this(conditions, parent) + { + this.NameImpl = string.Concat(DynamicTokenIncludePrefix, name); + } + + /// Check that all conditions of this contextual as it's parent matches + /// match state + public bool AllConditionsMatch() + { + return (this.Parent?.AllConditionsMatch() ?? true) && this.Conditions.All(p => p.IsMatch); + } + + /// + public bool UpdateContext(IContext context) + { + return this.Contextuals.UpdateContext(context); + } + + /// + public IInvariantSet GetTokensUsed() + { + MutableInvariantSet tokensUsed = this.Contextuals.GetMutableTokensUsed(); + if (this.Parent != null) + { + tokensUsed.AddRange(this.Parent.Contextuals.GetMutableTokensUsed()); + } + return tokensUsed.Lock(); + } + + /// + public IContextualState GetDiagnosticState() + { + return this.Contextuals.GetDiagnosticState(); + } +} diff --git a/ContentPatcher/Framework/Tokens/DynamicTokenValue.cs b/ContentPatcher/Framework/Tokens/DynamicTokenValue.cs index 5981f4039..1de6bca8f 100644 --- a/ContentPatcher/Framework/Tokens/DynamicTokenValue.cs +++ b/ContentPatcher/Framework/Tokens/DynamicTokenValue.cs @@ -1,40 +1,22 @@ using ContentPatcher.Framework.Conditions; -using Pathoschild.Stardew.Common.Utilities; namespace ContentPatcher.Framework.Tokens; /// A conditional value for a dynamic token. -internal class DynamicTokenValue : IContextual +internal class DynamicTokenValue : DynamicTokenContextual { - /********* - ** Fields - *********/ - /// The underlying contextual values. - private readonly AggregateContextual Contextuals; - - /********* ** Accessors *********/ /// The token for which this value is registered. public ManagedManualToken ParentToken { get; } - /// The name of the token whose value to set. - public string Name => this.ParentToken.ValueProvider.Name; + /// + public override string Name => this.ParentToken.ValueProvider.Name; /// The token value to set. public ITokenString Value { get; } - /// The conditions that must match to set this value. - public Condition[] Conditions { get; } - - /// - public bool IsMutable => this.Contextuals.IsMutable; - - /// - public bool IsReady => this.Contextuals.IsReady; - - /********* ** Public methods *********/ @@ -42,31 +24,10 @@ internal class DynamicTokenValue : IContextual /// The token whose value to set. /// The token value to set. /// The conditions that must match to set this value. - public DynamicTokenValue(ManagedManualToken parentToken, IManagedTokenString value, Condition[] conditions) + public DynamicTokenValue(ManagedManualToken parentToken, IManagedTokenString value, Condition[] conditions, DynamicTokenContextual? parent) : base(conditions, parent) { this.ParentToken = parentToken; this.Value = value; - this.Conditions = conditions; - this.Contextuals = new AggregateContextual() - .Add(value) - .Add(this.Conditions); - } - - /// - public bool UpdateContext(IContext context) - { - return this.Contextuals.UpdateContext(context); - } - - /// - public IInvariantSet GetTokensUsed() - { - return this.Contextuals.GetTokensUsed(); - } - - /// - public IContextualState GetDiagnosticState() - { - return this.Contextuals.GetDiagnosticState(); + this.Contextuals.Add(value); } }