/// <summary>Get the dynamic and config token names defined by a content pack.</summary> /// <param name="content">The content pack to read.</param> private ISet <string> GetLocalTokenNames(ContentConfig content) { InvariantHashSet names = new InvariantHashSet(); // dynamic tokens if (content.DynamicTokens?.Any() == true) { foreach (string name in content.DynamicTokens.Select(p => p.Name)) { if (!string.IsNullOrWhiteSpace(name)) { names.Add(name); } } } // config schema if (content.ConfigSchema != null) { foreach (string name in content.ConfigSchema.Select(p => p.Key)) { if (!string.IsNullOrWhiteSpace(name)) { names.Add(name); } } } // exclude tokens that conflict with a built-in condition, which will be ignored names.RemoveWhere(p => Enum.TryParse(p, ignoreCase: true, out ConditionType _)); return(names); }
/********* ** Private methods *********/ /// <summary>Get the dynamic and config token names defined by a content pack.</summary> /// <param name="content">The content pack to read.</param> private ISet <string> GetLocalTokenNames(ContentConfig content) { InvariantHashSet names = new InvariantHashSet(); // dynamic tokens foreach (string name in content.DynamicTokens.Select(p => p.Name)) { if (!string.IsNullOrWhiteSpace(name)) { names.Add(name); } } // config schema foreach (string name in content.ConfigSchema.Select(p => p.Key)) { if (!string.IsNullOrWhiteSpace(name)) { names.Add(name); } } // exclude tokens that conflict with a built-in condition, which will be ignored names.RemoveWhere(p => this.GetEnum <ConditionType>(p) != null); return(names); }
/// <summary>Get the text representation of a token's values.</summary> /// <param name="context">Provides access to contextual tokens.</param> /// <param name="part">The token string part whose value to fetch.</param> /// <param name="unavailableTokens">A list of unavailable or unready token names to update if needed.</param> /// <param name="errors">The errors which occurred (if any).</param> /// <param name="text">The text representation, if available.</param> /// <returns>Returns true if the token is ready and <paramref name="text"/> was set, else false.</returns> private bool TryGetTokenText(IContext context, TokenStringPart part, InvariantHashSet unavailableTokens, InvariantHashSet errors, out string text) { switch (part.LexToken) { case LexTokenToken lexToken: { // get token IToken token = context.GetToken(lexToken.Name, enforceContext: true); if (token == null || !token.IsReady) { unavailableTokens.Add(lexToken.Name); text = null; return(false); } // get token input if (part.Input != null) { // update input part.Input.UpdateContext(context); // check for unavailable tokens string[] unavailableInputTokens = part.Input .GetTokensUsed() .Where(name => context.GetToken(name, enforceContext: true)?.IsReady != true) .ToArray(); if (unavailableInputTokens.Any()) { foreach (string tokenName in unavailableInputTokens) { unavailableTokens.Add(tokenName); } text = null; return(false); } } // validate input if (!token.TryValidateInput(part.InputArgs, out string error)) { errors.Add(error); text = null; return(false); } // get text representation string[] values = token.GetValues(part.InputArgs).ToArray(); text = string.Join(", ", values); return(true); } default: text = part.LexToken.ToString(); return(true); } }
/// <summary>Edit a matched asset.</summary> /// <param name="asset">A helper which encapsulates metadata about an asset and enables changes to it.</param> public void Edit <T>(IAssetData asset) { IPatch[] patches = this.GetCurrentEditors(asset).ToArray(); if (!patches.Any()) { throw new InvalidOperationException($"Can't edit asset key '{asset.AssetName}' because no patches currently apply. This should never happen."); } InvariantHashSet loggedContentPacks = new InvariantHashSet(); foreach (IPatch patch in patches) { if (this.Verbose) { this.VerboseLog($"Applied patch \"{patch.LogName}\" to {asset.AssetName}."); } else if (loggedContentPacks.Add(patch.ContentPack.Manifest.Name)) { this.Monitor.Log($"{patch.ContentPack.Manifest.Name} edited {asset.AssetName}.", LogLevel.Trace); } try { patch.Edit <T>(asset); patch.IsApplied = true; } catch (Exception ex) { this.Monitor.Log($"unhandled exception applying patch: {patch.LogName}.\n{ex}", LogLevel.Error); patch.IsApplied = false; } } }
/// <inheritdoc /> public override bool UpdateContext(IContext context) { return(this.IsChanged(() => { bool changed = false; foreach (var pair in this.Values) { PlayerType type = pair.Key; InvariantHashSet values = pair.Value; InvariantHashSet oldValues = new InvariantHashSet(values); values.Clear(); if (this.MarkReady(this.IsPlayerLoaded())) { Farmer player = type == PlayerType.HostPlayer ? Game1.MasterPlayer : Game1.player; if (player != null) { foreach (string value in this.FetchValues(player)) { values.Add(value); } } } changed |= this.IsChanged(oldValues, values); } return changed; })); }
/// <summary>Add a dynamic token value to the context.</summary> /// <param name="tokenValue">The token to add.</param> public void Add(DynamicTokenValue tokenValue) { // validate if (this.ParentContext.Contains(tokenValue.Name, enforceContext: false)) { throw new InvalidOperationException($"Can't register a '{tokenValue}' token because there's a global token with that name."); } if (this.LocalContext.Contains(tokenValue.Name, enforceContext: false)) { throw new InvalidOperationException($"Can't register a '{tokenValue.Name}' dynamic token because there's a config token with that name."); } // get (or create) token if (!this.DynamicContext.Tokens.TryGetValue(tokenValue.Name, out DynamicToken token)) { this.DynamicContext.Save(token = new DynamicToken(tokenValue.Name, this.Scope)); } // add token value token.AddTokensUsed(tokenValue.GetTokensUsed()); token.AddAllowedValues(tokenValue.Value); this.DynamicTokenValues.Add(tokenValue); // track tokens which should trigger an update to this token Queue <string> tokenQueue = new Queue <string>(tokenValue.GetTokensUsed()); InvariantHashSet visited = new InvariantHashSet(); while (tokenQueue.Any()) { // get token name string tokenName = tokenQueue.Dequeue(); if (!visited.Add(tokenName)) { continue; } // if the current token uses other tokens, they may affect the being added too IToken curToken = this.GetToken(tokenName, enforceContext: false); foreach (string name in curToken.GetTokensUsed()) { tokenQueue.Enqueue(name); } if (curToken is DynamicToken curDynamicToken) { foreach (string name in curDynamicToken.GetPossibleTokensUsed()) { tokenQueue.Enqueue(name); } } // add dynamic value as a dependency of the current token if (!this.TokenDependents.TryGetValue(curToken.Name, out InvariantHashSet used)) { this.TokenDependents.Add(curToken.Name, used = new InvariantHashSet()); } used.Add(tokenValue.Name); } }
/// <summary>Update the current context.</summary> /// <param name="globalContext">The global token context.</param> /// <param name="globalChangedTokens">The global token values which changed, or <c>null</c> to update all tokens.</param> public void UpdateContext(IContext globalContext, InvariantHashSet globalChangedTokens) { // get affected tokens (or null to update all tokens) InvariantHashSet affectedTokens = null; if (globalChangedTokens != null) { affectedTokens = new InvariantHashSet(); foreach (string globalToken in globalChangedTokens) { foreach (string affectedToken in this.GetTokensAffectedBy(globalToken)) { affectedTokens.Add(affectedToken); } } if (!affectedTokens.Any()) { return; } } // update local standard tokens foreach (IToken token in this.LocalContext.Tokens.Values) { if (token.IsMutable && affectedTokens?.Contains(token.Name) != false) { token.UpdateContext(this); } } // reset dynamic tokens // note: since token values are affected by the order they're defined, only updating tokens affected by globalChangedTokens is not trivial. foreach (DynamicToken token in this.DynamicContext.Tokens.Values) { token.SetValue(null); token.SetReady(false); } foreach (DynamicTokenValue tokenValue in this.DynamicTokenValues) { tokenValue.UpdateContext(this); if (tokenValue.IsReady && tokenValue.Conditions.All(p => p.IsMatch(this))) { DynamicToken token = this.DynamicContext.Tokens[tokenValue.Name]; token.SetValue(tokenValue.Value); token.SetReady(true); } } }
/// <summary>Prepare a local asset file for a patch to use.</summary> /// <param name="pack">The content pack being loaded.</param> /// <param name="path">The asset path in the content patch.</param> /// <param name="config">The config values to apply.</param> /// <param name="conditions">The conditions to apply.</param> /// <param name="error">The error reason if preparing the asset fails.</param> /// <param name="tokenedPath">The parsed value.</param> /// <param name="shouldPreload">Whether to preload assets if needed.</param> /// <returns>Returns whether the local asset was successfully prepared.</returns> private bool TryPrepareLocalAsset(ManagedContentPack pack, string path, InvariantDictionary <ConfigField> config, ConditionDictionary conditions, out string error, out TokenString tokenedPath, bool shouldPreload) { // normalise raw value path = this.NormaliseLocalAssetPath(pack, path); if (path == null) { error = $"must set the {nameof(PatchConfig.FromFile)} field for this action type."; tokenedPath = null; return(false); } // tokenise if (!this.TryParseTokenString(path, config, out string tokenError, out TokenStringBuilder builder)) { error = $"the {nameof(PatchConfig.FromFile)} is invalid: {tokenError}"; tokenedPath = null; return(false); } tokenedPath = builder.Build(); // preload & validate possible file paths InvariantHashSet missingFiles = new InvariantHashSet(); foreach (string localKey in this.ConditionFactory.GetPossibleStrings(tokenedPath, conditions)) { if (!pack.FileExists(localKey)) { missingFiles.Add(localKey); } else if (shouldPreload) { pack.PreloadIfNeeded(localKey); } } if (missingFiles.Any()) { error = tokenedPath.ConditionTokens.Any() || missingFiles.Count > 1 ? $"{nameof(PatchConfig.FromFile)} '{path}' matches files which don't exist ({string.Join(", ", missingFiles.OrderBy(p => p))})." : $"{nameof(PatchConfig.FromFile)} '{path}' matches a file which doesn't exist."; tokenedPath = null; return(false); } // looks OK error = null; return(true); }
/// <summary>Update the current context.</summary> /// <param name="changedGlobalTokens">The global tokens which changed value.</param> public void UpdateContext(out InvariantHashSet changedGlobalTokens) { // update global tokens changedGlobalTokens = new InvariantHashSet(); foreach (IToken token in this.GlobalContext.Tokens.Values) { bool changed = (token.IsMutable && token.UpdateContext(this)) || // token changed state/value (this.IsFirstUpdate && token.IsReady); // tokens implicitly change to ready on their first update, even if they were ready from creation if (changed) { changedGlobalTokens.Add(token.Name); } } // update mod contexts foreach (ModTokenContext localContext in this.LocalTokens.Values) { localContext.UpdateContext(changedGlobalTokens); } this.IsFirstUpdate = false; }
/// <summary>Add a dynamic token value to the context.</summary> /// <param name="name">The token name.</param> /// <param name="rawValue">The token value to set.</param> /// <param name="conditions">The conditions that must match to set this value.</param> public void AddDynamicToken(string name, IManagedTokenString rawValue, IEnumerable <Condition> conditions) { // validate if (this.ParentContext.Contains(name, enforceContext: false)) { throw new InvalidOperationException($"Can't register a '{name}' token because there's a global token with that name."); } if (this.LocalContext.Contains(name, enforceContext: false)) { throw new InvalidOperationException($"Can't register a '{name}' dynamic token because there's a config token with that name."); } // get (or create) token if (!this.DynamicTokens.TryGetValue(name, out ManagedManualToken managed)) { managed = new ManagedManualToken(name, this.Scope); this.DynamicTokens[name] = managed; this.DynamicContext.Save(managed.Token); } // create token value handler var tokenValue = new DynamicTokenValue(managed, rawValue, conditions); string[] tokensUsed = tokenValue.GetTokensUsed().ToArray(); // save value info managed.ValueProvider.AddTokensUsed(tokensUsed); managed.ValueProvider.AddAllowedValues(rawValue); this.DynamicTokenValues.Add(tokenValue); // track tokens which should trigger an update to this token Queue <string> tokenQueue = new Queue <string>(tokensUsed); InvariantHashSet visited = new InvariantHashSet(); while (tokenQueue.Any()) { // get token name string usedTokenName = tokenQueue.Dequeue(); if (!visited.Add(usedTokenName)) { continue; } // if the used token uses other tokens, they may affect the one being added too IToken usedToken = this.GetToken(usedTokenName, enforceContext: false); foreach (string nextTokenName in usedToken.GetTokensUsed()) { tokenQueue.Enqueue(nextTokenName); } // add new token as a dependent of the used token if (!this.TokenDependents.TryGetValue(usedToken.Name, out InvariantHashSet used)) { this.TokenDependents.Add(usedToken.Name, used = new InvariantHashSet()); } used.Add(name); } // track new token this.HasNewTokens = true; }
/// <summary>Update the current context.</summary> /// <param name="contentHelper">The content helper which manages game assets.</param> /// <param name="language">The current language.</param> /// <param name="date">The current in-game date (if applicable).</param> /// <param name="weather">The current in-game weather (if applicable).</param> /// <param name="spouse">The current player's internal spouse name (if applicable).</param> /// <param name="dayEvent">The day event (e.g. wedding or festival) occurring today (if applicable).</param> /// <param name="seenEvents">The event IDs which the player has seen.</param> /// <param name="mailFlags">The mail flags set for the player.</param> /// <param name="friendships">The current player's friendship details.</param> public void UpdateContext(IContentHelper contentHelper, LocalizedContentManager.LanguageCode language, SDate date, Weather?weather, string dayEvent, string spouse, int[] seenEvents, string[] mailFlags, IEnumerable <KeyValuePair <string, Friendship> > friendships) { this.VerboseLog("Propagating context..."); // update context this.ConditionContext.Set(language: language, date: date, weather: weather, dayEvent: dayEvent, spouse: spouse, seenEvents: seenEvents, mailFlags: mailFlags, friendships: friendships); IDictionary <ConditionKey, string> tokenisableConditions = this.ConditionContext.GetSingleValueConditions(); // update patches InvariantHashSet reloadAssetNames = new InvariantHashSet(); string prevAssetName = null; foreach (IPatch patch in this.Patches.OrderBy(p => p.AssetName).ThenBy(p => p.LogName)) { // log asset name if (this.Verbose && prevAssetName != patch.AssetName) { this.VerboseLog($" {patch.AssetName}:"); prevAssetName = patch.AssetName; } // track old values string wasAssetName = patch.AssetName; bool wasApplied = patch.MatchesContext; // update patch bool changed = patch.UpdateContext(this.ConditionContext, tokenisableConditions); bool shouldApply = patch.MatchesContext; // track patches to reload bool reload = (wasApplied && changed) || (!wasApplied && shouldApply); if (reload) { patch.IsApplied = false; if (wasApplied) { reloadAssetNames.Add(wasAssetName); } if (shouldApply) { reloadAssetNames.Add(patch.AssetName); } } // log change if (this.Verbose) { IList <string> changes = new List <string>(); if (wasApplied != shouldApply) { changes.Add(shouldApply ? "enabled" : "disabled"); } if (wasAssetName != patch.AssetName) { changes.Add($"target: {wasAssetName} => {patch.AssetName}"); } string changesStr = string.Join(", ", changes); this.VerboseLog($" [{(shouldApply ? "X" : " ")}] {patch.LogName}: {(changes.Any() ? changesStr : "OK")}"); } } // rebuild asset name lookup this.PatchesByCurrentTarget = new InvariantDictionary <HashSet <IPatch> >( from patchGroup in this.Patches.GroupBy(p => p.AssetName, StringComparer.InvariantCultureIgnoreCase) let key = patchGroup.Key let value = new HashSet <IPatch>(patchGroup) select new KeyValuePair <string, HashSet <IPatch> >(key, value) ); // reload assets if needed if (reloadAssetNames.Any()) { this.VerboseLog($" reloading {reloadAssetNames.Count} assets: {string.Join(", ", reloadAssetNames.OrderBy(p => p))}"); contentHelper.InvalidateCache(asset => { this.VerboseLog($" [{(reloadAssetNames.Contains(asset.AssetName) ? "X" : " ")}] reload {asset.AssetName}"); return(reloadAssetNames.Contains(asset.AssetName)); }); } }
/// <summary>Update the current context.</summary> /// <param name="contentHelper">The content helper through which to invalidate assets.</param> public void UpdateContext(IContentHelper contentHelper) { this.VerboseLog("Propagating context..."); // update patches InvariantHashSet reloadAssetNames = new InvariantHashSet(); string prevAssetName = null; foreach (IPatch patch in this.Patches.OrderByIgnoreCase(p => p.AssetName).ThenByIgnoreCase(p => p.LogName)) { // log asset name if (this.Verbose && prevAssetName != patch.AssetName) { this.VerboseLog($" {patch.AssetName}:"); prevAssetName = patch.AssetName; } // track old values string wasAssetName = patch.AssetName; bool wasApplied = patch.MatchesContext; // update patch IContext tokenContext = this.TokenManager.TrackLocalTokens(patch.ContentPack.Pack); bool changed = patch.UpdateContext(tokenContext); bool shouldApply = patch.MatchesContext; // track patches to reload bool reload = (wasApplied && changed) || (!wasApplied && shouldApply); if (reload) { patch.IsApplied = false; if (wasApplied) { reloadAssetNames.Add(wasAssetName); } if (shouldApply) { reloadAssetNames.Add(patch.AssetName); } } // log change if (this.Verbose) { IList <string> changes = new List <string>(); if (wasApplied != shouldApply) { changes.Add(shouldApply ? "enabled" : "disabled"); } if (wasAssetName != patch.AssetName) { changes.Add($"target: {wasAssetName} => {patch.AssetName}"); } string changesStr = string.Join(", ", changes); this.VerboseLog($" [{(shouldApply ? "X" : " ")}] {patch.LogName}: {(changes.Any() ? changesStr : "OK")}"); } // warn for invalid load patch if (patch is LoadPatch loadPatch && patch.MatchesContext && !patch.ContentPack.FileExists(loadPatch.LocalAsset.Value)) { this.Monitor.Log($"Patch error: {patch.LogName} has a {nameof(PatchConfig.FromFile)} which matches non-existent file '{loadPatch.LocalAsset.Value}'.", LogLevel.Error); } } // rebuild asset name lookup this.PatchesByCurrentTarget = new InvariantDictionary <HashSet <IPatch> >( from patchGroup in this.Patches.GroupByIgnoreCase(p => p.AssetName) let key = patchGroup.Key let value = new HashSet <IPatch>(patchGroup) select new KeyValuePair <string, HashSet <IPatch> >(key, value) ); // reload assets if needed if (reloadAssetNames.Any()) { this.VerboseLog($" reloading {reloadAssetNames.Count} assets: {string.Join(", ", reloadAssetNames.OrderByIgnoreCase(p => p))}"); contentHelper.InvalidateCache(asset => { this.VerboseLog($" [{(reloadAssetNames.Contains(asset.AssetName) ? "X" : " ")}] reload {asset.AssetName}"); return(reloadAssetNames.Contains(asset.AssetName)); }); } }
/// <summary>Prepare a local asset file for a patch to use.</summary> /// <param name="pack">The content pack being loaded.</param> /// <param name="path">The asset path in the content patch.</param> /// <param name="config">The config values to apply.</param> /// <param name="conditions">The conditions to apply.</param> /// <param name="error">The error reason if preparing the asset fails.</param> /// <param name="tokenedPath">The parsed value.</param> /// <param name="checkOnly">Prepare the asset info and check if it's valid, but don't actually preload the asset.</param> /// <returns>Returns whether the local asset was successfully prepared.</returns> private bool TryPrepareLocalAsset(IContentPack pack, string path, InvariantDictionary <ConfigField> config, ConditionDictionary conditions, out string error, out TokenString tokenedPath, bool checkOnly) { // normalise raw value path = this.NormaliseLocalAssetPath(pack, path); if (path == null) { error = $"must set the {nameof(PatchConfig.FromFile)} field for this action type."; tokenedPath = null; return(false); } // tokenise if (!this.TryParseTokenString(path, config, out string tokenError, out TokenStringBuilder builder)) { error = $"the {nameof(PatchConfig.FromFile)} is invalid: {tokenError}"; tokenedPath = null; return(false); } tokenedPath = builder.Build(); // validate all possible files exist // + preload PNG assets to avoid load-in-draw-loop error InvariantHashSet missingFiles = new InvariantHashSet(); foreach (string localKey in this.ConditionFactory.GetPossibleStrings(tokenedPath, conditions)) { // check-only mode if (checkOnly) { if (!this.AssetLoader.FileExists(pack, localKey)) { missingFiles.Add(localKey); } continue; } // else preload try { if (this.AssetLoader.PreloadIfNeeded(pack, localKey)) { this.VerboseLog($" preloaded {localKey}."); } } catch (FileNotFoundException) { missingFiles.Add(localKey); } } if (missingFiles.Any()) { error = tokenedPath.ConditionTokens.Any() || missingFiles.Count > 1 ? $"{nameof(PatchConfig.FromFile)} '{path}' matches files which don't exist ({string.Join(", ", missingFiles.OrderBy(p => p))})." : $"{nameof(PatchConfig.FromFile)} '{path}' matches a file which doesn't exist."; tokenedPath = null; return(false); } // looks OK error = null; return(true); }