diff --git a/Mods/AutoFish/AutoFish.csproj b/Mods/AutoFish/AutoFish.csproj index bc33fade..5241dcfd 100644 --- a/Mods/AutoFish/AutoFish.csproj +++ b/Mods/AutoFish/AutoFish.csproj @@ -31,8 +31,8 @@ 4 - - ..\assemblies\Mod.dll + + ..\assemblies\StardewModdingAPI.dll ..\assemblies\StardewValley.dll diff --git a/Mods/AutoFish/AutoFish/ModConfig.cs b/Mods/AutoFish/AutoFish/ModConfig.cs index 3604f5cd..7f13085b 100644 --- a/Mods/AutoFish/AutoFish/ModConfig.cs +++ b/Mods/AutoFish/AutoFish/ModConfig.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using System.Text; @@ -12,5 +12,6 @@ namespace AutoFish public bool autoHit { get; set; } = true; public bool fastBite { get; set; } = false; public bool catchTreasure { get; set; } = true; + public bool autoPlay { get; set; } = true; } } diff --git a/Mods/AutoFish/AutoFish/ModEntry.cs b/Mods/AutoFish/AutoFish/ModEntry.cs index 0d33b57e..0ded242c 100644 --- a/Mods/AutoFish/AutoFish/ModEntry.cs +++ b/Mods/AutoFish/AutoFish/ModEntry.cs @@ -25,6 +25,12 @@ namespace AutoFish public override List GetConfigMenuItems() { List options = new List(); + ModOptionsCheckbox _optionsCheckboxPlay = new ModOptionsCheckbox("自动钓鱼", 0x8765, delegate (bool value) { + this.Config.autoPlay = value; + this.Helper.WriteConfig(this.Config); + }, -1, -1); + _optionsCheckboxPlay.isChecked = this.Config.autoPlay; + options.Add(_optionsCheckboxPlay); ModOptionsCheckbox _optionsCheckboxAutoHit = new ModOptionsCheckbox("自动起钩", 0x8765, delegate (bool value) { this.Config.autoHit = value; this.Helper.WriteConfig(this.Config); @@ -43,12 +49,6 @@ namespace AutoFish }, -1, -1); _optionsCheckboxFastBite.isChecked = this.Config.fastBite; options.Add(_optionsCheckboxFastBite); - ModOptionsCheckbox _optionsCheckboxCatchTreasure = new ModOptionsCheckbox("钓取宝箱", 0x8765, delegate (bool value) { - this.Config.catchTreasure = value; - this.Helper.WriteConfig(this.Config); - }, -1, -1); - _optionsCheckboxCatchTreasure.isChecked = this.Config.catchTreasure; - options.Add(_optionsCheckboxCatchTreasure); return options; } @@ -71,7 +71,7 @@ namespace AutoFish currentTool.castingPower = 1; } - if (Game1.activeClickableMenu is BobberBar) // 自动小游戏 + if (this.Config.autoPlay && Game1.activeClickableMenu is BobberBar) // 自动小游戏 { BobberBar bar = Game1.activeClickableMenu as BobberBar; float barPos = this.Helper.Reflection.GetField(bar, "bobberBarPos").GetValue(); diff --git a/Mods/Automate/Automate.csproj b/Mods/Automate/Automate.csproj index 295083d6..4ee34b50 100644 --- a/Mods/Automate/Automate.csproj +++ b/Mods/Automate/Automate.csproj @@ -33,8 +33,8 @@ 7.2 - - ..\assemblies\Mod.dll + + ..\assemblies\StardewModdingAPI.dll ..\assemblies\StardewValley.dll diff --git a/Mods/ContentPatcher/Common/CommonHelper.cs b/Mods/ContentPatcher/Common/CommonHelper.cs new file mode 100644 index 00000000..720736e4 --- /dev/null +++ b/Mods/ContentPatcher/Common/CommonHelper.cs @@ -0,0 +1,345 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Graphics; +using Pathoschild.Stardew.Common.UI; +using StardewModdingAPI; +using StardewValley; +using StardewValley.Locations; +using StardewValley.Menus; + +namespace Pathoschild.Stardew.Common +{ + /// Provides common utility methods for interacting with the game code shared by my various mods. + internal static class CommonHelper + { + /********* + ** Fields + *********/ + /// A blank pixel which can be colorised and stretched to draw geometric shapes. + private static readonly Lazy LazyPixel = new Lazy(() => + { + Texture2D pixel = new Texture2D(Game1.graphics.GraphicsDevice, 1, 1); + pixel.SetData(new[] { Color.White }); + return pixel; + }); + + + /********* + ** Accessors + *********/ + /// A blank pixel which can be colorised and stretched to draw geometric shapes. + public static Texture2D Pixel => CommonHelper.LazyPixel.Value; + + /// The width of the horizontal and vertical scroll edges (between the origin position and start of content padding). + public static readonly Vector2 ScrollEdgeSize = new Vector2(CommonSprites.Scroll.TopLeft.Width * Game1.pixelZoom, CommonSprites.Scroll.TopLeft.Height * Game1.pixelZoom); + + + /********* + ** Public methods + *********/ + /**** + ** Game + ****/ + /// Get all game locations. + public static IEnumerable GetLocations() + { + return Game1.locations + .Concat( + from location in Game1.locations.OfType() + from building in location.buildings + where building.indoors.Value != null + select building.indoors.Value + ); + } + + /**** + ** Fonts + ****/ + /// Get the dimensions of a space character. + /// The font to measure. + public static float GetSpaceWidth(SpriteFont font) + { + return font.MeasureString("A B").X - font.MeasureString("AB").X; + } + + /**** + ** UI + ****/ + /// Draw a pretty hover box for the given text. + /// The sprite batch being drawn. + /// The text to display. + /// The position at which to draw the text. + /// The maximum width to display. + public static Vector2 DrawHoverBox(SpriteBatch spriteBatch, string label, in Vector2 position, float wrapWidth) + { + const int paddingSize = 27; + const int gutterSize = 20; + + Vector2 labelSize = spriteBatch.DrawTextBlock(Game1.smallFont, label, position + new Vector2(gutterSize), wrapWidth); // draw text to get wrapped text dimensions + IClickableMenu.drawTextureBox(spriteBatch, Game1.menuTexture, new Rectangle(0, 256, 60, 60), (int)position.X, (int)position.Y, (int)labelSize.X + paddingSize + gutterSize, (int)labelSize.Y + paddingSize, Color.White); + spriteBatch.DrawTextBlock(Game1.smallFont, label, position + new Vector2(gutterSize), wrapWidth); // draw again over texture box + + return labelSize + new Vector2(paddingSize); + } + + /// Draw a button background. + /// The sprite batch to which to draw. + /// The top-left pixel coordinate at which to draw the button. + /// The button content's pixel size. + /// The pixel position at which the content begins. + /// The button's outer bounds. + /// The padding between the content and border. + public static void DrawButton(SpriteBatch spriteBatch, in Vector2 position, in Vector2 contentSize, out Vector2 contentPos, out Rectangle bounds, int padding = 0) + { + CommonHelper.DrawContentBox( + spriteBatch: spriteBatch, + texture: CommonSprites.Button.Sheet, + background: CommonSprites.Button.Background, + top: CommonSprites.Button.Top, + right: CommonSprites.Button.Right, + bottom: CommonSprites.Button.Bottom, + left: CommonSprites.Button.Left, + topLeft: CommonSprites.Button.TopLeft, + topRight: CommonSprites.Button.TopRight, + bottomRight: CommonSprites.Button.BottomRight, + bottomLeft: CommonSprites.Button.BottomLeft, + position: position, + contentSize: contentSize, + contentPos: out contentPos, + bounds: out bounds, + padding: padding + ); + } + + /// Draw a scroll background. + /// The sprite batch to which to draw. + /// The top-left pixel coordinate at which to draw the scroll. + /// The scroll content's pixel size. + /// The pixel position at which the content begins. + /// The scroll's outer bounds. + /// The padding between the content and border. + public static void DrawScroll(SpriteBatch spriteBatch, in Vector2 position, in Vector2 contentSize, out Vector2 contentPos, out Rectangle bounds, int padding = 5) + { + CommonHelper.DrawContentBox( + spriteBatch: spriteBatch, + texture: CommonSprites.Scroll.Sheet, + background: in CommonSprites.Scroll.Background, + top: CommonSprites.Scroll.Top, + right: CommonSprites.Scroll.Right, + bottom: CommonSprites.Scroll.Bottom, + left: CommonSprites.Scroll.Left, + topLeft: CommonSprites.Scroll.TopLeft, + topRight: CommonSprites.Scroll.TopRight, + bottomRight: CommonSprites.Scroll.BottomRight, + bottomLeft: CommonSprites.Scroll.BottomLeft, + position: position, + contentSize: contentSize, + contentPos: out contentPos, + bounds: out bounds, + padding: padding + ); + } + + /// Draw a generic content box like a scroll or button. + /// The sprite batch to which to draw. + /// The texture to draw. + /// The source rectangle for the background. + /// The source rectangle for the top border. + /// The source rectangle for the right border. + /// The source rectangle for the bottom border. + /// The source rectangle for the left border. + /// The source rectangle for the top-left corner. + /// The source rectangle for the top-right corner. + /// The source rectangle for the bottom-right corner. + /// The source rectangle for the bottom-left corner. + /// The top-left pixel coordinate at which to draw the button. + /// The button content's pixel size. + /// The pixel position at which the content begins. + /// The box's outer bounds. + /// The padding between the content and border. + public static void DrawContentBox(SpriteBatch spriteBatch, Texture2D texture, in Rectangle background, in Rectangle top, in Rectangle right, in Rectangle bottom, in Rectangle left, in Rectangle topLeft, in Rectangle topRight, in Rectangle bottomRight, in Rectangle bottomLeft, in Vector2 position, in Vector2 contentSize, out Vector2 contentPos, out Rectangle bounds, int padding) + { + int cornerWidth = topLeft.Width * Game1.pixelZoom; + int cornerHeight = topLeft.Height * Game1.pixelZoom; + int innerWidth = (int)(contentSize.X + padding * 2); + int innerHeight = (int)(contentSize.Y + padding * 2); + int outerWidth = innerWidth + cornerWidth * 2; + int outerHeight = innerHeight + cornerHeight * 2; + int x = (int)position.X; + int y = (int)position.Y; + + // draw scroll background + spriteBatch.Draw(texture, new Rectangle(x + cornerWidth, y + cornerHeight, innerWidth, innerHeight), background, Color.White); + + // draw borders + spriteBatch.Draw(texture, new Rectangle(x + cornerWidth, y, innerWidth, cornerHeight), top, Color.White); + spriteBatch.Draw(texture, new Rectangle(x + cornerWidth, y + cornerHeight + innerHeight, innerWidth, cornerHeight), bottom, Color.White); + spriteBatch.Draw(texture, new Rectangle(x, y + cornerHeight, cornerWidth, innerHeight), left, Color.White); + spriteBatch.Draw(texture, new Rectangle(x + cornerWidth + innerWidth, y + cornerHeight, cornerWidth, innerHeight), right, Color.White); + + // draw corners + spriteBatch.Draw(texture, new Rectangle(x, y, cornerWidth, cornerHeight), topLeft, Color.White); + spriteBatch.Draw(texture, new Rectangle(x, y + cornerHeight + innerHeight, cornerWidth, cornerHeight), bottomLeft, Color.White); + spriteBatch.Draw(texture, new Rectangle(x + cornerWidth + innerWidth, y, cornerWidth, cornerHeight), topRight, Color.White); + spriteBatch.Draw(texture, new Rectangle(x + cornerWidth + innerWidth, y + cornerHeight + innerHeight, cornerWidth, cornerHeight), bottomRight, Color.White); + + // set out params + contentPos = new Vector2(x + cornerWidth + padding, y + cornerHeight + padding); + bounds = new Rectangle(x, y, outerWidth, outerHeight); + } + + /// Show an informational message to the player. + /// The message to show. + /// The number of milliseconds during which to keep the message on the screen before it fades (or null for the default time). + public static void ShowInfoMessage(string message, int? duration = null) + { + Game1.addHUDMessage(new HUDMessage(message, 3) { noIcon = true, timeLeft = duration ?? HUDMessage.defaultTime }); + } + + /// Show an error message to the player. + /// The message to show. + public static void ShowErrorMessage(string message) + { + Game1.addHUDMessage(new HUDMessage(message, 3)); + } + + /**** + ** Drawing + ****/ + /// Draw a sprite to the screen. + /// The sprite batch. + /// The X-position at which to start the line. + /// The X-position at which to start the line. + /// The line dimensions. + /// The color to tint the sprite. + public static void DrawLine(this SpriteBatch batch, float x, float y, in Vector2 size, in Color? color = null) + { + batch.Draw(CommonHelper.Pixel, new Rectangle((int)x, (int)y, (int)size.X, (int)size.Y), color ?? Color.White); + } + + /// Draw a block of text to the screen with the specified wrap width. + /// The sprite batch. + /// The sprite font. + /// The block of text to write. + /// The position at which to draw the text. + /// The width at which to wrap the text. + /// The text color. + /// Whether to draw bold text. + /// The font scale. + /// Returns the text dimensions. + public static Vector2 DrawTextBlock(this SpriteBatch batch, SpriteFont font, string text, in Vector2 position, float wrapWidth, in Color? color = null, bool bold = false, float scale = 1) + { + if (text == null) + return new Vector2(0, 0); + + // get word list + List words = new List(); + foreach (string word in text.Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries)) + { + // split on newlines + string wordPart = word; + int newlineIndex; + while ((newlineIndex = wordPart.IndexOf(Environment.NewLine, StringComparison.InvariantCulture)) >= 0) + { + if (newlineIndex == 0) + { + words.Add(Environment.NewLine); + wordPart = wordPart.Substring(Environment.NewLine.Length); + } + else if (newlineIndex > 0) + { + words.Add(wordPart.Substring(0, newlineIndex)); + words.Add(Environment.NewLine); + wordPart = wordPart.Substring(newlineIndex + Environment.NewLine.Length); + } + } + + // add remaining word (after newline split) + if (wordPart.Length > 0) + words.Add(wordPart); + } + + // track draw values + float xOffset = 0; + float yOffset = 0; + float lineHeight = font.MeasureString("ABC").Y * scale; + float spaceWidth = CommonHelper.GetSpaceWidth(font) * scale; + float blockWidth = 0; + float blockHeight = lineHeight; + foreach (string word in words) + { + // check wrap width + float wordWidth = font.MeasureString(word).X * scale; + if (word == Environment.NewLine || ((wordWidth + xOffset) > wrapWidth && (int)xOffset != 0)) + { + xOffset = 0; + yOffset += lineHeight; + blockHeight += lineHeight; + } + if (word == Environment.NewLine) + continue; + + // draw text + Vector2 wordPosition = new Vector2(position.X + xOffset, position.Y + yOffset); + if (bold) + Utility.drawBoldText(batch, word, font, wordPosition, color ?? Color.Black, scale); + else + batch.DrawString(font, word, wordPosition, color ?? Color.Black, 0, Vector2.Zero, scale, SpriteEffects.None, 1); + + // update draw values + if (xOffset + wordWidth > blockWidth) + blockWidth = xOffset + wordWidth; + xOffset += wordWidth + spaceWidth; + } + + // return text position & dimensions + return new Vector2(blockWidth, blockHeight); + } + + /**** + ** Error handling + ****/ + /// Intercept errors thrown by the action. + /// Encapsulates monitoring and logging. + /// The verb describing where the error occurred (e.g. "looking that up"). This is displayed on the screen, so it should be simple and avoid characters that might not be available in the sprite font. + /// The action to invoke. + /// A callback invoked if an error is intercepted. + public static void InterceptErrors(this IMonitor monitor, string verb, Action action, Action onError = null) + { + monitor.InterceptErrors(verb, null, action, onError); + } + + /// Intercept errors thrown by the action. + /// Encapsulates monitoring and logging. + /// The verb describing where the error occurred (e.g. "looking that up"). This is displayed on the screen, so it should be simple and avoid characters that might not be available in the sprite font. + /// A more detailed form of if applicable. This is displayed in the log, so it can be more technical and isn't constrained by the sprite font. + /// The action to invoke. + /// A callback invoked if an error is intercepted. + public static void InterceptErrors(this IMonitor monitor, string verb, string detailedVerb, Action action, Action onError = null) + { + try + { + action(); + } + catch (Exception ex) + { + monitor.InterceptError(ex, verb, detailedVerb); + onError?.Invoke(ex); + } + } + + /// Log an error and warn the user. + /// Encapsulates monitoring and logging. + /// The exception to handle. + /// The verb describing where the error occurred (e.g. "looking that up"). This is displayed on the screen, so it should be simple and avoid characters that might not be available in the sprite font. + /// A more detailed form of if applicable. This is displayed in the log, so it can be more technical and isn't constrained by the sprite font. + public static void InterceptError(this IMonitor monitor, Exception ex, string verb, string detailedVerb = null) + { + detailedVerb = detailedVerb ?? verb; + monitor.Log($"Something went wrong {detailedVerb}:\n{ex}", LogLevel.Error); + CommonHelper.ShowErrorMessage($"Huh. Something went wrong {verb}. The error log has the technical details."); + } + } +} diff --git a/Mods/ContentPatcher/Common/DataParsers/CropDataParser.cs b/Mods/ContentPatcher/Common/DataParsers/CropDataParser.cs new file mode 100644 index 00000000..a84f4226 --- /dev/null +++ b/Mods/ContentPatcher/Common/DataParsers/CropDataParser.cs @@ -0,0 +1,93 @@ +using System; +using System.Linq; +using StardewModdingAPI.Utilities; +using StardewValley; +using SObject = StardewValley.Object; + +namespace Pathoschild.Stardew.Common.DataParsers +{ + /// Analyses crop data for a tile. + internal class CropDataParser + { + /********* + ** Accessors + *********/ + /// The crop. + public Crop Crop { get; } + + /// The seasons in which the crop grows. + public string[] Seasons { get; } + + /// The phase index in when the crop can be harvested. + public int HarvestablePhase { get; } + + /// The number of days needed between planting and first harvest. + public int DaysToFirstHarvest { get; } + + /// The number of days needed between harvests, after the first harvest. + public int DaysToSubsequentHarvest { get; } + + /// Whether the crop can be harvested multiple times. + public bool HasMultipleHarvests { get; } + + /// Whether the crop is ready to harvest now. + public bool CanHarvestNow { get; } + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The crop. + public CropDataParser(Crop crop) + { + this.Crop = crop; + if (crop != null) + { + this.Seasons = crop.seasonsToGrowIn.ToArray(); + this.HasMultipleHarvests = crop.regrowAfterHarvest.Value == -1; + this.HarvestablePhase = crop.phaseDays.Count - 1; + this.CanHarvestNow = (crop.currentPhase.Value >= this.HarvestablePhase) && (!crop.fullyGrown.Value || crop.dayOfCurrentPhase.Value <= 0); + this.DaysToFirstHarvest = crop.phaseDays.Take(crop.phaseDays.Count - 1).Sum(); // ignore harvestable phase + this.DaysToSubsequentHarvest = crop.regrowAfterHarvest.Value; + } + } + + /// Get the date when the crop will next be ready to harvest. + public SDate GetNextHarvest() + { + // get crop + Crop crop = this.Crop; + if (crop == null) + throw new InvalidOperationException("Can't get the harvest date because there's no crop."); + + // ready now + if (this.CanHarvestNow) + return SDate.Now(); + + // growing: days until next harvest + if (!crop.fullyGrown.Value) + { + int daysUntilLastPhase = this.DaysToFirstHarvest - this.Crop.dayOfCurrentPhase.Value - crop.phaseDays.Take(crop.currentPhase.Value).Sum(); + return SDate.Now().AddDays(daysUntilLastPhase); + } + + // regrowable crop harvested today + if (crop.dayOfCurrentPhase.Value >= crop.regrowAfterHarvest.Value) + return SDate.Now().AddDays(crop.regrowAfterHarvest.Value); + + // regrowable crop + // dayOfCurrentPhase decreases to 0 when fully grown, where <=0 is harvestable + return SDate.Now().AddDays(crop.dayOfCurrentPhase.Value); + } + + /// Get a sample item acquired by harvesting the crop. + public Item GetSampleDrop() + { + if (this.Crop == null) + throw new InvalidOperationException("Can't get a sample drop because there's no crop."); + + return new SObject(this.Crop.indexOfHarvest.Value, 1); + } + } +} diff --git a/Mods/ContentPatcher/Common/Integrations/Automate/AutomateIntegration.cs b/Mods/ContentPatcher/Common/Integrations/Automate/AutomateIntegration.cs new file mode 100644 index 00000000..9ee33a2d --- /dev/null +++ b/Mods/ContentPatcher/Common/Integrations/Automate/AutomateIntegration.cs @@ -0,0 +1,44 @@ +using System.Collections.Generic; +using Microsoft.Xna.Framework; +using StardewModdingAPI; +using StardewValley; + +namespace Pathoschild.Stardew.Common.Integrations.Automate +{ + /// Handles the logic for integrating with the Automate mod. + internal class AutomateIntegration : BaseIntegration + { + /********* + ** Fields + *********/ + /// The mod's public API. + private readonly IAutomateApi ModApi; + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// An API for fetching metadata about loaded mods. + /// Encapsulates monitoring and logging. + public AutomateIntegration(IModRegistry modRegistry, IMonitor monitor) + : base("Automate", "Pathoschild.Automate", "1.11.0", modRegistry, monitor) + { + if (!this.IsLoaded) + return; + + // get mod API + this.ModApi = this.GetValidatedApi(); + this.IsLoaded = this.ModApi != null; + } + + /// Get the status of machines in a tile area. This is a specialised API for Data Layers and similar mods. + /// The location for which to display data. + /// The tile area for which to display data. + public IDictionary GetMachineStates(GameLocation location, Rectangle tileArea) + { + this.AssertLoaded(); + return this.ModApi.GetMachineStates(location, tileArea); + } + } +} diff --git a/Mods/ContentPatcher/Common/Integrations/Automate/IAutomateApi.cs b/Mods/ContentPatcher/Common/Integrations/Automate/IAutomateApi.cs new file mode 100644 index 00000000..15801325 --- /dev/null +++ b/Mods/ContentPatcher/Common/Integrations/Automate/IAutomateApi.cs @@ -0,0 +1,15 @@ +using System.Collections.Generic; +using Microsoft.Xna.Framework; +using StardewValley; + +namespace Pathoschild.Stardew.Common.Integrations.Automate +{ + /// The API provided by the Automate mod. + public interface IAutomateApi + { + /// Get the status of machines in a tile area. This is a specialised API for Data Layers and similar mods. + /// The location for which to display data. + /// The tile area for which to display data. + IDictionary GetMachineStates(GameLocation location, Rectangle tileArea); + } +} diff --git a/Mods/ContentPatcher/Common/Integrations/BaseIntegration.cs b/Mods/ContentPatcher/Common/Integrations/BaseIntegration.cs new file mode 100644 index 00000000..13898dbc --- /dev/null +++ b/Mods/ContentPatcher/Common/Integrations/BaseIntegration.cs @@ -0,0 +1,82 @@ +using System; +using StardewModdingAPI; + +namespace Pathoschild.Stardew.Common.Integrations +{ + /// The base implementation for a mod integration. + internal abstract class BaseIntegration : IModIntegration + { + /********* + ** Fields + *********/ + /// The mod's unique ID. + protected string ModID { get; } + + /// An API for fetching metadata about loaded mods. + protected IModRegistry ModRegistry { get; } + + /// Encapsulates monitoring and logging. + protected IMonitor Monitor { get; } + + + /********* + ** Accessors + *********/ + /// A human-readable name for the mod. + public string Label { get; } + + /// Whether the mod is available. + public bool IsLoaded { get; protected set; } + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// A human-readable name for the mod. + /// The mod's unique ID. + /// The minimum version of the mod that's supported. + /// An API for fetching metadata about loaded mods. + /// Encapsulates monitoring and logging. + protected BaseIntegration(string label, string modID, string minVersion, IModRegistry modRegistry, IMonitor monitor) + { + // init + this.Label = label; + this.ModID = modID; + this.ModRegistry = modRegistry; + this.Monitor = monitor; + + // validate mod + IManifest manifest = modRegistry.Get(this.ModID)?.Manifest; + if (manifest == null) + return; + if (manifest.Version.IsOlderThan(minVersion)) + { + monitor.Log($"Detected {label} {manifest.Version}, but need {minVersion} or later. Disabled integration with this mod.", LogLevel.Warn); + return; + } + this.IsLoaded = true; + } + + /// Get an API for the mod, and show a message if it can't be loaded. + /// The API type. + protected TInterface GetValidatedApi() where TInterface : class + { + TInterface api = this.ModRegistry.GetApi(this.ModID); + if (api == null) + { + this.Monitor.Log($"Detected {this.Label}, but couldn't fetch its API. Disabled integration with this mod.", LogLevel.Warn); + return null; + } + return api; + } + + /// Assert that the integration is loaded. + /// The integration isn't loaded. + protected void AssertLoaded() + { + if (!this.IsLoaded) + throw new InvalidOperationException($"The {this.Label} integration isn't loaded."); + } + } +} diff --git a/Mods/ContentPatcher/Common/Integrations/BetterJunimos/BetterJunimosIntegration.cs b/Mods/ContentPatcher/Common/Integrations/BetterJunimos/BetterJunimosIntegration.cs new file mode 100644 index 00000000..6c649fca --- /dev/null +++ b/Mods/ContentPatcher/Common/Integrations/BetterJunimos/BetterJunimosIntegration.cs @@ -0,0 +1,40 @@ +using StardewModdingAPI; + +namespace Pathoschild.Stardew.Common.Integrations.BetterJunimos +{ + /// Handles the logic for integrating with the Better Junimos mod. + internal class BetterJunimosIntegration : BaseIntegration + { + /********* + ** Fields + *********/ + /// The mod's public API. + private readonly IBetterJunimosApi ModApi; + + + /********* + ** Accessors + *********/ + /// The Junimo Hut coverage radius. + public int MaxRadius { get; } + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// An API for fetching metadata about loaded mods. + /// Encapsulates monitoring and logging. + public BetterJunimosIntegration(IModRegistry modRegistry, IMonitor monitor) + : base("Better Junimos", "hawkfalcon.BetterJunimos", "0.5.0", modRegistry, monitor) + { + if (!this.IsLoaded) + return; + + // get mod API + this.ModApi = this.GetValidatedApi(); + this.IsLoaded = this.ModApi != null; + this.MaxRadius = this.ModApi?.GetJunimoHutMaxRadius() ?? 0; + } + } +} diff --git a/Mods/ContentPatcher/Common/Integrations/BetterJunimos/IBetterJunimosApi.cs b/Mods/ContentPatcher/Common/Integrations/BetterJunimos/IBetterJunimosApi.cs new file mode 100644 index 00000000..6081e89b --- /dev/null +++ b/Mods/ContentPatcher/Common/Integrations/BetterJunimos/IBetterJunimosApi.cs @@ -0,0 +1,9 @@ +namespace Pathoschild.Stardew.Common.Integrations.BetterJunimos +{ + /// The API provided by the Better Junimos mod. + public interface IBetterJunimosApi + { + /// Get the maximum radius for Junimo Huts. + int GetJunimoHutMaxRadius(); + } +} diff --git a/Mods/ContentPatcher/Common/Integrations/BetterSprinklers/BetterSprinklersIntegration.cs b/Mods/ContentPatcher/Common/Integrations/BetterSprinklers/BetterSprinklersIntegration.cs new file mode 100644 index 00000000..f7f48248 --- /dev/null +++ b/Mods/ContentPatcher/Common/Integrations/BetterSprinklers/BetterSprinklersIntegration.cs @@ -0,0 +1,49 @@ +using System.Collections.Generic; +using Microsoft.Xna.Framework; +using StardewModdingAPI; + +namespace Pathoschild.Stardew.Common.Integrations.BetterSprinklers +{ + /// Handles the logic for integrating with the Better Sprinklers mod. + internal class BetterSprinklersIntegration : BaseIntegration + { + /********* + ** Fields + *********/ + /// The mod's public API. + private readonly IBetterSprinklersApi ModApi; + + + /********* + ** Accessors + *********/ + /// The maximum possible sprinkler radius. + public int MaxRadius { get; } + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// An API for fetching metadata about loaded mods. + /// Encapsulates monitoring and logging. + public BetterSprinklersIntegration(IModRegistry modRegistry, IMonitor monitor) + : base("Better Sprinklers", "Speeder.BetterSprinklers", "2.3.1-unofficial.6-pathoschild", modRegistry, monitor) + { + if (!this.IsLoaded) + return; + + // get mod API + this.ModApi = this.GetValidatedApi(); + this.IsLoaded = this.ModApi != null; + this.MaxRadius = this.ModApi?.GetMaxGridSize() ?? 0; + } + + /// Get the configured Sprinkler tiles relative to (0, 0). + public IDictionary GetSprinklerTiles() + { + this.AssertLoaded(); + return this.ModApi.GetSprinklerCoverage(); + } + } +} diff --git a/Mods/ContentPatcher/Common/Integrations/BetterSprinklers/IBetterSprinklersApi.cs b/Mods/ContentPatcher/Common/Integrations/BetterSprinklers/IBetterSprinklersApi.cs new file mode 100644 index 00000000..c213f02e --- /dev/null +++ b/Mods/ContentPatcher/Common/Integrations/BetterSprinklers/IBetterSprinklersApi.cs @@ -0,0 +1,15 @@ +using System.Collections.Generic; +using Microsoft.Xna.Framework; + +namespace Pathoschild.Stardew.Common.Integrations.BetterSprinklers +{ + /// The API provided by the Better Sprinklers mod. + public interface IBetterSprinklersApi + { + /// Get the maximum supported coverage width or height. + int GetMaxGridSize(); + + /// Get the relative tile coverage by supported sprinkler ID. + IDictionary GetSprinklerCoverage(); + } +} diff --git a/Mods/ContentPatcher/Common/Integrations/Cobalt/CobaltIntegration.cs b/Mods/ContentPatcher/Common/Integrations/Cobalt/CobaltIntegration.cs new file mode 100644 index 00000000..4cb7c36d --- /dev/null +++ b/Mods/ContentPatcher/Common/Integrations/Cobalt/CobaltIntegration.cs @@ -0,0 +1,48 @@ +using System.Collections.Generic; +using Microsoft.Xna.Framework; +using StardewModdingAPI; + +namespace Pathoschild.Stardew.Common.Integrations.Cobalt +{ + /// Handles the logic for integrating with the Cobalt mod. + internal class CobaltIntegration : BaseIntegration + { + /********* + ** Fields + *********/ + /// The mod's public API. + private readonly ICobaltApi ModApi; + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// An API for fetching metadata about loaded mods. + /// Encapsulates monitoring and logging. + public CobaltIntegration(IModRegistry modRegistry, IMonitor monitor) + : base("Cobalt", "spacechase0.Cobalt", "1.1", modRegistry, monitor) + { + if (!this.IsLoaded) + return; + + // get mod API + this.ModApi = this.GetValidatedApi(); + this.IsLoaded = this.ModApi != null; + } + + /// Get the cobalt sprinkler's object ID. + public int GetSprinklerId() + { + this.AssertLoaded(); + return this.ModApi.GetSprinklerId(); + } + + /// Get the configured Sprinkler tiles relative to (0, 0). + public IEnumerable GetSprinklerTiles() + { + this.AssertLoaded(); + return this.ModApi.GetSprinklerCoverage(Vector2.Zero); + } + } +} diff --git a/Mods/ContentPatcher/Common/Integrations/Cobalt/ICobaltApi.cs b/Mods/ContentPatcher/Common/Integrations/Cobalt/ICobaltApi.cs new file mode 100644 index 00000000..4952043f --- /dev/null +++ b/Mods/ContentPatcher/Common/Integrations/Cobalt/ICobaltApi.cs @@ -0,0 +1,19 @@ +using System.Collections.Generic; +using Microsoft.Xna.Framework; + +namespace Pathoschild.Stardew.Common.Integrations.Cobalt +{ + /// The API provided by the Cobalt mod. + public interface ICobaltApi + { + /********* + ** Public methods + *********/ + /// Get the cobalt sprinkler's object ID. + int GetSprinklerId(); + + /// Get the cobalt sprinkler coverage. + /// The tile position containing the sprinkler. + IEnumerable GetSprinklerCoverage(Vector2 origin); + } +} diff --git a/Mods/ContentPatcher/Common/Integrations/CustomFarmingRedux/CustomFarmingReduxIntegration.cs b/Mods/ContentPatcher/Common/Integrations/CustomFarmingRedux/CustomFarmingReduxIntegration.cs new file mode 100644 index 00000000..277c95c6 --- /dev/null +++ b/Mods/ContentPatcher/Common/Integrations/CustomFarmingRedux/CustomFarmingReduxIntegration.cs @@ -0,0 +1,49 @@ +using System; +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Graphics; +using StardewModdingAPI; +using StardewValley; +using SObject = StardewValley.Object; + +namespace Pathoschild.Stardew.Common.Integrations.CustomFarmingRedux +{ + /// Handles the logic for integrating with the Custom Farming Redux mod. + internal class CustomFarmingReduxIntegration : BaseIntegration + { + /********* + ** Fields + *********/ + /// The mod's public API. + private readonly ICustomFarmingApi ModApi; + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// An API for fetching metadata about loaded mods. + /// Encapsulates monitoring and logging. + public CustomFarmingReduxIntegration(IModRegistry modRegistry, IMonitor monitor) + : base("Custom Farming Redux", "Platonymous.CustomFarming", "2.8.5", modRegistry, monitor) + { + if (!this.IsLoaded) + return; + + // get mod API + this.ModApi = this.GetValidatedApi(); + this.IsLoaded = this.ModApi != null; + } + + /// Get the sprite info for a custom object, or null if the object isn't custom. + /// The custom object. + public SpriteInfo GetSprite(SObject obj) + { + this.AssertLoaded(); + + Tuple data = this.ModApi.getRealItemAndTexture(obj); + return data != null + ? new SpriteInfo(data.Item2, data.Item3) + : null; + } + } +} diff --git a/Mods/ContentPatcher/Common/Integrations/CustomFarmingRedux/ICustomFarmingApi.cs b/Mods/ContentPatcher/Common/Integrations/CustomFarmingRedux/ICustomFarmingApi.cs new file mode 100644 index 00000000..14b80ffb --- /dev/null +++ b/Mods/ContentPatcher/Common/Integrations/CustomFarmingRedux/ICustomFarmingApi.cs @@ -0,0 +1,20 @@ +using System; +using System.Diagnostics.CodeAnalysis; +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Graphics; +using StardewValley; + +namespace Pathoschild.Stardew.Common.Integrations.CustomFarmingRedux +{ + /// The API provided by the Custom Farming Redux mod. + [SuppressMessage("ReSharper", "InconsistentNaming", Justification = "The naming convention is defined by the Custom Farming Redux mod.")] + public interface ICustomFarmingApi + { + /********* + ** Public methods + *********/ + /// Get metadata for a custom machine and draw metadata for an object. + /// The item that would be replaced by the custom item. + Tuple getRealItemAndTexture(StardewValley.Object dummy); + } +} diff --git a/Mods/ContentPatcher/Common/Integrations/FarmExpansion/FarmExpansionIntegration.cs b/Mods/ContentPatcher/Common/Integrations/FarmExpansion/FarmExpansionIntegration.cs new file mode 100644 index 00000000..a41135e5 --- /dev/null +++ b/Mods/ContentPatcher/Common/Integrations/FarmExpansion/FarmExpansionIntegration.cs @@ -0,0 +1,49 @@ +using StardewModdingAPI; +using StardewValley; + +namespace Pathoschild.Stardew.Common.Integrations.FarmExpansion +{ + /// Handles the logic for integrating with the Farm Expansion mod. + internal class FarmExpansionIntegration : BaseIntegration + { + /********* + ** Fields + *********/ + /// The mod's public API. + private readonly IFarmExpansionApi ModApi; + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// An API for fetching metadata about loaded mods. + /// Encapsulates monitoring and logging. + public FarmExpansionIntegration(IModRegistry modRegistry, IMonitor monitor) + : base("Farm Expansion", "Advize.FarmExpansion", "3.3", modRegistry, monitor) + { + if (!this.IsLoaded) + return; + + // get mod API + this.ModApi = this.GetValidatedApi(); + this.IsLoaded = this.ModApi != null; + } + + /// Add a blueprint to all future carpenter menus for the farm area. + /// The blueprint to add. + public void AddFarmBluePrint(BluePrint blueprint) + { + this.AssertLoaded(); + this.ModApi.AddFarmBluePrint(blueprint); + } + + /// Add a blueprint to all future carpenter menus for the expansion area. + /// The blueprint to add. + public void AddExpansionBluePrint(BluePrint blueprint) + { + this.AssertLoaded(); + this.ModApi.AddExpansionBluePrint(blueprint); + } + } +} diff --git a/Mods/ContentPatcher/Common/Integrations/FarmExpansion/IFarmExpansionApi.cs b/Mods/ContentPatcher/Common/Integrations/FarmExpansion/IFarmExpansionApi.cs new file mode 100644 index 00000000..2c4d92a1 --- /dev/null +++ b/Mods/ContentPatcher/Common/Integrations/FarmExpansion/IFarmExpansionApi.cs @@ -0,0 +1,16 @@ +using StardewValley; + +namespace Pathoschild.Stardew.Common.Integrations.FarmExpansion +{ + /// The API provided by the Farm Expansion mod. + public interface IFarmExpansionApi + { + /// Add a blueprint to all future carpenter menus for the farm area. + /// The blueprint to add. + void AddFarmBluePrint(BluePrint blueprint); + + /// Add a blueprint to all future carpenter menus for the expansion area. + /// The blueprint to add. + void AddExpansionBluePrint(BluePrint blueprint); + } +} diff --git a/Mods/ContentPatcher/Common/Integrations/IModIntegration.cs b/Mods/ContentPatcher/Common/Integrations/IModIntegration.cs new file mode 100644 index 00000000..17327ed8 --- /dev/null +++ b/Mods/ContentPatcher/Common/Integrations/IModIntegration.cs @@ -0,0 +1,15 @@ +namespace Pathoschild.Stardew.Common.Integrations +{ + /// Handles integration with a given mod. + internal interface IModIntegration + { + /********* + ** Accessors + *********/ + /// A human-readable name for the mod. + string Label { get; } + + /// Whether the mod is available. + bool IsLoaded { get; } + } +} diff --git a/Mods/ContentPatcher/Common/Integrations/LineSprinklers/ILineSprinklersApi.cs b/Mods/ContentPatcher/Common/Integrations/LineSprinklers/ILineSprinklersApi.cs new file mode 100644 index 00000000..a945c8c3 --- /dev/null +++ b/Mods/ContentPatcher/Common/Integrations/LineSprinklers/ILineSprinklersApi.cs @@ -0,0 +1,15 @@ +using System.Collections.Generic; +using Microsoft.Xna.Framework; + +namespace Pathoschild.Stardew.Common.Integrations.LineSprinklers +{ + /// The API provided by the Line Sprinklers mod. + public interface ILineSprinklersApi + { + /// Get the maximum supported coverage width or height. + int GetMaxGridSize(); + + /// Get the relative tile coverage by supported sprinkler ID. + IDictionary GetSprinklerCoverage(); + } +} diff --git a/Mods/ContentPatcher/Common/Integrations/LineSprinklers/LineSprinklersIntegration.cs b/Mods/ContentPatcher/Common/Integrations/LineSprinklers/LineSprinklersIntegration.cs new file mode 100644 index 00000000..d5aa4fce --- /dev/null +++ b/Mods/ContentPatcher/Common/Integrations/LineSprinklers/LineSprinklersIntegration.cs @@ -0,0 +1,49 @@ +using System.Collections.Generic; +using Microsoft.Xna.Framework; +using StardewModdingAPI; + +namespace Pathoschild.Stardew.Common.Integrations.LineSprinklers +{ + /// Handles the logic for integrating with the Line Sprinklers mod. + internal class LineSprinklersIntegration : BaseIntegration + { + /********* + ** Fields + *********/ + /// The mod's public API. + private readonly ILineSprinklersApi ModApi; + + + /********* + ** Accessors + *********/ + /// The maximum possible sprinkler radius. + public int MaxRadius { get; } + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// An API for fetching metadata about loaded mods. + /// Encapsulates monitoring and logging. + public LineSprinklersIntegration(IModRegistry modRegistry, IMonitor monitor) + : base("Line Sprinklers", "hootless.LineSprinklers", "1.1.0", modRegistry, monitor) + { + if (!this.IsLoaded) + return; + + // get mod API + this.ModApi = this.GetValidatedApi(); + this.IsLoaded = this.ModApi != null; + this.MaxRadius = this.ModApi?.GetMaxGridSize() ?? 0; + } + + /// Get the configured Sprinkler tiles relative to (0, 0). + public IDictionary GetSprinklerTiles() + { + this.AssertLoaded(); + return this.ModApi.GetSprinklerCoverage(); + } + } +} diff --git a/Mods/ContentPatcher/Common/Integrations/PelicanFiber/PelicanFiberIntegration.cs b/Mods/ContentPatcher/Common/Integrations/PelicanFiber/PelicanFiberIntegration.cs new file mode 100644 index 00000000..f90cfb74 --- /dev/null +++ b/Mods/ContentPatcher/Common/Integrations/PelicanFiber/PelicanFiberIntegration.cs @@ -0,0 +1,49 @@ +using StardewModdingAPI; +using StardewValley; + +namespace Pathoschild.Stardew.Common.Integrations.PelicanFiber +{ + /// Handles the logic for integrating with the Pelican Fiber mod. + internal class PelicanFiberIntegration : BaseIntegration + { + /********* + ** Fields + *********/ + /// The full type name of the Pelican Fiber mod's build menu. + private readonly string MenuTypeName = "PelicanFiber.Framework.ConstructionMenu"; + + /// An API for accessing private code. + private readonly IReflectionHelper Reflection; + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// An API for fetching metadata about loaded mods. + /// An API for accessing private code. + /// Encapsulates monitoring and logging. + public PelicanFiberIntegration(IModRegistry modRegistry, IReflectionHelper reflection, IMonitor monitor) + : base("Pelican Fiber", "jwdred.PelicanFiber", "3.0.2", modRegistry, monitor) + { + this.Reflection = reflection; + } + + /// Get whether the Pelican Fiber build menu is open. + public bool IsBuildMenuOpen() + { + this.AssertLoaded(); + return Game1.activeClickableMenu?.GetType().FullName == this.MenuTypeName; + } + + /// Get the selected blueprint from the Pelican Fiber build menu, if it's open. + public BluePrint GetBuildMenuBlueprint() + { + this.AssertLoaded(); + if (!this.IsBuildMenuOpen()) + return null; + + return this.Reflection.GetProperty(Game1.activeClickableMenu, "CurrentBlueprint").GetValue(); + } + } +} diff --git a/Mods/ContentPatcher/Common/Integrations/PrismaticTools/IPrismaticToolsApi.cs b/Mods/ContentPatcher/Common/Integrations/PrismaticTools/IPrismaticToolsApi.cs new file mode 100644 index 00000000..b2a61ed3 --- /dev/null +++ b/Mods/ContentPatcher/Common/Integrations/PrismaticTools/IPrismaticToolsApi.cs @@ -0,0 +1,19 @@ +using System.Collections.Generic; +using Microsoft.Xna.Framework; + +namespace Pathoschild.Stardew.Common.Integrations.PrismaticTools +{ + /// The API provided by the Prismatic Tools mod. + public interface IPrismaticToolsApi + { + /// Whether prismatic sprinklers also act as scarecrows. + bool ArePrismaticSprinklersScarecrows { get; } + + /// The prismatic sprinkler object ID. + int SprinklerIndex { get; } + + /// Get the relative tile coverage for a prismatic sprinkler. + /// The sprinkler tile. + IEnumerable GetSprinklerCoverage(Vector2 origin); + } +} diff --git a/Mods/ContentPatcher/Common/Integrations/PrismaticTools/PrismaticToolsIntegration.cs b/Mods/ContentPatcher/Common/Integrations/PrismaticTools/PrismaticToolsIntegration.cs new file mode 100644 index 00000000..b35e6f35 --- /dev/null +++ b/Mods/ContentPatcher/Common/Integrations/PrismaticTools/PrismaticToolsIntegration.cs @@ -0,0 +1,55 @@ +using System.Collections.Generic; +using Microsoft.Xna.Framework; +using StardewModdingAPI; + +namespace Pathoschild.Stardew.Common.Integrations.PrismaticTools +{ + /// Handles the logic for integrating with the Prismatic Tools mod. + internal class PrismaticToolsIntegration : BaseIntegration + { + /********* + ** Fields + *********/ + /// The mod's public API. + private readonly IPrismaticToolsApi ModApi; + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// An API for fetching metadata about loaded mods. + /// Encapsulates monitoring and logging. + public PrismaticToolsIntegration(IModRegistry modRegistry, IMonitor monitor) + : base("Prismatic Tools", "stokastic.PrismaticTools", "1.3.0", modRegistry, monitor) + { + if (!this.IsLoaded) + return; + + // get mod API + this.ModApi = this.GetValidatedApi(); + this.IsLoaded = this.ModApi != null; + } + + /// Get whether prismatic sprinklers also act as scarecrows. + public bool ArePrismaticSprinklersScarecrows() + { + this.AssertLoaded(); + return this.ModApi.ArePrismaticSprinklersScarecrows; + } + + /// Get the prismatic sprinkler object ID. + public int GetSprinklerID() + { + this.AssertLoaded(); + return this.ModApi.SprinklerIndex; + } + + /// Get the relative tile coverage for a prismatic sprinkler. + public IEnumerable GetSprinklerCoverage() + { + this.AssertLoaded(); + return this.ModApi.GetSprinklerCoverage(Vector2.Zero); + } + } +} diff --git a/Mods/ContentPatcher/Common/Integrations/SimpleSprinkler/ISimplerSprinklerApi.cs b/Mods/ContentPatcher/Common/Integrations/SimpleSprinkler/ISimplerSprinklerApi.cs new file mode 100644 index 00000000..68d8e05a --- /dev/null +++ b/Mods/ContentPatcher/Common/Integrations/SimpleSprinkler/ISimplerSprinklerApi.cs @@ -0,0 +1,12 @@ +using System.Collections.Generic; +using Microsoft.Xna.Framework; + +namespace Pathoschild.Stardew.Common.Integrations.SimpleSprinkler +{ + /// The API provided by the Simple Sprinkler mod. + public interface ISimplerSprinklerApi + { + /// Get the relative tile coverage for supported sprinkler IDs (additive to the game's default coverage). + IDictionary GetNewSprinklerCoverage(); + } +} diff --git a/Mods/ContentPatcher/Common/Integrations/SimpleSprinkler/SimpleSprinklerIntegration.cs b/Mods/ContentPatcher/Common/Integrations/SimpleSprinkler/SimpleSprinklerIntegration.cs new file mode 100644 index 00000000..ef21dd31 --- /dev/null +++ b/Mods/ContentPatcher/Common/Integrations/SimpleSprinkler/SimpleSprinklerIntegration.cs @@ -0,0 +1,41 @@ +using System.Collections.Generic; +using Microsoft.Xna.Framework; +using StardewModdingAPI; + +namespace Pathoschild.Stardew.Common.Integrations.SimpleSprinkler +{ + /// Handles the logic for integrating with the Simple Sprinkler mod. + internal class SimpleSprinklerIntegration : BaseIntegration + { + /********* + ** Fields + *********/ + /// The mod's public API. + private readonly ISimplerSprinklerApi ModApi; + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// An API for fetching metadata about loaded mods. + /// Encapsulates monitoring and logging. + public SimpleSprinklerIntegration(IModRegistry modRegistry, IMonitor monitor) + : base("Simple Sprinklers", "tZed.SimpleSprinkler", "1.6.0", modRegistry, monitor) + { + if (!this.IsLoaded) + return; + + // get mod API + this.ModApi = this.GetValidatedApi(); + this.IsLoaded = this.ModApi != null; + } + + /// Get the Sprinkler tiles relative to (0, 0), additive to the game's default sprinkler coverage. + public IDictionary GetNewSprinklerTiles() + { + this.AssertLoaded(); + return this.ModApi.GetNewSprinklerCoverage(); + } + } +} diff --git a/Mods/ContentPatcher/Common/PathUtilities.cs b/Mods/ContentPatcher/Common/PathUtilities.cs new file mode 100644 index 00000000..40b174f0 --- /dev/null +++ b/Mods/ContentPatcher/Common/PathUtilities.cs @@ -0,0 +1,86 @@ +using System; +using System.Diagnostics.Contracts; +using System.IO; +using System.Linq; +using System.Text.RegularExpressions; + +namespace Pathoschild.Stardew.Common +{ + /// Provides utilities for normalising file paths. + /// This class is duplicated from StardewModdingAPI.Toolkit.Utilities. + internal static class PathUtilities + { + /********* + ** Fields + *********/ + /// The possible directory separator characters in a file path. + private static readonly char[] PossiblePathSeparators = new[] { '/', '\\', Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar }.Distinct().ToArray(); + + /// The preferred directory separator chaeacter in an asset key. + private static readonly string PreferredPathSeparator = Path.DirectorySeparatorChar.ToString(); + + + /********* + ** Public methods + *********/ + /// Get the segments from a path (e.g. /usr/bin/boop => usr, bin, and boop). + /// The path to split. + /// The number of segments to match. Any additional segments will be merged into the last returned part. + public static string[] GetSegments(string path, int? limit = null) + { + return limit.HasValue + ? path.Split(PathUtilities.PossiblePathSeparators, limit.Value, StringSplitOptions.RemoveEmptyEntries) + : path.Split(PathUtilities.PossiblePathSeparators, StringSplitOptions.RemoveEmptyEntries); + } + + /// Normalise path separators in a file path. + /// The file path to normalise. + [Pure] + public static string NormalisePathSeparators(string path) + { + string[] parts = PathUtilities.GetSegments(path); + string normalised = string.Join(PathUtilities.PreferredPathSeparator, parts); + if (path.StartsWith(PathUtilities.PreferredPathSeparator)) + normalised = PathUtilities.PreferredPathSeparator + normalised; // keep root slash + return normalised; + } + + /// Get a directory or file path relative to a given source path. + /// The source folder path. + /// The target folder or file path. + [Pure] + public static string GetRelativePath(string sourceDir, string targetPath) + { + // convert to URIs + Uri from = new Uri(sourceDir.TrimEnd(PathUtilities.PossiblePathSeparators) + "/"); + Uri to = new Uri(targetPath.TrimEnd(PathUtilities.PossiblePathSeparators) + "/"); + if (from.Scheme != to.Scheme) + throw new InvalidOperationException($"Can't get path for '{targetPath}' relative to '{sourceDir}'."); + + // get relative path + string relative = PathUtilities.NormalisePathSeparators(Uri.UnescapeDataString(from.MakeRelativeUri(to).ToString())); + if (relative == "") + relative = "./"; + return relative; + } + + /// Get whether a path is relative and doesn't try to climb out of its containing folder (e.g. doesn't contain ../). + /// The path to check. + public static bool IsSafeRelativePath(string path) + { + if (string.IsNullOrWhiteSpace(path)) + return true; + + return + !Path.IsPathRooted(path) + && PathUtilities.GetSegments(path).All(segment => segment.Trim() != ".."); + } + + /// Get whether a string is a valid 'slug', containing only basic characters that are safe in all contexts (e.g. filenames, URLs, etc). + /// The string to check. + public static bool IsSlug(string str) + { + return !Regex.IsMatch(str, "[^a-z0-9_.-]", RegexOptions.IgnoreCase); + } + } +} diff --git a/Mods/ContentPatcher/Common/SpriteInfo.cs b/Mods/ContentPatcher/Common/SpriteInfo.cs new file mode 100644 index 00000000..b7c3be5e --- /dev/null +++ b/Mods/ContentPatcher/Common/SpriteInfo.cs @@ -0,0 +1,31 @@ +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Graphics; + +namespace Pathoschild.Stardew.Common +{ + /// Represents a single sprite in a spritesheet. + internal class SpriteInfo + { + /********* + ** Accessors + *********/ + /// The spritesheet texture. + public Texture2D Spritesheet { get; } + + /// The area in the spritesheet containing the sprite. + public Rectangle SourceRectangle { get; } + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The spritesheet texture. + /// The area in the spritesheet containing the sprite. + public SpriteInfo(Texture2D spritesheet, Rectangle sourceRectangle) + { + this.Spritesheet = spritesheet; + this.SourceRectangle = sourceRectangle; + } + } +} diff --git a/Mods/ContentPatcher/Common/StringEnumArrayConverter.cs b/Mods/ContentPatcher/Common/StringEnumArrayConverter.cs new file mode 100644 index 00000000..29e78167 --- /dev/null +++ b/Mods/ContentPatcher/Common/StringEnumArrayConverter.cs @@ -0,0 +1,153 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Linq; +using Newtonsoft.Json; +using Newtonsoft.Json.Converters; +using Newtonsoft.Json.Linq; + +namespace Pathoschild.Stardew.Common +{ + /// A variant of which represents arrays in JSON as a comma-delimited string. + internal class StringEnumArrayConverter : StringEnumConverter + { + /********* + ** Fields + *********/ + /// Whether to return null values for missing data instead of an empty array. + public bool AllowNull { get; set; } + + + /********* + ** Public methods + *********/ + /// Get whether this instance can convert the specified object type. + /// The object type. + public override bool CanConvert(Type type) + { + if (!type.IsArray) + return false; + + Type elementType = this.GetElementType(type); + return elementType != null && base.CanConvert(elementType); + } + + /// Read a JSON representation. + /// The JSON reader from which to read. + /// The value type. + /// The raw value of the object being read. + /// The calling serializer. + public override object ReadJson(JsonReader reader, Type valueType, object rawValue, JsonSerializer serializer) + { + // get element type + Type elementType = this.GetElementType(valueType); + if (elementType == null) + throw new InvalidOperationException("Couldn't extract enum array element type."); // should never happen since we validate in CanConvert + + // parse + switch (reader.TokenType) + { + case JsonToken.Null: + return this.GetNullOrEmptyArray(elementType); + + case JsonToken.StartArray: + { + string[] elements = JArray.Load(reader).Values().ToArray(); + object[] parsed = elements.Select(raw => this.ParseOne(raw, elementType)).ToArray(); + return this.Cast(parsed, elementType); + } + + case JsonToken.String: + { + string value = (string)JToken.Load(reader); + + if (string.IsNullOrWhiteSpace(value)) + return this.GetNullOrEmptyArray(elementType); + + object[] parsed = this.ParseMany(value, elementType).ToArray(); + return this.Cast(parsed, elementType); + } + + default: + return base.ReadJson(reader, valueType, rawValue, serializer); + } + } + + /// Write a JSON representation. + /// The JSON writer to which to write. + /// The value. + /// The calling serializer. + public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer) + { + if (value == null) + writer.WriteNull(); + else if (value is IEnumerable list) + { + string[] array = (from object element in list where element != null select element.ToString()).ToArray(); + writer.WriteValue(string.Join(", ", array)); + } + else + base.WriteJson(writer, value, serializer); + } + + + /********* + ** Private methods + *********/ + /// Get the underlying array element type (bypassing if necessary). + /// The array type. + private Type GetElementType(Type type) + { + if (!type.IsArray) + return null; + + type = type.GetElementType(); + if (type == null) + return null; + + type = Nullable.GetUnderlyingType(type) ?? type; + + return type; + } + + /// Parse a string into individual values. + /// The input string. + /// The enum type. + private IEnumerable ParseMany(string input, Type elementType) + { + string[] values = input.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries); + foreach (string value in values) + yield return this.ParseOne(value, elementType); + } + + /// Parse a string into one value. + /// The input string. + /// The enum type. + private object ParseOne(string input, Type elementType) + { + return Enum.Parse(elementType, input, ignoreCase: true); + } + + /// Get null or an empty array, depending on the value of . + /// The enum type. + private Array GetNullOrEmptyArray(Type elementType) + { + return this.AllowNull + ? null + : Array.CreateInstance(elementType, 0); + } + + /// Create an array of elements with the given type. + /// The array elements. + /// The array element type. + private Array Cast(object[] elements, Type elementType) + { + if (elements == null) + return null; + + Array result = Array.CreateInstance(elementType, elements.Length); + Array.Copy(elements, result, result.Length); + return result; + } + } +} diff --git a/Mods/ContentPatcher/Common/TileHelper.cs b/Mods/ContentPatcher/Common/TileHelper.cs new file mode 100644 index 00000000..c96aeb92 --- /dev/null +++ b/Mods/ContentPatcher/Common/TileHelper.cs @@ -0,0 +1,126 @@ +using System.Collections.Generic; +using System.Linq; +using Microsoft.Xna.Framework; +using StardewValley; +using xTile.Layers; + +namespace Pathoschild.Stardew.Common +{ + /// Provides extension methods for working with tiles. + internal static class TileHelper + { + /********* + ** Public methods + *********/ + /**** + ** Location + ****/ + /// Get the tile coordinates in the game location. + /// The game location to search. + public static IEnumerable GetTiles(this GameLocation location) + { + if (location?.Map?.Layers == null) + return Enumerable.Empty(); + + Layer layer = location.Map.Layers[0]; + return TileHelper.GetTiles(0, 0, layer.LayerWidth, layer.LayerHeight); + } + + /**** + ** Rectangle + ****/ + /// Get the tile coordinates in the tile area. + /// The tile area to search. + public static IEnumerable GetTiles(this Rectangle area) + { + return TileHelper.GetTiles(area.X, area.Y, area.Width, area.Height); + } + + /// Expand a rectangle equally in all directions. + /// The rectangle to expand. + /// The number of tiles to add in each direction. + public static Rectangle Expand(this Rectangle area, int distance) + { + return new Rectangle(area.X - distance, area.Y - distance, area.Width + distance * 2, area.Height + distance * 2); + } + + /**** + ** Tiles + ****/ + /// Get the eight tiles surrounding the given tile. + /// The center tile. + public static IEnumerable GetSurroundingTiles(this Vector2 tile) + { + return Utility.getSurroundingTileLocationsArray(tile); + } + + /// Get the tiles surrounding the given tile area. + /// The center tile area. + public static IEnumerable GetSurroundingTiles(this Rectangle area) + { + for (int x = area.X - 1; x <= area.X + area.Width; x++) + { + for (int y = area.Y - 1; y <= area.Y + area.Height; y++) + { + if (!area.Contains(x, y)) + yield return new Vector2(x, y); + } + } + } + + /// Get the four tiles adjacent to the given tile. + /// The center tile. + public static IEnumerable GetAdjacentTiles(this Vector2 tile) + { + return Utility.getAdjacentTileLocationsArray(tile); + } + + /// Get a rectangular grid of tiles. + /// The X coordinate of the top-left tile. + /// The Y coordinate of the top-left tile. + /// The grid width. + /// The grid height. + public static IEnumerable GetTiles(int x, int y, int width, int height) + { + for (int curX = x, maxX = x + width - 1; curX <= maxX; curX++) + { + for (int curY = y, maxY = y + height - 1; curY <= maxY; curY++) + yield return new Vector2(curX, curY); + } + } + + /// Get all tiles which are on-screen. + public static IEnumerable GetVisibleTiles() + { + return TileHelper.GetVisibleArea().GetTiles(); + } + + /// Get the tile area visible on-screen. + public static Rectangle GetVisibleArea() + { + return new Rectangle( + x: Game1.viewport.X / Game1.tileSize, + y: Game1.viewport.Y / Game1.tileSize, + width: (int)(Game1.viewport.Width / (decimal)Game1.tileSize) + 2, // extend off-screen slightly to avoid edges popping in + height: (int)(Game1.viewport.Height / (decimal)Game1.tileSize) + 2 + ); + } + + /**** + ** Cursor + ****/ + /// Get the tile under the player's cursor (not restricted to the player's grab tile range). + public static Vector2 GetTileFromCursor() + { + return TileHelper.GetTileFromScreenPosition(Game1.getMouseX(), Game1.getMouseY()); + } + + /// Get the tile at the pixel coordinate relative to the top-left corner of the screen. + /// The pixel X coordinate. + /// The pixel Y coordinate. + public static Vector2 GetTileFromScreenPosition(float x, float y) + { + return new Vector2((int)((Game1.viewport.X + x) / Game1.tileSize), (int)((Game1.viewport.Y + y) / Game1.tileSize)); + } + } +} diff --git a/Mods/ContentPatcher/Common/UI/BaseOverlay.cs b/Mods/ContentPatcher/Common/UI/BaseOverlay.cs new file mode 100644 index 00000000..4b515ec5 --- /dev/null +++ b/Mods/ContentPatcher/Common/UI/BaseOverlay.cs @@ -0,0 +1,214 @@ +using System; +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Graphics; +using Microsoft.Xna.Framework.Input; +using StardewModdingAPI; +using StardewModdingAPI.Events; +using StardewValley; +using Rectangle = xTile.Dimensions.Rectangle; + +namespace Pathoschild.Stardew.Common.UI +{ + /// An interface which supports user interaction and overlays the active menu (if any). + internal abstract class BaseOverlay : IDisposable + { + /********* + ** Fields + *********/ + /// The SMAPI events available for mods. + private readonly IModEvents Events; + + /// An API for checking and changing input state. + protected readonly IInputHelper InputHelper; + + /// The last viewport bounds. + private Rectangle LastViewport; + + /// Indicates whether to keep the overlay active. If null, the overlay is kept until explicitly disposed. + private readonly Func KeepAliveCheck; + + + /********* + ** Public methods + *********/ + /// Release all resources. + public virtual void Dispose() + { + this.Events.Display.Rendered -= this.OnRendered; + this.Events.GameLoop.UpdateTicked -= this.OnUpdateTicked; + this.Events.Input.ButtonPressed -= this.OnButtonPressed; + this.Events.Input.CursorMoved -= this.OnCursorMoved; + this.Events.Input.MouseWheelScrolled -= this.OnMouseWheelScrolled; + } + + + /********* + ** Protected methods + *********/ + /**** + ** Implementation + ****/ + /// Construct an instance. + /// The SMAPI events available for mods. + /// An API for checking and changing input state. + /// Indicates whether to keep the overlay active. If null, the overlay is kept until explicitly disposed. + protected BaseOverlay(IModEvents events, IInputHelper inputHelper, Func keepAlive = null) + { + this.Events = events; + this.InputHelper = inputHelper; + this.KeepAliveCheck = keepAlive; + this.LastViewport = new Rectangle(Game1.viewport.X, Game1.viewport.Y, Game1.viewport.Width, Game1.viewport.Height); + + events.Display.Rendered += this.OnRendered; + events.GameLoop.UpdateTicked += this.OnUpdateTicked; + events.Input.ButtonPressed += this.OnButtonPressed; + events.Input.CursorMoved += this.OnCursorMoved; + events.Input.MouseWheelScrolled += this.OnMouseWheelScrolled; + } + + /// Draw the overlay to the screen. + /// The sprite batch being drawn. + protected virtual void Draw(SpriteBatch batch) { } + + /// The method invoked when the player left-clicks. + /// The X-position of the cursor. + /// The Y-position of the cursor. + /// Whether the event has been handled and shouldn't be propagated further. + protected virtual bool ReceiveLeftClick(int x, int y) + { + return false; + } + + /// The method invoked when the player presses a button. + /// The button that was pressed. + /// Whether the event has been handled and shouldn't be propagated further. + protected virtual bool ReceiveButtonPress(SButton input) + { + return false; + } + + /// The method invoked when the player uses the mouse scroll wheel. + /// The scroll amount. + /// Whether the event has been handled and shouldn't be propagated further. + protected virtual bool ReceiveScrollWheelAction(int amount) + { + return false; + } + + /// The method invoked when the cursor is hovered. + /// The cursor's X position. + /// The cursor's Y position. + /// Whether the event has been handled and shouldn't be propagated further. + protected virtual bool ReceiveCursorHover(int x, int y) + { + return false; + } + + /// The method invoked when the player resizes the game windoww. + /// The previous game window bounds. + /// The new game window bounds. + protected virtual void ReceiveGameWindowResized(Rectangle oldBounds, Rectangle newBounds) { } + + /// Draw the mouse cursor. + /// Derived from . + protected void DrawCursor() + { + if (Game1.options.hardwareCursor) + return; + Game1.spriteBatch.Draw(Game1.mouseCursors, new Vector2(Game1.getMouseX(), Game1.getMouseY()), Game1.getSourceRectForStandardTileSheet(Game1.mouseCursors, Game1.options.SnappyMenus ? 44 : 0, 16, 16), Color.White * Game1.mouseCursorTransparency, 0.0f, Vector2.Zero, Game1.pixelZoom + Game1.dialogueButtonScale / 150f, SpriteEffects.None, 1f); + } + + /**** + ** Event listeners + ****/ + /// The method called when the game finishes drawing components to the screen. + /// The source of the event. + /// The event arguments. + private void OnRendered(object sender, RenderedEventArgs e) + { + this.Draw(Game1.spriteBatch); + } + + /// The method called once per event tick. + /// The source of the event. + /// The event arguments. + private void OnUpdateTicked(object sender, UpdateTickedEventArgs e) + { + // detect end of life + if (this.KeepAliveCheck != null && !this.KeepAliveCheck()) + { + this.Dispose(); + return; + } + + // trigger window resize event + Rectangle newViewport = Game1.viewport; + if (this.LastViewport.Width != newViewport.Width || this.LastViewport.Height != newViewport.Height) + { + newViewport = new Rectangle(newViewport.X, newViewport.Y, newViewport.Width, newViewport.Height); + this.ReceiveGameWindowResized(this.LastViewport, newViewport); + this.LastViewport = newViewport; + } + } + + /// The method invoked when the player presses a key. + /// The source of the event. + /// The event arguments. + private void OnButtonPressed(object sender, ButtonPressedEventArgs e) + { + bool handled = e.Button == SButton.MouseLeft || e.Button.IsUseToolButton() + ? this.ReceiveLeftClick(Game1.getMouseX(), Game1.getMouseY()) + : this.ReceiveButtonPress(e.Button); + + if (handled) + this.InputHelper.Suppress(e.Button); + } + + /// The method invoked when the mouse wheel is scrolled. + /// The source of the event. + /// The event arguments. + private void OnMouseWheelScrolled(object sender, MouseWheelScrolledEventArgs e) + { + bool scrollHandled = this.ReceiveScrollWheelAction(e.Delta); + if (scrollHandled) + { + MouseState cur = Game1.oldMouseState; + Game1.oldMouseState = new MouseState( + x: cur.X, + y: cur.Y, + scrollWheel: e.NewValue, + leftButton: cur.LeftButton, + middleButton: cur.MiddleButton, + rightButton: cur.RightButton, + xButton1: cur.XButton1, + xButton2: cur.XButton2 + ); + } + } + + /// The method invoked when the in-game cursor is moved. + /// The source of the event. + /// The event arguments. + private void OnCursorMoved(object sender, CursorMovedEventArgs e) + { + int x = (int)e.NewPosition.ScreenPixels.X; + int y = (int)e.NewPosition.ScreenPixels.Y; + + bool hoverHandled = this.ReceiveCursorHover(x, y); + if (hoverHandled) + { + MouseState cur = Game1.oldMouseState; + Game1.oldMouseState = new MouseState( + x: x, + y: y, + scrollWheel: cur.ScrollWheelValue, + leftButton: cur.LeftButton, + middleButton: cur.MiddleButton, + rightButton: cur.RightButton, + xButton1: cur.XButton1, + xButton2: cur.XButton2 + ); + } + } + } +} diff --git a/Mods/ContentPatcher/Common/UI/CommonSprites.cs b/Mods/ContentPatcher/Common/UI/CommonSprites.cs new file mode 100644 index 00000000..3da68991 --- /dev/null +++ b/Mods/ContentPatcher/Common/UI/CommonSprites.cs @@ -0,0 +1,79 @@ +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Graphics; +using StardewValley; + +namespace Pathoschild.Stardew.Common.UI +{ + /// Simplifies access to the game's sprite sheets. + /// Each sprite is represented by a rectangle, which specifies the coordinates and dimensions of the image in the sprite sheet. + internal static class CommonSprites + { + /// Sprites used to draw a button. + public static class Button + { + /// The sprite sheet containing the icon sprites. + public static Texture2D Sheet => Game1.mouseCursors; + + /// The legend background. + public static readonly Rectangle Background = new Rectangle(297, 364, 1, 1); + + /// The top border. + public static readonly Rectangle Top = new Rectangle(279, 284, 1, 4); + + /// The bottom border. + public static readonly Rectangle Bottom = new Rectangle(279, 296, 1, 4); + + /// The left border. + public static readonly Rectangle Left = new Rectangle(274, 289, 4, 1); + + /// The right border. + public static readonly Rectangle Right = new Rectangle(286, 289, 4, 1); + + /// The top-left corner. + public static readonly Rectangle TopLeft = new Rectangle(274, 284, 4, 4); + + /// The top-right corner. + public static readonly Rectangle TopRight = new Rectangle(286, 284, 4, 4); + + /// The bottom-left corner. + public static readonly Rectangle BottomLeft = new Rectangle(274, 296, 4, 4); + + /// The bottom-right corner. + public static readonly Rectangle BottomRight = new Rectangle(286, 296, 4, 4); + } + + /// Sprites used to draw a scroll. + public static class Scroll + { + /// The sprite sheet containing the icon sprites. + public static Texture2D Sheet => Game1.mouseCursors; + + /// The legend background. + public static readonly Rectangle Background = new Rectangle(334, 321, 1, 1); + + /// The top border. + public static readonly Rectangle Top = new Rectangle(331, 318, 1, 2); + + /// The bottom border. + public static readonly Rectangle Bottom = new Rectangle(327, 334, 1, 2); + + /// The left border. + public static readonly Rectangle Left = new Rectangle(325, 320, 6, 1); + + /// The right border. + public static readonly Rectangle Right = new Rectangle(344, 320, 6, 1); + + /// The top-left corner. + public static readonly Rectangle TopLeft = new Rectangle(325, 318, 6, 2); + + /// The top-right corner. + public static readonly Rectangle TopRight = new Rectangle(344, 318, 6, 2); + + /// The bottom-left corner. + public static readonly Rectangle BottomLeft = new Rectangle(325, 334, 6, 2); + + /// The bottom-right corner. + public static readonly Rectangle BottomRight = new Rectangle(344, 334, 6, 2); + } + } +} diff --git a/Mods/ContentPatcher/Common/Utilities/ConstraintSet.cs b/Mods/ContentPatcher/Common/Utilities/ConstraintSet.cs new file mode 100644 index 00000000..98cf678e --- /dev/null +++ b/Mods/ContentPatcher/Common/Utilities/ConstraintSet.cs @@ -0,0 +1,141 @@ +using System.Collections.Generic; + +namespace Pathoschild.Stardew.Common.Utilities +{ + /// A logical collection of values defined by restriction and exclusion values which may be infinite. + /// + /// + /// Unlike a typical collection, a constraint set doesn't necessarily track the values it contains. For + /// example, a constraint set of values with one exclusion only stores one number but + /// logically contains elements. + /// + /// + /// + /// A constraint set is defined by two inner sets: contains values which are + /// explicitly not part of the set, and contains values which are explicitly + /// part of the set. Crucially, an empty means an unbounded set (i.e. it + /// contains all possible values). If a value is part of both and + /// , the exclusion takes priority. + /// + /// + internal class ConstraintSet + { + /********* + ** Accessors + *********/ + /// The specific values to contain (or empty to match any value). + public HashSet RestrictToValues { get; } + + /// The specific values to exclude. + public HashSet ExcludeValues { get; } + + /// Whether the constraint set matches a finite set of values. + public bool IsBounded => this.RestrictToValues.Count != 0; + + /// Get whether the constraint set logically matches an infinite set of values. + public bool IsInfinite => !this.IsBounded; + + /// Whether there are any constraints placed on the set of values. + public bool IsConstrained => this.RestrictToValues.Count != 0 || this.ExcludeValues.Count != 0; + + + /********* + ** Public methods + *********/ + /// Construct an instance. + public ConstraintSet() + : this(EqualityComparer.Default) { } + + /// Construct an instance. + /// The equality comparer to use when comparing values in the set, or to use the default implementation for the set type. + public ConstraintSet(IEqualityComparer comparer) + { + this.RestrictToValues = new HashSet(comparer); + this.ExcludeValues = new HashSet(comparer); + } + + /// Bound the constraint set by adding the given value to the set of allowed values. If the constraint set is unbounded, this makes it bounded. + /// The value. + /// Returns true if the value was added; else false if it was already present. + public bool AddBound(T value) + { + return this.RestrictToValues.Add(value); + } + + /// Bound the constraint set by adding the given values to the set of allowed values. If the constraint set is unbounded, this makes it bounded. + /// The values. + /// Returns true if any value was added; else false if all values were already present. + public bool AddBound(IEnumerable values) + { + bool anyAdded = false; + foreach (T value in values) + { + if (this.RestrictToValues.Add(value)) + anyAdded = true; + } + return anyAdded; + } + + /// Add values to exclude. + /// The value to exclude. + /// Returns true if the value was added; else false if it was already present. + public bool Exclude(T value) + { + return this.ExcludeValues.Add(value); + } + + /// Add values to exclude. + /// The values to exclude. + /// Returns true if any value was added; else false if all values were already present. + public bool Exclude(IEnumerable values) + { + bool anyAdded = false; + foreach (T value in values) + { + if (this.ExcludeValues.Add(value)) + anyAdded = true; + } + return anyAdded; + } + + /// Get whether this constraint allows some values that would be allowed by another. + /// The other + public bool Intersects(ConstraintSet other) + { + // If both sets are unbounded, they're guaranteed to intersect since exclude can't be unbounded. + if (this.IsInfinite && other.IsInfinite) + return true; + + // if either set is bounded, they can only intersect in the included subset. + if (this.IsBounded) + { + foreach (T value in this.RestrictToValues) + { + if (this.Allows(value) && other.Allows(value)) + return true; + } + } + if (other.IsBounded) + { + foreach (T value in other.RestrictToValues) + { + if (other.Allows(value) && this.Allows(value)) + return true; + } + } + + // else no intersection + return false; + } + + /// Get whether the constraints allow the given value. + /// The value to match. + public bool Allows(T value) + { + if (this.ExcludeValues.Contains(value)) + return false; + + return this.IsInfinite || this.RestrictToValues.Contains(value); + } + } +} diff --git a/Mods/ContentPatcher/Common/Utilities/InvariantDictionary.cs b/Mods/ContentPatcher/Common/Utilities/InvariantDictionary.cs new file mode 100644 index 00000000..4bad98e7 --- /dev/null +++ b/Mods/ContentPatcher/Common/Utilities/InvariantDictionary.cs @@ -0,0 +1,30 @@ +using System; +using System.Collections.Generic; + +namespace Pathoschild.Stardew.Common.Utilities +{ + /// An implementation of whose keys are guaranteed to use . + internal class InvariantDictionary : Dictionary + { + /********* + ** Public methods + *********/ + /// Construct an instance. + public InvariantDictionary() + : base(StringComparer.InvariantCultureIgnoreCase) { } + + /// Construct an instance. + /// The values to add. + public InvariantDictionary(IDictionary values) + : base(values, StringComparer.InvariantCultureIgnoreCase) { } + + /// Construct an instance. + /// The values to add. + public InvariantDictionary(IEnumerable> values) + : base(StringComparer.InvariantCultureIgnoreCase) + { + foreach (var entry in values) + this.Add(entry.Key, entry.Value); + } + } +} diff --git a/Mods/ContentPatcher/Common/Utilities/InvariantHashSet.cs b/Mods/ContentPatcher/Common/Utilities/InvariantHashSet.cs new file mode 100644 index 00000000..6f0530d8 --- /dev/null +++ b/Mods/ContentPatcher/Common/Utilities/InvariantHashSet.cs @@ -0,0 +1,32 @@ +using System; +using System.Collections.Generic; + +namespace Pathoschild.Stardew.Common.Utilities +{ + /// An implementation of for strings which always uses . + internal class InvariantHashSet : HashSet + { + /********* + ** Public methods + *********/ + /// Construct an instance. + public InvariantHashSet() + : base(StringComparer.InvariantCultureIgnoreCase) { } + + /// Construct an instance. + /// The values to add. + public InvariantHashSet(IEnumerable values) + : base(values, StringComparer.InvariantCultureIgnoreCase) { } + + /// Construct an instance. + /// The single value to add. + public InvariantHashSet(string value) + : base(new[] { value }, StringComparer.InvariantCultureIgnoreCase) { } + + /// Get a hashset for boolean true/false. + public static InvariantHashSet Boolean() + { + return new InvariantHashSet(new[] { "true", "false" }); + } + } +} diff --git a/Mods/ContentPatcher/Common/Utilities/ObjectReferenceComparer.cs b/Mods/ContentPatcher/Common/Utilities/ObjectReferenceComparer.cs new file mode 100644 index 00000000..020ebfad --- /dev/null +++ b/Mods/ContentPatcher/Common/Utilities/ObjectReferenceComparer.cs @@ -0,0 +1,29 @@ +using System.Collections.Generic; +using System.Runtime.CompilerServices; + +namespace Pathoschild.Stardew.Common.Utilities +{ + /// A comparer which considers two references equal if they point to the same instance. + /// The value type. + internal class ObjectReferenceComparer : IEqualityComparer + { + /********* + ** Public methods + *********/ + /// Determines whether the specified objects are equal. + /// true if the specified objects are equal; otherwise, false. + /// The first object to compare. + /// The second object to compare. + public bool Equals(T x, T y) + { + return object.ReferenceEquals(x, y); + } + + /// Get a hash code for the specified object. + /// The value. + public int GetHashCode(T obj) + { + return RuntimeHelpers.GetHashCode(obj); + } + } +} diff --git a/Mods/ContentPatcher/ContentPatcher.csproj b/Mods/ContentPatcher/ContentPatcher.csproj new file mode 100644 index 00000000..9bad46ea --- /dev/null +++ b/Mods/ContentPatcher/ContentPatcher.csproj @@ -0,0 +1,271 @@ + + + + + Debug + AnyCPU + {8E1D56B0-D640-4EB0-A703-E280C40A655D} + Library + Properties + ContentPatcher + ContentPatcher + v4.5.2 + 512 + true + + + true + full + false + bin\Debug\ + DEBUG;TRACE + prompt + 7.2 + 4 + + + pdbonly + true + bin\Release\ + TRACE + prompt + 4 + 7.2 + + + + ..\assemblies\StardewModdingAPI.dll + + + ..\assemblies\StardewValley.dll + + + False + ..\assemblies\BmFont.dll + + + False + ..\assemblies\Google.Android.Vending.Expansion.Downloader.dll + + + False + ..\assemblies\Google.Android.Vending.Expansion.ZipFile.dll + + + False + ..\assemblies\Google.Android.Vending.Licensing.dll + + + False + ..\assemblies\Java.Interop.dll + + + False + ..\assemblies\Microsoft.AppCenter.dll + + + False + ..\assemblies\Microsoft.AppCenter.Analytics.dll + + + False + ..\assemblies\Microsoft.AppCenter.Analytics.Android.Bindings.dll + + + False + ..\assemblies\Microsoft.AppCenter.Android.Bindings.dll + + + False + ..\assemblies\Microsoft.AppCenter.Crashes.dll + + + False + ..\assemblies\Microsoft.AppCenter.Crashes.Android.Bindings.dll + + + False + ..\assemblies\Mono.Android.dll + + + False + ..\assemblies\Mono.Security.dll + + + False + ..\assemblies\MonoGame.Framework.dll + + + ..\assemblies\mscorlib.dll + + + ..\assemblies\System.dll + + + ..\assemblies\System.Xml + + + ..\assemblies\System.Net.Http + + + ..\assemblies\System.Runtime.Serialization + + + False + ..\assemblies\Xamarin.Android.Arch.Core.Common.dll + + + False + ..\assemblies\Xamarin.Android.Arch.Lifecycle.Common.dll + + + False + ..\assemblies\Xamarin.Android.Arch.Lifecycle.Runtime.dll + + + False + ..\assemblies\Xamarin.Android.Support.Annotations.dll + + + False + ..\assemblies\Xamarin.Android.Support.Compat.dll + + + False + ..\assemblies\Xamarin.Android.Support.Core.UI.dll + + + False + ..\assemblies\Xamarin.Android.Support.Core.Utils.dll + + + False + ..\assemblies\Xamarin.Android.Support.Fragment.dll + + + False + ..\assemblies\Xamarin.Android.Support.Media.Compat.dll + + + False + ..\assemblies\Xamarin.Android.Support.v4.dll + + + False + ..\assemblies\xTile.dll + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Mods/ContentPatcher/Framework/CaseInsensitiveExtensions.cs b/Mods/ContentPatcher/Framework/CaseInsensitiveExtensions.cs new file mode 100644 index 00000000..11dee20c --- /dev/null +++ b/Mods/ContentPatcher/Framework/CaseInsensitiveExtensions.cs @@ -0,0 +1,52 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace ContentPatcher.Framework +{ + /// Provides case-insensitive extension methods. + internal static class CaseInsensitiveExtensions + { + /********* + ** Public methods + *********/ + /// Get the set difference of two sequences, using the invariant culture and ignoring case. + /// The first sequence to compare. + /// The second sequence to compare. + /// or is . + public static IEnumerable ExceptIgnoreCase(this IEnumerable source, IEnumerable other) + { + return source.Except(other, StringComparer.InvariantCultureIgnoreCase); + } + + /// Group the elements of a sequence according to a specified key selector function, comparing the keys using the invariant culture and ignoring case. + /// The type of the elements of . + /// The sequence whose elements to group. + /// A function to extract the key for each element. + /// or is . + public static IEnumerable> GroupByIgnoreCase(this IEnumerable source, Func keySelector) + { + return source.GroupBy(keySelector, StringComparer.InvariantCultureIgnoreCase); + } + + /// Sort the elements of a sequence in ascending order by using a specified comparer. + /// The type of the elements of . + /// A sequence of values to order. + /// A function to extract a key from an element. + /// or is . + public static IOrderedEnumerable OrderByIgnoreCase(this IEnumerable source, Func keySelector) + { + return source.OrderBy(keySelector, StringComparer.InvariantCultureIgnoreCase); + } + + /// Perform a subsequent ordering of the elements in a sequence in ascending order according to a key. + /// The type of the elements of . + /// The sequence whose elements to group. + /// A function to extract the key for each element. + /// or is . + public static IOrderedEnumerable ThenByIgnoreCase(this IOrderedEnumerable source, Func keySelector) + { + return source.ThenBy(keySelector, StringComparer.InvariantCultureIgnoreCase); + } + } +} diff --git a/Mods/ContentPatcher/Framework/Commands/CommandHandler.cs b/Mods/ContentPatcher/Framework/Commands/CommandHandler.cs new file mode 100644 index 00000000..5bfa4501 --- /dev/null +++ b/Mods/ContentPatcher/Framework/Commands/CommandHandler.cs @@ -0,0 +1,356 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Text.RegularExpressions; +using ContentPatcher.Framework.Conditions; +using ContentPatcher.Framework.Patches; +using ContentPatcher.Framework.Tokens; +using Pathoschild.Stardew.Common.Utilities; +using StardewModdingAPI; + +namespace ContentPatcher.Framework.Commands +{ + /// Handles the 'patch' console command. + internal class CommandHandler + { + /********* + ** Fields + *********/ + /// Encapsulates monitoring and logging. + private readonly IMonitor Monitor; + + /// Manages loaded tokens. + private readonly TokenManager TokenManager; + + /// Manages loaded patches. + private readonly PatchManager PatchManager; + + /// A callback which immediately updates the current condition context. + private readonly Action UpdateContext; + + /// A regex pattern matching asset names which incorrectly include the Content folder. + private readonly Regex AssetNameWithContentPattern = new Regex(@"^Content[/\\]", RegexOptions.Compiled | RegexOptions.IgnoreCase); + + /// A regex pattern matching asset names which incorrectly include an extension. + private readonly Regex AssetNameWithExtensionPattern = new Regex(@"(\.\w+)$", RegexOptions.Compiled | RegexOptions.IgnoreCase); + + /// A regex pattern matching asset names which incorrectly include the locale code. + private readonly Regex AssetNameWithLocalePattern = new Regex(@"^\.(?:de-DE|es-ES|ja-JP|pt-BR|ru-RU|zh-CN)(?:\.xnb)?$", RegexOptions.Compiled | RegexOptions.IgnoreCase); + + + /********* + ** Accessors + *********/ + /// The name of the root command. + public string CommandName { get; } = "patch"; + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// Manages loaded tokens. + /// Manages loaded patches. + /// Encapsulates monitoring and logging. + /// A callback which immediately updates the current condition context. + public CommandHandler(TokenManager tokenManager, PatchManager patchManager, IMonitor monitor, Action updateContext) + { + this.TokenManager = tokenManager; + this.PatchManager = patchManager; + this.Monitor = monitor; + this.UpdateContext = updateContext; + } + + /// Handle a console command. + /// The command arguments. + /// Returns whether the command was handled. + public bool Handle(string[] args) + { + string subcommand = args.FirstOrDefault(); + string[] subcommandArgs = args.Skip(1).ToArray(); + + switch (subcommand?.ToLower()) + { + case null: + case "help": + return this.HandleHelp(subcommandArgs); + + case "summary": + return this.HandleSummary(); + + case "update": + return this.HandleUpdate(); + + default: + this.Monitor.Log($"The '{this.CommandName} {args[0]}' command isn't valid. Type '{this.CommandName} help' for a list of valid commands."); + return false; + } + } + + + /********* + ** Private methods + *********/ + /**** + ** Commands + ****/ + /// Handle the 'patch help' command. + /// The subcommand arguments. + /// Returns whether the command was handled. + private bool HandleHelp(string[] args) + { + // generate command info + var helpEntries = new InvariantDictionary + { + ["help"] = $"{this.CommandName} help\n Usage: {this.CommandName} help\n Lists all available {this.CommandName} commands.\n\n Usage: {this.CommandName} help \n Provides information for a specific {this.CommandName} command.\n - cmd: The {this.CommandName} command name.", + ["summary"] = $"{this.CommandName} summary\n Usage: {this.CommandName} summary\n Shows a summary of the current conditions and loaded patches.", + ["update"] = $"{this.CommandName} update\n Usage: {this.CommandName} update\n Imediately refreshes the condition context and rechecks all patches." + }; + + // build output + StringBuilder help = new StringBuilder(); + if (!args.Any()) + { + help.AppendLine( + $"The '{this.CommandName}' command is the entry point for Content Patcher commands. These are " + + "intended for troubleshooting and aren't intended for players. You use it by specifying a more " + + $"specific command (like 'help' in '{this.CommandName} help'). Here are the available commands:\n\n" + ); + foreach (var entry in helpEntries.OrderByIgnoreCase(p => p.Key)) + { + help.AppendLine(entry.Value); + help.AppendLine(); + } + } + else if (helpEntries.TryGetValue(args[0], out string entry)) + help.AppendLine(entry); + else + help.AppendLine($"Unknown command '{this.CommandName} {args[0]}'. Type '{this.CommandName} help' for available commands."); + + // write output + this.Monitor.Log(help.ToString()); + + return true; + } + + /// Handle the 'patch summary' command. + /// Returns whether the command was handled. + private bool HandleSummary() + { + StringBuilder output = new StringBuilder(); + + // add condition summary + output.AppendLine(); + output.AppendLine("====================="); + output.AppendLine("== Global tokens =="); + output.AppendLine("====================="); + { + // get data + IToken[] tokens = + ( + from token in this.TokenManager.GetTokens(enforceContext: false) + let subkeys = token.GetSubkeys().ToArray() + let rootValues = !token.RequiresSubkeys ? token.GetValues(token.Name).ToArray() : new string[0] + let multiValue = + subkeys.Length > 1 + || rootValues.Length > 1 + || (subkeys.Length == 1 && token.GetValues(subkeys[0]).Count() > 1) + orderby multiValue, token.Name.Key // single-value tokens first, then alphabetically + select token + ) + .ToArray(); + int labelWidth = tokens.Max(p => p.Name.Key.Length); + + // print table header + output.AppendLine($" {"token name".PadRight(labelWidth)} | value"); + output.AppendLine($" {"".PadRight(labelWidth, '-')} | -----"); + + // print tokens + foreach (IToken token in tokens) + { + output.Append($" {token.Name.Key.PadRight(labelWidth)} | "); + + if (!token.IsValidInContext) + output.AppendLine("[ ] n/a"); + else if (token.RequiresSubkeys) + { + bool isFirst = true; + foreach (TokenName name in token.GetSubkeys().OrderByIgnoreCase(key => key.Subkey)) + { + if (isFirst) + { + output.Append("[X] "); + isFirst = false; + } + else + output.Append($" {"".PadRight(labelWidth, ' ')} | "); + output.AppendLine($":{name.Subkey}: {string.Join(", ", token.GetValues(name))}"); + } + } + else + output.AppendLine("[X] " + string.Join(", ", token.GetValues(token.Name).OrderByIgnoreCase(p => p))); + } + } + output.AppendLine(); + + // add patch summary + var patches = this.GetAllPatches() + .GroupByIgnoreCase(p => p.ContentPack.Manifest.Name) + .OrderByIgnoreCase(p => p.Key); + + output.AppendLine( + "=====================\n" + + "== Content patches ==\n" + + "=====================\n" + + "The following patches were loaded. For each patch:\n" + + " - 'loaded' shows whether the patch is loaded and enabled (see details for the reason if not).\n" + + " - 'conditions' shows whether the patch matches with the current conditions (see details for the reason if not). If this is unexpectedly false, check (a) the conditions above and (b) your Where field.\n" + + " - 'applied' shows whether the target asset was loaded and patched. If you expected it to be loaded by this point but it's false, double-check (a) that the game has actually loaded the asset yet, and (b) your Targets field is correct.\n" + + "\n" + ); + foreach (IGrouping patchGroup in patches) + { + ModTokenContext tokenContext = this.TokenManager.TrackLocalTokens(patchGroup.First().ContentPack.Pack); + output.AppendLine($"{patchGroup.Key}:"); + output.AppendLine("".PadRight(patchGroup.Key.Length + 1, '-')); + + // print tokens + { + IToken[] localTokens = tokenContext + .GetTokens(localOnly: true, enforceContext: false) + .Where(p => p.Name.Key != ConditionType.HasFile.ToString()) // no value to display + .ToArray(); + if (localTokens.Any()) + { + output.AppendLine(); + output.AppendLine(" Local tokens:"); + foreach (IToken token in localTokens.OrderBy(p => p.Name)) + { + if (token.RequiresSubkeys) + { + foreach (TokenName name in token.GetSubkeys().OrderBy(p => p)) + output.AppendLine($" {name}: {string.Join(", ", token.GetValues(name))}"); + } + else + output.AppendLine($" {token.Name}: {string.Join(", ", token.GetValues(token.Name))}"); + } + } + } + + // print patches + output.AppendLine(); + output.AppendLine(" loaded | conditions | applied | name + details"); + output.AppendLine(" ------- | ---------- | ------- | --------------"); + foreach (PatchInfo patch in patchGroup.OrderByIgnoreCase(p => p.ShortName)) + { + // log checkbox and patch name + output.Append($" [{(patch.IsLoaded ? "X" : " ")}] | [{(patch.MatchesContext ? "X" : " ")}] | [{(patch.IsApplied ? "X" : " ")}] | {patch.ShortName}"); + + // log raw target (if not in name) + if (!patch.ShortName.Contains($"{patch.Type} {patch.RawTargetAsset}")) + output.Append($" | {patch.Type} {patch.RawTargetAsset}"); + + // log parsed target if tokenised + if (patch.MatchesContext && patch.ParsedTargetAsset != null && patch.ParsedTargetAsset.Tokens.Any()) + output.Append($" | => {patch.ParsedTargetAsset.Value}"); + + // log reason not applied + string errorReason = this.GetReasonNotLoaded(patch, tokenContext); + if (errorReason != null) + output.Append($" // {errorReason}"); + + // log common issues + if (errorReason == null && patch.IsLoaded && !patch.IsApplied && patch.ParsedTargetAsset?.Value != null) + { + string assetName = patch.ParsedTargetAsset.Value; + + List issues = new List(); + if (this.AssetNameWithContentPattern.IsMatch(assetName)) + issues.Add("shouldn't include 'Content/' prefix"); + if (this.AssetNameWithExtensionPattern.IsMatch(assetName)) + { + var match = this.AssetNameWithExtensionPattern.Match(assetName); + issues.Add($"shouldn't include '{match.Captures[0]}' extension"); + } + if (this.AssetNameWithLocalePattern.IsMatch(assetName)) + issues.Add("shouldn't include language code (use conditions instead)"); + + if (issues.Any()) + output.Append($" | hint: asset name may be incorrect ({string.Join("; ", issues)})."); + } + + // end line + output.AppendLine(); + } + output.AppendLine(); // blank line between groups + } + + this.Monitor.Log(output.ToString()); + return true; + } + + /// Handle the 'patch update' command. + /// Returns whether the command was handled. + private bool HandleUpdate() + { + this.UpdateContext(); + return true; + } + + + /**** + ** Helpers + ****/ + /// Get basic info about all patches, including those which couldn't be loaded. + public IEnumerable GetAllPatches() + { + foreach (IPatch patch in this.PatchManager.GetPatches()) + yield return new PatchInfo(patch); + foreach (DisabledPatch patch in this.PatchManager.GetPermanentlyDisabledPatches()) + yield return new PatchInfo(patch); + } + + /// Get a human-readable reason that the patch isn't applied. + /// The patch to check. + /// The token context for the content pack. + private string GetReasonNotLoaded(PatchInfo patch, IContext tokenContext) + { + if (patch.IsApplied) + return null; + + // load error + if (!patch.IsLoaded) + return $"not loaded: {patch.ReasonDisabled}"; + + // uses tokens not available in the current context + { + IList tokensOutOfContext = patch + .TokensUsed + .Union(patch.ParsedConditions.Keys) + .Where(p => !tokenContext.GetToken(p, enforceContext: false).IsValidInContext) + .OrderByIgnoreCase(p => p.ToString()) + .ToArray(); + + if (tokensOutOfContext.Any()) + return $"uses tokens not available right now: {string.Join(", ", tokensOutOfContext)}"; + } + + // conditions not matched + if (!patch.MatchesContext && patch.ParsedConditions != null) + { + string[] failedConditions = ( + from condition in patch.ParsedConditions.Values + orderby condition.Name.ToString() + where !condition.IsMatch(tokenContext) + select $"{condition.Name} ({string.Join(", ", condition.Values)})" + ).ToArray(); + + if (failedConditions.Any()) + return $"conditions don't match: {string.Join(", ", failedConditions)}"; + } + + return null; + } + } +} diff --git a/Mods/ContentPatcher/Framework/Commands/PatchInfo.cs b/Mods/ContentPatcher/Framework/Commands/PatchInfo.cs new file mode 100644 index 00000000..05babf5e --- /dev/null +++ b/Mods/ContentPatcher/Framework/Commands/PatchInfo.cs @@ -0,0 +1,99 @@ +using System.Linq; +using ContentPatcher.Framework.Conditions; +using ContentPatcher.Framework.Patches; +using ContentPatcher.Framework.Tokens; + +namespace ContentPatcher.Framework.Commands +{ + /// A summary of patch info shown in the SMAPI console. + internal class PatchInfo + { + /********* + ** Accessors + *********/ + /// The patch name shown in log messages, without the content pack prefix. + public string ShortName { get; } + + /// The patch type. + public string Type { get; } + + /// The asset name to intercept. + public string RawTargetAsset { get; } + + /// The parsed asset name (if available). + public TokenString ParsedTargetAsset { get; } + + /// The parsed conditions (if available). + public ConditionDictionary ParsedConditions { get; } + + /// The content pack which requested the patch. + public ManagedContentPack ContentPack { get; } + + /// Whether the patch is loaded. + public bool IsLoaded { get; } + + /// Whether the patch should be applied in the current context. + public bool MatchesContext { get; } + + /// Whether the patch is currently applied. + public bool IsApplied { get; } + + /// The reason this patch is disabled (if applicable). + public string ReasonDisabled { get; } + + /// The tokens used by this patch in its fields. + public TokenName[] TokensUsed { get; } + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The patch to represent. + public PatchInfo(DisabledPatch patch) + { + this.ShortName = this.GetShortName(patch.ContentPack, patch.LogName); + this.Type = patch.Type; + this.RawTargetAsset = patch.AssetName; + this.ParsedTargetAsset = null; + this.ParsedConditions = null; + this.ContentPack = patch.ContentPack; + this.IsLoaded = false; + this.MatchesContext = false; + this.IsApplied = false; + this.ReasonDisabled = patch.ReasonDisabled; + this.TokensUsed = new TokenName[0]; + } + + /// Construct an instance. + /// The patch to represent. + public PatchInfo(IPatch patch) + { + this.ShortName = this.GetShortName(patch.ContentPack, patch.LogName); + this.Type = patch.Type.ToString(); + this.RawTargetAsset = patch.RawTargetAsset.Raw; + this.ParsedTargetAsset = patch.RawTargetAsset; + this.ParsedConditions = patch.Conditions; + this.ContentPack = patch.ContentPack; + this.IsLoaded = true; + this.MatchesContext = patch.MatchesContext; + this.IsApplied = patch.IsApplied; + this.TokensUsed = patch.GetTokensUsed().ToArray(); + } + + + /********* + ** Private methods + *********/ + /// Get the patch name shown in log messages, without the content pack prefix. + /// The content pack which requested the patch. + /// The unique patch name shown in log messages. + private string GetShortName(ManagedContentPack contentPack, string logName) + { + string prefix = contentPack.Manifest.Name + " > "; + return logName.StartsWith(prefix) + ? logName.Substring(prefix.Length) + : logName; + } + } +} diff --git a/Mods/ContentPatcher/Framework/Conditions/Condition.cs b/Mods/ContentPatcher/Framework/Conditions/Condition.cs new file mode 100644 index 00000000..50347bdf --- /dev/null +++ b/Mods/ContentPatcher/Framework/Conditions/Condition.cs @@ -0,0 +1,41 @@ +using System.Linq; +using ContentPatcher.Framework.Tokens; +using Pathoschild.Stardew.Common.Utilities; + +namespace ContentPatcher.Framework.Conditions +{ + /// A condition that can be checked against the token context. + internal class Condition + { + /********* + ** Accessors + *********/ + /// The token name in the context. + public TokenName Name { get; } + + /// The token values for which this condition is valid. + public InvariantHashSet Values { get; } + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The token name in the context. + /// The token values for which this condition is valid. + public Condition(TokenName name, InvariantHashSet values) + { + this.Name = name; + this.Values = values; + } + + /// Whether the condition matches. + /// The condition context. + public bool IsMatch(IContext context) + { + return context + .GetValues(this.Name, enforceContext: true) + .Any(value => this.Values.Contains(value)); + } + } +} diff --git a/Mods/ContentPatcher/Framework/Conditions/ConditionDictionary.cs b/Mods/ContentPatcher/Framework/Conditions/ConditionDictionary.cs new file mode 100644 index 00000000..e882239c --- /dev/null +++ b/Mods/ContentPatcher/Framework/Conditions/ConditionDictionary.cs @@ -0,0 +1,21 @@ +using System.Collections.Generic; +using ContentPatcher.Framework.Tokens; +using Pathoschild.Stardew.Common.Utilities; + +namespace ContentPatcher.Framework.Conditions +{ + /// A set of conditions that can be checked against the context. + internal class ConditionDictionary : Dictionary + { + /********* + ** Public methods + *********/ + /// Add an element with the given key and condition values. + /// The token name to add. + /// The token values to match. + public void Add(TokenName name, IEnumerable values) + { + this.Add(name, new Condition(name, new InvariantHashSet(values))); + } + } +} diff --git a/Mods/ContentPatcher/Framework/Conditions/ConditionType.cs b/Mods/ContentPatcher/Framework/Conditions/ConditionType.cs new file mode 100644 index 00000000..54f6fa7a --- /dev/null +++ b/Mods/ContentPatcher/Framework/Conditions/ConditionType.cs @@ -0,0 +1,93 @@ +namespace ContentPatcher.Framework.Conditions +{ + /// The condition types that can be checked. + internal enum ConditionType + { + /**** + ** Tokenisable basic conditions + ****/ + /// The day of month. + Day, + + /// The name. + DayOfWeek, + + /// The total number of days played in the current save. + DaysPlayed, + + /// The farm cave type. + FarmCave, + + /// The upgrade level for the main farmhouse. + FarmhouseUpgrade, + + /// The current farm name. + FarmName, + + /// The current farm type. + FarmType, + + /// The name. + Language, + + /// The name of the current player. + PlayerName, + + /// The gender of the current player. + PlayerGender, + + /// The preferred pet selected by the player. + PreferredPet, + + /// The season name. + Season, + + /// The current weather. + Weather, + + /// The current year number. + Year, + + /**** + ** Other basic conditions + ****/ + /// The name of today's festival (if any), or 'wedding' if the current player is getting married. + DayEvent, + + /// A letter ID or mail flag set for the player. + HasFlag, + + /// An installed mod ID. + HasMod, + + /// A profession ID the player has. + HasProfession, + + /// An event ID the player saw. + HasSeenEvent, + + /// The special items in the player's wallet. + HasWalletItem, + + /// The current player's internal spouse name (if any). + Spouse, + + /**** + ** Multi-part conditions + ****/ + /// The current player's number of hearts with the character. + Hearts, + + /// The current player's relationship status with the character (matching ) + Relationship, + + /// The current player's level for a skill. + SkillLevel, + + /**** + ** Magic conditions + ****/ + /// Whether a file exists in the content pack's folder. + HasFile + }; +} diff --git a/Mods/ContentPatcher/Framework/Conditions/PatchType.cs b/Mods/ContentPatcher/Framework/Conditions/PatchType.cs new file mode 100644 index 00000000..4ddfa106 --- /dev/null +++ b/Mods/ContentPatcher/Framework/Conditions/PatchType.cs @@ -0,0 +1,15 @@ +namespace ContentPatcher.Framework.Conditions +{ + /// The patch type. + internal enum PatchType + { + /// Load the initial version of the file. + Load, + + /// Edit an image. + EditImage, + + /// Edit a data file. + EditData + } +} diff --git a/Mods/ContentPatcher/Framework/Conditions/TokenString.cs b/Mods/ContentPatcher/Framework/Conditions/TokenString.cs new file mode 100644 index 00000000..16adbe2c --- /dev/null +++ b/Mods/ContentPatcher/Framework/Conditions/TokenString.cs @@ -0,0 +1,144 @@ +using System.Collections.Generic; +using System.Linq; +using System.Text; +using ContentPatcher.Framework.Lexing; +using ContentPatcher.Framework.Lexing.LexTokens; +using ContentPatcher.Framework.Tokens; +using Pathoschild.Stardew.Common.Utilities; + +namespace ContentPatcher.Framework.Conditions +{ + /// A string value which can contain condition tokens. + internal class TokenString + { + /********* + ** Fields + *********/ + /// The lexical tokens parsed from the raw string. + private readonly ILexToken[] LexTokens; + + /// The underlying value for . + private string ValueImpl; + + /// The underlying value for . + private bool IsReadyImpl; + + + /********* + ** Accessors + *********/ + /// The raw string without token substitution. + public string Raw { get; } + + /// The tokens used in the string. + public HashSet Tokens { get; } = new HashSet(); + + /// The unrecognised tokens in the string. + public InvariantHashSet InvalidTokens { get; } = new InvariantHashSet(); + + /// Whether the string contains any tokens (including invalid tokens). + public bool HasAnyTokens => this.Tokens.Count > 0 || this.InvalidTokens.Count > 0; + + /// Whether the token string value may change depending on the context. + public bool IsMutable { get; } + + /// Whether the token string consists of a single token with no surrounding text. + public bool IsSingleTokenOnly { get; } + + /// The string with tokens substituted for the last context update. + public string Value => this.ValueImpl; + + /// Whether all tokens in the value have been replaced. + public bool IsReady => this.IsReadyImpl; + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The raw string before token substitution. + /// The available token context. + public TokenString(string raw, IContext tokenContext) + { + // set raw value + this.Raw = raw?.Trim(); + if (string.IsNullOrWhiteSpace(this.Raw)) + { + this.ValueImpl = this.Raw; + this.IsReadyImpl = true; + return; + } + + // extract tokens + this.LexTokens = new Lexer().ParseBits(raw, impliedBraces: false).ToArray(); + foreach (LexTokenToken token in this.LexTokens.OfType()) + { + TokenName name = new TokenName(token.Name, token.InputArg?.Text); + if (tokenContext.Contains(name, enforceContext: false)) + this.Tokens.Add(name); + else + this.InvalidTokens.Add(token.Text); + } + + // set metadata + this.IsMutable = this.Tokens.Any(); + if (!this.IsMutable) + { + this.ValueImpl = this.Raw; + this.IsReadyImpl = !this.InvalidTokens.Any(); + } + this.IsSingleTokenOnly = this.LexTokens.Length == 1 && this.LexTokens.First().Type == LexTokenType.Token; + } + + /// Update the with the given tokens. + /// Provides access to contextual tokens. + /// Returns whether the value changed. + public bool UpdateContext(IContext context) + { + if (!this.IsMutable) + return false; + + string prevValue = this.Value; + this.GetApplied(context, out this.ValueImpl, out this.IsReadyImpl); + return this.Value != prevValue; + } + + + /********* + ** Private methods + *********/ + /// Get a new string with tokens substituted. + /// Provides access to contextual tokens. + /// The input string with tokens substituted. + /// Whether all tokens in the have been replaced. + private void GetApplied(IContext context, out string result, out bool isReady) + { + bool allReplaced = true; + StringBuilder str = new StringBuilder(); + foreach (ILexToken lexToken in this.LexTokens) + { + switch (lexToken) + { + case LexTokenToken lexTokenToken: + TokenName name = new TokenName(lexTokenToken.Name, lexTokenToken.InputArg?.Text); + IToken token = context.GetToken(name, enforceContext: true); + if (token != null) + str.Append(token.GetValues(name).FirstOrDefault()); + else + { + allReplaced = false; + str.Append(lexToken.Text); + } + break; + + default: + str.Append(lexToken.Text); + break; + } + } + + result = str.ToString(); + isReady = allReplaced; + } + } +} diff --git a/Mods/ContentPatcher/Framework/Conditions/Weather.cs b/Mods/ContentPatcher/Framework/Conditions/Weather.cs new file mode 100644 index 00000000..028a3cfd --- /dev/null +++ b/Mods/ContentPatcher/Framework/Conditions/Weather.cs @@ -0,0 +1,21 @@ +namespace ContentPatcher.Framework.Conditions +{ + /// An in-game weather. + internal enum Weather + { + /// The weather is sunny (including festival/wedding days). This is the default weather if no other value applies. + Sun, + + /// Rain is falling, but without lightning. + Rain, + + /// Rain is falling with lightning. + Storm, + + /// Snow is falling. + Snow, + + /// The wind is blowing with visible debris (e.g. flower petals in spring and leaves in fall). + Wind + } +} diff --git a/Mods/ContentPatcher/Framework/ConfigFileHandler.cs b/Mods/ContentPatcher/Framework/ConfigFileHandler.cs new file mode 100644 index 00000000..1763ebcb --- /dev/null +++ b/Mods/ContentPatcher/Framework/ConfigFileHandler.cs @@ -0,0 +1,206 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using ContentPatcher.Framework.Conditions; +using ContentPatcher.Framework.ConfigModels; +using ContentPatcher.Framework.Tokens; +using Pathoschild.Stardew.Common.Utilities; +using StardewModdingAPI; + +namespace ContentPatcher.Framework +{ + /// Handles the logic for reading, normalising, and saving the configuration for a content pack. + internal class ConfigFileHandler + { + /********* + ** Fields + *********/ + /// The name of the config file. + private readonly string Filename; + + /// Parse a comma-delimited set of case-insensitive condition values. + private readonly Func ParseCommaDelimitedField; + + /// A callback to invoke when a validation warning occurs. This is passed the content pack, label, and reason phrase respectively. + private readonly Action LogWarning; + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The name of the config file. + /// Parse a comma-delimited set of case-insensitive condition values. + /// A callback to invoke when a validation warning occurs. This is passed the content pack, label, and reason phrase respectively. + public ConfigFileHandler(string filename, Func parseCommandDelimitedField, Action logWarning) + { + this.Filename = filename; + this.ParseCommaDelimitedField = parseCommandDelimitedField; + this.LogWarning = logWarning; + } + + /// Read the configuration file for a content pack. + /// The content pack. + /// The raw config schema from the mod's content.json. + public InvariantDictionary Read(ManagedContentPack contentPack, InvariantDictionary rawSchema) + { + InvariantDictionary config = this.LoadConfigSchema(rawSchema, logWarning: (field, reason) => this.LogWarning(contentPack, $"{nameof(ContentConfig.ConfigSchema)} field '{field}'", reason)); + this.LoadConfigValues(contentPack, config, logWarning: (field, reason) => this.LogWarning(contentPack, $"{this.Filename} > {field}", reason)); + return config; + } + + /// Save the configuration file for a content pack. + /// The content pack. + /// The configuration to save. + /// The mod helper through which to save the file. + public void Save(ManagedContentPack contentPack, InvariantDictionary config, IModHelper modHelper) + { + // save if settings valid + if (config.Any()) + { + InvariantDictionary data = new InvariantDictionary(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(); + } + } + + + /********* + ** Private methods + *********/ + /// Parse a raw config schema for a content pack. + /// The raw config schema. + /// The callback to invoke on each validation warning, passed the field name and reason respectively. + private InvariantDictionary LoadConfigSchema(InvariantDictionary rawSchema, Action logWarning) + { + InvariantDictionary schema = new InvariantDictionary(); + 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; + } + + /// Load config values from the content pack. + /// The content pack whose config file to read. + /// The config schema. + /// The callback to invoke on each validation warning, passed the field name and reason respectively. + private void LoadConfigValues(ManagedContentPack contentPack, InvariantDictionary config, Action logWarning) + { + if (!config.Any()) + return; + + // read raw config + InvariantDictionary configValues = new InvariantDictionary( + from entry in (contentPack.ReadJsonFile>(this.Filename) ?? new InvariantDictionary()) + let key = entry.Key.Trim() + let value = this.ParseCommaDelimitedField(entry.Value) + select new KeyValuePair(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 + 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; + } + } + } + } +} diff --git a/Mods/ContentPatcher/Framework/ConfigModels/ConfigField.cs b/Mods/ContentPatcher/Framework/ConfigModels/ConfigField.cs new file mode 100644 index 00000000..544763c4 --- /dev/null +++ b/Mods/ContentPatcher/Framework/ConfigModels/ConfigField.cs @@ -0,0 +1,43 @@ +using Pathoschild.Stardew.Common.Utilities; + +namespace ContentPatcher.Framework.ConfigModels +{ + /// The parsed schema and value for a field in the config.json file. + internal class ConfigField + { + /********* + ** Accessors + *********/ + /// The values to allow. + public InvariantHashSet AllowValues { get; } + + /// The default values if the field is missing or (if is false) blank. + public InvariantHashSet DefaultValues { get; } + + /// Whether to allow blank values. + public bool AllowBlank { get; } + + /// Whether the player can specify multiple values for this field. + public bool AllowMultiple { get; } + + /// The value read from the player settings. + public InvariantHashSet Value { get; set; } + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The values to allow. + /// The default values if the field is missing or (if is false) blank. + /// Whether to allow blank values. + /// Whether the player can specify multiple values for this field. + public ConfigField(InvariantHashSet allowValues, InvariantHashSet defaultValues, bool allowBlank, bool allowMultiple) + { + this.AllowValues = allowValues; + this.DefaultValues = defaultValues; + this.AllowBlank = allowBlank; + this.AllowMultiple = allowMultiple; + } + } +} diff --git a/Mods/ContentPatcher/Framework/ConfigModels/ConfigSchemaFieldConfig.cs b/Mods/ContentPatcher/Framework/ConfigModels/ConfigSchemaFieldConfig.cs new file mode 100644 index 00000000..197ddf7c --- /dev/null +++ b/Mods/ContentPatcher/Framework/ConfigModels/ConfigSchemaFieldConfig.cs @@ -0,0 +1,18 @@ +namespace ContentPatcher.Framework.ConfigModels +{ + /// The schema for a field in the config.json file. + internal class ConfigSchemaFieldConfig + { + /// The comma-delimited values to allow. + public string AllowValues { get; set; } + + /// The default value if the field is missing or (if is false) blank. + public string Default { get; set; } + + /// Whether to allow blank values. + public bool AllowBlank { get; set; } = false; + + /// Whether the player can specify multiple values for this field. + public bool AllowMultiple { get; set; } = false; + } +} diff --git a/Mods/ContentPatcher/Framework/ConfigModels/ContentConfig.cs b/Mods/ContentPatcher/Framework/ConfigModels/ContentConfig.cs new file mode 100644 index 00000000..96b5c902 --- /dev/null +++ b/Mods/ContentPatcher/Framework/ConfigModels/ContentConfig.cs @@ -0,0 +1,21 @@ +using Pathoschild.Stardew.Common.Utilities; +using StardewModdingAPI; + +namespace ContentPatcher.Framework.ConfigModels +{ + /// The model for a content patch file. + internal class ContentConfig + { + /// The format version. + public ISemanticVersion Format { get; set; } + + /// The user-defined tokens whose values may depend on other tokens. + public DynamicTokenConfig[] DynamicTokens { get; set; } + + /// The changes to make. + public PatchConfig[] Changes { get; set; } + + /// The schema for the config.json file (if any). + public InvariantDictionary ConfigSchema { get; set; } + } +} diff --git a/Mods/ContentPatcher/Framework/ConfigModels/DynamicTokenConfig.cs b/Mods/ContentPatcher/Framework/ConfigModels/DynamicTokenConfig.cs new file mode 100644 index 00000000..197b81d2 --- /dev/null +++ b/Mods/ContentPatcher/Framework/ConfigModels/DynamicTokenConfig.cs @@ -0,0 +1,20 @@ +using Pathoschild.Stardew.Common.Utilities; + +namespace ContentPatcher.Framework.ConfigModels +{ + /// A user-defined token whose value may depend on other tokens. + internal class DynamicTokenConfig + { + /********* + ** Accessors + *********/ + /// The name of the token to set. + public string Name { get; set; } + + /// The value to set. + public string Value { get; set; } + + /// The criteria to apply. See readme for valid values. + public InvariantDictionary When { get; set; } + } +} diff --git a/Mods/ContentPatcher/Framework/ConfigModels/ModConfig.cs b/Mods/ContentPatcher/Framework/ConfigModels/ModConfig.cs new file mode 100644 index 00000000..b3f54728 --- /dev/null +++ b/Mods/ContentPatcher/Framework/ConfigModels/ModConfig.cs @@ -0,0 +1,39 @@ +using Newtonsoft.Json; +using Pathoschild.Stardew.Common; +using StardewModdingAPI; + +namespace ContentPatcher.Framework.ConfigModels +{ + /// The mod configuration. + internal class ModConfig + { + /********* + ** Accessors + *********/ + /// Whether to enable debug features. + public bool EnableDebugFeatures { get; set; } + + /// The control bindings. + public ModConfigControls Controls { get; set; } = new ModConfigControls(); + + + /********* + ** Nested models + *********/ + /// A set of control bindings. + internal class ModConfigControls + { + /// Toggle the display of debug information. + [JsonConverter(typeof(StringEnumArrayConverter))] + public SButton[] ToggleDebug { get; set; } = { SButton.F3 }; + + /// Switch to the previous texture. + [JsonConverter(typeof(StringEnumArrayConverter))] + public SButton[] DebugPrevTexture { get; set; } = { SButton.LeftControl }; + + /// Switch to the next texture. + [JsonConverter(typeof(StringEnumArrayConverter))] + public SButton[] DebugNextTexture { get; set; } = { SButton.RightControl }; + } + } +} diff --git a/Mods/ContentPatcher/Framework/ConfigModels/PatchConfig.cs b/Mods/ContentPatcher/Framework/ConfigModels/PatchConfig.cs new file mode 100644 index 00000000..733585bf --- /dev/null +++ b/Mods/ContentPatcher/Framework/ConfigModels/PatchConfig.cs @@ -0,0 +1,83 @@ +using System.Collections.Generic; +using Microsoft.Xna.Framework; +using Pathoschild.Stardew.Common.Utilities; + +namespace ContentPatcher.Framework.ConfigModels +{ + /// The input settings for a patch from the configuration file. + internal class PatchConfig + { + /********* + ** Accessors + *********/ + /**** + ** All actions + ****/ + /// A name for this patch shown in log messages. + public string LogName { get; set; } + + /// The patch type to apply. + public string Action { get; set; } + + /// The asset key to change. + public string Target { get; set; } + + /// Whether to apply this patch. + /// This must be a string to support config tokens. + public string Enabled { get; set; } = "true"; + + /// The criteria to apply. See readme for valid values. + public InvariantDictionary When { get; set; } + + /**** + ** Some actions + ****/ + /// The local file to load. + public string FromFile { get; set; } + + /**** + ** EditImage + ****/ + /// The sprite area from which to read an image. + public Rectangle FromArea { get; set; } + + /// The sprite area to overwrite. + public Rectangle ToArea { get; set; } + + /// Indicates how the image should be patched. + public string PatchMode { get; set; } + + /**** + ** EditData + ****/ + /// The data records to edit. + public IDictionary Entries { get; set; } + + /// The individual fields to edit in data records. + public IDictionary> Fields { get; set; } + + + /********* + ** Public methods + *********/ + /// Construct an instance. + public PatchConfig() { } + + /// Construct an instance. + /// The other patch to clone. + public PatchConfig(PatchConfig other) + { + this.LogName = other.LogName; + this.Action = other.Action; + this.Target = other.Target; + this.Enabled = other.Enabled; + this.When = other.When != null ? new InvariantDictionary(other.When) : null; + this.FromFile = other.FromFile; + this.FromArea = other.FromArea; + this.ToArea = other.ToArea; + this.PatchMode = other.PatchMode; + this.Entries = other.Entries != null ? new Dictionary(other.Entries) : null; + this.Fields = other.Fields != null ? new Dictionary>(other.Fields) : null; + } + } +} diff --git a/Mods/ContentPatcher/Framework/Constants/FarmCaveType.cs b/Mods/ContentPatcher/Framework/Constants/FarmCaveType.cs new file mode 100644 index 00000000..d9d5fb74 --- /dev/null +++ b/Mods/ContentPatcher/Framework/Constants/FarmCaveType.cs @@ -0,0 +1,17 @@ +using StardewValley; + +namespace ContentPatcher.Framework.Constants +{ + /// A farm cave type. + internal enum FarmCaveType + { + /// The player hasn't chosen a farm cave yet. + None = Farmer.caveNothing, + + /// The fruit bat cave. + Bats = Farmer.caveBats, + + /// The mushroom cave. + Mushrooms = Farmer.caveMushrooms + } +} diff --git a/Mods/ContentPatcher/Framework/Constants/FarmType.cs b/Mods/ContentPatcher/Framework/Constants/FarmType.cs new file mode 100644 index 00000000..1cd48ec9 --- /dev/null +++ b/Mods/ContentPatcher/Framework/Constants/FarmType.cs @@ -0,0 +1,26 @@ +using StardewValley; + +namespace ContentPatcher.Framework.Constants +{ + /// A farm type. + internal enum FarmType + { + /// The standard farm type. + Standard = 0, + + /// The riverland farm type. + Riverland = 1, + + /// The forest farm type. + Forest = 2, + + /// The hill-top farm type. + Hilltop = 3, + + /// The wilderness farm type. + Wilderness = 4, + + /// A custom farm type. + Custom = 100 + } +} diff --git a/Mods/ContentPatcher/Framework/Constants/Gender.cs b/Mods/ContentPatcher/Framework/Constants/Gender.cs new file mode 100644 index 00000000..1b1a2a75 --- /dev/null +++ b/Mods/ContentPatcher/Framework/Constants/Gender.cs @@ -0,0 +1,12 @@ +namespace ContentPatcher.Framework.Constants +{ + /// A player gender. + internal enum Gender + { + /// The female gender. + Female, + + /// The male gender. + Male + } +} diff --git a/Mods/ContentPatcher/Framework/Constants/PetType.cs b/Mods/ContentPatcher/Framework/Constants/PetType.cs new file mode 100644 index 00000000..c189bd77 --- /dev/null +++ b/Mods/ContentPatcher/Framework/Constants/PetType.cs @@ -0,0 +1,12 @@ +namespace ContentPatcher.Framework.Constants +{ + /// A pet type. + internal enum PetType + { + /// The cat pet. + Cat, + + /// The dog pet. + Dog + } +} diff --git a/Mods/ContentPatcher/Framework/Constants/Profession.cs b/Mods/ContentPatcher/Framework/Constants/Profession.cs new file mode 100644 index 00000000..8191cade --- /dev/null +++ b/Mods/ContentPatcher/Framework/Constants/Profession.cs @@ -0,0 +1,113 @@ +using StardewValley; + +namespace ContentPatcher.Framework.Constants +{ + /// A player profession. + internal enum Profession + { + /*** + ** Combat + ***/ + /// The acrobat profession for the combat skill. + Acrobat = Farmer.acrobat, + + /// The brute profession for the combat skill. + Brute = Farmer.brute, + + /// The defender profession for the combat skill. + Defender = Farmer.defender, + + /// The desperado profession for the combat skill. + Desperado = Farmer.desperado, + + /// The fighter profession for the combat skill. + Fighter = Farmer.fighter, + + /// The scout profession for the combat skill. + Scout = Farmer.scout, + + /*** + ** Farming + ***/ + /// The agriculturist profession for the farming skill. + Agriculturist = Farmer.agriculturist, + + /// The shepherd profession for the farming skill. + Artisan = Farmer.artisan, + + /// The coopmaster profession for the farming skill. + Coopmaster = Farmer.butcher, // game's constant name doesn't match usage + + /// The rancher profession for the farming skill. + Rancher = Farmer.rancher, + + /// The shepherd profession for the farming skill. + Shepherd = Farmer.shepherd, + + /// The tiller profession for the farming skill. + Tiller = Farmer.tiller, + + /*** + ** Fishing + ***/ + /// The angler profession for the fishing skill. + Angler = Farmer.angler, + + /// The fisher profession for the fishing skill. + Fisher = Farmer.fisher, + + /// The mariner profession for the fishing skill. + Mariner = Farmer.baitmaster, // game's constant name is confusing + + /// The pirate profession for the fishing skill. + Pirate = Farmer.pirate, + + /// The luremaster profession for the fishing skill. + Luremaster = Farmer.mariner, // game's constant name is confusing + + /// The trapper profession for the fishing skill. + Trapper = Farmer.trapper, + + /*** + ** Foraging + ***/ + /// The botanist profession for the foraging skill. + Botanist = Farmer.botanist, + + /// The forester profession for the foraging skill. + Forester = Farmer.forester, + + /// The gatherer profession for the foraging skill. + Gatherer = Farmer.gatherer, + + /// The lumberjack profession for the foraging skill. + Lumberjack = Farmer.lumberjack, + + /// The tapper profession for the foraging skill. + Tapper = Farmer.tapper, + + /// The tracker profession for the foraging skill. + Tracker = Farmer.tracker, + + /*** + ** Mining + ***/ + /// The blacksmith profession for the foraging skill. + Blacksmith = Farmer.blacksmith, + + /// The excavator profession for the foraging skill. + Excavator = Farmer.excavator, + + /// The gemologist profession for the foraging skill. + Gemologist = Farmer.gemologist, + + /// The geologist profession for the foraging skill. + Geologist = Farmer.geologist, + + /// The miner profession for the foraging skill. + Miner = Farmer.miner, + + /// The prospector profession for the foraging skill. + Prospector = Farmer.burrower, // game's constant name is confusing + } +} diff --git a/Mods/ContentPatcher/Framework/Constants/Skill.cs b/Mods/ContentPatcher/Framework/Constants/Skill.cs new file mode 100644 index 00000000..8d209152 --- /dev/null +++ b/Mods/ContentPatcher/Framework/Constants/Skill.cs @@ -0,0 +1,26 @@ +using StardewValley; + +namespace ContentPatcher.Framework.Constants +{ + /// A player Skill. + internal enum Skill + { + /// The combat skill. + Combat = Farmer.combatSkill, + + /// The farming skill. + Farming = Farmer.farmingSkill, + + /// The fishing skill. + Fishing = Farmer.fishingSkill, + + /// The foraging skill. + Foraging = Farmer.foragingSkill, + + /// The luck skill. + Luck = Farmer.luckSkill, + + /// The mining skill. + Mining = Farmer.miningSkill + } +} diff --git a/Mods/ContentPatcher/Framework/Constants/WalletItem.cs b/Mods/ContentPatcher/Framework/Constants/WalletItem.cs new file mode 100644 index 00000000..6a603d9c --- /dev/null +++ b/Mods/ContentPatcher/Framework/Constants/WalletItem.cs @@ -0,0 +1,36 @@ +namespace ContentPatcher.Framework.Constants +{ + /// A special item slot in the player's wallet. + internal enum WalletItem + { + /// Unlocks speaking to the Dwarf. + DwarvishTranslationGuide, + + /// Unlocks the sewers. + RustyKey, + + /// Unlocks the desert casino. + ClubCard, + + /// Permanently increases daily luck. + SpecialCharm, + + /// Unlocks the Skull Cavern in the desert, and the Junimo Kart machine in the Stardrop Saloon. + SkullKey, + + /// Unlocks the ability to find secret notes. + MagnifyingGlass, + + /// Unlocks the Witch's Swamp. + DarkTalisman, + + /// Unlocks magical buildings through the Wizard, and the dark shrines in the Witch's Swamp. + MagicInk, + + /// Increases sell price of blackberries and salmonberries. + BearsKnowledge, + + /// Increases sell price of spring onions. + SpringOnionMastery + } +} diff --git a/Mods/ContentPatcher/Framework/DebugOverlay.cs b/Mods/ContentPatcher/Framework/DebugOverlay.cs new file mode 100644 index 00000000..3915fecc --- /dev/null +++ b/Mods/ContentPatcher/Framework/DebugOverlay.cs @@ -0,0 +1,109 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Graphics; +using Pathoschild.Stardew.Common; +using Pathoschild.Stardew.Common.UI; +using StardewModdingAPI; +using StardewModdingAPI.Events; +using StardewValley; + +namespace ContentPatcher.Framework +{ + /// Renders debug information to the screen. + internal class DebugOverlay : BaseOverlay + { + /********* + ** Fields + *********/ + /// The size of the margin around the displayed legend. + private readonly int Margin = 30; + + /// The padding between the border and content. + private readonly int Padding = 5; + + /// The content helper from which to read textures. + private readonly IContentHelper Content; + + /// The spritesheets to render. + private readonly string[] TextureNames; + + /// The current spritesheet to display. + private string CurrentName; + + /// The current texture to display. + private Texture2D CurrentTexture; + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The SMAPI events available for mods. + /// An API for checking and changing input state. + /// The content helper from which to read textures. + public DebugOverlay(IModEvents events, IInputHelper inputHelper, IContentHelper contentHelper) + : base(events, inputHelper) + { + this.Content = contentHelper; + this.TextureNames = this.GetTextureNames(contentHelper).OrderByIgnoreCase(p => p).ToArray(); + this.NextTexture(); + } + + /// Switch to the next texture. + public void NextTexture() + { + int index = Array.IndexOf(this.TextureNames, this.CurrentName) + 1; + if (index >= this.TextureNames.Length) + index = 0; + this.CurrentName = this.TextureNames[index]; + this.CurrentTexture = this.Content.Load(this.CurrentName, ContentSource.GameContent); + } + + /// Switch to the previous data map. + public void PrevTexture() + { + int index = Array.IndexOf(this.TextureNames, this.CurrentName) - 1; + if (index < 0) + index = this.TextureNames.Length - 1; + this.CurrentName = this.TextureNames[index]; + this.CurrentTexture = this.Content.Load(this.CurrentName, ContentSource.GameContent); + } + + + /********* + ** Protected methods + *********/ + /// Draw to the screen. + /// The sprite batch to which to draw. + protected override void Draw(SpriteBatch spriteBatch) + { + Vector2 labelSize = Game1.smallFont.MeasureString(this.CurrentName); + int contentWidth = (int)Math.Max(labelSize.X, this.CurrentTexture?.Width ?? 0); + + CommonHelper.DrawScroll(spriteBatch, new Vector2(this.Margin), new Vector2(contentWidth, labelSize.Y + this.Padding + (this.CurrentTexture?.Height ?? (int)labelSize.Y)), out Vector2 contentPos, out Rectangle _, padding: this.Padding); + spriteBatch.DrawString(Game1.smallFont, this.CurrentName, new Vector2(contentPos.X + ((contentWidth - labelSize.X) / 2), contentPos.Y), Color.Black); + + if (this.CurrentTexture != null) + spriteBatch.Draw(this.CurrentTexture, contentPos + new Vector2(0, labelSize.Y + this.Padding), Color.White); + else + spriteBatch.DrawString(Game1.smallFont, "(null)", contentPos + new Vector2(0, labelSize.Y + this.Padding), Color.Black); + } + + /// Get all texture asset names in the given content helper. + /// The content helper to search. + private IEnumerable GetTextureNames(IContentHelper contentHelper) + { + // get all texture keys from the content helper (this is such a hack) + IList textureKeys = new List(); + contentHelper.InvalidateCache(asset => + { + if (asset.DataType == typeof(Texture2D) && !asset.AssetName.Contains("..") && !asset.AssetName.StartsWith(StardewModdingAPI.Constants.ExecutionPath)) + textureKeys.Add(asset.AssetName); + return false; + }); + return textureKeys; + } + } +} diff --git a/Mods/ContentPatcher/Framework/GenericTokenContext.cs b/Mods/ContentPatcher/Framework/GenericTokenContext.cs new file mode 100644 index 00000000..ecf711f7 --- /dev/null +++ b/Mods/ContentPatcher/Framework/GenericTokenContext.cs @@ -0,0 +1,84 @@ +using System; +using System.Collections.Generic; +using ContentPatcher.Framework.Tokens; + +namespace ContentPatcher.Framework +{ + /// A generic token context. + /// The token type to store. + internal class GenericTokenContext : IContext where TToken : class, IToken + { + /********* + ** Accessors + *********/ + /// The available tokens. + public IDictionary Tokens { get; } = new Dictionary(); + + + /********* + ** Accessors + *********/ + /// Save the given token to the context. + /// The token to save. + public void Save(TToken token) + { + this.Tokens[token.Name] = token; + } + + /// Get whether the context contains the given token. + /// The token name. + /// Whether to only consider tokens that are available in the context. + public bool Contains(TokenName name, bool enforceContext) + { + return this.GetToken(name, enforceContext) != null; + } + + /// Get the underlying token which handles a key. + /// The token name. + /// Whether to only consider tokens that are available in the context. + /// Returns the matching token, or null if none was found. + public IToken GetToken(TokenName name, bool enforceContext) + { + return this.Tokens.TryGetValue(name.GetRoot(), out TToken token) && this.ShouldConsider(token, enforceContext) + ? token + : null; + } + + /// Get the underlying tokens. + /// Whether to only consider tokens that are available in the context. + public IEnumerable GetTokens(bool enforceContext) + { + foreach (TToken token in this.Tokens.Values) + { + if (this.ShouldConsider(token, enforceContext)) + yield return token; + } + } + + /// Get the current values of the given token for comparison. + /// The token name. + /// Whether to only consider tokens that are available in the context. + /// Return the values of the matching token, or an empty list if the token doesn't exist. + /// The specified key is null. + public IEnumerable GetValues(TokenName name, bool enforceContext) + { + IToken token = this.GetToken(name, enforceContext); + return token?.GetValues(name) ?? new string[0]; + } + + + /********* + ** Private methods + *********/ + /// Get whether a given token should be considered. + /// The token to check. + /// Whether to only consider tokens that are available in the context. + private bool ShouldConsider(IToken token, bool enforceContext) + { + return !enforceContext || token.IsValidInContext; + } + } + + /// A generic token context. + internal class GenericTokenContext : GenericTokenContext { } +} diff --git a/Mods/ContentPatcher/Framework/Lexing/LexTokens/ILexToken.cs b/Mods/ContentPatcher/Framework/Lexing/LexTokens/ILexToken.cs new file mode 100644 index 00000000..55bd0d24 --- /dev/null +++ b/Mods/ContentPatcher/Framework/Lexing/LexTokens/ILexToken.cs @@ -0,0 +1,15 @@ +namespace ContentPatcher.Framework.Lexing.LexTokens +{ + /// A lexical token within a string, which combines one or more patterns into a cohesive part. + internal interface ILexToken + { + /********* + ** Accessors + *********/ + /// The lexical token type. + LexTokenType Type { get; } + + /// A text representation of the lexical token. + string Text { get; } + } +} diff --git a/Mods/ContentPatcher/Framework/Lexing/LexTokens/LexBit.cs b/Mods/ContentPatcher/Framework/Lexing/LexTokens/LexBit.cs new file mode 100644 index 00000000..aa0ba7a7 --- /dev/null +++ b/Mods/ContentPatcher/Framework/Lexing/LexTokens/LexBit.cs @@ -0,0 +1,28 @@ +namespace ContentPatcher.Framework.Lexing.LexTokens +{ + /// A low-level character pattern within a string/ + internal class LexBit + { + /********* + ** Accessors + *********/ + /// The lexical character pattern type. + public LexBitType Type { get; } + + /// The raw matched text. + public string Text { get; } + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The lexical character pattern type. + /// The raw matched text. + public LexBit(LexBitType type, string text) + { + this.Type = type; + this.Text = text; + } + } +} diff --git a/Mods/ContentPatcher/Framework/Lexing/LexTokens/LexBitType.cs b/Mods/ContentPatcher/Framework/Lexing/LexTokens/LexBitType.cs new file mode 100644 index 00000000..2909cbe1 --- /dev/null +++ b/Mods/ContentPatcher/Framework/Lexing/LexTokens/LexBitType.cs @@ -0,0 +1,21 @@ +namespace ContentPatcher.Framework.Lexing.LexTokens +{ + /// A lexical character pattern type. + public enum LexBitType + { + /// A literal string. + Literal, + + /// The characters which start a token ('{{'). + StartToken, + + /// The characters which end a token ('}}'). + EndToken, + + /// The character which separates a token name from its input argument (':'). + InputArgSeparator, + + /// The character which pipes the output of one token into the input of another ('|'). + TokenPipe + } +} diff --git a/Mods/ContentPatcher/Framework/Lexing/LexTokens/LexTokenInputArg.cs b/Mods/ContentPatcher/Framework/Lexing/LexTokens/LexTokenInputArg.cs new file mode 100644 index 00000000..a55009b0 --- /dev/null +++ b/Mods/ContentPatcher/Framework/Lexing/LexTokens/LexTokenInputArg.cs @@ -0,0 +1,33 @@ +using System.Linq; + +namespace ContentPatcher.Framework.Lexing.LexTokens +{ + /// A lexical token representing the input argument for a Content Patcher token. + internal readonly struct LexTokenInputArg : ILexToken + { + /********* + ** Accessors + *********/ + /// The lexical token type. + public LexTokenType Type { get; } + + /// A text representation of the lexical token. + public string Text { get; } + + /// The lexical tokens making up the input argument. + public ILexToken[] Parts { get; } + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The lexical tokens making up the input argument. + public LexTokenInputArg(ILexToken[] tokenParts) + { + this.Type = LexTokenType.TokenInput; + this.Text = string.Join("", tokenParts.Select(p => p.Text)); + this.Parts = tokenParts; + } + } +} diff --git a/Mods/ContentPatcher/Framework/Lexing/LexTokens/LexTokenLiteral.cs b/Mods/ContentPatcher/Framework/Lexing/LexTokens/LexTokenLiteral.cs new file mode 100644 index 00000000..8bec5848 --- /dev/null +++ b/Mods/ContentPatcher/Framework/Lexing/LexTokens/LexTokenLiteral.cs @@ -0,0 +1,27 @@ +namespace ContentPatcher.Framework.Lexing.LexTokens +{ + /// A lexical token representing a literal string value. + internal readonly struct LexTokenLiteral : ILexToken + { + /********* + ** Accessors + *********/ + /// The lexical token type. + public LexTokenType Type { get; } + + /// A text representation of the lexical token. + public string Text { get; } + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The literal text value. + public LexTokenLiteral(string text) + { + this.Type = LexTokenType.Literal; + this.Text = text; + } + } +} diff --git a/Mods/ContentPatcher/Framework/Lexing/LexTokens/LexTokenPipe.cs b/Mods/ContentPatcher/Framework/Lexing/LexTokens/LexTokenPipe.cs new file mode 100644 index 00000000..d7f4ab71 --- /dev/null +++ b/Mods/ContentPatcher/Framework/Lexing/LexTokens/LexTokenPipe.cs @@ -0,0 +1,27 @@ +namespace ContentPatcher.Framework.Lexing.LexTokens +{ + /// A lexical token which represents a pipe that transfers the output of one token into the input of another. + internal readonly struct LexTokenPipe : ILexToken + { + /********* + ** Accessors + *********/ + /// The lexical token type. + public LexTokenType Type { get; } + + /// A text representation of the lexical token. + public string Text { get; } + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// A text representation of the lexical token. + public LexTokenPipe(string text) + { + this.Type = LexTokenType.TokenPipe; + this.Text = text; + } + } +} diff --git a/Mods/ContentPatcher/Framework/Lexing/LexTokens/LexTokenToken.cs b/Mods/ContentPatcher/Framework/Lexing/LexTokens/LexTokenToken.cs new file mode 100644 index 00000000..3735f99f --- /dev/null +++ b/Mods/ContentPatcher/Framework/Lexing/LexTokens/LexTokenToken.cs @@ -0,0 +1,72 @@ +using System.Text; + +namespace ContentPatcher.Framework.Lexing.LexTokens +{ + /// A lexical token representing a Content Patcher token. + internal readonly struct LexTokenToken : ILexToken + { + /********* + ** Accessors + *********/ + /// The lexical token type. + public LexTokenType Type { get; } + + /// A text representation of the lexical token. + public string Text { get; } + + /// The Content Patcher token name. + public string Name { get; } + + /// The input argument passed to the Content Patcher token. + public LexTokenInputArg? InputArg { get; } + + /// Whether the token omits the start/end character patterns because it's in a token-only context. + public bool ImpliedBraces { get; } + + /// A sequence of tokens to invoke after this token is processed, each getting the output of the previous token as its input. + public LexTokenToken[] PipedTokens { get; } + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The Content Patcher token name. + /// The input argument passed to the Content Patcher token. + /// Whether the token omits the start/end character patterns because it's in a token-only context. + /// A sequence of tokens to invoke after this token is processed, each getting the output of the previous token as its input. + public LexTokenToken(string name, LexTokenInputArg? inputArg, bool impliedBraces, LexTokenToken[] pipedTokens) + { + this.Type = LexTokenType.Token; + this.Text = LexTokenToken.GetRawText(name, inputArg, impliedBraces); + this.Name = name; + this.InputArg = inputArg; + this.ImpliedBraces = impliedBraces; + this.PipedTokens = pipedTokens; + } + + + /********* + ** Private methods + *********/ + /// Get a string representation of a token. + /// The Content Patcher token name. + /// The input argument passed to the Content Patcher token. + /// Whether the token omits the start/end character patterns because it's in a token-only context. + private static string GetRawText(string name, LexTokenInputArg? tokenInputArgArgument, bool impliedBraces) + { + StringBuilder str = new StringBuilder(); + if (!impliedBraces) + str.Append("{{"); + str.Append(name); + if (tokenInputArgArgument != null) + { + str.Append(":"); + str.Append(tokenInputArgArgument.Value.Text); + } + if (!impliedBraces) + str.Append("}}"); + return str.ToString(); + } + } +} diff --git a/Mods/ContentPatcher/Framework/Lexing/LexTokens/LexTokenType.cs b/Mods/ContentPatcher/Framework/Lexing/LexTokens/LexTokenType.cs new file mode 100644 index 00000000..5d6eed19 --- /dev/null +++ b/Mods/ContentPatcher/Framework/Lexing/LexTokens/LexTokenType.cs @@ -0,0 +1,18 @@ +namespace ContentPatcher.Framework.Lexing.LexTokens +{ + /// A lexical token type. + public enum LexTokenType + { + /// A literal string. + Literal, + + /// A Content Patcher token. + Token, + + /// The input argument to a Content Patcher token. + TokenInput, + + /// A pipe which transfers the output of one token into the input of another. + TokenPipe + } +} diff --git a/Mods/ContentPatcher/Framework/Lexing/Lexer.cs b/Mods/ContentPatcher/Framework/Lexing/Lexer.cs new file mode 100644 index 00000000..f2d16b3a --- /dev/null +++ b/Mods/ContentPatcher/Framework/Lexing/Lexer.cs @@ -0,0 +1,305 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.RegularExpressions; +using ContentPatcher.Framework.Lexing.LexTokens; + +namespace ContentPatcher.Framework.Lexing +{ + /// Handles parsing raw strings into tokens. + internal class Lexer + { + /********* + ** Fields + *********/ + /// A regular expression which matches lexical patterns that split lexical patterns. For example, ':' is a pattern that splits a token name and its input arguments. The split pattern is itself a lexical pattern. + private readonly Regex LexicalSplitPattern = new Regex(@"({{|}}|:|\|)", RegexOptions.Compiled); + + + /********* + ** Public methods + *********/ + /// Break a raw string into its constituent lexical character patterns. + /// The raw text to tokenise. + public IEnumerable TokeniseString(string rawText) + { + // special cases + if (rawText == null) + yield break; + if (string.IsNullOrWhiteSpace(rawText)) + { + yield return new LexBit(LexBitType.Literal, rawText); + yield break; + } + + // parse + string[] parts = this.LexicalSplitPattern.Split(rawText); + foreach (string part in parts) + { + if (part == "") + continue; // split artifact + + LexBitType type; + switch (part) + { + case "{{": + type = LexBitType.StartToken; + break; + + case "}}": + type = LexBitType.EndToken; + break; + + case ":": + type = LexBitType.InputArgSeparator; + break; + + case "|": + type = LexBitType.TokenPipe; + break; + + default: + type = LexBitType.Literal; + break; + } + + yield return new LexBit(type, part); + } + } + + /// Parse a sequence of lexical character patterns into higher-level lexical tokens. + /// The raw text to tokenise. + /// Whether we're parsing a token context (so the outer '{{' and '}}' are implied); else parse as a tokenisable string which main contain a mix of literal and {{token}} values. + public IEnumerable ParseBits(string rawText, bool impliedBraces) + { + IEnumerable bits = this.TokeniseString(rawText); + return this.ParseBits(bits, impliedBraces); + } + + /// Parse a sequence of lexical character patterns into higher-level lexical tokens. + /// The lexical character patterns to parse. + /// Whether we're parsing a token context (so the outer '{{' and '}}' are implied); else parse as a tokenisable string which main contain a mix of literal and {{token}} values. + public IEnumerable ParseBits(IEnumerable bits, bool impliedBraces) + { + return this.ParseBitQueue(new Queue(bits), impliedBraces, trim: false); + } + + + /********* + ** Private methods + *********/ + /// Parse a sequence of lexical character patterns into higher-level lexical tokens. + /// The lexical character patterns to parse. + /// Whether we're parsing a token context (so the outer '{{' and '}}' are implied); else parse as a tokenisable string which main contain a mix of literal and {{token}} values. + /// Whether the value should be trimmed. + private IEnumerable ParseBitQueue(Queue input, bool impliedBraces, bool trim) + { + // perform a raw parse + IEnumerable RawParse() + { + // 'Implied braces' means we're parsing inside a token. This necessarily starts with a token name, + // optionally followed by an input argument and token pipes. + if (impliedBraces) + { + while (input.Any()) + { + yield return this.ExtractToken(input, impliedBraces: true); + if (!input.Any()) + yield break; + + var next = input.Peek(); + switch (next.Type) + { + case LexBitType.TokenPipe: + yield return new LexTokenPipe(input.Dequeue().Text); + break; + + default: + throw new InvalidOperationException($"Unexpected {next.Type}, expected {LexBitType.Literal} or {LexBitType.TokenPipe}"); + } + } + yield break; + } + + // Otherwise this is a tokenisable string which may contain a mix of literal and {{token}} values. + while (input.Any()) + { + LexBit next = input.Peek(); + switch (next.Type) + { + // start token + case LexBitType.StartToken: + yield return this.ExtractToken(input, impliedBraces: false); + break; + + // pipe/separator outside token + case LexBitType.Literal: + case LexBitType.TokenPipe: + case LexBitType.InputArgSeparator: + input.Dequeue(); + yield return new LexTokenLiteral(next.Text); + break; + + // anything else is invalid + default: + throw new InvalidOperationException($"Unexpected {next.Type}, expected {LexBitType.StartToken} or {LexBitType.Literal}"); + } + } + } + + // normalise literal values + LinkedList tokens = new LinkedList(RawParse()); + IList> removeQueue = new List>(); + for (LinkedListNode node = tokens.First; node != null; node = node.Next) + { + if (node.Value.Type != LexTokenType.Literal) + continue; + + // fetch info + ILexToken current = node.Value; + ILexToken previous = node.Previous?.Value; + ILexToken next = node.Next?.Value; + string newText = node.Value.Text; + + // collapse sequential literals + if (previous?.Type == LexTokenType.Literal) + { + newText = previous.Text + newText; + removeQueue.Add(node.Previous); + } + + // trim before/after separator + if (next?.Type == LexTokenType.TokenInput || next?.Type == LexTokenType.TokenPipe) + newText = newText.TrimEnd(); + if (previous?.Type == LexTokenType.TokenInput || previous?.Type == LexTokenType.TokenPipe) + newText = newText.TrimStart(); + + // trim whole result + if (trim && (previous == null || next == null)) + { + if (previous == null) + newText = newText.TrimStart(); + if (next == null) + newText = newText.TrimEnd(); + + if (newText == "") + removeQueue.Add(node); + } + + // replace value if needed + if (newText != current.Text) + node.Value = new LexTokenLiteral(newText); + } + foreach (LinkedListNode entry in removeQueue) + tokens.Remove(entry); + + // yield result + return tokens; + } + + /// Extract a token from the front of a lexical input queue. + /// The input from which to extract a token. The extracted lexical bits will be removed from the queue. + /// Whether we're parsing a token context (so the outer '{{' and '}}' are implied); else parse as a tokenisable string which main contain a mix of literal and {{token}} values. + /// Whether a should signal the end of the token. Only valid if is true. + /// Returns the token, or multiple tokens if chained using . + public LexTokenToken ExtractToken(Queue input, bool impliedBraces, bool endBeforePipe = false) + { + LexBit GetNextAndAssert() + { + if (!input.Any()) + throw new InvalidOperationException(); + return input.Dequeue(); + } + + // start token + if (!impliedBraces) + { + LexBit startToken = GetNextAndAssert(); + if (startToken.Type != LexBitType.StartToken) + throw new InvalidOperationException($"Unexpected {startToken.Type} at start of token."); + } + + // extract token name + LexBit name = GetNextAndAssert(); + if (name.Type != LexBitType.Literal) + throw new InvalidOperationException($"Unexpected {name.Type} where token name should be."); + + // extract input argument if present + LexTokenInputArg? inputArg = null; + if (input.Any() && input.Peek().Type == LexBitType.InputArgSeparator) + { + input.Dequeue(); + inputArg = this.ExtractInputArgument(input); + } + + // extract piped tokens + IList pipedTokens = new List(); + if (!endBeforePipe) + { + while (input.Any() && input.Peek().Type == LexBitType.TokenPipe) + { + input.Dequeue(); + pipedTokens.Add(this.ExtractToken(input, impliedBraces: true, endBeforePipe: true)); + } + } + + // end token + if (!impliedBraces) + { + LexBit endToken = GetNextAndAssert(); + if (endToken.Type != LexBitType.EndToken) + throw new InvalidOperationException($"Unexpected {endToken.Type} before end of token."); + } + + return new LexTokenToken(name.Text.Trim(), inputArg, impliedBraces, pipedTokens.ToArray()); + } + + /// Extract a token input argument from the front of a lexical input queue. + /// The input from which to extract an input argument. The extracted lexical bits will be removed from the queue. + public LexTokenInputArg ExtractInputArgument(Queue input) + { + // extract input arg parts + Queue inputArgBits = new Queue(); + int tokenDepth = 0; + bool reachedEnd = false; + while (!reachedEnd && input.Any()) + { + LexBit next = input.Peek(); + switch (next.Type) + { + case LexBitType.StartToken: + tokenDepth++; + inputArgBits.Enqueue(input.Dequeue()); + break; + + case LexBitType.TokenPipe: + if (tokenDepth > 0) + throw new InvalidOperationException($"Unexpected {next.Type} within token input argument"); + + reachedEnd = true; + break; + + case LexBitType.EndToken: + tokenDepth--; + + if (tokenDepth < 0) + { + reachedEnd = true; + break; + } + + inputArgBits.Enqueue(input.Dequeue()); + break; + + default: + inputArgBits.Enqueue(input.Dequeue()); + break; + } + } + + // parse + ILexToken[] tokenised = this.ParseBitQueue(inputArgBits, impliedBraces: false, trim: true).ToArray(); + return new LexTokenInputArg(tokenised); + } + } +} diff --git a/Mods/ContentPatcher/Framework/ManagedContentPack.cs b/Mods/ContentPatcher/Framework/ManagedContentPack.cs new file mode 100644 index 00000000..2db4cbce --- /dev/null +++ b/Mods/ContentPatcher/Framework/ManagedContentPack.cs @@ -0,0 +1,111 @@ +using System; +using System.Collections.Generic; +using System.IO; +using Pathoschild.Stardew.Common; +using StardewModdingAPI; + +namespace ContentPatcher.Framework +{ + /// Handles loading assets from content packs. + internal class ManagedContentPack + { + /********* + ** Fields + *********/ + /// A dictionary which matches case-insensitive relative paths to the exact path on disk, for case-insensitive file lookups on Linux/Mac. + private IDictionary RelativePaths; + + + /********* + ** Accessors + *********/ + /// The managed content pack. + public IContentPack Pack { get; } + + /// The content pack's manifest. + public IManifest Manifest => this.Pack.Manifest; + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The content pack to manage. + public ManagedContentPack(IContentPack pack) + { + this.Pack = pack; + } + + /// Get whether a file exists in the content pack. + /// The asset key. + public bool HasFile(string key) + { + return this.GetRealPath(key) != null; + } + + /// Get an asset from the content pack. + /// The asset type. + /// The asset key. + public T Load(string key) + { + key = this.GetRealPath(key) ?? throw new FileNotFoundException($"The file '{key}' does not exist in the {this.Pack.Manifest.Name} content patch folder."); + return this.Pack.LoadAsset(key); + } + + /// Read a JSON file from the content pack folder. + /// The model type. + /// The file path relative to the content pack directory. + /// Returns the deserialised model, or null if the file doesn't exist or is empty. + public TModel ReadJsonFile(string path) where TModel : class + { + return this.Pack.ReadJsonFile(path); + } + + /// Save data to a JSON file in the content pack's folder. + /// The model type. This should be a plain class that has public properties for the data you want. The properties can be complex types. + /// The file path relative to the mod folder. + /// The arbitrary data to save. + public void WriteJsonFile(string path, TModel data) where TModel : class + { + this.Pack.WriteJsonFile(path, data); + } + + /// Get the raw absolute path for a path within the content pack. + /// The path relative to the content pack folder. + public string GetFullPath(string relativePath) + { + return Path.Combine(this.Pack.DirectoryPath, relativePath); + } + + + /********* + ** Private methods + *********/ + /// Get the actual relative path within the content pack for a file, matched case-insensitively, or null if not found. + /// The case-insensitive asset key. + private string GetRealPath(string key) + { + key = PathUtilities.NormalisePathSeparators(key); + + // cache file paths + if (this.RelativePaths == null) + { + this.RelativePaths = new Dictionary(StringComparer.InvariantCultureIgnoreCase); + foreach (string path in this.GetRealRelativePaths()) + this.RelativePaths[path] = path; + } + + // find match + return this.RelativePaths.TryGetValue(key, out string relativePath) + ? relativePath + : null; + } + + /// Get all relative paths in the content pack directory. + private IEnumerable GetRealRelativePaths() + { + foreach (string path in Directory.EnumerateFiles(this.Pack.DirectoryPath, "*", SearchOption.AllDirectories)) + yield return path.Substring(this.Pack.DirectoryPath.Length + 1); + } + } +} diff --git a/Mods/ContentPatcher/Framework/Migrations/AggregateMigration.cs b/Mods/ContentPatcher/Framework/Migrations/AggregateMigration.cs new file mode 100644 index 00000000..81ecee1f --- /dev/null +++ b/Mods/ContentPatcher/Framework/Migrations/AggregateMigration.cs @@ -0,0 +1,105 @@ +using System.Collections.Generic; +using System.Linq; +using ContentPatcher.Framework.Conditions; +using ContentPatcher.Framework.ConfigModels; +using ContentPatcher.Framework.Tokens; +using StardewModdingAPI; + +namespace ContentPatcher.Framework.Migrations +{ + /// Aggregates content pack migrations. + internal class AggregateMigration : IMigration + { + /********* + ** Fields + *********/ + /// The valid format versions. + private readonly HashSet ValidVersions; + + /// The migrations to apply. + private readonly IMigration[] Migrations; + + + /********* + ** Accessors + *********/ + /// The version to which this migration applies. + public ISemanticVersion Version { get; } + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The content pack version. + /// The valid format versions. + /// The migrations to apply. + public AggregateMigration(ISemanticVersion version, string[] validVersions, IMigration[] migrations) + { + this.Version = version; + this.ValidVersions = new HashSet(validVersions); + this.Migrations = migrations.Where(m => m.Version.IsNewerThan(version)).ToArray(); + } + + /// Migrate a content pack. + /// The content pack data to migrate. + /// An error message which indicates why migration failed. + /// Returns whether the content pack was successfully migrated. + public bool TryMigrate(ContentConfig content, out string error) + { + // validate format version + if (!this.ValidVersions.Contains(content.Format.ToString())) + { + error = $"unsupported format {content.Format} (supported version: {string.Join(", ", this.ValidVersions)})."; + return false; + } + + // apply migrations + foreach (IMigration migration in this.Migrations) + { + if (!migration.TryMigrate(content, out error)) + return false; + } + + // no issues found + error = null; + return true; + } + + /// Migrate a token name. + /// The token name to migrate. + /// An error message which indicates why migration failed (if any). + /// Returns whether migration succeeded. + public bool TryMigrate(ref TokenName name, out string error) + { + // apply migrations + foreach (IMigration migration in this.Migrations) + { + if (!migration.TryMigrate(ref name, out error)) + return false; + } + + // no issues found + error = null; + return true; + } + + /// Migrate a tokenised string. + /// The tokenised string to migrate. + /// An error message which indicates why migration failed (if any). + /// Returns whether migration succeeded. + public bool TryMigrate(ref TokenString tokenStr, out string error) + { + // apply migrations + foreach (IMigration migration in this.Migrations) + { + if (!migration.TryMigrate(ref tokenStr, out error)) + return false; + } + + // no issues found + error = null; + return true; + } + } +} diff --git a/Mods/ContentPatcher/Framework/Migrations/BaseMigration.cs b/Mods/ContentPatcher/Framework/Migrations/BaseMigration.cs new file mode 100644 index 00000000..5d09c324 --- /dev/null +++ b/Mods/ContentPatcher/Framework/Migrations/BaseMigration.cs @@ -0,0 +1,96 @@ +using ContentPatcher.Framework.Conditions; +using ContentPatcher.Framework.ConfigModels; +using ContentPatcher.Framework.Tokens; +using Pathoschild.Stardew.Common.Utilities; +using StardewModdingAPI; + +namespace ContentPatcher.Framework.Migrations +{ + /// The base implementation for a format version migrator. + internal abstract class BaseMigration : IMigration + { + /********* + ** Private methods + *********/ + /// The tokens added in this format version. + protected InvariantHashSet AddedTokens { get; set; } + + + /********* + ** Accessors + *********/ + /// The format version to which this migration applies. + public ISemanticVersion Version { get; } + + + /********* + ** Public methods + *********/ + /// Migrate a content pack. + /// The content pack data to migrate. + /// An error message which indicates why migration failed. + /// Returns whether the content pack was successfully migrated. + public virtual bool TryMigrate(ContentConfig content, out string error) + { + error = null; + return true; + } + + /// Migrate a token name. + /// The token name to migrate. + /// An error message which indicates why migration failed (if any). + /// Returns whether migration succeeded. + public virtual bool TryMigrate(ref TokenName name, out string error) + { + // tokens which need a higher version + if (this.AddedTokens.Contains(name.Key)) + { + error = this.GetNounPhraseError($"using token {name}"); + return false; + } + + // no issue found + error = null; + return true; + } + + /// Migrate a tokenised string. + /// The tokenised string to migrate. + /// An error message which indicates why migration failed (if any). + /// Returns whether migration succeeded. + public virtual bool TryMigrate(ref TokenString tokenStr, out string error) + { + // tokens which need a high version + foreach (TokenName token in tokenStr.Tokens) + { + if (this.AddedTokens.Contains(token.Key)) + { + error = this.GetNounPhraseError($"using token {token.Key}"); + return false; + } + } + + // no issue found + error = null; + return true; + } + + + /********* + ** Protected methods + *********/ + /// Construct an instance. + /// The version to which this migration applies. + protected BaseMigration(ISemanticVersion version) + { + this.Version = version; + } + + /// Get an error message indicating an action or feature requires a newer format version. + /// The noun phrase, like "using X feature". + protected string GetNounPhraseError(string nounPhrase) + { + return $"{nounPhrase} requires {nameof(ContentConfig.Format)} version {this.Version} or later"; + } + } +} diff --git a/Mods/ContentPatcher/Framework/Migrations/IMigration.cs b/Mods/ContentPatcher/Framework/Migrations/IMigration.cs new file mode 100644 index 00000000..a8704ebc --- /dev/null +++ b/Mods/ContentPatcher/Framework/Migrations/IMigration.cs @@ -0,0 +1,39 @@ +using ContentPatcher.Framework.Conditions; +using ContentPatcher.Framework.ConfigModels; +using ContentPatcher.Framework.Tokens; +using StardewModdingAPI; + +namespace ContentPatcher.Framework.Migrations +{ + /// Migrates patches to a given format version. + internal interface IMigration + { + /********* + ** Accessors + *********/ + /// The format version to which this migration applies. + ISemanticVersion Version { get; } + + + /********* + ** Public methods + *********/ + /// Migrate a content pack. + /// The content pack data to migrate. + /// An error message which indicates why migration failed. + /// Returns whether migration succeeded. + bool TryMigrate(ContentConfig content, out string error); + + /// Migrate a token name. + /// The token name to migrate. + /// An error message which indicates why migration failed (if any). + /// Returns whether migration succeeded. + bool TryMigrate(ref TokenName name, out string error); + + /// Migrate a tokenised string. + /// The tokenised string to migrate. + /// An error message which indicates why migration failed (if any). + /// Returns whether migration succeeded. + bool TryMigrate(ref TokenString tokenStr, out string error); + } +} diff --git a/Mods/ContentPatcher/Framework/Migrations/Migration_1_3.cs b/Mods/ContentPatcher/Framework/Migrations/Migration_1_3.cs new file mode 100644 index 00000000..027a2763 --- /dev/null +++ b/Mods/ContentPatcher/Framework/Migrations/Migration_1_3.cs @@ -0,0 +1,56 @@ +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using ContentPatcher.Framework.ConfigModels; +using StardewModdingAPI; + +namespace ContentPatcher.Framework.Migrations +{ + /// Migrate patches to format version 1.3. + [SuppressMessage("ReSharper", "InconsistentNaming", Justification = "Named for clarity.")] + internal class Migration_1_3 : BaseMigration + { + /********* + ** Public methods + *********/ + /// Construct an instance. + public Migration_1_3() + : base(new SemanticVersion(1, 3, 0)) { } + + /// Migrate a content pack. + /// The content pack data to migrate. + /// An error message which indicates why migration failed. + /// Returns whether the content pack was successfully migrated. + public override bool TryMigrate(ContentConfig content, out string error) + { + if (!base.TryMigrate(content, out error)) + return false; + + // 1.3 adds config.json + if (content.ConfigSchema?.Any() == true) + { + error = this.GetNounPhraseError($"using the {nameof(ContentConfig.ConfigSchema)} field"); + return false; + } + + // check patch format + foreach (PatchConfig patch in content.Changes) + { + // 1.3 adds tokens in FromFile + if (patch.FromFile != null && patch.FromFile.Contains("{{")) + { + error = this.GetNounPhraseError($"using the {{{{token}}}} feature in {nameof(PatchConfig.FromFile)} fields"); + return false; + } + + // 1.3 adds When + if (content.Changes.Any(p => p.When != null && p.When.Any())) + { + error = this.GetNounPhraseError($"using the condition feature ({nameof(ContentConfig.Changes)}.{nameof(PatchConfig.When)} field)"); + return false; + } + } + + return true; + } + } +} diff --git a/Mods/ContentPatcher/Framework/Migrations/Migration_1_4.cs b/Mods/ContentPatcher/Framework/Migrations/Migration_1_4.cs new file mode 100644 index 00000000..22024b53 --- /dev/null +++ b/Mods/ContentPatcher/Framework/Migrations/Migration_1_4.cs @@ -0,0 +1,30 @@ +using System.Diagnostics.CodeAnalysis; +using ContentPatcher.Framework.Conditions; +using Pathoschild.Stardew.Common.Utilities; +using StardewModdingAPI; + +namespace ContentPatcher.Framework.Migrations +{ + /// Migrate patches to format version 1.4. + [SuppressMessage("ReSharper", "InconsistentNaming", Justification = "Named for clarity.")] + internal class Migration_1_4 : BaseMigration + { + /********* + ** Public methods + *********/ + /// Construct an instance. + public Migration_1_4() + : base(new SemanticVersion(1, 4, 0)) + { + this.AddedTokens = new InvariantHashSet + { + ConditionType.DayEvent.ToString(), + ConditionType.HasFlag.ToString(), + ConditionType.HasSeenEvent.ToString(), + ConditionType.Hearts.ToString(), + ConditionType.Relationship.ToString(), + ConditionType.Spouse.ToString() + }; + } + } +} diff --git a/Mods/ContentPatcher/Framework/Migrations/Migration_1_5.cs b/Mods/ContentPatcher/Framework/Migrations/Migration_1_5.cs new file mode 100644 index 00000000..c4f0ac54 --- /dev/null +++ b/Mods/ContentPatcher/Framework/Migrations/Migration_1_5.cs @@ -0,0 +1,65 @@ +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using ContentPatcher.Framework.Conditions; +using ContentPatcher.Framework.ConfigModels; +using Pathoschild.Stardew.Common.Utilities; +using StardewModdingAPI; + +namespace ContentPatcher.Framework.Migrations +{ + /// Migrate patches to format version 1.5. + [SuppressMessage("ReSharper", "InconsistentNaming", Justification = "Named for clarity.")] + internal class Migration_1_5 : BaseMigration + { + /********* + ** Public methods + *********/ + /// Construct an instance. + public Migration_1_5() + : base(new SemanticVersion(1, 5, 0)) + { + this.AddedTokens = new InvariantHashSet + { + ConditionType.FarmCave.ToString(), + ConditionType.FarmhouseUpgrade.ToString(), + ConditionType.FarmName.ToString(), + ConditionType.HasFile.ToString(), + ConditionType.HasProfession.ToString(), + ConditionType.PlayerGender.ToString(), + ConditionType.PlayerName.ToString(), + ConditionType.PreferredPet.ToString(), + ConditionType.Year.ToString() + }; + } + + /// Migrate a content pack. + /// The content pack data to migrate. + /// An error message which indicates why migration failed. + /// Returns whether the content pack was successfully migrated. + public override bool TryMigrate(ContentConfig content, out string error) + { + if (!base.TryMigrate(content, out error)) + return false; + + // 1.5 adds dynamic tokens + if (content.DynamicTokens?.Any() == true) + { + error = this.GetNounPhraseError($"using the {nameof(ContentConfig.DynamicTokens)} field"); + return false; + } + + // check patch format + foreach (PatchConfig patch in content.Changes) + { + // 1.5 adds multiple Target values + if (patch.Target?.Contains(",") == true) + { + error = this.GetNounPhraseError($"specifying multiple {nameof(PatchConfig.Target)} values"); + return false; + } + } + + return true; + } + } +} diff --git a/Mods/ContentPatcher/Framework/Migrations/Migration_1_6.cs b/Mods/ContentPatcher/Framework/Migrations/Migration_1_6.cs new file mode 100644 index 00000000..0936f9d9 --- /dev/null +++ b/Mods/ContentPatcher/Framework/Migrations/Migration_1_6.cs @@ -0,0 +1,46 @@ +using System.Diagnostics.CodeAnalysis; +using ContentPatcher.Framework.Conditions; +using ContentPatcher.Framework.ConfigModels; +using Pathoschild.Stardew.Common.Utilities; +using StardewModdingAPI; + +namespace ContentPatcher.Framework.Migrations +{ + /// Migrate patches to format version 1.6. + [SuppressMessage("ReSharper", "InconsistentNaming", Justification = "Named for clarity.")] + internal class Migration_1_6 : BaseMigration + { + /********* + ** Public methods + *********/ + /// Construct an instance. + public Migration_1_6() + : base(new SemanticVersion(1, 6, 0)) + { + this.AddedTokens = new InvariantHashSet + { + ConditionType.HasWalletItem.ToString(), + ConditionType.SkillLevel.ToString() + }; + } + + /// Migrate a content pack. + /// The content pack data to migrate. + /// An error message which indicates why migration failed. + /// Returns whether the content pack was successfully migrated. + public override bool TryMigrate(ContentConfig content, out string error) + { + if (!base.TryMigrate(content, out error)) + return false; + + // before 1.6, the 'sun' weather included 'wind' + foreach (PatchConfig patch in content.Changes) + { + if (patch.When != null && patch.When.TryGetValue(ConditionType.Weather.ToString(), out string value) && value.Contains("Sun")) + patch.When[ConditionType.Weather.ToString()] = $"{value}, Wind"; + } + + return true; + } + } +} diff --git a/Mods/ContentPatcher/Framework/ModTokenContext.cs b/Mods/ContentPatcher/Framework/ModTokenContext.cs new file mode 100644 index 00000000..92dcbc5d --- /dev/null +++ b/Mods/ContentPatcher/Framework/ModTokenContext.cs @@ -0,0 +1,161 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using ContentPatcher.Framework.Tokens; + +namespace ContentPatcher.Framework +{ + /// Manages the token context for a specific content pack. + internal class ModTokenContext : IContext + { + /********* + ** Fields + *********/ + /// The available global tokens. + private readonly IContext GlobalContext; + + /// The standard self-contained tokens. + private readonly GenericTokenContext StandardContext = new GenericTokenContext(); + + /// The dynamic tokens whose value depends on . + private readonly GenericTokenContext DynamicContext = new GenericTokenContext(); + + /// The conditional values used to set the values of tokens. + private readonly IList DynamicTokenValues = new List(); + + /// The underlying token contexts in priority order. + private readonly IContext[] Contexts; + + + /********* + ** Public methods + *********/ + /**** + ** Token management + ****/ + /// Construct an instance. + /// Manages the available global tokens. + public ModTokenContext(TokenManager tokenManager) + { + this.GlobalContext = tokenManager; + this.Contexts = new[] { this.GlobalContext, this.StandardContext, this.DynamicContext }; + } + + /// Add a standard token to the context. + /// The config token to add. + public void Add(IToken token) + { + if (token.Name.HasSubkey()) + throw new InvalidOperationException($"Can't register the '{token.Name}' mod token because subkeys aren't supported."); + if (this.GlobalContext.Contains(token.Name, enforceContext: false)) + throw new InvalidOperationException($"Can't register the '{token.Name}' mod token because there's a global token with that name."); + if (this.StandardContext.Contains(token.Name, enforceContext: false)) + throw new InvalidOperationException($"The '{token.Name}' token is already registered."); + + this.StandardContext.Tokens[token.Name] = token; + } + + /// Add a dynamic token value to the context. + /// The token to add. + public void Add(DynamicTokenValue tokenValue) + { + // validate + if (this.GlobalContext.Contains(tokenValue.Name, enforceContext: false)) + throw new InvalidOperationException($"Can't register a '{tokenValue.Name}' token because there's a global token with that name."); + if (this.StandardContext.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)); + + // add token value + token.AddAllowedValues(tokenValue.Value); + this.DynamicTokenValues.Add(tokenValue); + } + + /// Update the current context. + public void UpdateContext(IContext globalContext) + { + // update config tokens + foreach (IToken token in this.StandardContext.Tokens.Values) + { + if (token.IsMutable) + token.UpdateContext(this); + } + + // reset dynamic tokens + foreach (DynamicToken token in this.DynamicContext.Tokens.Values) + token.SetValidInContext(false); + foreach (DynamicTokenValue tokenValue in this.DynamicTokenValues) + { + if (tokenValue.Conditions.Values.All(p => p.IsMatch(this))) + { + DynamicToken token = this.DynamicContext.Tokens[tokenValue.Name]; + token.SetValue(tokenValue.Value); + token.SetValidInContext(true); + } + } + } + + /// Get the underlying tokens. + /// Whether to only return local tokens. + /// Whether to only consider tokens that are available in the context. + public IEnumerable GetTokens(bool localOnly, bool enforceContext) + { + foreach (IContext context in this.Contexts) + { + if (localOnly && context == this.GlobalContext) + continue; + + foreach (IToken token in context.GetTokens(enforceContext)) + yield return token; + } + } + + /**** + ** IContext + ****/ + /// Get whether the context contains the given token. + /// The token name. + /// Whether to only consider tokens that are available in the context. + public bool Contains(TokenName name, bool enforceContext) + { + return this.Contexts.Any(p => p.Contains(name, enforceContext)); + } + + /// Get the underlying token which handles a name. + /// The token name. + /// Whether to only consider tokens that are available in the context. + /// Returns the matching token, or null if none was found. + public IToken GetToken(TokenName name, bool enforceContext) + { + foreach (IContext context in this.Contexts) + { + IToken token = context.GetToken(name, enforceContext); + if (token != null) + return token; + } + + return null; + } + + /// Get the underlying tokens. + /// Whether to only consider tokens that are available in the context. + public IEnumerable GetTokens(bool enforceContext) + { + return this.GetTokens(localOnly: false, enforceContext: enforceContext); + } + + /// Get the current values of the given token for comparison. + /// The token name. + /// Whether to only consider tokens that are available in the context. + /// Return the values of the matching token, or an empty list if the token doesn't exist. + /// The specified token name is null. + public IEnumerable GetValues(TokenName name, bool enforceContext) + { + IToken token = this.GetToken(name, enforceContext); + return token?.GetValues(name) ?? Enumerable.Empty(); + } + } +} diff --git a/Mods/ContentPatcher/Framework/PatchManager.cs b/Mods/ContentPatcher/Framework/PatchManager.cs new file mode 100644 index 00000000..7c43f84e --- /dev/null +++ b/Mods/ContentPatcher/Framework/PatchManager.cs @@ -0,0 +1,310 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using ContentPatcher.Framework.Conditions; +using ContentPatcher.Framework.ConfigModels; +using ContentPatcher.Framework.Patches; +using ContentPatcher.Framework.Tokens; +using ContentPatcher.Framework.Validators; +using Microsoft.Xna.Framework.Graphics; +using Pathoschild.Stardew.Common.Utilities; +using StardewModdingAPI; + +namespace ContentPatcher.Framework +{ + /// Manages loaded patches. + internal class PatchManager : IAssetLoader, IAssetEditor + { + /********* + ** Fields + *********/ + /**** + ** State + ****/ + /// Manages the available contextual tokens. + private readonly TokenManager TokenManager; + + /// Encapsulates monitoring and logging. + private readonly IMonitor Monitor; + + /// Handle special validation logic on loaded or edited assets. + private readonly IAssetValidator[] AssetValidators; + + /// The patches which are permanently disabled for this session. + private readonly IList PermanentlyDisabledPatches = new List(); + + /// The patches to apply. + private readonly HashSet Patches = new HashSet(); + + /// The patches to apply, indexed by asset name. + private InvariantDictionary> PatchesByCurrentTarget = new InvariantDictionary>(); + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// Encapsulates monitoring and logging. + /// Manages the available contextual tokens. + /// Handle special validation logic on loaded or edited assets. + public PatchManager(IMonitor monitor, TokenManager tokenManager, IAssetValidator[] assetValidators) + { + this.Monitor = monitor; + this.TokenManager = tokenManager; + this.AssetValidators = assetValidators; + } + + /**** + ** Patching + ****/ + /// Get whether this instance can load the initial version of the given asset. + /// Basic metadata about the asset being loaded. + public bool CanLoad(IAssetInfo asset) + { + IPatch[] patches = this.GetCurrentLoaders(asset).ToArray(); + if (patches.Length > 1) + { + this.Monitor.Log($"Multiple patches want to load {asset.AssetName} ({string.Join(", ", from entry in patches orderby entry.LogName select entry.LogName)}). None will be applied.", LogLevel.Error); + return false; + } + + bool canLoad = patches.Any(); + this.Monitor.VerboseLog($"check: [{(canLoad ? "X" : " ")}] can load {asset.AssetName}"); + return canLoad; + } + + /// Get whether this instance can edit the given asset. + /// Basic metadata about the asset being loaded. + public bool CanEdit(IAssetInfo asset) + { + bool canEdit = this.GetCurrentEditors(asset).Any(); + this.Monitor.VerboseLog($"check: [{(canEdit ? "X" : " ")}] can edit {asset.AssetName}"); + return canEdit; + } + + /// Load a matched asset. + /// Basic metadata about the asset being loaded. + public T Load(IAssetInfo asset) + { + // get applicable patches for context + IPatch[] patches = this.GetCurrentLoaders(asset).ToArray(); + if (!patches.Any()) + throw new InvalidOperationException($"Can't load asset key '{asset.AssetName}' because no patches currently apply. This should never happen because it means validation failed."); + if (patches.Length > 1) + throw new InvalidOperationException($"Can't load asset key '{asset.AssetName}' because multiple patches apply ({string.Join(", ", from entry in patches orderby entry.LogName select entry.LogName)}). This should never happen because it means validation failed."); + + // apply patch + IPatch patch = patches.Single(); + if (this.Monitor.IsVerbose) + this.Monitor.VerboseLog($"Patch \"{patch.LogName}\" loaded {asset.AssetName}."); + else + this.Monitor.Log($"{patch.ContentPack.Manifest.Name} loaded {asset.AssetName}.", LogLevel.Trace); + + T data = patch.Load(asset); + + foreach (IAssetValidator validator in this.AssetValidators) + { + if (!validator.TryValidate(asset, data, patch, out string error)) + { + this.Monitor.Log($"Can't apply patch {patch.LogName} to {asset.AssetName}: {error}.", LogLevel.Error); + return default; + } + } + + patch.IsApplied = true; + return data; + } + + /// Edit a matched asset. + /// A helper which encapsulates metadata about an asset and enables changes to it. + public void Edit(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.Monitor.IsVerbose) + this.Monitor.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(asset); + patch.IsApplied = true; + } + catch (Exception ex) + { + this.Monitor.Log($"unhandled exception applying patch: {patch.LogName}.\n{ex}", LogLevel.Error); + patch.IsApplied = false; + } + } + } + + /// Update the current context. + /// The content helper through which to invalidate assets. + public void UpdateContext(IContentHelper contentHelper) + { + this.Monitor.VerboseLog("Propagating context..."); + + // update patches + InvariantHashSet reloadAssetNames = new InvariantHashSet(); + string prevAssetName = null; + foreach (IPatch patch in this.Patches.OrderByIgnoreCase(p => p.TargetAsset).ThenByIgnoreCase(p => p.LogName)) + { + // log asset name + if (this.Monitor.IsVerbose && prevAssetName != patch.TargetAsset) + { + this.Monitor.VerboseLog($" {patch.TargetAsset}:"); + prevAssetName = patch.TargetAsset; + } + + // track old values + string wasAssetName = patch.TargetAsset; + 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.TargetAsset); + } + + // log change + if (this.Monitor.IsVerbose) + { + IList changes = new List(); + if (wasApplied != shouldApply) + changes.Add(shouldApply ? "enabled" : "disabled"); + if (wasAssetName != patch.TargetAsset) + changes.Add($"target: {wasAssetName} => {patch.TargetAsset}"); + string changesStr = string.Join(", ", changes); + + this.Monitor.VerboseLog($" [{(shouldApply ? "X" : " ")}] {patch.LogName}: {(changes.Any() ? changesStr : "OK")}"); + } + + // warn for invalid load patch + if (patch is LoadPatch loadPatch && patch.MatchesContext && !patch.ContentPack.HasFile(loadPatch.FromLocalAsset.Value)) + this.Monitor.Log($"Patch error: {patch.LogName} has a {nameof(PatchConfig.FromFile)} which matches non-existent file '{loadPatch.FromLocalAsset.Value}'.", LogLevel.Error); + } + + // rebuild asset name lookup + this.PatchesByCurrentTarget = new InvariantDictionary>( + from patchGroup in this.Patches.GroupByIgnoreCase(p => p.TargetAsset) + let key = patchGroup.Key + let value = new HashSet(patchGroup) + select new KeyValuePair>(key, value) + ); + + // reload assets if needed + if (reloadAssetNames.Any()) + { + this.Monitor.VerboseLog($" reloading {reloadAssetNames.Count} assets: {string.Join(", ", reloadAssetNames.OrderByIgnoreCase(p => p))}"); + contentHelper.InvalidateCache(asset => + { + this.Monitor.VerboseLog($" [{(reloadAssetNames.Contains(asset.AssetName) ? "X" : " ")}] reload {asset.AssetName}"); + return reloadAssetNames.Contains(asset.AssetName); + }); + } + } + + /**** + ** Patches + ****/ + /// Add a patch. + /// The patch to add. + public void Add(IPatch patch) + { + // set initial context + IContext tokenContext = this.TokenManager.TrackLocalTokens(patch.ContentPack.Pack); + patch.UpdateContext(tokenContext); + + // add to patch list + this.Monitor.VerboseLog($" added {patch.Type} {patch.TargetAsset}."); + this.Patches.Add(patch); + + // add to lookup cache + if (this.PatchesByCurrentTarget.TryGetValue(patch.TargetAsset, out HashSet patches)) + patches.Add(patch); + else + this.PatchesByCurrentTarget[patch.TargetAsset] = new HashSet { patch }; + } + + /// Add a patch that's permanently disabled for this session. + /// The patch to add. + public void AddPermanentlyDisabled(DisabledPatch patch) + { + this.PermanentlyDisabledPatches.Add(patch); + } + + /// Get valid patches regardless of context. + public IEnumerable GetPatches() + { + return this.Patches; + } + + /// Get valid patches regardless of context. + /// The asset name for which to find patches. + public IEnumerable GetPatches(string assetName) + { + if (this.PatchesByCurrentTarget.TryGetValue(assetName, out HashSet patches)) + return patches; + return new IPatch[0]; + } + + /// Get patches which are permanently disabled for this session, along with the reason they were. + public IEnumerable GetPermanentlyDisabledPatches() + { + return this.PermanentlyDisabledPatches; + } + + /// Get patches which load the given asset in the current context. + /// The asset being intercepted. + public IEnumerable GetCurrentLoaders(IAssetInfo asset) + { + return this + .GetPatches(asset.AssetName) + .Where(patch => patch.Type == PatchType.Load && patch.MatchesContext && patch.IsValidInContext); + } + + /// Get patches which edit the given asset in the current context. + /// The asset being intercepted. + public IEnumerable GetCurrentEditors(IAssetInfo asset) + { + PatchType? patchType = this.GetEditType(asset.DataType); + if (patchType == null) + return new IPatch[0]; + + return this + .GetPatches(asset.AssetName) + .Where(patch => patch.Type == patchType && patch.MatchesContext); + } + + /********* + ** Private methods + *********/ + /// Get the patch type which applies when editing a given asset type. + /// The asset type. + private PatchType? GetEditType(Type assetType) + { + if (assetType == typeof(Texture2D)) + return PatchType.EditImage; + if (assetType.IsGenericType && assetType.GetGenericTypeDefinition() == typeof(Dictionary<,>)) + return PatchType.EditData; + + return null; + } + } +} diff --git a/Mods/ContentPatcher/Framework/Patches/DisabledPatch.cs b/Mods/ContentPatcher/Framework/Patches/DisabledPatch.cs new file mode 100644 index 00000000..1d0a7b8d --- /dev/null +++ b/Mods/ContentPatcher/Framework/Patches/DisabledPatch.cs @@ -0,0 +1,42 @@ +namespace ContentPatcher.Framework.Patches +{ + /// An invalid patch that couldn't be loaded. + internal class DisabledPatch + { + /********* + ** Accessors + *********/ + /// A unique name for this patch shown in log messages. + public string LogName { get; } + + /// The raw patch type. + public string Type { get; } + + /// The raw asset name to intercept. + public string AssetName { get; } + + /// The content pack which requested the patch. + public ManagedContentPack ContentPack { get; } + + /// The reason this patch is disabled. + public string ReasonDisabled { get; } + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// A unique name for this patch shown in log messages. + /// The raw patch type. + /// The raw asset name to intercept. + /// The content pack which requested the patch. + /// The reason this patch is disabled. + public DisabledPatch(string logName, string type, string assetName, ManagedContentPack contentPack, string reasonDisabled) + { + this.LogName = logName; + this.Type = type; + this.ContentPack = contentPack; + this.AssetName = assetName; + this.ReasonDisabled = reasonDisabled; + } + } +} diff --git a/Mods/ContentPatcher/Framework/Patches/EditDataPatch.cs b/Mods/ContentPatcher/Framework/Patches/EditDataPatch.cs new file mode 100644 index 00000000..0b746417 --- /dev/null +++ b/Mods/ContentPatcher/Framework/Patches/EditDataPatch.cs @@ -0,0 +1,184 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using ContentPatcher.Framework.Conditions; +using ContentPatcher.Framework.ConfigModels; +using ContentPatcher.Framework.Tokens; +using Microsoft.Xna.Framework.Graphics; +using StardewModdingAPI; + +namespace ContentPatcher.Framework.Patches +{ + /// Metadata for a data to edit into a data file. + internal class EditDataPatch : Patch + { + /********* + ** Fields + *********/ + /// Encapsulates monitoring and logging. + private readonly IMonitor Monitor; + + /// The data records to edit. + private readonly EditDataPatchRecord[] Records; + + /// The data fields to edit. + private readonly EditDataPatchField[] Fields; + + /// The token strings which contain mutable tokens. + private readonly TokenString[] MutableTokenStrings; + + /// Whether the next context update is the first one. + private bool IsFirstUpdate = true; + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// A unique name for this patch shown in log messages. + /// The content pack which requested the patch. + /// The normalised asset name to intercept. + /// The conditions which determine whether this patch should be applied. + /// The data records to edit. + /// The data fields to edit. + /// Encapsulates monitoring and logging. + /// Normalise an asset name. + public EditDataPatch(string logName, ManagedContentPack contentPack, TokenString assetName, ConditionDictionary conditions, IEnumerable records, IEnumerable fields, IMonitor monitor, Func normaliseAssetName) + : base(logName, PatchType.EditData, contentPack, assetName, conditions, normaliseAssetName) + { + this.Records = records.ToArray(); + this.Fields = fields.ToArray(); + this.Monitor = monitor; + this.MutableTokenStrings = this.GetTokenStrings(this.Records, this.Fields).Where(str => str.Tokens.Any()).ToArray(); + } + + /// Update the patch data when the context changes. + /// Provides access to contextual tokens. + /// Returns whether the patch data changed. + public override bool UpdateContext(IContext context) + { + bool changed = base.UpdateContext(context); + + // We need to update all token strings once. After this first time, we can skip + // updating any immutable tokens. + if (this.IsFirstUpdate) + { + this.IsFirstUpdate = false; + foreach (TokenString str in this.GetTokenStrings(this.Records, this.Fields)) + changed |= str.UpdateContext(context); + } + else + { + foreach (TokenString str in this.MutableTokenStrings) + changed |= str.UpdateContext(context); + } + + return changed; + } + + /// Get the tokens used by this patch in its fields. + public override IEnumerable GetTokensUsed() + { + if (this.MutableTokenStrings.Length == 0) + return base.GetTokensUsed(); + + return base + .GetTokensUsed() + .Union(this.MutableTokenStrings.SelectMany(p => p.Tokens)); + } + + /// Apply the patch to a loaded asset. + /// The asset type. + /// The asset to edit. + /// The current patch type doesn't support editing assets. + public override void Edit(IAssetData asset) + { + // validate + if (!typeof(T).IsGenericType || typeof(T).GetGenericTypeDefinition() != typeof(Dictionary<,>)) + { + this.Monitor.Log($"Can't apply data patch \"{this.LogName}\" to {this.TargetAsset}: this file isn't a data file (found {(typeof(T) == typeof(Texture2D) ? "image" : typeof(T).Name)}).", LogLevel.Warn); + return; + } + + // get dictionary's key type + Type keyType = typeof(T).GetGenericArguments().FirstOrDefault(); + if (keyType == null) + throw new InvalidOperationException("Can't parse the asset's dictionary key type."); + + // get underlying apply method + MethodInfo method = this.GetType().GetMethod(nameof(this.ApplyImpl), BindingFlags.Instance | BindingFlags.NonPublic); + if (method == null) + throw new InvalidOperationException("Can't fetch the internal apply method."); + + // invoke method + method + .MakeGenericMethod(keyType) + .Invoke(this, new object[] { asset }); + } + + + /********* + ** Private methods + *********/ + /// Get all token strings in the given data. + /// The data records to edit. + /// The data fields to edit. + private IEnumerable GetTokenStrings(IEnumerable records, IEnumerable fields) + { + foreach (TokenString tokenStr in records.SelectMany(p => p.GetTokenStrings())) + yield return tokenStr; + foreach (TokenString tokenStr in fields.SelectMany(p => p.GetTokenStrings())) + yield return tokenStr; + } + + /// Apply the patch to an asset. + /// The dictionary key type. + /// The asset to edit. + private void ApplyImpl(IAssetData asset) + { + IDictionary data = asset.AsDictionary().Data; + + // apply records + if (this.Records != null) + { + foreach (EditDataPatchRecord record in this.Records) + { + TKey key = (TKey)Convert.ChangeType(record.Key.Value, typeof(TKey)); + if (record.Value.Value != null) + data[key] = record.Value.Value; + else + data.Remove(key); + } + } + + // apply fields + if (this.Fields != null) + { + foreach (var recordGroup in this.Fields.GroupByIgnoreCase(p => p.Key.Value)) + { + TKey key = (TKey)Convert.ChangeType(recordGroup.Key, typeof(TKey)); + if (!data.ContainsKey(key)) + { + this.Monitor.Log($"Can't apply data patch \"{this.LogName}\" to {this.TargetAsset}: there's no record matching key '{key}' under {nameof(PatchConfig.Fields)}.", LogLevel.Warn); + continue; + } + + string[] actualFields = data[key].Split('/'); + foreach (EditDataPatchField field in recordGroup) + { + if (field.FieldIndex < 0 || field.FieldIndex > actualFields.Length - 1) + { + this.Monitor.Log($"Can't apply data field \"{this.LogName}\" to {this.TargetAsset}: record '{key}' under {nameof(PatchConfig.Fields)} has no field with index {field.FieldIndex} (must be 0 to {actualFields.Length - 1}).", LogLevel.Warn); + continue; + } + + actualFields[field.FieldIndex] = field.Value.Value; + } + + data[key] = string.Join("/", actualFields); + } + } + } + } +} diff --git a/Mods/ContentPatcher/Framework/Patches/EditDataPatchField.cs b/Mods/ContentPatcher/Framework/Patches/EditDataPatchField.cs new file mode 100644 index 00000000..ae34ab5d --- /dev/null +++ b/Mods/ContentPatcher/Framework/Patches/EditDataPatchField.cs @@ -0,0 +1,43 @@ +using System.Collections.Generic; +using ContentPatcher.Framework.Conditions; + +namespace ContentPatcher.Framework.Patches +{ + /// An specific field in a data file to change. + internal class EditDataPatchField + { + /********* + ** Accessors + *********/ + /// The unique key for the entry in the data file. + public TokenString Key { get; } + + /// The field index to change. + public int FieldIndex { get; } + + /// The entry value to set. + public TokenString Value { get; } + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The unique key for the entry in the data file. + /// The field number to change. + /// The entry value to set. + public EditDataPatchField(TokenString key, int field, TokenString value) + { + this.Key = key; + this.FieldIndex = field; + this.Value = value; + } + + /// Get all token strings used in the record. + public IEnumerable GetTokenStrings() + { + yield return this.Key; + yield return this.Value; + } + } +} diff --git a/Mods/ContentPatcher/Framework/Patches/EditDataPatchRecord.cs b/Mods/ContentPatcher/Framework/Patches/EditDataPatchRecord.cs new file mode 100644 index 00000000..ededaa27 --- /dev/null +++ b/Mods/ContentPatcher/Framework/Patches/EditDataPatchRecord.cs @@ -0,0 +1,38 @@ +using System.Collections.Generic; +using ContentPatcher.Framework.Conditions; + +namespace ContentPatcher.Framework.Patches +{ + /// An entry in a data file to change. + internal class EditDataPatchRecord + { + /********* + ** Accessors + *********/ + /// The unique key for the entry in the data file. + public TokenString Key { get; } + + /// The entry value to set. + public TokenString Value { get; } + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The unique key for the entry in the data file. + /// The entry value to set. + public EditDataPatchRecord(TokenString key, TokenString value) + { + this.Key = key; + this.Value = value; + } + + /// Get all token strings used in the record. + public IEnumerable GetTokenStrings() + { + yield return this.Key; + yield return this.Value; + } + } +} diff --git a/Mods/ContentPatcher/Framework/Patches/EditImagePatch.cs b/Mods/ContentPatcher/Framework/Patches/EditImagePatch.cs new file mode 100644 index 00000000..081d3250 --- /dev/null +++ b/Mods/ContentPatcher/Framework/Patches/EditImagePatch.cs @@ -0,0 +1,129 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using ContentPatcher.Framework.Conditions; +using ContentPatcher.Framework.Tokens; +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Graphics; +using StardewModdingAPI; +using StardewValley; + +namespace ContentPatcher.Framework.Patches +{ + /// Metadata for an asset that should be patched with a new image. + internal class EditImagePatch : Patch + { + /********* + ** Fields + *********/ + /// Encapsulates monitoring and logging. + private readonly IMonitor Monitor; + + /// The asset key to load from the content pack instead. + private readonly TokenString FromLocalAsset; + + /// The sprite area from which to read an image. + private readonly Rectangle? FromArea; + + /// The sprite area to overwrite. + private readonly Rectangle? ToArea; + + /// Indicates how the image should be patched. + private readonly PatchMode PatchMode; + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// A unique name for this patch shown in log messages. + /// The content pack which requested the patch. + /// The normalised asset name to intercept. + /// The conditions which determine whether this patch should be applied. + /// The asset key to load from the content pack instead. + /// The sprite area from which to read an image. + /// The sprite area to overwrite. + /// Indicates how the image should be patched. + /// Encapsulates monitoring and logging. + /// Normalise an asset name. + public EditImagePatch(string logName, ManagedContentPack contentPack, TokenString assetName, ConditionDictionary conditions, TokenString fromLocalAsset, Rectangle fromArea, Rectangle toArea, PatchMode patchMode, IMonitor monitor, Func normaliseAssetName) + : base(logName, PatchType.EditImage, contentPack, assetName, conditions, normaliseAssetName) + { + this.FromLocalAsset = fromLocalAsset; + this.FromArea = fromArea != Rectangle.Empty ? fromArea : null as Rectangle?; + this.ToArea = toArea != Rectangle.Empty ? toArea : null as Rectangle?; + this.PatchMode = patchMode; + this.Monitor = monitor; + } + + /// Update the patch data when the context changes. + /// The condition context. + /// Returns whether the patch data changed. + public override bool UpdateContext(IContext context) + { + bool localAssetChanged = this.FromLocalAsset.UpdateContext(context); + return base.UpdateContext(context) || localAssetChanged; + } + + /// Apply the patch to a loaded asset. + /// The asset type. + /// The asset to edit. + public override void Edit(IAssetData asset) + { + // validate + if (typeof(T) != typeof(Texture2D)) + { + this.Monitor.Log($"Can't apply image patch \"{this.LogName}\" to {this.TargetAsset}: this file isn't an image file (found {typeof(T)}).", LogLevel.Warn); + return; + } + + // fetch data + Texture2D source = this.ContentPack.Load(this.FromLocalAsset.Value); + Rectangle sourceArea = this.FromArea ?? new Rectangle(0, 0, source.Width, source.Height); + Rectangle targetArea = this.ToArea ?? new Rectangle(0, 0, sourceArea.Width, sourceArea.Height); + IAssetDataForImage editor = asset.AsImage(); + + // validate error conditions + if (sourceArea.X < 0 || sourceArea.Y < 0 || sourceArea.Width < 0 || sourceArea.Height < 0) + { + this.Monitor.Log($"Can't apply image patch \"{this.LogName}\": source area (X:{sourceArea.X}, Y:{sourceArea.Y}, Width:{sourceArea.Width}, Height:{sourceArea.Height}) has negative values, which isn't valid.", LogLevel.Error); + return; + } + if (targetArea.X < 0 || targetArea.Y < 0 || targetArea.Width < 0 || targetArea.Height < 0) + { + this.Monitor.Log($"Can't apply image patch \"{this.LogName}\": target area (X:{targetArea.X}, Y:{targetArea.Y}, Width:{targetArea.Width}, Height:{targetArea.Height}) has negative values, which isn't valid.", LogLevel.Error); + return; + } + if (targetArea.Right > editor.Data.Width) + { + this.Monitor.Log($"Can't apply image patch \"{this.LogName}\": target area (X:{targetArea.X}, Y:{targetArea.Y}, Width:{targetArea.Width}, Height:{targetArea.Height}) extends past the right edge of the image (Width:{editor.Data.Width}), which isn't allowed. Patches can only extend the tilesheet downwards.", LogLevel.Error); + return; + } + if (sourceArea.Width != targetArea.Width || sourceArea.Height != targetArea.Height) + { + string sourceAreaLabel = this.FromArea.HasValue ? $"{nameof(this.FromArea)}" : "source image"; + string targetAreaLabel = this.ToArea.HasValue ? $"{nameof(this.ToArea)}" : "target image"; + this.Monitor.Log($"Can't apply image patch \"{this.LogName}\": {sourceAreaLabel} size (Width:{sourceArea.Width}, Height:{sourceArea.Height}) doesn't match {targetAreaLabel} size (Width:{targetArea.Width}, Height:{targetArea.Height}).", LogLevel.Error); + return; + } + + // extend tilesheet if needed + if (targetArea.Bottom > editor.Data.Height) + { + Texture2D original = editor.Data; + Texture2D texture = new Texture2D(Game1.graphics.GraphicsDevice, original.Width, targetArea.Bottom); + editor.ReplaceWith(texture); + editor.PatchImage(original); + } + + // apply source image + editor.PatchImage(source, sourceArea, this.ToArea, this.PatchMode); + } + + /// Get the tokens used by this patch in its fields. + public override IEnumerable GetTokensUsed() + { + return base.GetTokensUsed().Union(this.FromLocalAsset.Tokens); + } + } +} diff --git a/Mods/ContentPatcher/Framework/Patches/IPatch.cs b/Mods/ContentPatcher/Framework/Patches/IPatch.cs new file mode 100644 index 00000000..3c50775e --- /dev/null +++ b/Mods/ContentPatcher/Framework/Patches/IPatch.cs @@ -0,0 +1,68 @@ +using System.Collections.Generic; +using ContentPatcher.Framework.Conditions; +using ContentPatcher.Framework.Tokens; +using StardewModdingAPI; + +namespace ContentPatcher.Framework.Patches +{ + /// A patch which can be applied to an asset. + internal interface IPatch + { + /********* + ** Accessors + *********/ + /// A unique name for this patch shown in log messages. + string LogName { get; } + + /// The patch type. + PatchType Type { get; } + + /// The content pack which requested the patch. + ManagedContentPack ContentPack { get; } + + /// The asset key to load from the content pack instead. + TokenString FromLocalAsset { get; } + + /// The normalised asset name to intercept. + string TargetAsset { get; } + + /// The raw asset name to intercept, including tokens. + TokenString RawTargetAsset { get; } + + /// The conditions which determine whether this patch should be applied. + ConditionDictionary Conditions { get; } + + /// Whether this patch should be applied in the latest context. + bool MatchesContext { get; } + + /// Whether this patch is valid if is true. + bool IsValidInContext { get; } + + /// Whether the patch is currently applied to the target asset. + bool IsApplied { get; set; } + + + /********* + ** Public methods + *********/ + /// Update the patch data when the context changes. + /// Provides access to contextual tokens. + /// Returns whether the patch data changed. + bool UpdateContext(IContext context); + + /// Load the initial version of the asset. + /// The asset type. + /// The asset to load. + /// The current patch type doesn't support loading assets. + T Load(IAssetInfo asset); + + /// Apply the patch to a loaded asset. + /// The asset type. + /// The asset to edit. + /// The current patch type doesn't support editing assets. + void Edit(IAssetData asset); + + /// Get the tokens used by this patch in its fields. + IEnumerable GetTokensUsed(); + } +} diff --git a/Mods/ContentPatcher/Framework/Patches/LoadPatch.cs b/Mods/ContentPatcher/Framework/Patches/LoadPatch.cs new file mode 100644 index 00000000..a3c83fa1 --- /dev/null +++ b/Mods/ContentPatcher/Framework/Patches/LoadPatch.cs @@ -0,0 +1,65 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using ContentPatcher.Framework.Conditions; +using ContentPatcher.Framework.Tokens; +using Microsoft.Xna.Framework.Graphics; +using StardewModdingAPI; + +namespace ContentPatcher.Framework.Patches +{ + /// Metadata for an asset that should be replaced with a content pack file. + internal class LoadPatch : Patch + { + /********* + ** Public methods + *********/ + /// Construct an instance. + /// A unique name for this patch shown in log messages. + /// The content pack which requested the patch. + /// The normalised asset name to intercept. + /// The conditions which determine whether this patch should be applied. + /// The asset key to load from the content pack instead. + /// Normalise an asset name. + public LoadPatch(string logName, ManagedContentPack contentPack, TokenString assetName, ConditionDictionary conditions, TokenString localAsset, Func normaliseAssetName) + : base(logName, PatchType.Load, contentPack, assetName, conditions, normaliseAssetName) + { + this.FromLocalAsset = localAsset; + } + + /// Load the initial version of the asset. + /// The asset to load. + public override T Load(IAssetInfo asset) + { + T data = this.ContentPack.Load(this.FromLocalAsset.Value); + return (data as object) is Texture2D texture + ? (T)(object)this.CloneTexture(texture) + : data; + } + + /// Get the tokens used by this patch in its fields. + public override IEnumerable GetTokensUsed() + { + return base.GetTokensUsed().Union(this.FromLocalAsset.Tokens); + } + + + /********* + ** Private methods + *********/ + /// Clone a texture. + /// The texture to clone. + /// Cloning a texture is necessary when loading to avoid having it shared between different content managers, which can lead to undesirable effects like two players having synchronised texture changes. + private Texture2D CloneTexture(Texture2D source) + { + // get data + int[] pixels = new int[source.Width * source.Height]; + source.GetData(pixels); + + // create clone + Texture2D target = new Texture2D(source.GraphicsDevice, source.Width, source.Height); + target.SetData(pixels); + return target; + } + } +} diff --git a/Mods/ContentPatcher/Framework/Patches/Patch.cs b/Mods/ContentPatcher/Framework/Patches/Patch.cs new file mode 100644 index 00000000..02c009cb --- /dev/null +++ b/Mods/ContentPatcher/Framework/Patches/Patch.cs @@ -0,0 +1,136 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using ContentPatcher.Framework.Conditions; +using ContentPatcher.Framework.Tokens; +using StardewModdingAPI; + +namespace ContentPatcher.Framework.Patches +{ + /// Metadata for a conditional patch. + internal abstract class Patch : IPatch + { + /********* + ** Fields + *********/ + /// Normalise an asset name. + private readonly Func NormaliseAssetName; + + + /********* + ** Accessors + *********/ + /// The last context used to update this patch. + protected IContext LastContext { get; private set; } + + /// A unique name for this patch shown in log messages. + public string LogName { get; } + + /// The patch type. + public PatchType Type { get; } + + /// The content pack which requested the patch. + public ManagedContentPack ContentPack { get; } + + /// The raw asset key to intercept (if applicable), including tokens. + public TokenString FromLocalAsset { get; protected set; } + + /// The normalised asset name to intercept. + public string TargetAsset { get; private set; } + + /// The raw asset name to intercept, including tokens. + public TokenString RawTargetAsset { get; } + + /// The conditions which determine whether this patch should be applied. + public ConditionDictionary Conditions { get; } + + /// Whether this patch should be applied in the latest context. + public bool MatchesContext { get; private set; } + + /// Whether this patch is valid if is true. + public bool IsValidInContext { get; protected set; } = true; + + /// Whether the patch is currently applied to the target asset. + public bool IsApplied { get; set; } + + + /********* + ** Public methods + *********/ + /// Update the patch data when the context changes. + /// Provides access to contextual tokens. + /// Returns whether the patch data changed. + public virtual bool UpdateContext(IContext context) + { + this.LastContext = context; + + // update conditions + bool conditionsChanged; + { + bool wasMatch = this.MatchesContext; + this.MatchesContext = + (this.Conditions.Count == 0 || this.Conditions.Values.All(p => p.IsMatch(context))) + && this.GetTokensUsed().All(p => context.Contains(p, enforceContext: true)); + conditionsChanged = wasMatch != this.MatchesContext; + } + // update target asset + bool targetChanged = this.RawTargetAsset.UpdateContext(context); + this.TargetAsset = this.NormaliseAssetName(this.RawTargetAsset.Value); + + // update source asset + bool sourceChanged = false; + if (this.FromLocalAsset != null) + { + sourceChanged = this.FromLocalAsset.UpdateContext(context); + this.IsValidInContext = this.FromLocalAsset.IsReady && this.ContentPack.HasFile(this.FromLocalAsset.Value); + } + + return conditionsChanged || targetChanged || sourceChanged; + } + + /// Load the initial version of the asset. + /// The asset type. + /// The asset to load. + /// The current patch type doesn't support loading assets. + public virtual T Load(IAssetInfo asset) + { + throw new NotSupportedException("This patch type doesn't support loading assets."); + } + + /// Apply the patch to a loaded asset. + /// The asset type. + /// The asset to edit. + /// The current patch type doesn't support editing assets. + public virtual void Edit(IAssetData asset) + { + throw new NotSupportedException("This patch type doesn't support loading assets."); + } + + /// Get the tokens used by this patch in its fields. + public virtual IEnumerable GetTokensUsed() + { + return this.RawTargetAsset.Tokens; + } + + + /********* + ** Protected methods + *********/ + /// Construct an instance. + /// A unique name for this patch shown in log messages. + /// The patch type. + /// The content pack which requested the patch. + /// The normalised asset name to intercept. + /// The conditions which determine whether this patch should be applied. + /// Normalise an asset name. + protected Patch(string logName, PatchType type, ManagedContentPack contentPack, TokenString assetName, ConditionDictionary conditions, Func normaliseAssetName) + { + this.LogName = logName; + this.Type = type; + this.ContentPack = contentPack; + this.RawTargetAsset = assetName; + this.Conditions = conditions; + this.NormaliseAssetName = normaliseAssetName; + } + } +} diff --git a/Mods/ContentPatcher/Framework/RawContentPack.cs b/Mods/ContentPatcher/Framework/RawContentPack.cs new file mode 100644 index 00000000..84bc56a7 --- /dev/null +++ b/Mods/ContentPatcher/Framework/RawContentPack.cs @@ -0,0 +1,40 @@ +using ContentPatcher.Framework.ConfigModels; +using ContentPatcher.Framework.Migrations; +using StardewModdingAPI; + +namespace ContentPatcher.Framework +{ + /// A content pack being loaded. + internal class RawContentPack + { + /********* + ** Accessors + *********/ + /// The managed content pack instance. + public ManagedContentPack ManagedPack { get; } + + /// The raw content configuration for this content pack. + public ContentConfig Content { get; } + + /// The migrations to apply for the content pack version. + public IMigration Migrator { get; } + + /// The content pack's manifest. + public IManifest Manifest => this.ManagedPack.Manifest; + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The managed content pack instance. + /// The raw content configuration for this content pack. + /// The migrations to apply for the content pack version. + public RawContentPack(ManagedContentPack contentPack, ContentConfig content, IMigration migrator) + { + this.ManagedPack = contentPack; + this.Content = content; + this.Migrator = migrator; + } + } +} diff --git a/Mods/ContentPatcher/Framework/TokenManager.cs b/Mods/ContentPatcher/Framework/TokenManager.cs new file mode 100644 index 00000000..ebe432d6 --- /dev/null +++ b/Mods/ContentPatcher/Framework/TokenManager.cs @@ -0,0 +1,232 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using ContentPatcher.Framework.Conditions; +using ContentPatcher.Framework.Constants; +using ContentPatcher.Framework.Tokens; +using ContentPatcher.Framework.Tokens.ValueProviders; +using Pathoschild.Stardew.Common.Utilities; +using StardewModdingAPI; +using StardewModdingAPI.Utilities; +using StardewValley; + +namespace ContentPatcher.Framework +{ + /// Manages the available contextual tokens. + internal class TokenManager : IContext + { + /********* + ** Fields + *********/ + /// The available global tokens. + private readonly GenericTokenContext GlobalContext = new GenericTokenContext(); + + /// The available tokens defined within the context of each content pack. + private readonly Dictionary LocalTokens = new Dictionary(); + + + /********* + ** Accessors + *********/ + /// Whether the basic save info is loaded (including the date, weather, and player info). The in-game locations and world may not exist yet. + public bool IsBasicInfoLoaded { get; set; } + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The content helper from which to load data assets. + /// The installed mod IDs. + public TokenManager(IContentHelper contentHelper, IEnumerable installedMods) + { + foreach (IValueProvider valueProvider in this.GetGlobalValueProviders(contentHelper, installedMods)) + this.GlobalContext.Tokens[new TokenName(valueProvider.Name)] = new GenericToken(valueProvider); + } + + /// Get the tokens which are defined for a specific content pack. This returns a reference to the list, which can be held for a live view of the tokens. If the content pack isn't currently tracked, this will add it. + /// The content pack to manage. + public ModTokenContext TrackLocalTokens(IContentPack contentPack) + { + if (!this.LocalTokens.TryGetValue(contentPack, out ModTokenContext localTokens)) + { + this.LocalTokens[contentPack] = localTokens = new ModTokenContext(this); + foreach (IValueProvider valueProvider in this.GetLocalValueProviders(contentPack)) + localTokens.Add(new GenericToken(valueProvider)); + } + + return localTokens; + } + + /// Update the current context. + public void UpdateContext() + { + foreach (IToken token in this.GlobalContext.Tokens.Values) + { + if (token.IsMutable) + token.UpdateContext(this); + } + + foreach (ModTokenContext localContext in this.LocalTokens.Values) + localContext.UpdateContext(this); + } + + /**** + ** IContext + ****/ + /// Get whether the context contains the given token. + /// The token name. + /// Whether to only consider tokens that are available in the context. + public bool Contains(TokenName name, bool enforceContext) + { + return this.GlobalContext.Contains(name, enforceContext); + } + + /// Get the underlying token which handles a key. + /// The token name. + /// Whether to only consider tokens that are available in the context. + /// Returns the matching token, or null if none was found. + public IToken GetToken(TokenName name, bool enforceContext) + { + return this.GlobalContext.GetToken(name, enforceContext); + } + + /// Get the underlying tokens. + /// Whether to only consider tokens that are available in the context. + public IEnumerable GetTokens(bool enforceContext) + { + return this.GlobalContext.GetTokens(enforceContext); + } + + /// Get the current values of the given token for comparison. + /// The token name. + /// Whether to only consider tokens that are available in the context. + /// Return the values of the matching token, or an empty list if the token doesn't exist. + /// The specified key is null. + public IEnumerable GetValues(TokenName name, bool enforceContext) + { + return this.GlobalContext.GetValues(name, enforceContext); + } + + + /********* + ** Private methods + *********/ + /// Get the global value providers with which to initialise the token manager. + /// The content helper from which to load data assets. + /// The installed mod IDs. + private IEnumerable GetGlobalValueProviders(IContentHelper contentHelper, IEnumerable installedMods) + { + bool NeedsBasicInfo() => this.IsBasicInfoLoaded; + + // installed mods + yield return new ImmutableValueProvider(ConditionType.HasMod.ToString(), new InvariantHashSet(installedMods), canHaveMultipleValues: true); + + // language + yield return new ConditionTypeValueProvider(ConditionType.Language, () => contentHelper.CurrentLocaleConstant.ToString(), allowedValues: Enum.GetNames(typeof(LocalizedContentManager.LanguageCode)).Where(p => p != LocalizedContentManager.LanguageCode.th.ToString())); + + // in-game date + yield return new ConditionTypeValueProvider(ConditionType.Season, () => SDate.Now().Season, NeedsBasicInfo, allowedValues: new[] { "Spring", "Summer", "Fall", "Winter" }); + yield return new ConditionTypeValueProvider(ConditionType.Day, () => SDate.Now().Day.ToString(CultureInfo.InvariantCulture), NeedsBasicInfo, allowedValues: Enumerable.Range(1, 28).Select(p => p.ToString())); + yield return new ConditionTypeValueProvider(ConditionType.DayOfWeek, () => SDate.Now().DayOfWeek.ToString(), NeedsBasicInfo, allowedValues: Enum.GetNames(typeof(DayOfWeek))); + yield return new ConditionTypeValueProvider(ConditionType.Year, () => SDate.Now().Year.ToString(CultureInfo.InvariantCulture), NeedsBasicInfo); + yield return new ConditionTypeValueProvider(ConditionType.DaysPlayed, () => Game1.stats.DaysPlayed.ToString(CultureInfo.InvariantCulture), NeedsBasicInfo); + + // other in-game conditions + yield return new ConditionTypeValueProvider(ConditionType.DayEvent, () => this.GetDayEvent(contentHelper), NeedsBasicInfo); + yield return new ConditionTypeValueProvider(ConditionType.FarmCave, () => this.GetEnum(Game1.player.caveChoice.Value, FarmCaveType.None).ToString(), NeedsBasicInfo); + yield return new ConditionTypeValueProvider(ConditionType.FarmhouseUpgrade, () => Game1.player.HouseUpgradeLevel.ToString(), NeedsBasicInfo); + yield return new ConditionTypeValueProvider(ConditionType.FarmName, () => Game1.player.farmName.Value, NeedsBasicInfo); + yield return new ConditionTypeValueProvider(ConditionType.FarmType, () => this.GetEnum(Game1.whichFarm, FarmType.Custom).ToString(), NeedsBasicInfo); + yield return new ConditionTypeValueProvider(ConditionType.HasFlag, () => this.GetMailFlags(), NeedsBasicInfo); + yield return new ConditionTypeValueProvider(ConditionType.HasSeenEvent, () => this.GetEventsSeen(), NeedsBasicInfo); + yield return new ConditionTypeValueProvider(ConditionType.PlayerGender, () => (Game1.player.IsMale ? Gender.Male : Gender.Female).ToString(), NeedsBasicInfo); + yield return new ConditionTypeValueProvider(ConditionType.PreferredPet, () => (Game1.player.catPerson ? PetType.Cat : PetType.Dog).ToString(), NeedsBasicInfo); + yield return new ConditionTypeValueProvider(ConditionType.PlayerName, () => Game1.player.Name, NeedsBasicInfo); + yield return new ConditionTypeValueProvider(ConditionType.Spouse, () => Game1.player?.spouse, NeedsBasicInfo); + yield return new ConditionTypeValueProvider(ConditionType.Weather, () => this.GetCurrentWeather(), NeedsBasicInfo, allowedValues: Enum.GetNames(typeof(Weather))); + yield return new HasProfessionValueProvider(NeedsBasicInfo); + yield return new HasWalletItemValueProvider(NeedsBasicInfo); + yield return new SkillLevelValueProvider(NeedsBasicInfo); + yield return new VillagerRelationshipValueProvider(); + yield return new VillagerHeartsValueProvider(); + } + + /// Get the local value providers with which to initialise a local context. + /// The content pack for which to get tokens. + private IEnumerable GetLocalValueProviders(IContentPack contentPack) + { + yield return new HasFileValueProvider(contentPack.DirectoryPath); + } + + /// Get a constant for a given value. + /// The constant enum type. + /// The value to convert. + /// The value to use if the value is invalid. + private TEnum GetEnum(int value, TEnum defaultValue) + { + return Enum.IsDefined(typeof(TEnum), value) + ? (TEnum)(object)value + : defaultValue; + } + + /// Get the current weather from the game state. + private string GetCurrentWeather() + { + if (Utility.isFestivalDay(Game1.dayOfMonth, Game1.currentSeason) || (SaveGame.loaded?.weddingToday ?? Game1.weddingToday)) + return Weather.Sun.ToString(); + + if (Game1.isSnowing) + return Weather.Snow.ToString(); + if (RainManager.Instance.isRaining) + return (Game1.isLightning ? Weather.Storm : Weather.Rain).ToString(); + if (SaveGame.loaded?.isDebrisWeather ?? WeatherDebrisManager.Instance.isDebrisWeather) + return Weather.Wind.ToString(); + + return Weather.Sun.ToString(); + } + + /// Get the event IDs seen by the player. + private IEnumerable GetEventsSeen() + { + Farmer player = Game1.player; + if (player == null) + return new string[0]; + + return player.eventsSeen + .OrderBy(p => p) + .Select(p => p.ToString(CultureInfo.InvariantCulture)); + } + + /// Get the letter IDs and mail flags set for the player. + /// See game logic in . + private IEnumerable GetMailFlags() + { + Farmer player = Game1.player; + if (player == null) + return new string[0]; + + return player + .mailReceived + .Union(player.mailForTomorrow) + .Union(player.mailbox); + } + + /// Get the name for today's day event (e.g. wedding or festival) from the game data. + /// The content helper from which to load festival data. + private string GetDayEvent(IContentHelper contentHelper) + { + // marriage + if (SaveGame.loaded?.weddingToday ?? Game1.weddingToday) + return "wedding"; + + // festival + IDictionary festivalDates = contentHelper.Load>("Data\\Festivals\\FestivalDates", ContentSource.GameContent); + if (festivalDates.TryGetValue($"{Game1.currentSeason}{Game1.dayOfMonth}", out string festivalName)) + return festivalName; + + return null; + } + } +} diff --git a/Mods/ContentPatcher/Framework/Tokens/DynamicToken.cs b/Mods/ContentPatcher/Framework/Tokens/DynamicToken.cs new file mode 100644 index 00000000..25287910 --- /dev/null +++ b/Mods/ContentPatcher/Framework/Tokens/DynamicToken.cs @@ -0,0 +1,50 @@ +using ContentPatcher.Framework.Tokens.ValueProviders; +using Pathoschild.Stardew.Common.Utilities; + +namespace ContentPatcher.Framework.Tokens +{ + /// A dynamic token defined by a content pack. + internal class DynamicToken : GenericToken + { + /********* + ** Accessors + *********/ + /// The underlying value provider. + private readonly DynamicTokenValueProvider DynamicValues; + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The token name. + public DynamicToken(TokenName name) + : base(new DynamicTokenValueProvider(name.Key)) + { + this.DynamicValues = (DynamicTokenValueProvider)base.Values; + } + + /// Add a set of possible values. + /// The possible values to add. + public void AddAllowedValues(InvariantHashSet possibleValues) + { + this.DynamicValues.AddAllowedValues(possibleValues); + this.CanHaveMultipleRootValues = this.DynamicValues.CanHaveMultipleValues(); + } + + /// Set the current values. + /// The values to set. + public void SetValue(InvariantHashSet values) + { + this.DynamicValues.SetValue(values); + } + + /// Set whether the token is valid in the current context. + /// The value to set. + public void SetValidInContext(bool validInContext) + { + this.DynamicValues.SetValidInContext(validInContext); + this.IsValidInContext = this.DynamicValues.IsValidInContext; + } + } +} diff --git a/Mods/ContentPatcher/Framework/Tokens/DynamicTokenValue.cs b/Mods/ContentPatcher/Framework/Tokens/DynamicTokenValue.cs new file mode 100644 index 00000000..20ca9246 --- /dev/null +++ b/Mods/ContentPatcher/Framework/Tokens/DynamicTokenValue.cs @@ -0,0 +1,36 @@ +using ContentPatcher.Framework.Conditions; +using Pathoschild.Stardew.Common.Utilities; + +namespace ContentPatcher.Framework.Tokens +{ + /// A conditional value for a dynamic token. + internal class DynamicTokenValue + { + /********* + ** Accessors + *********/ + /// The name of the token whose value to set. + public TokenName Name { get; } + + /// The token value to set. + public InvariantHashSet Value { get; } + + /// The conditions that must match to set this value. + public ConditionDictionary Conditions { get; } + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The name of the token whose value to set. + /// The token value to set. + /// The conditions that must match to set this value. + public DynamicTokenValue(TokenName key, InvariantHashSet value, ConditionDictionary conditions) + { + this.Name = key; + this.Value = value; + this.Conditions = conditions; + } + } +} diff --git a/Mods/ContentPatcher/Framework/Tokens/GenericToken.cs b/Mods/ContentPatcher/Framework/Tokens/GenericToken.cs new file mode 100644 index 00000000..59771756 --- /dev/null +++ b/Mods/ContentPatcher/Framework/Tokens/GenericToken.cs @@ -0,0 +1,239 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using ContentPatcher.Framework.Tokens.ValueProviders; +using Pathoschild.Stardew.Common.Utilities; + +namespace ContentPatcher.Framework.Tokens +{ + /// A combination of one or more value providers. + internal class GenericToken : IToken + { + /********* + ** Fields + *********/ + /// The underlying value provider. + protected IValueProvider Values { get; } + + /// Whether the root token may contain multiple values. + protected bool CanHaveMultipleRootValues { get; set; } + + + /********* + ** Accessors + *********/ + /// The token name. + public TokenName Name { get; } + + /// Whether the value can change after it's initialised. + public bool IsMutable { get; protected set; } = true; + + /// Whether this token recognises subkeys (e.g. Relationship:Abigail is a Relationship token with a Abigail subkey). + public bool CanHaveSubkeys { get; } + + /// Whether this token only allows subkeys (see ). + public bool RequiresSubkeys { get; } + + /// Whether the token is applicable in the current context. + public bool IsValidInContext { get; protected set; } + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The underlying value provider. + public GenericToken(IValueProvider provider) + { + this.Values = provider; + + this.Name = TokenName.Parse(provider.Name); + this.CanHaveSubkeys = provider.AllowsInput; + this.RequiresSubkeys = provider.RequiresInput; + this.CanHaveMultipleRootValues = provider.CanHaveMultipleValues(); + this.IsValidInContext = provider.IsValidInContext; + } + + /// Update the token data when the context changes. + /// The condition context. + /// Returns whether the token data changed. + public virtual void UpdateContext(IContext context) + { + if (this.Values.IsMutable) + { + this.Values.UpdateContext(context); + this.IsValidInContext = this.Values.IsValidInContext; + } + } + + /// Whether the token may return multiple values for the given name. + /// The token name. + public bool CanHaveMultipleValues(TokenName name) + { + return this.Values.CanHaveMultipleValues(name.Subkey); + } + + /// Perform custom validation. + /// The token name to validate. + /// The values to validate. + /// The validation error, if any. + /// Returns whether validation succeeded. + public bool TryValidate(TokenName name, InvariantHashSet values, out string error) + { + // parse data + KeyValuePair[] pairs = this.GetSubkeyValuePairsFor(name, values).ToArray(); + + // restrict to allowed subkeys + if (this.CanHaveSubkeys) + { + InvariantHashSet validKeys = this.GetAllowedSubkeys(); + if (validKeys?.Any() == true) + { + string[] invalidSubkeys = + ( + from pair in pairs + where pair.Key.Subkey != null && !validKeys.Contains(pair.Key.Subkey) + select pair.Key.Subkey + ) + .Distinct() + .ToArray(); + if (invalidSubkeys.Any()) + { + error = $"invalid subkeys ({string.Join(", ", invalidSubkeys)}); expected one of {string.Join(", ", validKeys)}"; + return false; + } + } + } + + // restrict to allowed values + { + InvariantHashSet validValues = this.GetAllowedValues(name); + if (validValues?.Any() == true) + { + string[] invalidValues = + ( + from pair in pairs + where !validValues.Contains(pair.Value) + select pair.Value + ) + .Distinct() + .ToArray(); + if (invalidValues.Any()) + { + error = $"invalid values ({string.Join(", ", invalidValues)}); expected one of {string.Join(", ", validValues)}"; + return false; + } + } + } + + // custom validation + foreach (KeyValuePair pair in pairs) + { + if (!this.Values.TryValidate(pair.Key.Subkey, new InvariantHashSet { pair.Value }, out error)) + return false; + } + + // no issues found + error = null; + return true; + } + + /// Get the current subkeys (if supported). + public virtual IEnumerable GetSubkeys() + { + return this.Values.GetValidInputs()?.Select(input => new TokenName(this.Name.Key, input)); + } + + /// Get the allowed values for a token name (or null if any value is allowed). + /// The key doesn't match this token, or the key does not respect or . + public virtual InvariantHashSet GetAllowedValues(TokenName name) + { + return this.Values.GetAllowedValues(name.Subkey); + } + + /// Get the current token values. + /// The token name to check. + /// The key doesn't match this token, or the key does not respect or . + public virtual IEnumerable GetValues(TokenName name) + { + this.AssertTokenName(name); + return this.Values.GetValues(name.Subkey); + } + + + /********* + ** Protected methods + *********/ + /// Get the allowed subkeys (or null if any value is allowed). + protected virtual InvariantHashSet GetAllowedSubkeys() + { + return this.Values.GetValidInputs(); + } + + /// Get the current token values. + /// The token name to check, if applicable. + /// The key doesn't match this token, or the key does not respect . + protected void AssertTokenName(TokenName? name) + { + if (name == null) + { + // missing subkey + if (this.RequiresSubkeys) + throw new InvalidOperationException($"The '{this.Name}' token requires a subkey."); + } + else + { + // not same root key + if (!this.Name.IsSameRootKey(name.Value)) + throw new InvalidOperationException($"The specified token key ({name}) is not handled by this token ({this.Name})."); + + // no subkey allowed + if (!this.CanHaveSubkeys && name.Value.HasSubkey()) + throw new InvalidOperationException($"The '{this.Name}' token does not allow subkeys (:)."); + } + } + + /// Try to parse a raw case-insensitive string into an enum value. + /// The enum type. + /// The raw string to parse. + /// The resulting enum value. + /// When parsing a numeric value, whether it must match one of the named enum values. + protected bool TryParseEnum(string raw, out TEnum result, bool mustBeNamed = true) where TEnum : struct + { + if (!Enum.TryParse(raw, true, out result)) + return false; + + if (mustBeNamed && !Enum.IsDefined(typeof(TEnum), result)) + return false; + + return true; + } + + /// Get the subkey/value pairs used in the given name and values. + /// The token name to validate. + /// The values to validate. + /// Returns the subkey/value pairs found. If the includes a subkey, the are treated as values of that subkey. Otherwise if is true, then each value is treated as subkey:value (if they contain a colon) or value (with a null subkey). + protected IEnumerable> GetSubkeyValuePairsFor(TokenName name, InvariantHashSet values) + { + // no subkeys in values + if (!this.CanHaveSubkeys || name.HasSubkey()) + { + foreach (string value in values) + yield return new KeyValuePair(name, value); + } + + // possible subkeys in values + else + { + foreach (string value in values) + { + string[] parts = value.Split(new[] { ':' }, 2); + if (parts.Length < 2) + yield return new KeyValuePair(name, parts[0]); + else + yield return new KeyValuePair(new TokenName(name.Key, parts[0]), parts[1]); + } + } + } + } +} diff --git a/Mods/ContentPatcher/Framework/Tokens/IContext.cs b/Mods/ContentPatcher/Framework/Tokens/IContext.cs new file mode 100644 index 00000000..e8f9253e --- /dev/null +++ b/Mods/ContentPatcher/Framework/Tokens/IContext.cs @@ -0,0 +1,31 @@ +using System; +using System.Collections.Generic; + +namespace ContentPatcher.Framework.Tokens +{ + /// Provides access to contextual tokens. + internal interface IContext + { + /// Get whether the context contains the given token. + /// The token name. + /// Whether to only consider tokens that are available in the context. + bool Contains(TokenName name, bool enforceContext); + + /// Get the underlying token which handles a key. + /// The token name. + /// Whether to only consider tokens that are available in the context. + /// Returns the matching token, or null if none was found. + IToken GetToken(TokenName name, bool enforceContext); + + /// Get the underlying tokens. + /// Whether to only consider tokens that are available in the context. + IEnumerable GetTokens(bool enforceContext); + + /// Get the current values of the given token for comparison. + /// The token name. + /// Whether to only consider tokens that are available in the context. + /// Return the values of the matching token, or an empty list if the token doesn't exist. + /// The specified key is null. + IEnumerable GetValues(TokenName name, bool enforceContext); + } +} diff --git a/Mods/ContentPatcher/Framework/Tokens/IToken.cs b/Mods/ContentPatcher/Framework/Tokens/IToken.cs new file mode 100644 index 00000000..dec7a2b6 --- /dev/null +++ b/Mods/ContentPatcher/Framework/Tokens/IToken.cs @@ -0,0 +1,60 @@ +using System; +using System.Collections.Generic; +using Pathoschild.Stardew.Common.Utilities; + +namespace ContentPatcher.Framework.Tokens +{ + /// A token whose value may change depending on the current context. + internal interface IToken + { + /********* + ** Accessors + *********/ + /// The token name. + TokenName Name { get; } + + /// Whether the token is applicable in the current context. + bool IsValidInContext { get; } + + /// Whether the value can change after it's initialised. + bool IsMutable { get; } + + /// Whether this token recognises subkeys (e.g. Relationship:Abigail is a Relationship token with a Abigail subkey). + bool CanHaveSubkeys { get; } + + /// Whether this token only allows subkeys (see ). + bool RequiresSubkeys { get; } + + + /********* + ** Public methods + *********/ + /// Update the token data when the context changes. + /// The condition context. + /// Returns whether the token data changed. + void UpdateContext(IContext context); + + /// Whether the token may return multiple values for the given name. + /// The token name. + bool CanHaveMultipleValues(TokenName name); + + /// Perform custom validation. + /// The token name to validate. + /// The values to validate. + /// The validation error, if any. + /// Returns whether validation succeeded. + bool TryValidate(TokenName name, InvariantHashSet values, out string error); + + /// Get the current subkeys (if supported). + IEnumerable GetSubkeys(); + + /// Get the allowed values for a token name (or null if any value is allowed). + /// The key doesn't match this token, or the key does not respect or . + InvariantHashSet GetAllowedValues(TokenName name); + + /// Get the current token values. + /// The token name to check. + /// The key doesn't match this token, or the key does not respect or . + IEnumerable GetValues(TokenName name); + } +} diff --git a/Mods/ContentPatcher/Framework/Tokens/ImmutableToken.cs b/Mods/ContentPatcher/Framework/Tokens/ImmutableToken.cs new file mode 100644 index 00000000..c171ee1b --- /dev/null +++ b/Mods/ContentPatcher/Framework/Tokens/ImmutableToken.cs @@ -0,0 +1,20 @@ +using ContentPatcher.Framework.Tokens.ValueProviders; +using Pathoschild.Stardew.Common.Utilities; + +namespace ContentPatcher.Framework.Tokens +{ + /// A tokens whose values don't change after it's initialised. + internal class ImmutableToken : GenericToken + { + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The token name. + /// Get the current token values. + /// The allowed values (or null if any value is allowed). + /// Whether the root may contain multiple values (or null to set it based on the given values). + public ImmutableToken(string name, InvariantHashSet values, InvariantHashSet allowedValues = null, bool? canHaveMultipleValues = null) + : base(new ImmutableValueProvider(name, values, allowedValues, canHaveMultipleValues)) { } + } +} diff --git a/Mods/ContentPatcher/Framework/Tokens/TokenName.cs b/Mods/ContentPatcher/Framework/Tokens/TokenName.cs new file mode 100644 index 00000000..ecdd7e49 --- /dev/null +++ b/Mods/ContentPatcher/Framework/Tokens/TokenName.cs @@ -0,0 +1,158 @@ +using System; +using ContentPatcher.Framework.Conditions; + +namespace ContentPatcher.Framework.Tokens +{ + /// Represents a token key and subkey if applicable (e.g. Relationship:Abigail is token key Relationship and subkey Abigail). + internal struct TokenName : IEquatable, IComparable + { + /********* + ** Accessors + *********/ + /// The token type. + public string Key { get; } + + /// The token subkey indicating which in-game object the condition type applies to, if applicable. For example, the NPC name when is . + public string Subkey { get; } + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The condition type. + /// A unique key indicating which in-game object the condition type applies to. For example, the NPC name when is . + public TokenName(string tokenKey, string subkey = null) + { + this.Key = tokenKey?.Trim(); + this.Subkey = subkey?.Trim(); + } + + /// Construct an instance. + /// The condition type. + /// A unique key indicating which in-game object the condition type applies to. For example, the NPC name when is . + public TokenName(ConditionType tokenKey, string subkey = null) + : this(tokenKey.ToString(), subkey) { } + + /// Get a string representation for this instance. + public override string ToString() + { + return this.HasSubkey() + ? $"{this.Key}:{this.Subkey}" + : this.Key; + } + + /// Get whether this key has the same root as another. + /// The other key to check. + public bool IsSameRootKey(TokenName other) + { + if (this.Key == null) + return other.Key == null; + + return this.Key.Equals(other.Key, StringComparison.InvariantCultureIgnoreCase); + } + + /// Whether this token key specifies a subkey. + public bool HasSubkey() + { + return !string.IsNullOrWhiteSpace(this.Subkey); + } + + /// Try to parse the as a global condition type. + /// The parsed condition type, if applicable. + public bool TryGetConditionType(out ConditionType type) + { + return Enum.TryParse(this.Key, true, out type); + } + + /// Get the root token (without the ). + public TokenName GetRoot() + { + return this.HasSubkey() + ? new TokenName(this.Key) + : this; + } + + /**** + ** IEquatable + ****/ + /// Get whether the current object is equal to another object of the same type. + /// An object to compare with this object. + public bool Equals(TokenName other) + { + return this.CompareTo(other) == 0; + } + + /// Get whether this instance and a specified object are equal. + /// The object to compare with the current instance. + public override bool Equals(object obj) + { + return obj is TokenName other && this.Equals(other); + } + + /// Get the hash code for this instance. + public override int GetHashCode() + { + return this.ToString().ToLowerInvariant().GetHashCode(); + } + + /**** + ** IComparable + ****/ + /// Compares the current instance with another object of the same type and returns an integer that indicates whether the current instance precedes, follows, or occurs in the same position in the sort order as the other object. + /// An object to compare with this instance. + /// A value that indicates the relative order of the objects being compared. The return value has these meanings: Value Meaning Less than zero This instance precedes in the sort order. Zero This instance occurs in the same position in the sort order as . Greater than zero This instance follows in the sort order. + public int CompareTo(object obj) + { + return string.Compare(this.ToString(), obj?.ToString(), StringComparison.OrdinalIgnoreCase); + } + + /**** + ** Static parsing + ****/ + /// Parse a raw string into a condition key if it's valid. + /// The raw string. + /// Returns true if was successfully parsed, else false. + public static TokenName Parse(string raw) + { + if (string.IsNullOrWhiteSpace(raw)) + throw new ArgumentNullException(nameof(raw)); + + // extract parts + string key; + string subkey; + { + string[] parts = raw.Trim().Split(new[] { ':' }, 2); + + key = parts[0].Trim(); + if (key == "") + throw new ArgumentException($"The main key in '{raw}' can't be blank."); + + subkey = parts.Length == 2 ? parts[1].Trim() : null; + if (subkey == "") + subkey = null; + } + + // create instance + return new TokenName(key, subkey); + } + + /// Parse a raw string into a condition key if it's valid. + /// The raw string. + /// The parsed condition key. + /// Returns true if was successfully parsed, else false. + public static bool TryParse(string raw, out TokenName key) + { + try + { + key = TokenName.Parse(raw); + return true; + } + catch + { + key = default(TokenName); + return false; + } + } + } +} diff --git a/Mods/ContentPatcher/Framework/Tokens/ValueProviders/BaseValueProvider.cs b/Mods/ContentPatcher/Framework/Tokens/ValueProviders/BaseValueProvider.cs new file mode 100644 index 00000000..ac1d4bbf --- /dev/null +++ b/Mods/ContentPatcher/Framework/Tokens/ValueProviders/BaseValueProvider.cs @@ -0,0 +1,248 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using ContentPatcher.Framework.Conditions; +using Pathoschild.Stardew.Common.Utilities; + +namespace ContentPatcher.Framework.Tokens.ValueProviders +{ + /// The base class for a value provider. + internal abstract class BaseValueProvider : IValueProvider + { + /********* + ** Fields + *********/ + /// Whether multiple values may exist when no input is provided. + protected bool CanHaveMultipleValuesForRoot { get; set; } + + /// Whether multiple values may exist when an input argument is provided. + protected bool CanHaveMultipleValuesForInput { get; set; } + + + /********* + ** Accessors + *********/ + /// The value provider name. + public string Name { get; } + + /// Whether the provided values can change after the provider is initialised. + public bool IsMutable { get; protected set; } = true; + + /// Whether the value provider allows an input argument (e.g. an NPC name for a relationship token). + public bool AllowsInput { get; private set; } + + /// Whether the value provider requires an input argument to work, and does not provide values without it (see ). + public bool RequiresInput { get; private set; } + + /// Whether values exist in the current context. + public bool IsValidInContext { get; protected set; } + + + /********* + ** Public methods + *********/ + /// Update the underlying values. + /// The condition context. + /// Returns whether the values changed. + public virtual void UpdateContext(IContext context) { } + + /// Whether the value provider may return multiple values for the given input. + /// The input argument, if applicable. + public bool CanHaveMultipleValues(string input = null) + { + return input != null + ? this.CanHaveMultipleValuesForInput + : this.CanHaveMultipleValuesForRoot; + } + + /// Validate that the provided values are valid for the input argument (regardless of whether they match). + /// The input argument, if applicable. + /// The values to validate. + /// The validation error, if any. + /// Returns whether validation succeeded. + public bool TryValidate(string input, InvariantHashSet values, out string error) + { + // parse data + KeyValuePair[] pairs = this.GetInputValuePairs(input, values).ToArray(); + + // restrict to allowed input + if (this.AllowsInput) + { + InvariantHashSet validInputs = this.GetValidInputs(); + if (validInputs?.Any() == true) + { + string[] invalidInputs = + ( + from pair in pairs + where pair.Key != null && !validInputs.Contains(pair.Key) + select pair.Key + ) + .Distinct() + .ToArray(); + if (invalidInputs.Any()) + { + error = $"invalid input arguments ({string.Join(", ", invalidInputs)}), expected any of {string.Join(", ", validInputs)}"; + return false; + } + } + } + + // restrict to allowed values + { + InvariantHashSet validValues = this.GetAllowedValues(input); + if (validValues?.Any() == true) + { + string[] invalidValues = + ( + from pair in pairs + where !validValues.Contains(pair.Value) + select pair.Value + ) + .Distinct() + .ToArray(); + if (invalidValues.Any()) + { + error = $"invalid values ({string.Join(", ", invalidValues)}); expected one of {string.Join(", ", validValues)}"; + return false; + } + } + } + + // custom validation + foreach (KeyValuePair pair in pairs) + { + if (!this.TryValidate(pair.Key, pair.Value, out error)) + return false; + } + + // no issues found + error = null; + return true; + } + + /// Get the set of valid input arguments if restricted, or an empty collection if unrestricted. + public virtual InvariantHashSet GetValidInputs() + { + return new InvariantHashSet(); + } + + /// Get the allowed values for an input argument (or null if any value is allowed). + /// The input argument, if applicable. + /// The input argument doesn't match this value provider, or does not respect or . + public virtual InvariantHashSet GetAllowedValues(string input) + { + return null; + } + + /// Get the current values. + /// The input argument, if applicable. + /// The input argument doesn't match this value provider, or does not respect or . + public virtual IEnumerable GetValues(string input) + { + this.AssertInputArgument(input); + yield break; + } + + + /********* + ** Protected methods + *********/ + /// Construct an instance. + /// The value provider name. + /// Whether the root value provider may contain multiple values. + protected BaseValueProvider(string name, bool canHaveMultipleValuesForRoot) + { + this.Name = name; + this.CanHaveMultipleValuesForRoot = canHaveMultipleValuesForRoot; + } + + /// Construct an instance. + /// The value provider name. + /// Whether the root value provider may contain multiple values. + protected BaseValueProvider(ConditionType type, bool canHaveMultipleValuesForRoot) + : this(type.ToString(), canHaveMultipleValuesForRoot) { } + + /// Validate that the provided value is valid for an input argument (regardless of whether they match). + /// The input argument, if applicable. + /// The value to validate. + /// The validation error, if any. + /// Returns whether validation succeeded. + protected virtual bool TryValidate(string input, string value, out string error) + { + error = null; + return true; + } + + /// Enable input arguments for this value provider. + /// Whether an input argument is required when using this value provider. + /// Whether the value provider may return multiple values for an input argument. + protected void EnableInputArguments(bool required, bool canHaveMultipleValues) + { + this.AllowsInput = true; + this.RequiresInput = required; + this.CanHaveMultipleValuesForInput = canHaveMultipleValues; + } + + /// Assert that an input argument is valid for the value provider. + /// The input argument to check, if applicable. + /// The input argument doesn't match this value provider, or does not respect or . + protected void AssertInputArgument(string input) + { + if (input == null) + { + // missing input argument + if (this.RequiresInput) + throw new InvalidOperationException($"The '{this.Name}' token requires an input argument."); + } + else + { + // no subkey allowed + if (!this.AllowsInput) + throw new InvalidOperationException($"The '{this.Name}' token does not allow input arguments."); + } + } + + /// Try to parse a raw case-insensitive string into an enum value. + /// The enum type. + /// The raw string to parse. + /// The resulting enum value. + /// When parsing a numeric value, whether it must match one of the named enum values. + protected bool TryParseEnum(string raw, out TEnum result, bool mustBeNamed = true) where TEnum : struct + { + if (!Enum.TryParse(raw, true, out result)) + return false; + + if (mustBeNamed && !Enum.IsDefined(typeof(TEnum), result)) + return false; + + return true; + } + + /// Parse a user-defined set of values for input/value pairs. For example, "Abigail:10" for a relationship token would be parsed as input argument 'Abigail' with value '10'. + /// The current input argument, if applicable. + /// The values to parse. + /// Returns the input/value pairs found. If is non-null, the are treated as values for that input argument. Otherwise if is true, then each value is treated as input:value (if they contain a colon) or value (with a null input). + protected IEnumerable> GetInputValuePairs(string input, InvariantHashSet values) + { + // no input arguments in values + if (!this.AllowsInput || input != null) + { + foreach (string value in values) + yield return new KeyValuePair(input, value); + } + + // possible input arguments in values + else + { + foreach (string value in values) + { + string[] parts = value.Split(new[] { ':' }, 2); + if (parts.Length < 2) + yield return new KeyValuePair(input, parts[0]); + else + yield return new KeyValuePair(parts[0], parts[1]); + } + } + } + } +} diff --git a/Mods/ContentPatcher/Framework/Tokens/ValueProviders/ConditionTypeValueProvider.cs b/Mods/ContentPatcher/Framework/Tokens/ValueProviders/ConditionTypeValueProvider.cs new file mode 100644 index 00000000..c39da86d --- /dev/null +++ b/Mods/ContentPatcher/Framework/Tokens/ValueProviders/ConditionTypeValueProvider.cs @@ -0,0 +1,90 @@ +using System; +using System.Collections.Generic; +using ContentPatcher.Framework.Conditions; +using Pathoschild.Stardew.Common.Utilities; + +namespace ContentPatcher.Framework.Tokens.ValueProviders +{ + /// A value provider for a built-in condition whose value may change with the context. + internal class ConditionTypeValueProvider : BaseValueProvider + { + /********* + ** Fields + *********/ + /// The allowed root values (or null if any value is allowed). + private readonly InvariantHashSet AllowedRootValues; + + /// Get the current values. + private readonly Func FetchValues; + + /// Get whether the value provider is applicable in the current context, or null if it's always applicable. + private readonly Func IsValidInContextImpl; + + /// The values as of the last context update. + private readonly InvariantHashSet Values = new InvariantHashSet(); + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The condition type. + /// Get the current values. + /// Get whether the value provider is applicable in the current context, or null if it's always applicable. + /// Whether the root may contain multiple values. + /// The allowed values (or null if any value is allowed). + public ConditionTypeValueProvider(ConditionType type, Func> values, Func isValidInContext = null, bool canHaveMultipleValues = false, IEnumerable allowedValues = null) + : base(type, canHaveMultipleValues) + { + this.IsValidInContextImpl = isValidInContext; + this.AllowedRootValues = allowedValues != null ? new InvariantHashSet(allowedValues) : null; + this.FetchValues = () => new InvariantHashSet(values()); + this.EnableInputArguments(required: false, canHaveMultipleValues: false); + } + + /// Construct an instance. + /// The condition type. + /// Get the current value. + /// Get whether the value provider is applicable in the current context, or null if it's always applicable. + /// Whether the root may contain multiple values. + /// The allowed values (or null if any value is allowed). + public ConditionTypeValueProvider(ConditionType type, Func value, Func isValidInContext = null, bool canHaveMultipleValues = false, IEnumerable allowedValues = null) + : this(type, () => new[] { value() }, isValidInContext, canHaveMultipleValues, allowedValues) { } + + /// Update the underlying values. + /// The condition context. + /// Returns whether the values changed. + public override void UpdateContext(IContext context) + { + this.IsValidInContext = this.IsValidInContextImpl == null || this.IsValidInContextImpl(); + this.Values.Clear(); + if (this.IsValidInContext) + { + foreach (string value in this.FetchValues()) + this.Values.Add(value); + } + } + + /// Get the allowed values for an input argument (or null if any value is allowed). + /// The input argument, if applicable. + /// The input argument doesn't match this value provider, or does not respect or . + public override InvariantHashSet GetAllowedValues(string input) + { + return input != null + ? InvariantHashSet.Boolean() + : this.AllowedRootValues; + } + + /// Get the current values. + /// The input argument, if applicable. + /// The input argument doesn't match this value provider, or does not respect or . + public override IEnumerable GetValues(string input) + { + this.AssertInputArgument(input); + + if (input != null) + return new[] { this.Values.Contains(input).ToString() }; + return this.Values; + } + } +} diff --git a/Mods/ContentPatcher/Framework/Tokens/ValueProviders/DynamicTokenValueProvider.cs b/Mods/ContentPatcher/Framework/Tokens/ValueProviders/DynamicTokenValueProvider.cs new file mode 100644 index 00000000..158319fa --- /dev/null +++ b/Mods/ContentPatcher/Framework/Tokens/ValueProviders/DynamicTokenValueProvider.cs @@ -0,0 +1,77 @@ +using System; +using System.Collections.Generic; +using Pathoschild.Stardew.Common.Utilities; + +namespace ContentPatcher.Framework.Tokens.ValueProviders +{ + /// A value provider for user-defined dynamic tokens. + internal class DynamicTokenValueProvider : BaseValueProvider + { + /********* + ** Fields + *********/ + /// The allowed root values (or null if any value is allowed). + private readonly InvariantHashSet AllowedRootValues; + + /// The current values. + private InvariantHashSet Values = new InvariantHashSet(); + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The value provider name. + public DynamicTokenValueProvider(string name) + : base(name, canHaveMultipleValuesForRoot: false) + { + this.AllowedRootValues = new InvariantHashSet(); + this.EnableInputArguments(required: false, canHaveMultipleValues: false); + } + + /// Add a set of possible values. + /// The possible values to add. + public void AddAllowedValues(InvariantHashSet possibleValues) + { + foreach (string value in possibleValues) + this.AllowedRootValues.Add(value); + this.CanHaveMultipleValuesForRoot = this.CanHaveMultipleValuesForRoot || possibleValues.Count > 1; + } + + /// Set the current values. + /// The values to set. + public void SetValue(InvariantHashSet values) + { + this.Values = values; + } + + /// Set whether the token is valid in the current context. + /// The value to set. + public void SetValidInContext(bool validInContext) + { + this.IsValidInContext = validInContext; + } + + /// Get the allowed values for an input argument (or null if any value is allowed). + /// The input argument, if applicable. + /// The input argument doesn't match this value provider, or does not respect or . + public override InvariantHashSet GetAllowedValues(string input) + { + return input != null + ? InvariantHashSet.Boolean() + : this.AllowedRootValues; + } + + /// Get the current values. + /// The input argument, if applicable. + /// The input argument doesn't match this value provider, or does not respect or . + public override IEnumerable GetValues(string input) + { + this.AssertInputArgument(input); + + if (input != null) + return new[] { this.Values.Contains(input).ToString() }; + return this.Values; + } + } +} diff --git a/Mods/ContentPatcher/Framework/Tokens/ValueProviders/HasFileValueProvider.cs b/Mods/ContentPatcher/Framework/Tokens/ValueProviders/HasFileValueProvider.cs new file mode 100644 index 00000000..4ff86b36 --- /dev/null +++ b/Mods/ContentPatcher/Framework/Tokens/ValueProviders/HasFileValueProvider.cs @@ -0,0 +1,100 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using ContentPatcher.Framework.Conditions; +using Pathoschild.Stardew.Common; +using Pathoschild.Stardew.Common.Utilities; + +namespace ContentPatcher.Framework.Tokens.ValueProviders +{ + /// A value provider which checks whether a file exists in the content pack's folder. + internal class HasFileValueProvider : BaseValueProvider + { + /********* + ** Fields + *********/ + /// The mod folder from which to load assets. + private readonly string ModFolder; + + /// The context as of the last update. + private IContext TokenContext; + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The absolute path to the mod folder. + public HasFileValueProvider(string modFolder) + : base(ConditionType.HasFile, canHaveMultipleValuesForRoot: false) + { + this.ModFolder = modFolder; + this.EnableInputArguments(required: true, canHaveMultipleValues: false); + } + + /// Update the underlying values. + /// The condition context. + /// Returns whether the values changed. + public override void UpdateContext(IContext context) + { + this.TokenContext = context; + this.IsValidInContext = true; + } + + /// Get the allowed values for an input argument (or null if any value is allowed). + /// The input argument, if applicable. + /// The input argument doesn't match this value provider, or does not respect or . + public override InvariantHashSet GetAllowedValues(string input) + { + return input != null + ? InvariantHashSet.Boolean() + : null; + } + + /// Get the current values. + /// The input argument, if applicable. + /// The input argument doesn't match this value provider, or does not respect or . + public override IEnumerable GetValues(string input) + { + this.AssertInputArgument(input); + + yield return this.GetPathExists(input).ToString(); + } + + + /********* + ** Private methods + *********/ + /// Get whether the given file path exists. + /// A relative file path. + /// The path is not relative or contains directory climbing (../). + private bool GetPathExists(string path) + { + if (string.IsNullOrWhiteSpace(path)) + return false; + + // parse tokens + TokenString tokenStr = new TokenString(path, this.TokenContext); + if (tokenStr.InvalidTokens.Any()) + return false; + tokenStr.UpdateContext(this.TokenContext); + path = tokenStr.Value; + + // get normalised path + if (string.IsNullOrWhiteSpace(path)) + return false; + path = PathUtilities.NormalisePathSeparators(path); + + // validate + if (Path.IsPathRooted(path)) + throw new InvalidOperationException($"The {ConditionType.HasFile} token requires a relative path."); + if (!PathUtilities.IsSafeRelativePath(path)) + throw new InvalidOperationException($"The {ConditionType.HasFile} token requires a relative path and cannot contain directory climbing (../)."); + + // check file existence + string fullPath = Path.Combine(this.ModFolder, PathUtilities.NormalisePathSeparators(path)); + return File.Exists(fullPath); + } + } +} diff --git a/Mods/ContentPatcher/Framework/Tokens/ValueProviders/HasProfessionValueProvider.cs b/Mods/ContentPatcher/Framework/Tokens/ValueProviders/HasProfessionValueProvider.cs new file mode 100644 index 00000000..88a984b2 --- /dev/null +++ b/Mods/ContentPatcher/Framework/Tokens/ValueProviders/HasProfessionValueProvider.cs @@ -0,0 +1,100 @@ +using System; +using System.Collections.Generic; +using ContentPatcher.Framework.Conditions; +using ContentPatcher.Framework.Constants; +using Pathoschild.Stardew.Common.Utilities; +using StardewValley; + +namespace ContentPatcher.Framework.Tokens.ValueProviders +{ + /// A value provider for the player's professions. + internal class HasProfessionValueProvider : BaseValueProvider + { + /********* + ** Fields + *********/ + /// Get whether the player data is available in the current context. + private readonly Func IsPlayerDataAvailable; + + /// The player's current professions. + private readonly HashSet Professions = new HashSet(); + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// Get whether the player data is available in the current context. + public HasProfessionValueProvider(Func isPlayerDataAvailable) + : base(ConditionType.HasProfession, canHaveMultipleValuesForRoot: true) + { + this.IsPlayerDataAvailable = isPlayerDataAvailable; + this.EnableInputArguments(required: false, canHaveMultipleValues: false); + } + + /// Update the underlying values. + /// The condition context. + /// Returns whether the values changed. + public override void UpdateContext(IContext context) + { + this.Professions.Clear(); + this.IsValidInContext = this.IsPlayerDataAvailable(); + if (this.IsValidInContext) + { + foreach (int professionID in Game1.player.professions) + this.Professions.Add((Profession)professionID); + } + } + + /// Get the allowed values for a token name (or null if any value is allowed). + /// The input argument, if applicable. + /// The input argument doesn't match this token, or does not respect or . + public override InvariantHashSet GetAllowedValues(string input) + { + return input != null + ? InvariantHashSet.Boolean() + : null; + } + + /// Get the current values. + /// The input argument, if applicable. + /// The input argument doesn't match this token, or does not respect or . + public override IEnumerable GetValues(string input) + { + this.AssertInputArgument(input); + + if (input != null) + { + bool hasProfession = this.TryParseEnum(input, out Profession profession, mustBeNamed: false) && this.Professions.Contains(profession); + yield return hasProfession.ToString(); + } + else + { + foreach (Profession profession in this.Professions) + yield return profession.ToString(); + } + } + + /// Validate that the provided value is valid for an input argument (regardless of whether they match). + /// The input argument, if applicable. + /// The value to validate. + /// The validation error, if any. + /// Returns whether validation succeeded. + protected override bool TryValidate(string input, string value, out string error) + { + if (!base.TryValidate(input, value, out error)) + return false; + + // validate profession IDs + string profession = input ?? value; + if (!this.TryParseEnum(profession, out Profession _, mustBeNamed: false)) + { + error = $"can't parse '{profession}' as a profession ID; must be one of [{string.Join(", ", Enum.GetNames(typeof(Profession)).OrderByIgnoreCase(p => p))}] or an integer ID."; + return false; + } + + error = null; + return true; + } + } +} diff --git a/Mods/ContentPatcher/Framework/Tokens/ValueProviders/HasWalletItemValueProvider.cs b/Mods/ContentPatcher/Framework/Tokens/ValueProviders/HasWalletItemValueProvider.cs new file mode 100644 index 00000000..9cd2203c --- /dev/null +++ b/Mods/ContentPatcher/Framework/Tokens/ValueProviders/HasWalletItemValueProvider.cs @@ -0,0 +1,94 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using ContentPatcher.Framework.Conditions; +using ContentPatcher.Framework.Constants; +using Pathoschild.Stardew.Common.Utilities; +using StardewValley; + +namespace ContentPatcher.Framework.Tokens.ValueProviders +{ + /// A value provider for the player's wallet items. + internal class HasWalletItemValueProvider : BaseValueProvider + { + /********* + ** Fields + *********/ + /// Get whether the player data is available in the current context. + private readonly Func IsPlayerDataAvailable; + + /// The defined wallet items and whether the player has them. + private readonly IDictionary> WalletItems = new Dictionary> + { + [WalletItem.DwarvishTranslationGuide] = () => Game1.player.canUnderstandDwarves, + [WalletItem.RustyKey] = () => Game1.player.hasRustyKey, + [WalletItem.ClubCard] = () => Game1.player.hasClubCard, + [WalletItem.SpecialCharm] = () => Game1.player.hasSpecialCharm, + [WalletItem.SkullKey] = () => Game1.player.hasSkullKey, + [WalletItem.MagnifyingGlass] = () => Game1.player.hasMagnifyingGlass, + [WalletItem.DarkTalisman] = () => Game1.player.hasDarkTalisman, + [WalletItem.MagicInk] = () => Game1.player.hasMagicInk, + [WalletItem.BearsKnowledge] = () => Game1.player.eventsSeen.Contains(2120303), + [WalletItem.SpringOnionMastery] = () => Game1.player.eventsSeen.Contains(3910979) + }; + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// Get whether the player data is available in the current context. + public HasWalletItemValueProvider(Func isPlayerDataAvailable) + : base(ConditionType.HasWalletItem, canHaveMultipleValuesForRoot: true) + { + this.IsPlayerDataAvailable = isPlayerDataAvailable; + this.EnableInputArguments(required: false, canHaveMultipleValues: false); + } + + /// Update the underlying values. + /// The condition context. + /// Returns whether the values changed. + public override void UpdateContext(IContext context) + { + this.IsValidInContext = this.IsPlayerDataAvailable(); + } + + /// Get the set of valid input arguments if restricted, or an empty collection if unrestricted. + public override InvariantHashSet GetValidInputs() + { + return new InvariantHashSet(this.WalletItems.Keys.Select(p => p.ToString())); + } + + /// Get the allowed values for an input argument (or null if any value is allowed). + /// The input argument, if applicable. + /// The input argument doesn't match this value provider, or does not respect or . + public override InvariantHashSet GetAllowedValues(string input) + { + return input != null + ? InvariantHashSet.Boolean() + : this.GetValidInputs(); + } + + /// Get the current values. + /// The input argument, if applicable. + /// The input argument doesn't match this value provider, or does not respect or . + public override IEnumerable GetValues(string input) + { + this.AssertInputArgument(input); + + if (input != null) + { + bool hasItem = this.TryParseEnum(input, out WalletItem item) && this.WalletItems[item](); + yield return hasItem.ToString(); + } + else + { + foreach (KeyValuePair> pair in this.WalletItems) + { + if (pair.Value()) + yield return pair.Key.ToString(); + } + } + } + } +} diff --git a/Mods/ContentPatcher/Framework/Tokens/ValueProviders/IValueProvider.cs b/Mods/ContentPatcher/Framework/Tokens/ValueProviders/IValueProvider.cs new file mode 100644 index 00000000..9512a52f --- /dev/null +++ b/Mods/ContentPatcher/Framework/Tokens/ValueProviders/IValueProvider.cs @@ -0,0 +1,61 @@ +using System; +using System.Collections.Generic; +using Pathoschild.Stardew.Common.Utilities; + +namespace ContentPatcher.Framework.Tokens.ValueProviders +{ + /// Provides values for a token name with optional input. + internal interface IValueProvider + { + /********* + ** Accessors + *********/ + /// The value provider name. + string Name { get; } + + /// Whether values exist in the current context. + bool IsValidInContext { get; } + + /// Whether the provided values can change after the provider is initialised. + bool IsMutable { get; } + + /// Whether the value provider allows an input argument (e.g. an NPC name for a relationship token). + bool AllowsInput { get; } + + /// Whether the value provider requires an input argument to work, and does not provide values without it (see ). + bool RequiresInput { get; } + + + /********* + ** Public methods + *********/ + /// Update the underlying values. + /// The condition context. + /// Returns whether the values changed. + void UpdateContext(IContext context); + + /// Whether the value provider may return multiple values for the given input. + /// The input argument, if applicable. + bool CanHaveMultipleValues(string input = null); + + /// Validate that the provided values are valid for the input argument (regardless of whether they match). + /// The input argument, if applicable. + /// The values to validate. + /// The validation error, if any. + /// Returns whether validation succeeded. + bool TryValidate(string input, InvariantHashSet values, out string error); + + /// Get the set of valid input arguments if restricted, or an empty collection if unrestricted. + InvariantHashSet GetValidInputs(); + + /// Get the allowed values for an input argument (or null if any value is allowed). + /// The input argument, if applicable. + /// The input argument doesn't match this value provider, or does not respect or . + InvariantHashSet GetAllowedValues(string input); + + /// Get the current values. + /// The input argument, if applicable. + /// The input argument doesn't match this value provider, or does not respect or . + IEnumerable GetValues(string input); + } +} diff --git a/Mods/ContentPatcher/Framework/Tokens/ValueProviders/ImmutableValueProvider.cs b/Mods/ContentPatcher/Framework/Tokens/ValueProviders/ImmutableValueProvider.cs new file mode 100644 index 00000000..54f62fa4 --- /dev/null +++ b/Mods/ContentPatcher/Framework/Tokens/ValueProviders/ImmutableValueProvider.cs @@ -0,0 +1,61 @@ +using System; +using System.Collections.Generic; +using Pathoschild.Stardew.Common.Utilities; + +namespace ContentPatcher.Framework.Tokens.ValueProviders +{ + /// A value provider whose values don't change after it's initialised. + internal class ImmutableValueProvider : BaseValueProvider + { + /********* + ** Fields + *********/ + /// The allowed root values (or null if any value is allowed). + private readonly InvariantHashSet AllowedRootValues; + + /// The current token values. + private readonly InvariantHashSet Values; + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The value provider name. + /// Get the current token values. + /// The allowed values (or null if any value is allowed). + /// Whether the root may contain multiple values (or null to set it based on the given values). + public ImmutableValueProvider(string name, InvariantHashSet values, InvariantHashSet allowedValues = null, bool? canHaveMultipleValues = null) + : base(name, canHaveMultipleValuesForRoot: false) + { + this.Values = values ?? new InvariantHashSet(); + this.AllowedRootValues = allowedValues; + this.CanHaveMultipleValuesForRoot = canHaveMultipleValues ?? (this.Values.Count > 1 || this.AllowedRootValues == null || this.AllowedRootValues.Count > 1); + this.EnableInputArguments(required: false, canHaveMultipleValues: false); + this.IsMutable = false; + this.IsValidInContext = true; + } + + /// Get the allowed values for an input argument (or null if any value is allowed). + /// The input argument, if applicable. + /// The input argument doesn't match this value provider, or does not respect or . + public override InvariantHashSet GetAllowedValues(string input) + { + return input != null + ? InvariantHashSet.Boolean() + : this.AllowedRootValues; + } + + /// Get the current values. + /// The input argument, if applicable. + /// The input argument doesn't match this value provider, or does not respect or . + public override IEnumerable GetValues(string input) + { + this.AssertInputArgument(input); + + if (input != null) + return new[] { this.Values.Contains(input).ToString() }; + return this.Values; + } + } +} diff --git a/Mods/ContentPatcher/Framework/Tokens/ValueProviders/SkillLevelValueProvider.cs b/Mods/ContentPatcher/Framework/Tokens/ValueProviders/SkillLevelValueProvider.cs new file mode 100644 index 00000000..b3bf6e7a --- /dev/null +++ b/Mods/ContentPatcher/Framework/Tokens/ValueProviders/SkillLevelValueProvider.cs @@ -0,0 +1,77 @@ +using System; +using System.Collections.Generic; +using ContentPatcher.Framework.Conditions; +using ContentPatcher.Framework.Constants; +using Pathoschild.Stardew.Common.Utilities; +using StardewValley; + +namespace ContentPatcher.Framework.Tokens.ValueProviders +{ + /// A value provider for the player's skill levels. + internal class SkillLevelValueProvider : BaseValueProvider + { + /********* + ** Fields + *********/ + /// Get whether the player data is available in the current context. + private readonly Func IsPlayerDataAvailable; + + /// The player's current skill levels. + private readonly IDictionary SkillLevels = new Dictionary(); + + + /********* + ** Public methods + *********/ + /// Construct an instance. + public SkillLevelValueProvider(Func isPlayerDataAvailable) + : base(ConditionType.SkillLevel, canHaveMultipleValuesForRoot: true) + { + this.IsPlayerDataAvailable = isPlayerDataAvailable; + this.EnableInputArguments(required: false, canHaveMultipleValues: false); + } + + /// Update the underlying values. + /// The condition context. + /// Returns whether the values changed. + public override void UpdateContext(IContext context) + { + this.SkillLevels.Clear(); + this.IsValidInContext = this.IsPlayerDataAvailable(); + if (this.IsValidInContext) + { + this.SkillLevels[Skill.Combat] = Game1.player.CombatLevel; + this.SkillLevels[Skill.Farming] = Game1.player.FarmingLevel; + this.SkillLevels[Skill.Fishing] = Game1.player.FishingLevel; + this.SkillLevels[Skill.Foraging] = Game1.player.ForagingLevel; + this.SkillLevels[Skill.Luck] = Game1.player.LuckLevel; + this.SkillLevels[Skill.Mining] = Game1.player.MiningLevel; + } + } + + /// Get the set of valid input arguments if restricted, or an empty collection if unrestricted. + public override InvariantHashSet GetValidInputs() + { + return new InvariantHashSet(Enum.GetNames(typeof(Skill))); + } + + /// Get the current values. + /// The input argument, if applicable. + /// The input argument doesn't match this value provider, or does not respect or . + public override IEnumerable GetValues(string input) + { + this.AssertInputArgument(input); + + if (input != null) + { + if (this.TryParseEnum(input, out Skill skill) && this.SkillLevels.TryGetValue(skill, out int level)) + yield return level.ToString(); + } + else + { + foreach (var pair in this.SkillLevels) + yield return $"{pair.Key}:{pair.Value}"; + } + } + } +} diff --git a/Mods/ContentPatcher/Framework/Tokens/ValueProviders/VillagerHeartsValueProvider.cs b/Mods/ContentPatcher/Framework/Tokens/ValueProviders/VillagerHeartsValueProvider.cs new file mode 100644 index 00000000..304de6ce --- /dev/null +++ b/Mods/ContentPatcher/Framework/Tokens/ValueProviders/VillagerHeartsValueProvider.cs @@ -0,0 +1,70 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using ContentPatcher.Framework.Conditions; +using Pathoschild.Stardew.Common.Utilities; +using StardewModdingAPI; +using StardewValley; + +namespace ContentPatcher.Framework.Tokens.ValueProviders +{ + /// A value provider for NPC friendship hearts. + internal class VillagerHeartsValueProvider : BaseValueProvider + { + /********* + ** Fields + *********/ + /// The relationships by NPC. + private readonly InvariantDictionary Values = new InvariantDictionary(); + + + /********* + ** Public methods + *********/ + /// Construct an instance. + public VillagerHeartsValueProvider() + : base(ConditionType.Hearts, canHaveMultipleValuesForRoot: false) + { + this.EnableInputArguments(required: false, canHaveMultipleValues: false); + } + + /// Update the underlying values. + /// The condition context. + /// Returns whether the values changed. + public override void UpdateContext(IContext context) + { + this.Values.Clear(); + this.IsValidInContext = Context.IsWorldReady; + if (this.IsValidInContext) + { + foreach (KeyValuePair pair in Game1.player.friendshipData.Pairs) + this.Values[pair.Key] = (pair.Value.Points / NPC.friendshipPointsPerHeartLevel).ToString(CultureInfo.InvariantCulture); + } + } + + /// Get the set of valid input arguments if restricted, or an empty collection if unrestricted. + public override InvariantHashSet GetValidInputs() + { + return new InvariantHashSet(this.Values.Keys); + } + + /// Get the current values. + /// The input argument, if applicable. + /// The input argument doesn't match this value provider, or does not respect or . + public override IEnumerable GetValues(string input) + { + this.AssertInputArgument(input); + + if (input != null) + { + if (this.Values.TryGetValue(input, out string value)) + yield return value; + } + else + { + foreach (var pair in this.Values) + yield return $"{pair.Key}:{pair.Value}"; + } + } + } +} diff --git a/Mods/ContentPatcher/Framework/Tokens/ValueProviders/VillagerRelationshipValueProvider.cs b/Mods/ContentPatcher/Framework/Tokens/ValueProviders/VillagerRelationshipValueProvider.cs new file mode 100644 index 00000000..478d2614 --- /dev/null +++ b/Mods/ContentPatcher/Framework/Tokens/ValueProviders/VillagerRelationshipValueProvider.cs @@ -0,0 +1,69 @@ +using System; +using System.Collections.Generic; +using ContentPatcher.Framework.Conditions; +using Pathoschild.Stardew.Common.Utilities; +using StardewModdingAPI; +using StardewValley; + +namespace ContentPatcher.Framework.Tokens.ValueProviders +{ + /// A value provider for NPC relationship types. + internal class VillagerRelationshipValueProvider : BaseValueProvider + { + /********* + ** Fields + *********/ + /// The relationships by NPC. + private readonly InvariantDictionary Values = new InvariantDictionary(); + + + /********* + ** Public methods + *********/ + /// Construct an instance. + public VillagerRelationshipValueProvider() + : base(ConditionType.Relationship, canHaveMultipleValuesForRoot: false) + { + this.EnableInputArguments(required: false, canHaveMultipleValues: false); + } + + /// Update the underlying values. + /// The condition context. + /// Returns whether the values changed. + public override void UpdateContext(IContext context) + { + this.Values.Clear(); + this.IsValidInContext = Context.IsWorldReady; + if (this.IsValidInContext) + { + foreach (KeyValuePair pair in Game1.player.friendshipData.Pairs) + this.Values[pair.Key] = pair.Value.Status.ToString(); + } + } + + /// Get the set of valid input arguments if restricted, or an empty collection if unrestricted. + public override InvariantHashSet GetValidInputs() + { + return new InvariantHashSet(this.Values.Keys); + } + + /// Get the current values. + /// The input argument, if applicable. + /// The input argument doesn't match this value provider, or does not respect or . + public override IEnumerable GetValues(string input) + { + this.AssertInputArgument(input); + + if (input != null) + { + if (this.Values.TryGetValue(input, out string value)) + yield return value; + } + else + { + foreach (var pair in this.Values) + yield return $"{pair.Key}:{pair.Value}"; + } + } + } +} diff --git a/Mods/ContentPatcher/Framework/Validators/BaseValidator.cs b/Mods/ContentPatcher/Framework/Validators/BaseValidator.cs new file mode 100644 index 00000000..337d4df2 --- /dev/null +++ b/Mods/ContentPatcher/Framework/Validators/BaseValidator.cs @@ -0,0 +1,24 @@ +using ContentPatcher.Framework.Patches; +using StardewModdingAPI; + +namespace ContentPatcher.Framework.Validators +{ + /// The base implementation for a content pack validator. + internal abstract class BaseValidator : IAssetValidator + { + /********* + ** Public methods + *********/ + /// Validate a content pack. + /// The asset being loaded. + /// The loaded asset data to validate. + /// The patch which loaded the asset. + /// An error message which indicates why validation failed. + /// Returns whether validation succeeded. + public virtual bool TryValidate(IAssetInfo asset, T data, IPatch patch, out string error) + { + error = null; + return false; + } + } +} diff --git a/Mods/ContentPatcher/Framework/Validators/IAssetValidator.cs b/Mods/ContentPatcher/Framework/Validators/IAssetValidator.cs new file mode 100644 index 00000000..08ff56ce --- /dev/null +++ b/Mods/ContentPatcher/Framework/Validators/IAssetValidator.cs @@ -0,0 +1,20 @@ +using ContentPatcher.Framework.Patches; +using StardewModdingAPI; + +namespace ContentPatcher.Framework.Validators +{ + /// Performs validation logic for an asset being loaded. + internal interface IAssetValidator + { + /********* + ** Public methods + *********/ + /// Validate a content pack. + /// The asset being loaded. + /// The loaded asset data to validate. + /// The patch which loaded the asset. + /// An error message which indicates why validation failed. + /// Returns whether validation succeeded. + bool TryValidate(IAssetInfo asset, T data, IPatch patch, out string error); + } +} diff --git a/Mods/ContentPatcher/Framework/Validators/StardewValley_1_3_36_Validator.cs b/Mods/ContentPatcher/Framework/Validators/StardewValley_1_3_36_Validator.cs new file mode 100644 index 00000000..810ccad0 --- /dev/null +++ b/Mods/ContentPatcher/Framework/Validators/StardewValley_1_3_36_Validator.cs @@ -0,0 +1,91 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.IO; +using ContentPatcher.Framework.Patches; +using StardewModdingAPI; +using xTile; +using xTile.Tiles; + +namespace ContentPatcher.Framework.Validators +{ + /// Validate content packs for compatibility with Stardew Valley 1.3.36. + [SuppressMessage("ReSharper", "InconsistentNaming", Justification = "Named for clarity.")] + internal class StardewValley_1_3_36_Validator : BaseValidator + { + /********* + ** Fields + *********/ + /// A map of tilesheets removed in Stardew Valley 1.3.36 and the new tilesheets that should be referenced instead. + private IDictionary ObsoleteTilesheets = new Dictionary(StringComparer.InvariantCultureIgnoreCase) + { + ["mine"] = "Mines/mine", + ["mine_dark"] = "Mines/mine_dark", + ["mine_lava"] = "Mines/mine_lava" + }; + + + /********* + ** Public methods + *********/ + /// Validate a content pack. + /// The asset being loaded. + /// The loaded asset data to validate. + /// The patch which loaded the asset. + /// An error message which indicates why validation failed. + /// Returns whether validation succeeded. + public override bool TryValidate(IAssetInfo asset, T data, IPatch patch, out string error) + { + // detect vanilla tilesheets removed in SDV 1.3.36 + if (data is Map map) + { + string mapFolderPath = Path.GetDirectoryName(patch.FromLocalAsset.Value); + foreach (TileSheet tilesheet in map.TileSheets) + { + string curKey = tilesheet.ImageSource; + + // skip if tilesheet exists relative to the content pack + string mapRelativeSource = Path.Combine(mapFolderPath, curKey); + if (patch.ContentPack.HasFile(mapRelativeSource)) + continue; + + // detect obsolete tilesheet references + if (this.IsObsoleteTilesheet(curKey, out string newKey)) + { + error = $"references vanilla tilesheet '{curKey}' removed in Stardew Valley 1.3.36, should use '{newKey}' instead"; + return false; + } + } + } + + error = null; + return true; + } + + + /********* + ** Private methods + *********/ + /// Get whether a given tilesheet image source is obsolete. + /// The tilesheet image source. + /// The key that should be replaced with, if it's obsolete. + private bool IsObsoleteTilesheet(string curKey, out string newKey) + { + if (curKey == null) + { + newKey = null; + return false; + } + + // exact match + if (this.ObsoleteTilesheets.TryGetValue(curKey, out newKey)) + return true; + + // strip .png + if (curKey.EndsWith(".png", StringComparison.InvariantCultureIgnoreCase) && this.ObsoleteTilesheets.TryGetValue(curKey.Substring(0, curKey.Length - 4), out newKey)) + return true; + + return false; + } + } +} diff --git a/Mods/ContentPatcher/ModEntry.cs b/Mods/ContentPatcher/ModEntry.cs new file mode 100644 index 00000000..d5911209 --- /dev/null +++ b/Mods/ContentPatcher/ModEntry.cs @@ -0,0 +1,811 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.IO; +using System.Linq; +using ContentPatcher.Framework; +using ContentPatcher.Framework.Commands; +using ContentPatcher.Framework.Conditions; +using ContentPatcher.Framework.ConfigModels; +using ContentPatcher.Framework.Lexing; +using ContentPatcher.Framework.Lexing.LexTokens; +using ContentPatcher.Framework.Migrations; +using ContentPatcher.Framework.Patches; +using ContentPatcher.Framework.Tokens; +using ContentPatcher.Framework.Validators; +using Pathoschild.Stardew.Common.Utilities; +using StardewModdingAPI; +using StardewModdingAPI.Enums; +using StardewModdingAPI.Events; + +namespace ContentPatcher +{ + /// The mod entry point. + internal class ModEntry : Mod + { + /********* + ** Fields + *********/ + /// The name of the file which contains patch metadata. + private readonly string PatchFileName = "content.json"; + + /// The name of the file which contains player settings. + private readonly string ConfigFileName = "config.json"; + + /// The supported format versions. + private readonly string[] SupportedFormatVersions = { "1.0", "1.3", "1.4", "1.5", "1.6" }; + + /// The format version migrations to apply. + private readonly Func Migrations = () => new IMigration[] + { + new Migration_1_3(), + new Migration_1_4(), + new Migration_1_5(), + new Migration_1_6() + }; + + /// The special validation logic to apply to assets affected by patches. + private readonly Func AssetValidators = () => new IAssetValidator[] + { + new StardewValley_1_3_36_Validator() + }; + + /// Manages the available contextual tokens. + private TokenManager TokenManager; + + /// Manages loaded patches. + private PatchManager PatchManager; + + /// Handles the 'patch' console command. + private CommandHandler CommandHandler; + + /// The mod configuration. + private ModConfig Config; + + /// The debug overlay (if enabled). + private DebugOverlay DebugOverlay; + + + /********* + ** Public methods + *********/ + /// The mod entry point, called after the mod is first loaded. + /// Provides simplified APIs for writing mods. + public override void Entry(IModHelper helper) + { + this.Config = helper.ReadConfig(); + + // init migrations + IMigration[] migrations = this.Migrations(); + + // fetch content packs + RawContentPack[] contentPacks = this.GetContentPacks(migrations).ToArray(); + string[] installedMods = + (contentPacks.Select(p => p.Manifest.UniqueID)) + .Concat(helper.ModRegistry.GetAll().Select(p => p.Manifest.UniqueID)) + .OrderByIgnoreCase(p => p) + .ToArray(); + + // load content packs and context + this.TokenManager = new TokenManager(helper.Content, installedMods); + this.PatchManager = new PatchManager(this.Monitor, this.TokenManager, this.AssetValidators()); + this.LoadContentPacks(contentPacks); + this.TokenManager.UpdateContext(); + + // register patcher + helper.Content.AssetLoaders.Add(this.PatchManager); + helper.Content.AssetEditors.Add(this.PatchManager); + + // set up events + if (this.Config.EnableDebugFeatures) + helper.Events.Input.ButtonPressed += this.OnButtonPressed; + helper.Events.GameLoop.ReturnedToTitle += this.OnReturnedToTitle; + helper.Events.GameLoop.DayStarted += this.OnDayStarted; + helper.Events.Specialised.LoadStageChanged += this.OnLoadStageChanged; + + // set up commands + this.CommandHandler = new CommandHandler(this.TokenManager, this.PatchManager, this.Monitor, this.UpdateContext); + helper.ConsoleCommands.Add(this.CommandHandler.CommandName, $"Starts a Content Patcher command. Type '{this.CommandHandler.CommandName} help' for details.", (name, args) => this.CommandHandler.Handle(args)); + } + + + /********* + ** Private methods + *********/ + /**** + ** Event handlers + ****/ + /// The method invoked when the player presses a button. + /// The event sender. + /// The event data. + private void OnButtonPressed(object sender, ButtonPressedEventArgs e) + { + if (this.Config.EnableDebugFeatures) + { + // toggle overlay + if (this.Config.Controls.ToggleDebug.Contains(e.Button)) + { + if (this.DebugOverlay == null) + this.DebugOverlay = new DebugOverlay(this.Helper.Events, this.Helper.Input, this.Helper.Content); + else + { + this.DebugOverlay.Dispose(); + this.DebugOverlay = null; + } + return; + } + + // cycle textures + if (this.DebugOverlay != null) + { + if (this.Config.Controls.DebugPrevTexture.Contains(e.Button)) + this.DebugOverlay.PrevTexture(); + if (this.Config.Controls.DebugNextTexture.Contains(e.Button)) + this.DebugOverlay.NextTexture(); + } + } + } + + /// Raised when the low-level stage in the game's loading process has changed. This is an advanced event for mods which need to run code at specific points in the loading process. The available stages or when they happen might change without warning in future versions (e.g. due to changes in the game's load process), so mods using this event are more likely to break or have bugs. + /// The event sender. + /// The event data. + private void OnLoadStageChanged(object sender, LoadStageChangedEventArgs e) + { + switch (e.NewStage) + { + case LoadStage.CreatedBasicInfo: + case LoadStage.SaveLoadedBasicInfo: + this.Monitor.VerboseLog($"Updating context: load stage changed to {e.NewStage}."); + this.TokenManager.IsBasicInfoLoaded = true; + this.UpdateContext(); + break; + } + } + + /// The method invoked when a new day starts. + /// The event sender. + /// The event data. + private void OnDayStarted(object sender, DayStartedEventArgs e) + { + this.Monitor.VerboseLog("Updating context: new day started."); + this.TokenManager.IsBasicInfoLoaded = true; + this.UpdateContext(); + } + + /// The method invoked when the player returns to the title screen. + /// The event sender. + /// The event data. + private void OnReturnedToTitle(object sender, ReturnedToTitleEventArgs e) + { + this.Monitor.VerboseLog("Updating context: returned to title."); + this.TokenManager.IsBasicInfoLoaded = false; + this.UpdateContext(); + } + + /**** + ** Methods + ****/ + /// Update the current context. + private void UpdateContext() + { + this.TokenManager.UpdateContext(); + this.PatchManager.UpdateContext(this.Helper.Content); + } + + /// Load the registered content packs. + /// The format version migrations to apply. + /// Returns the loaded content pack IDs. + [SuppressMessage("ReSharper", "AccessToModifiedClosure", Justification = "The value is used immediately, so this isn't an issue.")] + private IEnumerable GetContentPacks(IMigration[] migrations) + { + this.Monitor.VerboseLog("Preloading content packs..."); + + foreach (IContentPack contentPack in this.Helper.ContentPacks.GetOwned()) + { + RawContentPack rawContentPack; + try + { + // validate content.json has required fields + ContentConfig content = contentPack.ReadJsonFile(this.PatchFileName); + if (content == null) + { + this.Monitor.Log($"Ignored content pack '{contentPack.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 '{contentPack.Manifest.Name}' because it doesn't specify the required {nameof(ContentConfig.Format)} or {nameof(ContentConfig.Changes)} fields.", LogLevel.Error); + continue; + } + + // apply migrations + IMigration migrator = new AggregateMigration(content.Format, this.SupportedFormatVersions, migrations); + if (!migrator.TryMigrate(content, out string error)) + { + this.Monitor.Log($"Loading content pack '{contentPack.Manifest.Name}' failed: {error}.", LogLevel.Error); + continue; + } + + // init + rawContentPack = new RawContentPack(new ManagedContentPack(contentPack), content, migrator); + } + catch (Exception ex) + { + this.Monitor.Log($"Error preloading content pack '{contentPack.Manifest.Name}'. Technical details:\n{ex}", LogLevel.Error); + continue; + } + + yield return rawContentPack; + } + } + + /// Load the patches from all registered content packs. + /// The content packs to load. + /// Returns the loaded content pack IDs. + [SuppressMessage("ReSharper", "AccessToModifiedClosure", Justification = "The value is used immediately, so this isn't an issue.")] + private void LoadContentPacks(IEnumerable 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 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 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; + } + } + } + + /// Split patches with multiple target values. + /// The patches to split. + private IEnumerable SplitPatches(IEnumerable patches) + { + foreach (PatchConfig patch in patches) + { + if (string.IsNullOrWhiteSpace(patch.Target) || !patch.Target.Contains(",")) + { + yield return patch; + continue; + } + + int i = 0; + foreach (string target in patch.Target.Split(',')) + { + i++; + yield return new PatchConfig(patch) + { + LogName = !string.IsNullOrWhiteSpace(patch.LogName) ? $"{patch.LogName} {"".PadRight(i, 'I')}" : "", + Target = target.Trim() + }; + } + } + } + + /// Set a unique name for all patches in a content pack. + /// The content pack. + /// The patches to name. + private void NamePatches(ManagedContentPack contentPack, PatchConfig[] patches) + { + // add default log names + foreach (PatchConfig patch in patches) + { + if (string.IsNullOrWhiteSpace(patch.LogName)) + patch.LogName = $"{patch.Action} {patch.Target}"; + } + + // detect duplicate names + InvariantHashSet duplicateNames = new InvariantHashSet( + from patch in patches + group patch by patch.LogName into nameGroup + where nameGroup.Count() > 1 + select nameGroup.Key + ); + + // make names unique + int i = 0; + foreach (PatchConfig patch in patches) + { + i++; + + if (duplicateNames.Contains(patch.LogName)) + patch.LogName = $"entry #{i} ({patch.LogName})"; + + patch.LogName = $"{contentPack.Manifest.Name} > {patch.LogName}"; + } + } + + /// Load one patch from a content pack's content.json file. + /// The content pack being loaded. + /// The change to load. + /// The tokens available for this content pack. + /// The migrator which validates and migrates content pack data. + /// The callback to invoke with the error reason if loading it fails. + private bool LoadPatch(ManagedContentPack pack, PatchConfig entry, IContext tokenContext, IMigration migrator, Action 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(); + + // 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, tokenContext, migrator, out string error, out assetName)) + return TrackSkip($"the {nameof(PatchConfig.Target)} is invalid: {error}"); + } + + // parse 'enabled' + bool enabled = true; + { + if (entry.Enabled != null && !this.TryParseEnabled(entry.Enabled, tokenContext, migrator, out string error, out enabled)) + return TrackSkip($"invalid {nameof(PatchConfig.Enabled)} value '{entry.Enabled}': {error}"); + } + + // parse conditions + ConditionDictionary conditions; + { + if (!this.TryParseConditions(entry.When, tokenContext, migrator, 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, tokenContext, migrator, out string error, out TokenString fromAsset)) + return TrackSkip(error); + patch = new LoadPatch(entry.LogName, pack, assetName, conditions, fromAsset, this.Helper.Content.NormaliseAssetName); + } + 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 => p.Value != null && p.Value.Trim() == "")) + 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."); + + // parse entries + List entries = new List(); + if (entry.Entries != null) + { + foreach (KeyValuePair pair in entry.Entries) + { + if (!this.TryParseTokenString(pair.Key, tokenContext, migrator, out string keyError, out TokenString key)) + return TrackSkip($"{nameof(PatchConfig.Entries)} > '{key}' key is invalid: {keyError}."); + if (!this.TryParseTokenString(pair.Value, tokenContext, migrator, out string error, out TokenString value)) + return TrackSkip($"{nameof(PatchConfig.Entries)} > '{key}' value is invalid: {error}."); + entries.Add(new EditDataPatchRecord(key, value)); + } + } + + // parse fields + List fields = new List(); + if (entry.Fields != null) + { + foreach (KeyValuePair> recordPair in entry.Fields) + { + if (!this.TryParseTokenString(recordPair.Key, tokenContext, migrator, out string keyError, out TokenString key)) + return TrackSkip($"{nameof(PatchConfig.Fields)} > entry {recordPair.Key} is invalid: {keyError}."); + + foreach (var fieldPair in recordPair.Value) + { + int field = fieldPair.Key; + if (!this.TryParseTokenString(fieldPair.Value, tokenContext, migrator, out string valueError, out TokenString value)) + return TrackSkip($"{nameof(PatchConfig.Fields)} > entry {recordPair.Key} > field {field} is invalid: {valueError}."); + if (value.Raw?.Contains("/") == true) + return TrackSkip($"{nameof(PatchConfig.Fields)} > entry {recordPair.Key} > field {field} is invalid: value can't contain field delimiter character '/'."); + + fields.Add(new EditDataPatchField(key, field, value)); + } + } + } + + // save + patch = new EditDataPatch(entry.LogName, pack, assetName, conditions, entries, 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, tokenContext, migrator, out string error, out TokenString fromAsset)) + return TrackSkip(error); + patch = new EditImagePatch(entry.LogName, pack, assetName, conditions, fromAsset, entry.FromArea, entry.ToArea, patchMode, this.Monitor, this.Helper.Content.NormaliseAssetName); + } + break; + + default: + return TrackSkip($"unsupported patch type '{action}'."); + } + + // 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}"); + } + } + + /// Normalise and parse the given condition values. + /// The raw condition values to normalise. + /// The tokens available for this content pack. + /// The migrator which validates and migrates content pack data. + /// The normalised conditions. + /// An error message indicating why normalisation failed. + private bool TryParseConditions(InvariantDictionary 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 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; + } + + /// Parse a comma-delimited set of case-insensitive condition values. + /// The field value to parse. + public InvariantHashSet ParseCommaDelimitedField(string field) + { + if (string.IsNullOrWhiteSpace(field)) + return new InvariantHashSet(); + + IEnumerable values = ( + from value in field.Split(',') + where !string.IsNullOrWhiteSpace(value) + select value.Trim() + ); + return new InvariantHashSet(values); + } + + /// Parse a boolean value from a string which can contain tokens, and validate that it's valid. + /// The raw string which may contain tokens. + /// The tokens available for this content pack. + /// The migrator which validates and migrates content pack data. + /// An error phrase indicating why parsing failed (if applicable). + /// The parsed value. + private bool TryParseEnabled(string rawValue, IContext tokenContext, IMigration migrator, out string error, out bool parsed) + { + parsed = false; + + // analyse string + if (!this.TryParseTokenString(rawValue, tokenContext, migrator, out error, out TokenString tokenString)) + return false; + + // validate & extract tokens + string text = rawValue; + if (tokenString.HasAnyTokens) + { + // only one token allowed + if (!tokenString.IsSingleTokenOnly) + { + error = "can't be treated as a true/false value because it contains multiple tokens."; + return false; + } + + // check token options + TokenName tokenName = tokenString.Tokens.First(); + IToken token = tokenContext.GetToken(tokenName, enforceContext: false); + InvariantHashSet allowedValues = token?.GetAllowedValues(tokenName); + if (token == null || token.IsMutable || !token.IsValidInContext) + { + error = $"can only use static tokens in this field, consider using a {nameof(PatchConfig.When)} condition instead."; + return false; + } + if (allowedValues == null || !allowedValues.All(p => bool.TryParse(p, out _))) + { + error = "that token isn't restricted to 'true' or 'false'."; + return false; + } + if (token.CanHaveMultipleValues(tokenName)) + { + error = "can't be treated as a true/false value because that token can have multiple values."; + return false; + } + + text = token.GetValues(tokenName).First(); + } + + // parse text + if (!bool.TryParse(text, out parsed)) + { + error = $"can't parse {tokenString.Raw} as a true/false value."; + return false; + } + return true; + } + + /// Parse a string which can contain tokens, and validate that it's valid. + /// The raw string which may contain tokens. + /// The tokens available for this content pack. + /// The migrator which validates and migrates content pack data. + /// An error phrase indicating why parsing failed (if applicable). + /// The parsed value. + private bool TryParseTokenString(string rawValue, IContext tokenContext, IMigration migrator, out string error, out TokenString parsed) + { + // parse + parsed = new TokenString(rawValue, tokenContext); + if (!migrator.TryMigrate(ref parsed, out error)) + return false; + + // validate unknown tokens + if (parsed.InvalidTokens.Any()) + { + error = $"found unknown tokens ({string.Join(", ", parsed.InvalidTokens.OrderBy(p => p))})"; + parsed = null; + return false; + } + + // validate tokens + foreach (TokenName tokenName in parsed.Tokens) + { + IToken token = tokenContext.GetToken(tokenName, enforceContext: false); + if (token == null) + { + error = $"{{{{{tokenName}}}}} can't be used as a token because that token could not be found."; // should never happen + parsed = null; + return false; + } + if (token.CanHaveMultipleValues(tokenName)) + { + error = $"{{{{{tokenName}}}}} can't be used as a token because it can have multiple values."; + parsed = null; + return false; + } + } + + // looks OK + error = null; + return true; + } + + + /// Prepare a local asset file for a patch to use. + /// The content pack being loaded. + /// The asset path in the content patch. + /// The tokens available for this content pack. + /// The migrator which validates and migrates content pack data. + /// The error reason if preparing the asset fails. + /// The parsed value. + /// Returns whether the local asset was successfully prepared. + private bool TryPrepareLocalAsset(ManagedContentPack pack, string path, IContext tokenContext, IMigration migrator, out string error, out TokenString tokenedPath) + { + // 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, tokenContext, migrator, out string tokenError, out tokenedPath)) + { + error = $"the {nameof(PatchConfig.FromFile)} is invalid: {tokenError}"; + tokenedPath = null; + return false; + } + + // looks OK + error = null; + return true; + } + + /// Get a normalised file path relative to the content pack folder. + /// The content pack. + /// The relative asset path. + private string NormaliseLocalAssetPath(ManagedContentPack contentPack, string path) + { + // normalise asset name + if (string.IsNullOrWhiteSpace(path)) + return null; + string newPath = this.Helper.Content.NormaliseAssetName(path); + + // add .xnb extension if needed (it's stripped from asset names) + string fullPath = contentPack.GetFullPath(newPath); + if (!File.Exists(fullPath)) + { + if (File.Exists($"{fullPath}.xnb") || Path.GetExtension(path) == ".xnb") + newPath += ".xnb"; + } + + return newPath; + } + } +} diff --git a/Mods/ContentPatcher/Properties/AssemblyInfo.cs b/Mods/ContentPatcher/Properties/AssemblyInfo.cs new file mode 100644 index 00000000..2d23c767 --- /dev/null +++ b/Mods/ContentPatcher/Properties/AssemblyInfo.cs @@ -0,0 +1,36 @@ +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; + +// 有关程序集的一般信息由以下 +// 控制。更改这些特性值可修改 +// 与程序集关联的信息。 +[assembly: AssemblyTitle("ContentPatcher")] +[assembly: AssemblyDescription("")] +[assembly: AssemblyConfiguration("")] +[assembly: AssemblyCompany("")] +[assembly: AssemblyProduct("ContentPatcher")] +[assembly: AssemblyCopyright("Copyright © 2019")] +[assembly: AssemblyTrademark("")] +[assembly: AssemblyCulture("")] + +// 将 ComVisible 设置为 false 会使此程序集中的类型 +//对 COM 组件不可见。如果需要从 COM 访问此程序集中的类型 +//请将此类型的 ComVisible 特性设置为 true。 +[assembly: ComVisible(false)] + +// 如果此项目向 COM 公开,则下列 GUID 用于类型库的 ID +[assembly: Guid("8e1d56b0-d640-4eb0-a703-e280c40a655d")] + +// 程序集的版本信息由下列四个值组成: +// +// 主版本 +// 次版本 +// 生成号 +// 修订号 +// +// 可以指定所有值,也可以使用以下所示的 "*" 预置版本号和修订号 +//通过使用 "*",如下所示: +// [assembly: AssemblyVersion("1.0.*")] +[assembly: AssemblyVersion("1.0.0.0")] +[assembly: AssemblyFileVersion("1.0.0.0")] diff --git a/Mods/ConvenientChests/ConvenientChests.csproj b/Mods/ConvenientChests/ConvenientChests.csproj index 9b95ab91..6c62bcbb 100644 --- a/Mods/ConvenientChests/ConvenientChests.csproj +++ b/Mods/ConvenientChests/ConvenientChests.csproj @@ -31,8 +31,8 @@ 4 - - ..\assemblies\Mod.dll + + ..\assemblies\StardewModdingAPI.dll ..\assemblies\StardewValley.dll diff --git a/Mods/Mods.sln b/Mods/Mods.sln index 400ec87e..4a83c311 100644 --- a/Mods/Mods.sln +++ b/Mods/Mods.sln @@ -17,6 +17,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ScytheHarvesting", "ScytheH EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ConvenientChests", "ConvenientChests\ConvenientChests.csproj", "{84A712EC-5F80-43DC-879C-D3604B6F5644}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ContentPatcher", "ContentPatcher\ContentPatcher.csproj", "{8E1D56B0-D640-4EB0-A703-E280C40A655D}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -51,6 +53,10 @@ Global {84A712EC-5F80-43DC-879C-D3604B6F5644}.Debug|Any CPU.Build.0 = Debug|Any CPU {84A712EC-5F80-43DC-879C-D3604B6F5644}.Release|Any CPU.ActiveCfg = Release|Any CPU {84A712EC-5F80-43DC-879C-D3604B6F5644}.Release|Any CPU.Build.0 = Release|Any CPU + {8E1D56B0-D640-4EB0-A703-E280C40A655D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8E1D56B0-D640-4EB0-A703-E280C40A655D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8E1D56B0-D640-4EB0-A703-E280C40A655D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8E1D56B0-D640-4EB0-A703-E280C40A655D}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/Mods/ScytheHarvesting/ScytheHarvesting.csproj b/Mods/ScytheHarvesting/ScytheHarvesting.csproj index 1bde0f62..a82f471a 100644 --- a/Mods/ScytheHarvesting/ScytheHarvesting.csproj +++ b/Mods/ScytheHarvesting/ScytheHarvesting.csproj @@ -31,8 +31,8 @@ 4 - - ..\assemblies\Mod.dll + + ..\assemblies\StardewModdingAPI.dll ..\assemblies\StardewValley.dll diff --git a/Mods/SkullCavernElevator/SkullCavernElevator.csproj b/Mods/SkullCavernElevator/SkullCavernElevator.csproj index 9d97f2a4..a5a66043 100644 --- a/Mods/SkullCavernElevator/SkullCavernElevator.csproj +++ b/Mods/SkullCavernElevator/SkullCavernElevator.csproj @@ -31,8 +31,8 @@ 4 - - ..\assemblies\Mod.dll + + ..\assemblies\StardewModdingAPI.dll ..\assemblies\StardewValley.dll diff --git a/Mods/TimeSpeed/TimeSpeed.csproj b/Mods/TimeSpeed/TimeSpeed.csproj index ab32bf7c..c40358ce 100644 --- a/Mods/TimeSpeed/TimeSpeed.csproj +++ b/Mods/TimeSpeed/TimeSpeed.csproj @@ -31,8 +31,8 @@ 4 - - ..\assemblies\Mod.dll + + ..\assemblies\StardewModdingAPI.dll ..\assemblies\StardewValley.dll diff --git a/PatchStep.txt b/PatchStep.txt index 13e513ad..fa0a6b0a 100644 --- a/PatchStep.txt +++ b/PatchStep.txt @@ -1,4 +1,4 @@ -1. Inject assembly reference, namespace: SMDroid +1. Inject assembly reference, namespace: StardewModdingAPI 2.Modify class StardewValley.Game1, modify constructor methodinsert Instructions at beginning: newobj System.Void SMDroid.ModEntry::.ctor() diff --git a/src/Mod.csproj b/src/Mod.csproj index d2c13efa..1a4babdd 100644 --- a/src/Mod.csproj +++ b/src/Mod.csproj @@ -7,8 +7,8 @@ {23B6885E-0282-435B-A854-D49ABF1391D6} Library Properties - Mod - Mod + StardewModdingAPI + StardewModdingAPI v4.5.2 512 diff --git a/src/ModEntry.cs b/src/ModEntry.cs index c5d675df..eb9cfc72 100644 --- a/src/ModEntry.cs +++ b/src/ModEntry.cs @@ -40,6 +40,7 @@ namespace SMDroid this.ContentCore = new ContentCoordinator(serviceProvider, rootDirectory, Thread.CurrentThread.CurrentUICulture, SGame.ConstructorHack.Monitor, SGame.ConstructorHack.Reflection, SGame.ConstructorHack.JsonHelper, SGame.OnLoadingFirstAsset ?? SGame.ConstructorHack?.OnLoadingFirstAsset); this.NextContentManagerIsMain = true; this.core.RunInteractively(this.ContentCore); + SGame.printLog("ROOT Directory:" + rootDirectory); return this.ContentCore.CreateGameContentManager("Game1._temporaryContent"); } diff --git a/src/SMAPI/Constants.cs b/src/SMAPI/Constants.cs index aa2440c2..7e260124 100644 --- a/src/SMAPI/Constants.cs +++ b/src/SMAPI/Constants.cs @@ -167,7 +167,11 @@ namespace StardewModdingAPI removeAssemblyReferences = new[] { "StardewValley", - "MonoGame.Framework" + "Stardew Valley", + "Microsoft.Xna.Framework", + "Microsoft.Xna.Framework.Game", + "Microsoft.Xna.Framework.Graphics", + "Microsoft.Xna.Framework.Xact" }; targetAssemblies = new[] { diff --git a/src/SMAPI/Framework/ContentManagers/BaseContentManager.cs b/src/SMAPI/Framework/ContentManagers/BaseContentManager.cs index 7821e454..a29af051 100644 --- a/src/SMAPI/Framework/ContentManagers/BaseContentManager.cs +++ b/src/SMAPI/Framework/ContentManagers/BaseContentManager.cs @@ -29,6 +29,9 @@ namespace StardewModdingAPI.Framework.ContentManagers /// Encapsulates monitoring and logging. protected readonly IMonitor Monitor; + /// Reflector. + protected readonly Reflector Reflector; + /// Whether the content coordinator has been disposed. private bool IsDisposed; @@ -76,6 +79,7 @@ namespace StardewModdingAPI.Framework.ContentManagers this.Coordinator = coordinator ?? throw new ArgumentNullException(nameof(coordinator)); this.Cache = new ContentCache(this, reflection); this.Monitor = monitor ?? throw new ArgumentNullException(nameof(monitor)); + this.Reflector = reflection; this.OnDisposing = onDisposing; this.IsModContentManager = isModFolder; diff --git a/src/SMAPI/Framework/ContentManagers/ModContentManager.cs b/src/SMAPI/Framework/ContentManagers/ModContentManager.cs index 6485b3d4..a1947f8d 100644 --- a/src/SMAPI/Framework/ContentManagers/ModContentManager.cs +++ b/src/SMAPI/Framework/ContentManagers/ModContentManager.cs @@ -1,7 +1,10 @@ using System; +using System.Collections.Generic; using System.Globalization; using System.IO; +using System.Reflection; using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Content; using Microsoft.Xna.Framework.Graphics; using StardewModdingAPI.Framework.Exceptions; using StardewModdingAPI.Framework.Reflection; @@ -105,7 +108,7 @@ namespace StardewModdingAPI.Framework.ContentManagers { // XNB file case ".xnb": - return base.Load(relativePath, language); + return this.ModedLoad(relativePath, language); // unpacked data case ".json": @@ -186,5 +189,96 @@ namespace StardewModdingAPI.Framework.ContentManagers texture.SetData(data); return texture; } + + public T ModedLoad(string assetName, LanguageCode language){ + if (language != LanguageCode.en) + { + string key = assetName + "." + this.LanguageCodeString(language); + Dictionary _localizedAsset = this.Reflector.GetField>(this, "_localizedAsset").GetValue(); + if (!_localizedAsset.TryGetValue(key, out bool flag) | flag) + { + try + { + _localizedAsset[key] = true; + return this.ModedLoad(key); + } + catch (ContentLoadException) + { + _localizedAsset[key] = false; + } + } + } + return this.ModedLoad(assetName); + } + + public T ModedLoad(string assetName) + { + if (string.IsNullOrEmpty(assetName)) + { + throw new ArgumentNullException("assetName"); + } + T local = default(T); + string key = assetName.Replace('\\', '/'); + Dictionary loadedAssets = this.Reflector.GetField>(this, "loadedAssets").GetValue(); + if (loadedAssets.TryGetValue(key, out object obj2) && (obj2 is T)) + { + return (T)obj2; + } + local = this.ReadAsset(assetName, null); + loadedAssets[key] = local; + return local; + } + + protected override Stream OpenStream(string assetName) + { + Stream stream; + try + { + stream = new FileStream(Path.Combine(this.RootDirectory, assetName) + ".xnb", FileMode.Open, FileAccess.Read); + MemoryStream destination = new MemoryStream(); + stream.CopyTo(destination); + destination.Seek(0L, SeekOrigin.Begin); + stream.Close(); + stream = destination; + } + catch (Exception exception3) + { + throw new ContentLoadException("Opening stream error.", exception3); + } + return stream; + } + protected new T ReadAsset(string assetName, Action recordDisposableObject) + { + if (string.IsNullOrEmpty(assetName)) + { + throw new ArgumentNullException("assetName"); + } + ; + string str = assetName; + object obj2 = null; + if (this.Reflector.GetField(this, "graphicsDeviceService").GetValue() == null) + { + this.Reflector.GetField(this, "graphicsDeviceService").SetValue(this.ServiceProvider.GetService(typeof(IGraphicsDeviceService)) as IGraphicsDeviceService); + } + Stream input = this.OpenStream(assetName); + using (BinaryReader reader = new BinaryReader(input)) + { + using (ContentReader reader2 = this.Reflector.GetMethod(this, "GetContentReaderFromXnb").Invoke(assetName, input, reader, recordDisposableObject)) + { + MethodInfo method = reader2.GetType().GetMethod("ReadAsset", BindingFlags.Instance | BindingFlags.DeclaredOnly | BindingFlags.NonPublic, null, new Type[] { }, new ParameterModifier[] { }); + obj2 = method.MakeGenericMethod(new Type[] { typeof(T) }).Invoke(reader2, null); + if (obj2 is GraphicsResource graphics) + { + graphics.Name = str; + } + } + } + if (obj2 == null) + { + throw new Exception("Could not load " + str + " asset!"); + } + return (T)obj2; + } + } } diff --git a/src/SMAPI/Framework/SCore.cs b/src/SMAPI/Framework/SCore.cs index 8e6d5f2d..6abdf99a 100644 --- a/src/SMAPI/Framework/SCore.cs +++ b/src/SMAPI/Framework/SCore.cs @@ -928,7 +928,7 @@ namespace StardewModdingAPI.Framework Assembly modAssembly; try { - modAssembly = assemblyLoader.Load(mod, assemblyPath, assumeCompatible: mod.DataRecord?.Status == ModStatus.AssumeCompatible); + modAssembly = assemblyLoader.Load(mod, assemblyPath, true/*assumeCompatible: mod.DataRecord?.Status == ModStatus.AssumeCompatible*/); this.ModRegistry.TrackAssemblies(mod, modAssembly); } catch (IncompatibleInstructionException) // details already in trace logs diff --git a/src/SMAPI/Framework/SGame.cs b/src/SMAPI/Framework/SGame.cs index af76e15d..73c7725c 100644 --- a/src/SMAPI/Framework/SGame.cs +++ b/src/SMAPI/Framework/SGame.cs @@ -1,3 +1,4 @@ + using System; using System.Collections.Concurrent; using System.Collections.Generic; @@ -398,8 +399,8 @@ namespace StardewModdingAPI.Framework // this too. For example, doing this after mod event suppression would prevent the // user from doing anything on the overnight shipping screen. SInputState inputState = this.Input; - //if (Game1.game1.IsActive) - inputState.TrueUpdate(); + if (Game1.game1.IsActive) + inputState.TrueUpdate(); /********* ** Save events + suppress events during save @@ -529,7 +530,7 @@ namespace StardewModdingAPI.Framework /********* ** Input events (if window has focus) *********/ - //if (Game1.game1.IsActive) + if (Game1.game1.IsActive) { // raise events bool isChatInput = Game1.IsChatting || (Context.IsMultiplayer && Context.IsWorldReady && Game1.activeClickableMenu == null && Game1.currentMinigame == null && inputState.IsAnyDown(Game1.options.chatButton)); @@ -618,7 +619,10 @@ namespace StardewModdingAPI.Framework List options = this.Reflection.GetField>(optionsPage, "options").GetValue(); foreach(IModMetadata modMetadata in this.ModRegistry.GetAll()) { - options.InsertRange(0, modMetadata.Mod.GetConfigMenuItems()); + if(modMetadata.Mod != null) + { + options.InsertRange(0, modMetadata.Mod.GetConfigMenuItems()); + } } this.Reflection.GetMethod(optionsPage, "updateContentPositions").Invoke(); }