From 3da27346c6886fff4afb35d7fb46345c92ef1197 Mon Sep 17 00:00:00 2001 From: Jesse Plamondon-Willard Date: Fri, 12 May 2017 01:15:02 -0400 Subject: add basic dependencies to manifest (#285) --- src/StardewModdingAPI/Framework/Models/Manifest.cs | 6 +- .../Framework/Models/ManifestDependency.cs | 23 +++++++ .../Serialisation/ManifestFieldConverter.cs | 72 ++++++++++++++++++++++ .../Serialisation/SemanticVersionConverter.cs | 51 --------------- src/StardewModdingAPI/IManifest.cs | 3 + src/StardewModdingAPI/IManifestDependency.cs | 12 ++++ src/StardewModdingAPI/StardewModdingAPI.csproj | 4 +- 7 files changed, 118 insertions(+), 53 deletions(-) create mode 100644 src/StardewModdingAPI/Framework/Models/ManifestDependency.cs create mode 100644 src/StardewModdingAPI/Framework/Serialisation/ManifestFieldConverter.cs delete mode 100644 src/StardewModdingAPI/Framework/Serialisation/SemanticVersionConverter.cs create mode 100644 src/StardewModdingAPI/IManifestDependency.cs (limited to 'src') diff --git a/src/StardewModdingAPI/Framework/Models/Manifest.cs b/src/StardewModdingAPI/Framework/Models/Manifest.cs index 79be2075..be781585 100644 --- a/src/StardewModdingAPI/Framework/Models/Manifest.cs +++ b/src/StardewModdingAPI/Framework/Models/Manifest.cs @@ -21,7 +21,7 @@ namespace StardewModdingAPI.Framework.Models public string Author { get; set; } /// The mod version. - [JsonConverter(typeof(SemanticVersionConverter))] + [JsonConverter(typeof(ManifestFieldConverter))] public ISemanticVersion Version { get; set; } /// The minimum SMAPI version required by this mod, if any. @@ -30,6 +30,10 @@ namespace StardewModdingAPI.Framework.Models /// The name of the DLL in the directory that has the method. public string EntryDll { get; set; } + /// The other mods that must be loaded before this mod. + [JsonConverter(typeof(ManifestFieldConverter))] + public IManifestDependency[] Dependencies { get; set; } + /// The unique mod ID. public string UniqueID { get; set; } diff --git a/src/StardewModdingAPI/Framework/Models/ManifestDependency.cs b/src/StardewModdingAPI/Framework/Models/ManifestDependency.cs new file mode 100644 index 00000000..2f580c1d --- /dev/null +++ b/src/StardewModdingAPI/Framework/Models/ManifestDependency.cs @@ -0,0 +1,23 @@ +namespace StardewModdingAPI.Framework.Models +{ + /// A mod dependency listed in a mod manifest. + internal class ManifestDependency : IManifestDependency + { + /********* + ** Accessors + *********/ + /// The unique mod ID to require. + public string UniqueID { get; set; } + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The unique mod ID to require. + public ManifestDependency(string uniqueID) + { + this.UniqueID = uniqueID; + } + } +} diff --git a/src/StardewModdingAPI/Framework/Serialisation/ManifestFieldConverter.cs b/src/StardewModdingAPI/Framework/Serialisation/ManifestFieldConverter.cs new file mode 100644 index 00000000..6b5a6aaa --- /dev/null +++ b/src/StardewModdingAPI/Framework/Serialisation/ManifestFieldConverter.cs @@ -0,0 +1,72 @@ +using System; +using System.Collections.Generic; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using StardewModdingAPI.Framework.Models; + +namespace StardewModdingAPI.Framework.Serialisation +{ + /// Overrides how SMAPI reads and writes and fields. + internal class ManifestFieldConverter : JsonConverter + { + /********* + ** Accessors + *********/ + /// Whether this converter can write JSON. + public override bool CanWrite => false; + + + /********* + ** Public methods + *********/ + /// Get whether this instance can convert the specified object type. + /// The object type. + public override bool CanConvert(Type objectType) + { + return objectType == typeof(ISemanticVersion) || objectType == typeof(IManifestDependency[]); + } + + /// Reads the JSON representation of the object. + /// The JSON reader. + /// The object type. + /// The object being read. + /// The calling serializer. + public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer) + { + // semantic version + if (objectType == typeof(ISemanticVersion)) + { + JObject obj = JObject.Load(reader); + int major = obj.Value(nameof(ISemanticVersion.MajorVersion)); + int minor = obj.Value(nameof(ISemanticVersion.MinorVersion)); + int patch = obj.Value(nameof(ISemanticVersion.PatchVersion)); + string build = obj.Value(nameof(ISemanticVersion.Build)); + return new SemanticVersion(major, minor, patch, build); + } + + // manifest dependency + if (objectType == typeof(IManifestDependency[])) + { + List result = new List(); + foreach (JObject obj in JArray.Load(reader).Children()) + { + string uniqueID = obj.Value(nameof(IManifestDependency.UniqueID)); + result.Add(new ManifestDependency(uniqueID)); + } + return result.ToArray(); + } + + // unknown + throw new NotSupportedException($"Unknown type '{objectType?.FullName}'."); + } + + /// Writes the JSON representation of the object. + /// The JSON writer. + /// The value. + /// The calling serializer. + public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer) + { + throw new InvalidOperationException("This converter does not write JSON."); + } + } +} diff --git a/src/StardewModdingAPI/Framework/Serialisation/SemanticVersionConverter.cs b/src/StardewModdingAPI/Framework/Serialisation/SemanticVersionConverter.cs deleted file mode 100644 index 52ec999e..00000000 --- a/src/StardewModdingAPI/Framework/Serialisation/SemanticVersionConverter.cs +++ /dev/null @@ -1,51 +0,0 @@ -using System; -using Newtonsoft.Json; -using Newtonsoft.Json.Linq; - -namespace StardewModdingAPI.Framework.Serialisation -{ - /// Overrides how SMAPI reads and writes . - internal class SemanticVersionConverter : JsonConverter - { - /********* - ** Accessors - *********/ - /// Whether this converter can write JSON. - public override bool CanWrite => false; - - - /********* - ** Public methods - *********/ - /// Get whether this instance can convert the specified object type. - /// The object type. - public override bool CanConvert(Type objectType) - { - return objectType == typeof(ISemanticVersion); - } - - /// Reads the JSON representation of the object. - /// The JSON reader. - /// The object type. - /// The object being read. - /// The calling serializer. - public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer) - { - JObject obj = JObject.Load(reader); - int major = obj.Value("MajorVersion"); - int minor = obj.Value("MinorVersion"); - int patch = obj.Value("PatchVersion"); - string build = obj.Value("Build"); - return new SemanticVersion(major, minor, patch, build); - } - - /// Writes the JSON representation of the object. - /// The JSON writer. - /// The value. - /// The calling serializer. - public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer) - { - throw new InvalidOperationException("This converter does not write JSON."); - } - } -} diff --git a/src/StardewModdingAPI/IManifest.cs b/src/StardewModdingAPI/IManifest.cs index 38b83347..9533aadb 100644 --- a/src/StardewModdingAPI/IManifest.cs +++ b/src/StardewModdingAPI/IManifest.cs @@ -29,6 +29,9 @@ namespace StardewModdingAPI /// The name of the DLL in the directory that has the method. string EntryDll { get; } + /// The other mods that must be loaded before this mod. + IManifestDependency[] Dependencies { get; } + /// Any manifest fields which didn't match a valid field. IDictionary ExtraFields { get; } } diff --git a/src/StardewModdingAPI/IManifestDependency.cs b/src/StardewModdingAPI/IManifestDependency.cs new file mode 100644 index 00000000..7bd2e8b6 --- /dev/null +++ b/src/StardewModdingAPI/IManifestDependency.cs @@ -0,0 +1,12 @@ +namespace StardewModdingAPI +{ + /// A mod dependency listed in a mod manifest. + public interface IManifestDependency + { + /********* + ** Accessors + *********/ + /// The unique mod ID to require. + string UniqueID { get; } + } +} diff --git a/src/StardewModdingAPI/StardewModdingAPI.csproj b/src/StardewModdingAPI/StardewModdingAPI.csproj index 2a150eb6..86fc8b2b 100644 --- a/src/StardewModdingAPI/StardewModdingAPI.csproj +++ b/src/StardewModdingAPI/StardewModdingAPI.csproj @@ -133,6 +133,7 @@ + @@ -141,13 +142,14 @@ - + + -- cgit From 3a02402367fb13787d7ee18315273db9f299b7d9 Mon Sep 17 00:00:00 2001 From: Luke Wale Date: Sat, 13 May 2017 19:25:13 +0800 Subject: Added basic topological sort for mod dependencies (#285) --- src/StardewModdingAPI/Program.cs | 83 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 83 insertions(+) (limited to 'src') diff --git a/src/StardewModdingAPI/Program.cs b/src/StardewModdingAPI/Program.cs index 75bdba0f..b9ddb527 100644 --- a/src/StardewModdingAPI/Program.cs +++ b/src/StardewModdingAPI/Program.cs @@ -317,6 +317,7 @@ namespace StardewModdingAPI JsonHelper jsonHelper = new JsonHelper(); IList deprecationWarnings = new List(); ModMetadata[] mods = this.FindMods(Constants.ModPath, new JsonHelper(), deprecationWarnings); + mods = SortByDependencies(mods); modsLoaded = this.LoadMods(mods, jsonHelper, (SContentManager)Game1.content, deprecationWarnings); // log deprecation warnings together @@ -337,6 +338,88 @@ namespace StardewModdingAPI new Thread(this.RunConsoleLoop).Start(); } + private ModMetadata[] SortByDependencies(ModMetadata[] mods) + { + var unsortedMods = mods.ToList(); + var sortedMods = new Stack(); + var visitedMods = new bool[unsortedMods.Count]; + var currentChain = new List(); + bool success = true; + + for (int modIndex = 0; modIndex < unsortedMods.Count; modIndex++) + { + if (visitedMods[modIndex] == false) + { + success = SortByDependencies(modIndex, visitedMods, sortedMods, currentChain, unsortedMods); + } + if (!success) break; + } + + if (!success) + { + // Failed to sort list, return no mods. + this.Monitor.Log("No mods will be loaded.", LogLevel.Error); + return new ModMetadata[0]; + } + + return sortedMods.Reverse().ToArray(); + } + + private bool SortByDependencies(int modIndex, bool[] visitedMods, Stack sortedMods, List currentChain, List unsortedMods) + { + visitedMods[modIndex] = true; + var mod = unsortedMods[modIndex]; + + string missingMods = string.Empty; + foreach (var m in mod.Manifest.Dependencies) + { + if (!unsortedMods.Any(x => x.Manifest.UniqueID.Equals(m.UniqueID))) + { + missingMods += $",{m.UniqueID}"; + } + } + if (!string.IsNullOrEmpty(missingMods)) + { + this.Monitor.Log($"Mod {mod.Manifest.UniqueID} is missing dependencies; {missingMods.TrimStart(',')}", LogLevel.Error); + return false; + } + + var modsToLoadFirst = unsortedMods.Where(x => + mod.Manifest.Dependencies.Any(y => y.UniqueID == x.Manifest.UniqueID) + ).ToList(); + + var circularReferenceMod = currentChain.FirstOrDefault(x => modsToLoadFirst.Contains(x)); + if (circularReferenceMod != null) + { + this.Monitor.Log($"Circular reference found when loading Mod dependencies.", LogLevel.Error); + string chain = $"{mod.Manifest.UniqueID} -> {circularReferenceMod.Manifest.UniqueID}"; + for (int i = currentChain.Count - 1; i >= 0; i--) + { + chain = $"{currentChain[i].Manifest.UniqueID} -> " + chain; + if (currentChain[i].Manifest.UniqueID.Equals(mod.Manifest.UniqueID)) break; + } + this.Monitor.Log(chain, LogLevel.Error); + return false; + } + + currentChain.Add(mod); + + bool success = true; + foreach (var requiredMod in modsToLoadFirst) + { + int index = unsortedMods.IndexOf(requiredMod); + if (!visitedMods[index]) + { + success = SortByDependencies(index, visitedMods, sortedMods, currentChain, unsortedMods); + } + if (!success) break; + } + + sortedMods.Push(mod); + currentChain.Remove(mod); + return success; + } + /// Run a loop handling console input. [SuppressMessage("ReSharper", "FunctionNeverReturns", Justification = "The thread is aborted when the game exits.")] private void RunConsoleLoop() -- cgit From a3729c36f55e36f7159a2c88a939d17f9f486e61 Mon Sep 17 00:00:00 2001 From: Jesse Plamondon-Willard Date: Sat, 13 May 2017 16:58:17 -0400 Subject: refactor mod dependency logic a bit (#285) --- src/StardewModdingAPI/Program.cs | 80 ++++++++++++++++++++++++---------------- 1 file changed, 48 insertions(+), 32 deletions(-) (limited to 'src') diff --git a/src/StardewModdingAPI/Program.cs b/src/StardewModdingAPI/Program.cs index b9ddb527..3ec9e28b 100644 --- a/src/StardewModdingAPI/Program.cs +++ b/src/StardewModdingAPI/Program.cs @@ -317,7 +317,7 @@ namespace StardewModdingAPI JsonHelper jsonHelper = new JsonHelper(); IList deprecationWarnings = new List(); ModMetadata[] mods = this.FindMods(Constants.ModPath, new JsonHelper(), deprecationWarnings); - mods = SortByDependencies(mods); + mods = this.HandleModDependencies(mods); modsLoaded = this.LoadMods(mods, jsonHelper, (SContentManager)Game1.content, deprecationWarnings); // log deprecation warnings together @@ -338,7 +338,9 @@ namespace StardewModdingAPI new Thread(this.RunConsoleLoop).Start(); } - private ModMetadata[] SortByDependencies(ModMetadata[] mods) + /// Sort a set of mods by the order they should be loaded, and remove any mods that can't be loaded due to missing or conflicting dependencies. + /// The mods to process. + private ModMetadata[] HandleModDependencies(ModMetadata[] mods) { var unsortedMods = mods.ToList(); var sortedMods = new Stack(); @@ -346,13 +348,11 @@ namespace StardewModdingAPI var currentChain = new List(); bool success = true; - for (int modIndex = 0; modIndex < unsortedMods.Count; modIndex++) + for (int index = 0; index < unsortedMods.Count; index++) { - if (visitedMods[modIndex] == false) - { - success = SortByDependencies(modIndex, visitedMods, sortedMods, currentChain, unsortedMods); - } - if (!success) break; + success = this.HandleModDependencies(index, visitedMods, sortedMods, currentChain, unsortedMods); + if (!success) + break; } if (!success) @@ -365,33 +365,50 @@ namespace StardewModdingAPI return sortedMods.Reverse().ToArray(); } - private bool SortByDependencies(int modIndex, bool[] visitedMods, Stack sortedMods, List currentChain, List unsortedMods) + /// Sort a mod's dependencies by the order they should be loaded, and remove any mods that can't be loaded due to missing or conflicting dependencies. + /// The index of the mod being processed in the . + /// The mods which have been processed. + /// The list in which to save mods sorted by dependency order. + /// The current change of mod dependencies. + /// The mods remaining to sort. + /// Returns whether the mod can be loaded. + private bool HandleModDependencies(int modIndex, bool[] visitedMods, Stack sortedMods, List currentChain, List unsortedMods) { + // visit mod + if (visitedMods[modIndex]) + return true; // already sorted + ModMetadata mod = unsortedMods[modIndex]; visitedMods[modIndex] = true; - var mod = unsortedMods[modIndex]; - string missingMods = string.Empty; - foreach (var m in mod.Manifest.Dependencies) + // validate required dependencies are present { - if (!unsortedMods.Any(x => x.Manifest.UniqueID.Equals(m.UniqueID))) + string missingMods = null; + foreach (IManifestDependency dependency in mod.Manifest.Dependencies) { - missingMods += $",{m.UniqueID}"; + if (!unsortedMods.Any(m => m.Manifest.UniqueID.Equals(dependency.UniqueID))) + missingMods += $"{dependency.UniqueID}, "; + } + if (missingMods != null) + { + this.Monitor.Log($"Skipped {mod.DisplayName} because it requires mods which aren't installed ({missingMods.Substring(0, missingMods.Length - 2)}).", LogLevel.Error); + return false; } } - if (!string.IsNullOrEmpty(missingMods)) - { - this.Monitor.Log($"Mod {mod.Manifest.UniqueID} is missing dependencies; {missingMods.TrimStart(',')}", LogLevel.Error); - return false; - } - - var modsToLoadFirst = unsortedMods.Where(x => - mod.Manifest.Dependencies.Any(y => y.UniqueID == x.Manifest.UniqueID) - ).ToList(); - var circularReferenceMod = currentChain.FirstOrDefault(x => modsToLoadFirst.Contains(x)); + // get mods which should be loaded before this one + ModMetadata[] modsToLoadFirst = + ( + from unsorted in unsortedMods + where mod.Manifest.Dependencies.Any(required => required.UniqueID == unsorted.Manifest.UniqueID) + select unsorted + ) + .ToArray(); + + // detect circular references + ModMetadata circularReferenceMod = currentChain.FirstOrDefault(modsToLoadFirst.Contains); if (circularReferenceMod != null) { - this.Monitor.Log($"Circular reference found when loading Mod dependencies.", LogLevel.Error); + this.Monitor.Log($"Skipped {mod.DisplayName} because its dependencies have a circular reference: {string.Join(" => ", currentChain.Select(p => p.DisplayName))} => {circularReferenceMod.DisplayName}).", LogLevel.Error); string chain = $"{mod.Manifest.UniqueID} -> {circularReferenceMod.Manifest.UniqueID}"; for (int i = currentChain.Count - 1; i >= 0; i--) { @@ -401,20 +418,19 @@ namespace StardewModdingAPI this.Monitor.Log(chain, LogLevel.Error); return false; } - currentChain.Add(mod); + // recursively sort dependencies bool success = true; - foreach (var requiredMod in modsToLoadFirst) + foreach (ModMetadata requiredMod in modsToLoadFirst) { int index = unsortedMods.IndexOf(requiredMod); - if (!visitedMods[index]) - { - success = SortByDependencies(index, visitedMods, sortedMods, currentChain, unsortedMods); - } - if (!success) break; + success = this.HandleModDependencies(index, visitedMods, sortedMods, currentChain, unsortedMods); + if (!success) + break; } + // mark mod sorted sortedMods.Push(mod); currentChain.Remove(mod); return success; -- cgit From c932c5313705d0b9b0ed566c22d6a4935b69897c Mon Sep 17 00:00:00 2001 From: Jesse Plamondon-Willard Date: Sat, 13 May 2017 17:03:26 -0400 Subject: fix error when processing mods that have no dependencies (#285) --- src/StardewModdingAPI/Program.cs | 83 +++++++++++++++++++++------------------- 1 file changed, 44 insertions(+), 39 deletions(-) (limited to 'src') diff --git a/src/StardewModdingAPI/Program.cs b/src/StardewModdingAPI/Program.cs index 3ec9e28b..a86a9540 100644 --- a/src/StardewModdingAPI/Program.cs +++ b/src/StardewModdingAPI/Program.cs @@ -342,6 +342,7 @@ namespace StardewModdingAPI /// The mods to process. private ModMetadata[] HandleModDependencies(ModMetadata[] mods) { + this.Monitor.Log("Checking mod dependencies..."); var unsortedMods = mods.ToList(); var sortedMods = new Stack(); var visitedMods = new bool[unsortedMods.Count]; @@ -380,54 +381,58 @@ namespace StardewModdingAPI ModMetadata mod = unsortedMods[modIndex]; visitedMods[modIndex] = true; - // validate required dependencies are present + // process dependencies + bool success = true; + if (mod.Manifest.Dependencies != null && mod.Manifest.Dependencies.Any()) { - string missingMods = null; - foreach (IManifestDependency dependency in mod.Manifest.Dependencies) + // validate required dependencies are present { - if (!unsortedMods.Any(m => m.Manifest.UniqueID.Equals(dependency.UniqueID))) - missingMods += $"{dependency.UniqueID}, "; + string missingMods = null; + foreach (IManifestDependency dependency in mod.Manifest.Dependencies) + { + if (!unsortedMods.Any(m => m.Manifest.UniqueID.Equals(dependency.UniqueID))) + missingMods += $"{dependency.UniqueID}, "; + } + if (missingMods != null) + { + this.Monitor.Log($"Skipped {mod.DisplayName} because it requires mods which aren't installed ({missingMods.Substring(0, missingMods.Length - 2)}).", LogLevel.Error); + return false; + } } - if (missingMods != null) + + // get mods which should be loaded before this one + ModMetadata[] modsToLoadFirst = + ( + from unsorted in unsortedMods + where mod.Manifest.Dependencies.Any(required => required.UniqueID == unsorted.Manifest.UniqueID) + select unsorted + ) + .ToArray(); + + // detect circular references + ModMetadata circularReferenceMod = currentChain.FirstOrDefault(modsToLoadFirst.Contains); + if (circularReferenceMod != null) { - this.Monitor.Log($"Skipped {mod.DisplayName} because it requires mods which aren't installed ({missingMods.Substring(0, missingMods.Length - 2)}).", LogLevel.Error); + this.Monitor.Log($"Skipped {mod.DisplayName} because its dependencies have a circular reference: {string.Join(" => ", currentChain.Select(p => p.DisplayName))} => {circularReferenceMod.DisplayName}).", LogLevel.Error); + string chain = $"{mod.Manifest.UniqueID} -> {circularReferenceMod.Manifest.UniqueID}"; + for (int i = currentChain.Count - 1; i >= 0; i--) + { + chain = $"{currentChain[i].Manifest.UniqueID} -> " + chain; + if (currentChain[i].Manifest.UniqueID.Equals(mod.Manifest.UniqueID)) break; + } + this.Monitor.Log(chain, LogLevel.Error); return false; } - } + currentChain.Add(mod); - // get mods which should be loaded before this one - ModMetadata[] modsToLoadFirst = - ( - from unsorted in unsortedMods - where mod.Manifest.Dependencies.Any(required => required.UniqueID == unsorted.Manifest.UniqueID) - select unsorted - ) - .ToArray(); - - // detect circular references - ModMetadata circularReferenceMod = currentChain.FirstOrDefault(modsToLoadFirst.Contains); - if (circularReferenceMod != null) - { - this.Monitor.Log($"Skipped {mod.DisplayName} because its dependencies have a circular reference: {string.Join(" => ", currentChain.Select(p => p.DisplayName))} => {circularReferenceMod.DisplayName}).", LogLevel.Error); - string chain = $"{mod.Manifest.UniqueID} -> {circularReferenceMod.Manifest.UniqueID}"; - for (int i = currentChain.Count - 1; i >= 0; i--) + // recursively sort dependencies + foreach (ModMetadata requiredMod in modsToLoadFirst) { - chain = $"{currentChain[i].Manifest.UniqueID} -> " + chain; - if (currentChain[i].Manifest.UniqueID.Equals(mod.Manifest.UniqueID)) break; + int index = unsortedMods.IndexOf(requiredMod); + success = this.HandleModDependencies(index, visitedMods, sortedMods, currentChain, unsortedMods); + if (!success) + break; } - this.Monitor.Log(chain, LogLevel.Error); - return false; - } - currentChain.Add(mod); - - // recursively sort dependencies - bool success = true; - foreach (ModMetadata requiredMod in modsToLoadFirst) - { - int index = unsortedMods.IndexOf(requiredMod); - success = this.HandleModDependencies(index, visitedMods, sortedMods, currentChain, unsortedMods); - if (!success) - break; } // mark mod sorted -- cgit From 66d2b5746ab063b89ca42525a78e217e71d00858 Mon Sep 17 00:00:00 2001 From: Jesse Plamondon-Willard Date: Sat, 13 May 2017 17:24:41 -0400 Subject: move mod metadata resolution into its own class (#285) --- .../Framework/ModLoading/ModResolver.cs | 300 +++++++++++++++++++++ src/StardewModdingAPI/Framework/ModRegistry.cs | 28 -- src/StardewModdingAPI/Program.cs | 250 +---------------- src/StardewModdingAPI/StardewModdingAPI.csproj | 1 + 4 files changed, 304 insertions(+), 275 deletions(-) create mode 100644 src/StardewModdingAPI/Framework/ModLoading/ModResolver.cs (limited to 'src') diff --git a/src/StardewModdingAPI/Framework/ModLoading/ModResolver.cs b/src/StardewModdingAPI/Framework/ModLoading/ModResolver.cs new file mode 100644 index 00000000..450fe6bf --- /dev/null +++ b/src/StardewModdingAPI/Framework/ModLoading/ModResolver.cs @@ -0,0 +1,300 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using StardewModdingAPI.Framework.Models; +using StardewModdingAPI.Framework.Serialisation; + +namespace StardewModdingAPI.Framework.ModLoading +{ + /// Finds and processes mod metadata. + internal class ModResolver + { + /********* + ** Properties + *********/ + /// Encapsulates monitoring and logging. + private readonly IMonitor Monitor; + + /// Manages deprecation warnings. + private readonly DeprecationManager DeprecationManager; + + /// Metadata about mods that SMAPI should assume is compatible or broken, regardless of whether it detects incompatible code. + private readonly ModCompatibility[] CompatibilityRecords; + + + /********* + ** Public methods + *********/ + public ModResolver(IMonitor monitor, DeprecationManager deprecationManager, IEnumerable compatibilityRecords) + { + this.Monitor = monitor; + this.DeprecationManager = deprecationManager; + this.CompatibilityRecords = compatibilityRecords.ToArray(); + } + + /// Find all mods in the given folder. + /// The root mod path to search. + /// The JSON helper with which to read the manifest file. + /// A list to populate with any deprecation warnings. + public ModMetadata[] FindMods(string rootPath, JsonHelper jsonHelper, IList deprecationWarnings) + { + this.Monitor.Log("Finding mods..."); + void LogSkip(string displayName, string reasonPhrase, LogLevel level = LogLevel.Error) => this.Monitor.Log($"Skipped {displayName} because {reasonPhrase}", level); + + // load mod metadata + List mods = new List(); + foreach (string modRootPath in Directory.GetDirectories(rootPath)) + { + if (this.Monitor.IsExiting) + return new ModMetadata[0]; // exit in progress + + // init metadata + string displayName = modRootPath.Replace(rootPath, "").Trim('/', '\\'); + + // passthrough empty directories + DirectoryInfo directory = new DirectoryInfo(modRootPath); + while (!directory.GetFiles().Any() && directory.GetDirectories().Length == 1) + directory = directory.GetDirectories().First(); + + // get manifest path + string manifestPath = Path.Combine(directory.FullName, "manifest.json"); + if (!File.Exists(manifestPath)) + { + LogSkip(displayName, "it doesn't have a manifest.", LogLevel.Warn); + continue; + } + + // read manifest + Manifest manifest; + try + { + // read manifest file + string json = File.ReadAllText(manifestPath); + if (string.IsNullOrEmpty(json)) + { + LogSkip(displayName, "its manifest is empty."); + continue; + } + + // parse manifest + manifest = jsonHelper.ReadJsonFile(Path.Combine(directory.FullName, "manifest.json")); + if (manifest == null) + { + LogSkip(displayName, "its manifest is invalid."); + continue; + } + + // validate manifest + if (string.IsNullOrWhiteSpace(manifest.EntryDll)) + { + LogSkip(displayName, "its manifest doesn't set an entry DLL."); + continue; + } + if (string.IsNullOrWhiteSpace(manifest.UniqueID)) + deprecationWarnings.Add(() => this.Monitor.Log($"{manifest.Name} doesn't have a {nameof(IManifest.UniqueID)} in its manifest. This will be required in an upcoming SMAPI release.", LogLevel.Warn)); + } + catch (Exception ex) + { + LogSkip(displayName, $"parsing its manifest failed:\n{ex.GetLogSummary()}"); + continue; + } + if (!string.IsNullOrWhiteSpace(manifest.Name)) + displayName = manifest.Name; + + // validate compatibility + ModCompatibility compatibility = this.GetCompatibilityRecord(manifest); + if (compatibility?.Compatibility == ModCompatibilityType.AssumeBroken) + { + bool hasOfficialUrl = !string.IsNullOrWhiteSpace(compatibility.UpdateUrl); + bool hasUnofficialUrl = !string.IsNullOrWhiteSpace(compatibility.UnofficialUpdateUrl); + + string reasonPhrase = compatibility.ReasonPhrase ?? "it's not compatible with the latest version of the game"; + string error = $"{reasonPhrase}. Please check for a version newer than {compatibility.UpperVersion} here:"; + if (hasOfficialUrl) + error += !hasUnofficialUrl ? $" {compatibility.UpdateUrl}" : $"{Environment.NewLine}- official mod: {compatibility.UpdateUrl}"; + if (hasUnofficialUrl) + error += $"{Environment.NewLine}- unofficial update: {compatibility.UnofficialUpdateUrl}"; + + LogSkip(displayName, error); + } + + // validate SMAPI version + if (!string.IsNullOrWhiteSpace(manifest.MinimumApiVersion)) + { + try + { + ISemanticVersion minVersion = new SemanticVersion(manifest.MinimumApiVersion); + if (minVersion.IsNewerThan(Constants.ApiVersion)) + { + LogSkip(displayName, $"it needs SMAPI {minVersion} or later. Please update SMAPI to the latest version to use this mod."); + continue; + } + } + catch (FormatException ex) when (ex.Message.Contains("not a valid semantic version")) + { + LogSkip(displayName, $"it has an invalid minimum SMAPI version '{manifest.MinimumApiVersion}'. This should be a semantic version number like {Constants.ApiVersion}."); + continue; + } + } + + // create per-save directory + if (manifest.PerSaveConfigs) + { + deprecationWarnings.Add(() => this.DeprecationManager.Warn(manifest.Name, $"{nameof(Manifest)}.{nameof(Manifest.PerSaveConfigs)}", "1.0", DeprecationLevel.Info)); + try + { + string psDir = Path.Combine(directory.FullName, "psconfigs"); + Directory.CreateDirectory(psDir); + if (!Directory.Exists(psDir)) + { + LogSkip(displayName, "it requires per-save configuration files ('psconfigs') which couldn't be created for some reason."); + continue; + } + } + catch (Exception ex) + { + LogSkip(displayName, $"it requires per-save configuration files ('psconfigs') which couldn't be created: {ex.GetLogSummary()}"); + continue; + } + } + + // validate DLL path + string assemblyPath = Path.Combine(directory.FullName, manifest.EntryDll); + if (!File.Exists(assemblyPath)) + { + LogSkip(displayName, $"its DLL '{manifest.EntryDll}' doesn't exist."); + continue; + } + + // add mod metadata + mods.Add(new ModMetadata(displayName, directory.FullName, manifest, compatibility)); + } + + return this.HandleModDependencies(mods.ToArray()); + } + + + /********* + ** Private methods + *********/ + /// Get metadata that indicates whether SMAPI should assume the mod is compatible or broken, regardless of whether it detects incompatible code. + /// The mod manifest. + /// Returns the incompatibility record if applicable, else null. + private ModCompatibility GetCompatibilityRecord(IManifest manifest) + { + string key = !string.IsNullOrWhiteSpace(manifest.UniqueID) ? manifest.UniqueID : manifest.EntryDll; + return ( + from mod in this.CompatibilityRecords + where + mod.ID == key + && (mod.LowerSemanticVersion == null || !manifest.Version.IsOlderThan(mod.LowerSemanticVersion)) + && !manifest.Version.IsNewerThan(mod.UpperSemanticVersion) + select mod + ).FirstOrDefault(); + } + + /// Sort a set of mods by the order they should be loaded, and remove any mods that can't be loaded due to missing or conflicting dependencies. + /// The mods to process. + private ModMetadata[] HandleModDependencies(ModMetadata[] mods) + { + this.Monitor.Log("Checking mod dependencies..."); + var unsortedMods = mods.ToList(); + var sortedMods = new Stack(); + var visitedMods = new bool[unsortedMods.Count]; + var currentChain = new List(); + bool success = true; + + for (int index = 0; index < unsortedMods.Count; index++) + { + success = this.HandleModDependencies(index, visitedMods, sortedMods, currentChain, unsortedMods); + if (!success) + break; + } + + if (!success) + { + // Failed to sort list, return no mods. + this.Monitor.Log("No mods will be loaded.", LogLevel.Error); + return new ModMetadata[0]; + } + + return sortedMods.Reverse().ToArray(); + } + + /// Sort a mod's dependencies by the order they should be loaded, and remove any mods that can't be loaded due to missing or conflicting dependencies. + /// The index of the mod being processed in the . + /// The mods which have been processed. + /// The list in which to save mods sorted by dependency order. + /// The current change of mod dependencies. + /// The mods remaining to sort. + /// Returns whether the mod can be loaded. + private bool HandleModDependencies(int modIndex, bool[] visitedMods, Stack sortedMods, List currentChain, List unsortedMods) + { + // visit mod + if (visitedMods[modIndex]) + return true; // already sorted + ModMetadata mod = unsortedMods[modIndex]; + visitedMods[modIndex] = true; + + // process dependencies + bool success = true; + if (mod.Manifest.Dependencies != null && mod.Manifest.Dependencies.Any()) + { + // validate required dependencies are present + { + string missingMods = null; + foreach (IManifestDependency dependency in mod.Manifest.Dependencies) + { + if (!unsortedMods.Any(m => m.Manifest.UniqueID.Equals(dependency.UniqueID))) + missingMods += $"{dependency.UniqueID}, "; + } + if (missingMods != null) + { + this.Monitor.Log($"Skipped {mod.DisplayName} because it requires mods which aren't installed ({missingMods.Substring(0, missingMods.Length - 2)}).", LogLevel.Error); + return false; + } + } + + // get mods which should be loaded before this one + ModMetadata[] modsToLoadFirst = + ( + from unsorted in unsortedMods + where mod.Manifest.Dependencies.Any(required => required.UniqueID == unsorted.Manifest.UniqueID) + select unsorted + ) + .ToArray(); + + // detect circular references + ModMetadata circularReferenceMod = currentChain.FirstOrDefault(modsToLoadFirst.Contains); + if (circularReferenceMod != null) + { + this.Monitor.Log($"Skipped {mod.DisplayName} because its dependencies have a circular reference: {string.Join(" => ", currentChain.Select(p => p.DisplayName))} => {circularReferenceMod.DisplayName}).", LogLevel.Error); + string chain = $"{mod.Manifest.UniqueID} -> {circularReferenceMod.Manifest.UniqueID}"; + for (int i = currentChain.Count - 1; i >= 0; i--) + { + chain = $"{currentChain[i].Manifest.UniqueID} -> " + chain; + if (currentChain[i].Manifest.UniqueID.Equals(mod.Manifest.UniqueID)) break; + } + this.Monitor.Log(chain, LogLevel.Error); + return false; + } + currentChain.Add(mod); + + // recursively sort dependencies + foreach (ModMetadata requiredMod in modsToLoadFirst) + { + int index = unsortedMods.IndexOf(requiredMod); + success = this.HandleModDependencies(index, visitedMods, sortedMods, currentChain, unsortedMods); + if (!success) + break; + } + } + + // mark mod sorted + sortedMods.Push(mod); + currentChain.Remove(mod); + return success; + } + } +} diff --git a/src/StardewModdingAPI/Framework/ModRegistry.cs b/src/StardewModdingAPI/Framework/ModRegistry.cs index f015b7ba..3899aa3f 100644 --- a/src/StardewModdingAPI/Framework/ModRegistry.cs +++ b/src/StardewModdingAPI/Framework/ModRegistry.cs @@ -3,7 +3,6 @@ using System.Collections.Generic; using System.Diagnostics; using System.Linq; using System.Reflection; -using StardewModdingAPI.Framework.Models; namespace StardewModdingAPI.Framework { @@ -19,21 +18,10 @@ namespace StardewModdingAPI.Framework /// The friendly mod names treated as deprecation warning sources (assembly full name => mod name). private readonly IDictionary ModNamesByAssembly = new Dictionary(); - /// Metadata about mods that SMAPI should assume is compatible or broken, regardless of whether it detects incompatible code. - private readonly ModCompatibility[] CompatibilityRecords; - /********* ** Public methods *********/ - /// Construct an instance. - /// Metadata about mods that SMAPI should assume is compatible or broken, regardless of whether it detects incompatible code. - public ModRegistry(IEnumerable compatibilityRecords) - { - this.CompatibilityRecords = compatibilityRecords.ToArray(); - } - - /**** ** IModRegistry ****/ @@ -125,21 +113,5 @@ namespace StardewModdingAPI.Framework // no known assembly found return null; } - - /// Get metadata that indicates whether SMAPI should assume the mod is compatible or broken, regardless of whether it detects incompatible code. - /// The mod manifest. - /// Returns the incompatibility record if applicable, else null. - internal ModCompatibility GetCompatibilityRecord(IManifest manifest) - { - string key = !string.IsNullOrWhiteSpace(manifest.UniqueID) ? manifest.UniqueID : manifest.EntryDll; - return ( - from mod in this.CompatibilityRecords - where - mod.ID == key - && (mod.LowerSemanticVersion == null || !manifest.Version.IsOlderThan(mod.LowerSemanticVersion)) - && !manifest.Version.IsNewerThan(mod.UpperSemanticVersion) - select mod - ).FirstOrDefault(); - } } } diff --git a/src/StardewModdingAPI/Program.cs b/src/StardewModdingAPI/Program.cs index a86a9540..7b421895 100644 --- a/src/StardewModdingAPI/Program.cs +++ b/src/StardewModdingAPI/Program.cs @@ -269,7 +269,7 @@ namespace StardewModdingAPI this.GameInstance.VerboseLogging = this.Settings.VerboseLogging; // load core components - this.ModRegistry = new ModRegistry(this.Settings.ModCompatibility); + this.ModRegistry = new ModRegistry(); this.DeprecationManager = new DeprecationManager(this.Monitor, this.ModRegistry); this.CommandManager = new CommandManager(); @@ -316,8 +316,8 @@ namespace StardewModdingAPI // load mods JsonHelper jsonHelper = new JsonHelper(); IList deprecationWarnings = new List(); - ModMetadata[] mods = this.FindMods(Constants.ModPath, new JsonHelper(), deprecationWarnings); - mods = this.HandleModDependencies(mods); + ModMetadata[] mods = new ModResolver(this.Monitor, this.DeprecationManager, this.Settings.ModCompatibility) + .FindMods(Constants.ModPath, new JsonHelper(), deprecationWarnings); modsLoaded = this.LoadMods(mods, jsonHelper, (SContentManager)Game1.content, deprecationWarnings); // log deprecation warnings together @@ -338,109 +338,6 @@ namespace StardewModdingAPI new Thread(this.RunConsoleLoop).Start(); } - /// Sort a set of mods by the order they should be loaded, and remove any mods that can't be loaded due to missing or conflicting dependencies. - /// The mods to process. - private ModMetadata[] HandleModDependencies(ModMetadata[] mods) - { - this.Monitor.Log("Checking mod dependencies..."); - var unsortedMods = mods.ToList(); - var sortedMods = new Stack(); - var visitedMods = new bool[unsortedMods.Count]; - var currentChain = new List(); - bool success = true; - - for (int index = 0; index < unsortedMods.Count; index++) - { - success = this.HandleModDependencies(index, visitedMods, sortedMods, currentChain, unsortedMods); - if (!success) - break; - } - - if (!success) - { - // Failed to sort list, return no mods. - this.Monitor.Log("No mods will be loaded.", LogLevel.Error); - return new ModMetadata[0]; - } - - return sortedMods.Reverse().ToArray(); - } - - /// Sort a mod's dependencies by the order they should be loaded, and remove any mods that can't be loaded due to missing or conflicting dependencies. - /// The index of the mod being processed in the . - /// The mods which have been processed. - /// The list in which to save mods sorted by dependency order. - /// The current change of mod dependencies. - /// The mods remaining to sort. - /// Returns whether the mod can be loaded. - private bool HandleModDependencies(int modIndex, bool[] visitedMods, Stack sortedMods, List currentChain, List unsortedMods) - { - // visit mod - if (visitedMods[modIndex]) - return true; // already sorted - ModMetadata mod = unsortedMods[modIndex]; - visitedMods[modIndex] = true; - - // process dependencies - bool success = true; - if (mod.Manifest.Dependencies != null && mod.Manifest.Dependencies.Any()) - { - // validate required dependencies are present - { - string missingMods = null; - foreach (IManifestDependency dependency in mod.Manifest.Dependencies) - { - if (!unsortedMods.Any(m => m.Manifest.UniqueID.Equals(dependency.UniqueID))) - missingMods += $"{dependency.UniqueID}, "; - } - if (missingMods != null) - { - this.Monitor.Log($"Skipped {mod.DisplayName} because it requires mods which aren't installed ({missingMods.Substring(0, missingMods.Length - 2)}).", LogLevel.Error); - return false; - } - } - - // get mods which should be loaded before this one - ModMetadata[] modsToLoadFirst = - ( - from unsorted in unsortedMods - where mod.Manifest.Dependencies.Any(required => required.UniqueID == unsorted.Manifest.UniqueID) - select unsorted - ) - .ToArray(); - - // detect circular references - ModMetadata circularReferenceMod = currentChain.FirstOrDefault(modsToLoadFirst.Contains); - if (circularReferenceMod != null) - { - this.Monitor.Log($"Skipped {mod.DisplayName} because its dependencies have a circular reference: {string.Join(" => ", currentChain.Select(p => p.DisplayName))} => {circularReferenceMod.DisplayName}).", LogLevel.Error); - string chain = $"{mod.Manifest.UniqueID} -> {circularReferenceMod.Manifest.UniqueID}"; - for (int i = currentChain.Count - 1; i >= 0; i--) - { - chain = $"{currentChain[i].Manifest.UniqueID} -> " + chain; - if (currentChain[i].Manifest.UniqueID.Equals(mod.Manifest.UniqueID)) break; - } - this.Monitor.Log(chain, LogLevel.Error); - return false; - } - currentChain.Add(mod); - - // recursively sort dependencies - foreach (ModMetadata requiredMod in modsToLoadFirst) - { - int index = unsortedMods.IndexOf(requiredMod); - success = this.HandleModDependencies(index, visitedMods, sortedMods, currentChain, unsortedMods); - if (!success) - break; - } - } - - // mark mod sorted - sortedMods.Push(mod); - currentChain.Remove(mod); - return success; - } - /// Run a loop handling console input. [SuppressMessage("ReSharper", "FunctionNeverReturns", Justification = "The thread is aborted when the game exits.")] private void RunConsoleLoop() @@ -560,147 +457,6 @@ namespace StardewModdingAPI } } - /// Find all mods in the given folder. - /// The root mod path to search. - /// The JSON helper with which to read the manifest file. - /// A list to populate with any deprecation warnings. - private ModMetadata[] FindMods(string rootPath, JsonHelper jsonHelper, IList deprecationWarnings) - { - this.Monitor.Log("Finding mods..."); - void LogSkip(string displayName, string reasonPhrase, LogLevel level = LogLevel.Error) => this.Monitor.Log($"Skipped {displayName} because {reasonPhrase}", level); - - // load mod metadata - List mods = new List(); - foreach (string modRootPath in Directory.GetDirectories(rootPath)) - { - if (this.Monitor.IsExiting) - return new ModMetadata[0]; // exit in progress - - // init metadata - string displayName = modRootPath.Replace(rootPath, "").Trim('/', '\\'); - - // passthrough empty directories - DirectoryInfo directory = new DirectoryInfo(modRootPath); - while (!directory.GetFiles().Any() && directory.GetDirectories().Length == 1) - directory = directory.GetDirectories().First(); - - // get manifest path - string manifestPath = Path.Combine(directory.FullName, "manifest.json"); - if (!File.Exists(manifestPath)) - { - LogSkip(displayName, "it doesn't have a manifest.", LogLevel.Warn); - continue; - } - - // read manifest - Manifest manifest; - try - { - // read manifest file - string json = File.ReadAllText(manifestPath); - if (string.IsNullOrEmpty(json)) - { - LogSkip(displayName, "its manifest is empty."); - continue; - } - - // parse manifest - manifest = jsonHelper.ReadJsonFile(Path.Combine(directory.FullName, "manifest.json")); - if (manifest == null) - { - LogSkip(displayName, "its manifest is invalid."); - continue; - } - - // validate manifest - if (string.IsNullOrWhiteSpace(manifest.EntryDll)) - { - LogSkip(displayName, "its manifest doesn't set an entry DLL."); - continue; - } - if (string.IsNullOrWhiteSpace(manifest.UniqueID)) - deprecationWarnings.Add(() => this.Monitor.Log($"{manifest.Name} doesn't have a {nameof(IManifest.UniqueID)} in its manifest. This will be required in an upcoming SMAPI release.", LogLevel.Warn)); - } - catch (Exception ex) - { - LogSkip(displayName, $"parsing its manifest failed:\n{ex.GetLogSummary()}"); - continue; - } - if (!string.IsNullOrWhiteSpace(manifest.Name)) - displayName = manifest.Name; - - // validate compatibility - ModCompatibility compatibility = this.ModRegistry.GetCompatibilityRecord(manifest); - if (compatibility?.Compatibility == ModCompatibilityType.AssumeBroken) - { - bool hasOfficialUrl = !string.IsNullOrWhiteSpace(compatibility.UpdateUrl); - bool hasUnofficialUrl = !string.IsNullOrWhiteSpace(compatibility.UnofficialUpdateUrl); - - string reasonPhrase = compatibility.ReasonPhrase ?? "it's not compatible with the latest version of the game"; - string error = $"{reasonPhrase}. Please check for a version newer than {compatibility.UpperVersion} here:"; - if (hasOfficialUrl) - error += !hasUnofficialUrl ? $" {compatibility.UpdateUrl}" : $"{Environment.NewLine}- official mod: {compatibility.UpdateUrl}"; - if (hasUnofficialUrl) - error += $"{Environment.NewLine}- unofficial update: {compatibility.UnofficialUpdateUrl}"; - - LogSkip(displayName, error); - } - - // validate SMAPI version - if (!string.IsNullOrWhiteSpace(manifest.MinimumApiVersion)) - { - try - { - ISemanticVersion minVersion = new SemanticVersion(manifest.MinimumApiVersion); - if (minVersion.IsNewerThan(Constants.ApiVersion)) - { - LogSkip(displayName, $"it needs SMAPI {minVersion} or later. Please update SMAPI to the latest version to use this mod."); - continue; - } - } - catch (FormatException ex) when (ex.Message.Contains("not a valid semantic version")) - { - LogSkip(displayName, $"it has an invalid minimum SMAPI version '{manifest.MinimumApiVersion}'. This should be a semantic version number like {Constants.ApiVersion}."); - continue; - } - } - - // create per-save directory - if (manifest.PerSaveConfigs) - { - deprecationWarnings.Add(() => this.DeprecationManager.Warn(manifest.Name, $"{nameof(Manifest)}.{nameof(Manifest.PerSaveConfigs)}", "1.0", DeprecationLevel.Info)); - try - { - string psDir = Path.Combine(directory.FullName, "psconfigs"); - Directory.CreateDirectory(psDir); - if (!Directory.Exists(psDir)) - { - LogSkip(displayName, "it requires per-save configuration files ('psconfigs') which couldn't be created for some reason."); - continue; - } - } - catch (Exception ex) - { - LogSkip(displayName, $"it requires per-save configuration files ('psconfigs') which couldn't be created: {ex.GetLogSummary()}"); - continue; - } - } - - // validate DLL path - string assemblyPath = Path.Combine(directory.FullName, manifest.EntryDll); - if (!File.Exists(assemblyPath)) - { - LogSkip(displayName, $"its DLL '{manifest.EntryDll}' doesn't exist."); - continue; - } - - // add mod metadata - mods.Add(new ModMetadata(displayName, directory.FullName, manifest, compatibility)); - } - - return mods.ToArray(); - } - /// Load and hook up the given mods. /// The mods to load. /// The JSON helper with which to read mods' JSON files. diff --git a/src/StardewModdingAPI/StardewModdingAPI.csproj b/src/StardewModdingAPI/StardewModdingAPI.csproj index 86fc8b2b..2424f438 100644 --- a/src/StardewModdingAPI/StardewModdingAPI.csproj +++ b/src/StardewModdingAPI/StardewModdingAPI.csproj @@ -121,6 +121,7 @@ + -- cgit From 63edebaef1019ce103f5a86d55e1d1c4eb8d371c Mon Sep 17 00:00:00 2001 From: Jesse Plamondon-Willard Date: Sat, 13 May 2017 18:20:09 -0400 Subject: decouple mod metadata resolution from main SMAPI logic (#285) This makes the logic more self-contained for eventual unit testing, and makes failed mods available during dependency resolution so we can make errors more relevant. --- .../Framework/ModLoading/ModMetadata.cs | 34 +++ .../Framework/ModLoading/ModResolver.cs | 267 ++++++++++----------- src/StardewModdingAPI/Program.cs | 51 +++- src/StardewModdingAPI/SemanticVersion.cs | 17 ++ 4 files changed, 219 insertions(+), 150 deletions(-) (limited to 'src') diff --git a/src/StardewModdingAPI/Framework/ModLoading/ModMetadata.cs b/src/StardewModdingAPI/Framework/ModLoading/ModMetadata.cs index 1ac167dc..72c4692b 100644 --- a/src/StardewModdingAPI/Framework/ModLoading/ModMetadata.cs +++ b/src/StardewModdingAPI/Framework/ModLoading/ModMetadata.cs @@ -20,6 +20,12 @@ namespace StardewModdingAPI.Framework.ModLoading /// Optional metadata about a mod version that SMAPI should assume is compatible or broken, regardless of whether it detects incompatible code. public ModCompatibility Compatibility { get; } + /// The metadata resolution status. + public ModMetadataStatus Status { get; set; } + + /// The reason the metadata is invalid, if any. + public string Error { get; set; } + /********* ** Public methods @@ -30,11 +36,39 @@ namespace StardewModdingAPI.Framework.ModLoading /// The mod manifest. /// Optional metadata about a mod version that SMAPI should assume is compatible or broken, regardless of whether it detects incompatible code. public ModMetadata(string displayName, string directoryPath, IManifest manifest, ModCompatibility compatibility) + : this(displayName, directoryPath, manifest, compatibility, ModMetadataStatus.Found, null) + { + this.DisplayName = displayName; + this.DirectoryPath = directoryPath; + this.Manifest = manifest; + this.Compatibility = compatibility; + } + + /// Construct an instance. + /// The mod's display name. + /// The mod's full directory path. + /// The mod manifest. + /// Optional metadata about a mod version that SMAPI should assume is compatible or broken, regardless of whether it detects incompatible code. + /// The metadata resolution status. + /// The reason the metadata is invalid, if any. + public ModMetadata(string displayName, string directoryPath, IManifest manifest, ModCompatibility compatibility, ModMetadataStatus status, string error) { this.DisplayName = displayName; this.DirectoryPath = directoryPath; this.Manifest = manifest; this.Compatibility = compatibility; + this.Status = status; + this.Error = error; } } + + /// Indicates the status of a mod's metadata resolution. + internal enum ModMetadataStatus + { + /// The mod has been found, but hasn't been processed yet. + Found, + + /// The mod cannot be loaded. + Failed + } } diff --git a/src/StardewModdingAPI/Framework/ModLoading/ModResolver.cs b/src/StardewModdingAPI/Framework/ModLoading/ModResolver.cs index 450fe6bf..30c38aca 100644 --- a/src/StardewModdingAPI/Framework/ModLoading/ModResolver.cs +++ b/src/StardewModdingAPI/Framework/ModLoading/ModResolver.cs @@ -13,12 +13,6 @@ namespace StardewModdingAPI.Framework.ModLoading /********* ** Properties *********/ - /// Encapsulates monitoring and logging. - private readonly IMonitor Monitor; - - /// Manages deprecation warnings. - private readonly DeprecationManager DeprecationManager; - /// Metadata about mods that SMAPI should assume is compatible or broken, regardless of whether it detects incompatible code. private readonly ModCompatibility[] CompatibilityRecords; @@ -26,78 +20,43 @@ namespace StardewModdingAPI.Framework.ModLoading /********* ** Public methods *********/ - public ModResolver(IMonitor monitor, DeprecationManager deprecationManager, IEnumerable compatibilityRecords) + /// Construct an instance. + /// Metadata about mods that SMAPI should assume is compatible or broken, regardless of whether it detects incompatible code. + public ModResolver(IEnumerable compatibilityRecords) { - this.Monitor = monitor; - this.DeprecationManager = deprecationManager; this.CompatibilityRecords = compatibilityRecords.ToArray(); } + /// Read mod metadata from the given folder in dependency order. + /// The root path to search for mods. + /// The JSON helper with which to read manifests. + public IEnumerable GetMods(string rootPath, JsonHelper jsonHelper) + { + ModMetadata[] mods = this.GetDataFromFolder(rootPath, jsonHelper).ToArray(); + mods = this.ProcessDependencies(mods.ToArray()); + return mods; + } + + + /********* + ** Private methods + *********/ /// Find all mods in the given folder. /// The root mod path to search. /// The JSON helper with which to read the manifest file. - /// A list to populate with any deprecation warnings. - public ModMetadata[] FindMods(string rootPath, JsonHelper jsonHelper, IList deprecationWarnings) + private IEnumerable GetDataFromFolder(string rootPath, JsonHelper jsonHelper) { - this.Monitor.Log("Finding mods..."); - void LogSkip(string displayName, string reasonPhrase, LogLevel level = LogLevel.Error) => this.Monitor.Log($"Skipped {displayName} because {reasonPhrase}", level); - // load mod metadata - List mods = new List(); - foreach (string modRootPath in Directory.GetDirectories(rootPath)) + foreach (DirectoryInfo modDir in this.GetModFolders(rootPath)) { - if (this.Monitor.IsExiting) - return new ModMetadata[0]; // exit in progress - - // init metadata - string displayName = modRootPath.Replace(rootPath, "").Trim('/', '\\'); - - // passthrough empty directories - DirectoryInfo directory = new DirectoryInfo(modRootPath); - while (!directory.GetFiles().Any() && directory.GetDirectories().Length == 1) - directory = directory.GetDirectories().First(); - - // get manifest path - string manifestPath = Path.Combine(directory.FullName, "manifest.json"); - if (!File.Exists(manifestPath)) - { - LogSkip(displayName, "it doesn't have a manifest.", LogLevel.Warn); - continue; - } + string displayName = modDir.FullName.Replace(rootPath, "").Trim('/', '\\'); // read manifest Manifest manifest; - try - { - // read manifest file - string json = File.ReadAllText(manifestPath); - if (string.IsNullOrEmpty(json)) - { - LogSkip(displayName, "its manifest is empty."); - continue; - } - - // parse manifest - manifest = jsonHelper.ReadJsonFile(Path.Combine(directory.FullName, "manifest.json")); - if (manifest == null) - { - LogSkip(displayName, "its manifest is invalid."); - continue; - } - - // validate manifest - if (string.IsNullOrWhiteSpace(manifest.EntryDll)) - { - LogSkip(displayName, "its manifest doesn't set an entry DLL."); - continue; - } - if (string.IsNullOrWhiteSpace(manifest.UniqueID)) - deprecationWarnings.Add(() => this.Monitor.Log($"{manifest.Name} doesn't have a {nameof(IManifest.UniqueID)} in its manifest. This will be required in an upcoming SMAPI release.", LogLevel.Warn)); - } - catch (Exception ex) { - LogSkip(displayName, $"parsing its manifest failed:\n{ex.GetLogSummary()}"); - continue; + string manifestPath = Path.Combine(modDir.FullName, "manifest.json"); + if (!this.TryReadManifest(manifestPath, jsonHelper, out manifest, out string error)) + yield return new ModMetadata(displayName, modDir.FullName, null, null, ModMetadataStatus.Failed, error); } if (!string.IsNullOrWhiteSpace(manifest.Name)) displayName = manifest.Name; @@ -116,89 +75,35 @@ namespace StardewModdingAPI.Framework.ModLoading if (hasUnofficialUrl) error += $"{Environment.NewLine}- unofficial update: {compatibility.UnofficialUpdateUrl}"; - LogSkip(displayName, error); + yield return new ModMetadata(displayName, modDir.FullName, manifest, compatibility, ModMetadataStatus.Failed, error); } // validate SMAPI version if (!string.IsNullOrWhiteSpace(manifest.MinimumApiVersion)) { - try - { - ISemanticVersion minVersion = new SemanticVersion(manifest.MinimumApiVersion); - if (minVersion.IsNewerThan(Constants.ApiVersion)) - { - LogSkip(displayName, $"it needs SMAPI {minVersion} or later. Please update SMAPI to the latest version to use this mod."); - continue; - } - } - catch (FormatException ex) when (ex.Message.Contains("not a valid semantic version")) - { - LogSkip(displayName, $"it has an invalid minimum SMAPI version '{manifest.MinimumApiVersion}'. This should be a semantic version number like {Constants.ApiVersion}."); - continue; - } - } - - // create per-save directory - if (manifest.PerSaveConfigs) - { - deprecationWarnings.Add(() => this.DeprecationManager.Warn(manifest.Name, $"{nameof(Manifest)}.{nameof(Manifest.PerSaveConfigs)}", "1.0", DeprecationLevel.Info)); - try - { - string psDir = Path.Combine(directory.FullName, "psconfigs"); - Directory.CreateDirectory(psDir); - if (!Directory.Exists(psDir)) - { - LogSkip(displayName, "it requires per-save configuration files ('psconfigs') which couldn't be created for some reason."); - continue; - } - } - catch (Exception ex) - { - LogSkip(displayName, $"it requires per-save configuration files ('psconfigs') which couldn't be created: {ex.GetLogSummary()}"); - continue; - } + if (!SemanticVersion.TryParse(manifest.MinimumApiVersion, out ISemanticVersion minVersion)) + yield return new ModMetadata(displayName, modDir.FullName, manifest, compatibility, ModMetadataStatus.Failed, $"it has an invalid minimum SMAPI version '{manifest.MinimumApiVersion}'. This should be a semantic version number like {Constants.ApiVersion}."); + if (minVersion.IsNewerThan(Constants.ApiVersion)) + yield return new ModMetadata(displayName, modDir.FullName, manifest, compatibility, ModMetadataStatus.Failed, $"it needs SMAPI {minVersion} or later. Please update SMAPI to the latest version to use this mod."); } // validate DLL path - string assemblyPath = Path.Combine(directory.FullName, manifest.EntryDll); + string assemblyPath = Path.Combine(modDir.FullName, manifest.EntryDll); if (!File.Exists(assemblyPath)) { - LogSkip(displayName, $"its DLL '{manifest.EntryDll}' doesn't exist."); + yield return new ModMetadata(displayName, modDir.FullName, manifest, compatibility, ModMetadataStatus.Failed, $"its DLL '{manifest.EntryDll}' doesn't exist."); continue; } // add mod metadata - mods.Add(new ModMetadata(displayName, directory.FullName, manifest, compatibility)); + yield return new ModMetadata(displayName, modDir.FullName, manifest, compatibility); } - - return this.HandleModDependencies(mods.ToArray()); - } - - - /********* - ** Private methods - *********/ - /// Get metadata that indicates whether SMAPI should assume the mod is compatible or broken, regardless of whether it detects incompatible code. - /// The mod manifest. - /// Returns the incompatibility record if applicable, else null. - private ModCompatibility GetCompatibilityRecord(IManifest manifest) - { - string key = !string.IsNullOrWhiteSpace(manifest.UniqueID) ? manifest.UniqueID : manifest.EntryDll; - return ( - from mod in this.CompatibilityRecords - where - mod.ID == key - && (mod.LowerSemanticVersion == null || !manifest.Version.IsOlderThan(mod.LowerSemanticVersion)) - && !manifest.Version.IsNewerThan(mod.UpperSemanticVersion) - select mod - ).FirstOrDefault(); } /// Sort a set of mods by the order they should be loaded, and remove any mods that can't be loaded due to missing or conflicting dependencies. /// The mods to process. - private ModMetadata[] HandleModDependencies(ModMetadata[] mods) + private ModMetadata[] ProcessDependencies(ModMetadata[] mods) { - this.Monitor.Log("Checking mod dependencies..."); var unsortedMods = mods.ToList(); var sortedMods = new Stack(); var visitedMods = new bool[unsortedMods.Count]; @@ -207,17 +112,16 @@ namespace StardewModdingAPI.Framework.ModLoading for (int index = 0; index < unsortedMods.Count; index++) { - success = this.HandleModDependencies(index, visitedMods, sortedMods, currentChain, unsortedMods); + if (unsortedMods[index].Status == ModMetadataStatus.Failed) + continue; + + success = this.ProcessDependencies(index, visitedMods, sortedMods, currentChain, unsortedMods); if (!success) break; } if (!success) - { - // Failed to sort list, return no mods. - this.Monitor.Log("No mods will be loaded.", LogLevel.Error); return new ModMetadata[0]; - } return sortedMods.Reverse().ToArray(); } @@ -229,14 +133,18 @@ namespace StardewModdingAPI.Framework.ModLoading /// The current change of mod dependencies. /// The mods remaining to sort. /// Returns whether the mod can be loaded. - private bool HandleModDependencies(int modIndex, bool[] visitedMods, Stack sortedMods, List currentChain, List unsortedMods) + private bool ProcessDependencies(int modIndex, bool[] visitedMods, Stack sortedMods, List currentChain, List unsortedMods) { // visit mod if (visitedMods[modIndex]) return true; // already sorted - ModMetadata mod = unsortedMods[modIndex]; visitedMods[modIndex] = true; + // mod already failed + ModMetadata mod = unsortedMods[modIndex]; + if (mod.Status == ModMetadataStatus.Failed) + return false; + // process dependencies bool success = true; if (mod.Manifest.Dependencies != null && mod.Manifest.Dependencies.Any()) @@ -251,7 +159,8 @@ namespace StardewModdingAPI.Framework.ModLoading } if (missingMods != null) { - this.Monitor.Log($"Skipped {mod.DisplayName} because it requires mods which aren't installed ({missingMods.Substring(0, missingMods.Length - 2)}).", LogLevel.Error); + mod.Status = ModMetadataStatus.Failed; + mod.Error = $"it requires mods which aren't installed ({missingMods.Substring(0, missingMods.Length - 2)})."; return false; } } @@ -269,14 +178,8 @@ namespace StardewModdingAPI.Framework.ModLoading ModMetadata circularReferenceMod = currentChain.FirstOrDefault(modsToLoadFirst.Contains); if (circularReferenceMod != null) { - this.Monitor.Log($"Skipped {mod.DisplayName} because its dependencies have a circular reference: {string.Join(" => ", currentChain.Select(p => p.DisplayName))} => {circularReferenceMod.DisplayName}).", LogLevel.Error); - string chain = $"{mod.Manifest.UniqueID} -> {circularReferenceMod.Manifest.UniqueID}"; - for (int i = currentChain.Count - 1; i >= 0; i--) - { - chain = $"{currentChain[i].Manifest.UniqueID} -> " + chain; - if (currentChain[i].Manifest.UniqueID.Equals(mod.Manifest.UniqueID)) break; - } - this.Monitor.Log(chain, LogLevel.Error); + mod.Status = ModMetadataStatus.Failed; + mod.Error = $"its dependencies have a circular reference: {string.Join(" => ", currentChain.Select(p => p.DisplayName))} => {circularReferenceMod.DisplayName})."; return false; } currentChain.Add(mod); @@ -285,7 +188,7 @@ namespace StardewModdingAPI.Framework.ModLoading foreach (ModMetadata requiredMod in modsToLoadFirst) { int index = unsortedMods.IndexOf(requiredMod); - success = this.HandleModDependencies(index, visitedMods, sortedMods, currentChain, unsortedMods); + success = this.ProcessDependencies(index, visitedMods, sortedMods, currentChain, unsortedMods); if (!success) break; } @@ -296,5 +199,81 @@ namespace StardewModdingAPI.Framework.ModLoading currentChain.Remove(mod); return success; } + + /// Get all mod folders in a root folder, passing through empty folders as needed. + /// The root folder path to search. + private IEnumerable GetModFolders(string rootPath) + { + foreach (string modRootPath in Directory.GetDirectories(rootPath)) + { + DirectoryInfo directory = new DirectoryInfo(modRootPath); + + // if a folder only contains another folder, check the inner folder instead + while (!directory.GetFiles().Any() && directory.GetDirectories().Length == 1) + directory = directory.GetDirectories().First(); + + yield return directory; + } + } + + /// Read a manifest file if it's valid, else set a relevant error phrase. + /// The absolute path to the manifest file. + /// The JSON helper with which to read the manifest file. + /// The loaded manifest, if reading succeeded. + /// The read error, if reading failed. + /// Returns whether the manifest was read successfully. + private bool TryReadManifest(string path, JsonHelper jsonHelper, out Manifest manifest, out string errorPhrase) + { + try + { + // validate path + if (!File.Exists(path)) + { + manifest = null; + errorPhrase = "it doesn't have a manifest."; + return false; + } + + // parse manifest + manifest = jsonHelper.ReadJsonFile(path); + if (manifest == null) + { + errorPhrase = "its manifest is invalid."; + return false; + } + + // validate manifest + if (string.IsNullOrWhiteSpace(manifest.EntryDll)) + { + errorPhrase = "its manifest doesn't set an entry DLL."; + return false; + } + + errorPhrase = null; + return true; + } + catch (Exception ex) + { + manifest = null; + errorPhrase = $"parsing its manifest failed:\n{ex.GetLogSummary()}"; + return false; + } + } + + /// Get metadata that indicates whether SMAPI should assume the mod is compatible or broken, regardless of whether it detects incompatible code. + /// The mod manifest. + /// Returns the incompatibility record if applicable, else null. + private ModCompatibility GetCompatibilityRecord(IManifest manifest) + { + string key = !string.IsNullOrWhiteSpace(manifest.UniqueID) ? manifest.UniqueID : manifest.EntryDll; + return ( + from mod in this.CompatibilityRecords + where + mod.ID == key + && (mod.LowerSemanticVersion == null || !manifest.Version.IsOlderThan(mod.LowerSemanticVersion)) + && !manifest.Version.IsNewerThan(mod.UpperSemanticVersion) + select mod + ).FirstOrDefault(); + } } } diff --git a/src/StardewModdingAPI/Program.cs b/src/StardewModdingAPI/Program.cs index 7b421895..c8840538 100644 --- a/src/StardewModdingAPI/Program.cs +++ b/src/StardewModdingAPI/Program.cs @@ -313,14 +313,45 @@ namespace StardewModdingAPI // load mods int modsLoaded; { - // load mods + // get mod metadata (in dependency order) + this.Monitor.Log("Loading mod metadata..."); JsonHelper jsonHelper = new JsonHelper(); + ModMetadata[] mods = new ModResolver(this.Settings.ModCompatibility) + .GetMods(Constants.ModPath, new JsonHelper()) + .ToArray(); + + // check for deprecated metadata IList deprecationWarnings = new List(); - ModMetadata[] mods = new ModResolver(this.Monitor, this.DeprecationManager, this.Settings.ModCompatibility) - .FindMods(Constants.ModPath, new JsonHelper(), deprecationWarnings); - modsLoaded = this.LoadMods(mods, jsonHelper, (SContentManager)Game1.content, deprecationWarnings); + foreach (ModMetadata mod in mods) + { + // missing unique ID + if (string.IsNullOrWhiteSpace(mod.Manifest.UniqueID)) + deprecationWarnings.Add(() => this.Monitor.Log($"{mod.DisplayName} doesn't have specify a {nameof(IManifest.UniqueID)} field in its manifest. This will be required in an upcoming SMAPI release.", LogLevel.Warn)); - // log deprecation warnings together + // per-save directories + if ((mod.Manifest as Manifest)?.PerSaveConfigs == true) + { + deprecationWarnings.Add(() => this.DeprecationManager.Warn(mod.DisplayName, $"{nameof(Manifest)}.{nameof(Manifest.PerSaveConfigs)}", "1.0", DeprecationLevel.Info)); + try + { + string psDir = Path.Combine(mod.DirectoryPath, "psconfigs"); + Directory.CreateDirectory(psDir); + if (!Directory.Exists(psDir)) + { + mod.Status = ModMetadataStatus.Failed; + mod.Error = "it requires per-save configuration files ('psconfigs') which couldn't be created for some reason."; + } + } + catch (Exception ex) + { + mod.Status = ModMetadataStatus.Failed; + mod.Error = $"it requires per-save configuration files ('psconfigs') which couldn't be created: {ex.GetLogSummary()}"; + } + } + } + + // load mods + modsLoaded = this.LoadMods(mods, jsonHelper, (SContentManager)Game1.content, deprecationWarnings); foreach (Action warning in deprecationWarnings) warning(); } @@ -474,9 +505,17 @@ namespace StardewModdingAPI AppDomain.CurrentDomain.AssemblyResolve += (sender, e) => modAssemblyLoader.ResolveAssembly(e.Name); foreach (ModMetadata metadata in mods) { + // validate status + if (metadata.Status == ModMetadataStatus.Failed) + { + LogSkip(metadata, metadata.Error); + continue; + } + + // get basic info IManifest manifest = metadata.Manifest; string assemblyPath = Path.Combine(metadata.DirectoryPath, metadata.Manifest.EntryDll); - + // preprocess & load mod assembly Assembly modAssembly; try diff --git a/src/StardewModdingAPI/SemanticVersion.cs b/src/StardewModdingAPI/SemanticVersion.cs index db25dc11..a2adb657 100644 --- a/src/StardewModdingAPI/SemanticVersion.cs +++ b/src/StardewModdingAPI/SemanticVersion.cs @@ -182,6 +182,23 @@ namespace StardewModdingAPI return result; } + /// Parse a version string without throwing an exception if it fails. + /// The version string. + /// The parsed representation. + /// Returns whether parsing the version succeeded. + internal static bool TryParse(string version, out ISemanticVersion parsed) + { + try + { + parsed = new SemanticVersion(version); + return true; + } + catch + { + parsed = null; + return false; + } + } /********* ** Private methods -- cgit From 9b6c0d1021b07ec04b589f1bd0eb69e36082b600 Mon Sep 17 00:00:00 2001 From: Jesse Plamondon-Willard Date: Sat, 13 May 2017 18:58:19 -0400 Subject: decouple reading manifest files from validating metadata (#285) --- .../Framework/ModLoading/ModMetadata.cs | 15 +- .../Framework/ModLoading/ModResolver.cs | 219 +++++++++------------ src/StardewModdingAPI/Program.cs | 19 +- 3 files changed, 109 insertions(+), 144 deletions(-) (limited to 'src') diff --git a/src/StardewModdingAPI/Framework/ModLoading/ModMetadata.cs b/src/StardewModdingAPI/Framework/ModLoading/ModMetadata.cs index 72c4692b..7be85a83 100644 --- a/src/StardewModdingAPI/Framework/ModLoading/ModMetadata.cs +++ b/src/StardewModdingAPI/Framework/ModLoading/ModMetadata.cs @@ -36,7 +36,6 @@ namespace StardewModdingAPI.Framework.ModLoading /// The mod manifest. /// Optional metadata about a mod version that SMAPI should assume is compatible or broken, regardless of whether it detects incompatible code. public ModMetadata(string displayName, string directoryPath, IManifest manifest, ModCompatibility compatibility) - : this(displayName, directoryPath, manifest, compatibility, ModMetadataStatus.Found, null) { this.DisplayName = displayName; this.DirectoryPath = directoryPath; @@ -44,21 +43,15 @@ namespace StardewModdingAPI.Framework.ModLoading this.Compatibility = compatibility; } - /// Construct an instance. - /// The mod's display name. - /// The mod's full directory path. - /// The mod manifest. - /// Optional metadata about a mod version that SMAPI should assume is compatible or broken, regardless of whether it detects incompatible code. + /// Set the mod status. /// The metadata resolution status. /// The reason the metadata is invalid, if any. - public ModMetadata(string displayName, string directoryPath, IManifest manifest, ModCompatibility compatibility, ModMetadataStatus status, string error) + /// Return the instance for chaining. + public ModMetadata SetStatus(ModMetadataStatus status, string error = null) { - this.DisplayName = displayName; - this.DirectoryPath = directoryPath; - this.Manifest = manifest; - this.Compatibility = compatibility; this.Status = status; this.Error = error; + return this; } } diff --git a/src/StardewModdingAPI/Framework/ModLoading/ModResolver.cs b/src/StardewModdingAPI/Framework/ModLoading/ModResolver.cs index 30c38aca..829575af 100644 --- a/src/StardewModdingAPI/Framework/ModLoading/ModResolver.cs +++ b/src/StardewModdingAPI/Framework/ModLoading/ModResolver.cs @@ -10,99 +10,124 @@ namespace StardewModdingAPI.Framework.ModLoading /// Finds and processes mod metadata. internal class ModResolver { - /********* - ** Properties - *********/ - /// Metadata about mods that SMAPI should assume is compatible or broken, regardless of whether it detects incompatible code. - private readonly ModCompatibility[] CompatibilityRecords; - - /********* ** Public methods *********/ - /// Construct an instance. - /// Metadata about mods that SMAPI should assume is compatible or broken, regardless of whether it detects incompatible code. - public ModResolver(IEnumerable compatibilityRecords) - { - this.CompatibilityRecords = compatibilityRecords.ToArray(); - } - - /// Read mod metadata from the given folder in dependency order. + /// Get manifest metadata for each folder in the given root path. /// The root path to search for mods. /// The JSON helper with which to read manifests. - public IEnumerable GetMods(string rootPath, JsonHelper jsonHelper) - { - ModMetadata[] mods = this.GetDataFromFolder(rootPath, jsonHelper).ToArray(); - mods = this.ProcessDependencies(mods.ToArray()); - return mods; - } - - - /********* - ** Private methods - *********/ - /// Find all mods in the given folder. - /// The root mod path to search. - /// The JSON helper with which to read the manifest file. - private IEnumerable GetDataFromFolder(string rootPath, JsonHelper jsonHelper) + /// Metadata about mods that SMAPI should assume is compatible or broken, regardless of whether it detects incompatible code. + /// Returns the manifests by relative folder. + public IEnumerable ReadManifests(string rootPath, JsonHelper jsonHelper, IEnumerable compatibilityRecords) { - // load mod metadata + compatibilityRecords = compatibilityRecords.ToArray(); foreach (DirectoryInfo modDir in this.GetModFolders(rootPath)) { - string displayName = modDir.FullName.Replace(rootPath, "").Trim('/', '\\'); + // read file + Manifest manifest = null; + string path = Path.Combine(modDir.FullName, "manifest.json"); + string error = null; + try + { + // read manifest + manifest = jsonHelper.ReadJsonFile(path); - // read manifest - Manifest manifest; + // validate + if (manifest == null) + { + error = File.Exists(path) + ? "its manifest is invalid." + : "it doesn't have a manifest."; + } + else if (string.IsNullOrWhiteSpace(manifest.EntryDll)) + error = "its manifest doesn't set an entry DLL."; + } + catch (Exception ex) { - string manifestPath = Path.Combine(modDir.FullName, "manifest.json"); - if (!this.TryReadManifest(manifestPath, jsonHelper, out manifest, out string error)) - yield return new ModMetadata(displayName, modDir.FullName, null, null, ModMetadataStatus.Failed, error); + error = $"parsing its manifest failed:\n{ex.GetLogSummary()}"; } - if (!string.IsNullOrWhiteSpace(manifest.Name)) - displayName = manifest.Name; - // validate compatibility - ModCompatibility compatibility = this.GetCompatibilityRecord(manifest); - if (compatibility?.Compatibility == ModCompatibilityType.AssumeBroken) + // get compatibility record + ModCompatibility compatibility = null; + if(manifest != null) { - bool hasOfficialUrl = !string.IsNullOrWhiteSpace(compatibility.UpdateUrl); - bool hasUnofficialUrl = !string.IsNullOrWhiteSpace(compatibility.UnofficialUpdateUrl); + string key = !string.IsNullOrWhiteSpace(manifest.UniqueID) ? manifest.UniqueID : manifest.EntryDll; + compatibility = ( + from mod in compatibilityRecords + where + mod.ID == key + && (mod.LowerSemanticVersion == null || !manifest.Version.IsOlderThan(mod.LowerSemanticVersion)) + && !manifest.Version.IsNewerThan(mod.UpperSemanticVersion) + select mod + ).FirstOrDefault(); + } + // build metadata + string displayName = !string.IsNullOrWhiteSpace(manifest?.Name) + ? manifest.Name + : modDir.FullName.Replace(rootPath, "").Trim('/', '\\'); + ModMetadataStatus status = error == null + ? ModMetadataStatus.Found + : ModMetadataStatus.Failed; + + yield return new ModMetadata(displayName, modDir.FullName, manifest, compatibility).SetStatus(status, error); + } + } - string reasonPhrase = compatibility.ReasonPhrase ?? "it's not compatible with the latest version of the game"; - string error = $"{reasonPhrase}. Please check for a version newer than {compatibility.UpperVersion} here:"; - if (hasOfficialUrl) - error += !hasUnofficialUrl ? $" {compatibility.UpdateUrl}" : $"{Environment.NewLine}- official mod: {compatibility.UpdateUrl}"; - if (hasUnofficialUrl) - error += $"{Environment.NewLine}- unofficial update: {compatibility.UnofficialUpdateUrl}"; + /// Validate manifest metadata. + /// The mod manifests to validate. + public void ValidateManifests(IEnumerable mods) + { + foreach (ModMetadata mod in mods) + { + // skip if already failed + if (mod.Status == ModMetadataStatus.Failed) + continue; - yield return new ModMetadata(displayName, modDir.FullName, manifest, compatibility, ModMetadataStatus.Failed, error); + // validate compatibility + { + ModCompatibility compatibility = mod.Compatibility; + if (compatibility?.Compatibility == ModCompatibilityType.AssumeBroken) + { + bool hasOfficialUrl = !string.IsNullOrWhiteSpace(mod.Compatibility.UpdateUrl); + bool hasUnofficialUrl = !string.IsNullOrWhiteSpace(mod.Compatibility.UnofficialUpdateUrl); + + string reasonPhrase = compatibility.ReasonPhrase ?? "it's not compatible with the latest version of the game"; + string error = $"{reasonPhrase}. Please check for a version newer than {compatibility.UpperVersion} here:"; + if (hasOfficialUrl) + error += !hasUnofficialUrl ? $" {compatibility.UpdateUrl}" : $"{Environment.NewLine}- official mod: {compatibility.UpdateUrl}"; + if (hasUnofficialUrl) + error += $"{Environment.NewLine}- unofficial update: {compatibility.UnofficialUpdateUrl}"; + + mod.SetStatus(ModMetadataStatus.Failed, error); + continue; + } } // validate SMAPI version - if (!string.IsNullOrWhiteSpace(manifest.MinimumApiVersion)) + if (!string.IsNullOrWhiteSpace(mod.Manifest.MinimumApiVersion)) { - if (!SemanticVersion.TryParse(manifest.MinimumApiVersion, out ISemanticVersion minVersion)) - yield return new ModMetadata(displayName, modDir.FullName, manifest, compatibility, ModMetadataStatus.Failed, $"it has an invalid minimum SMAPI version '{manifest.MinimumApiVersion}'. This should be a semantic version number like {Constants.ApiVersion}."); + if (!SemanticVersion.TryParse(mod.Manifest.MinimumApiVersion, out ISemanticVersion minVersion)) + { + mod.SetStatus(ModMetadataStatus.Failed, $"it has an invalid minimum SMAPI version '{mod.Manifest.MinimumApiVersion}'. This should be a semantic version number like {Constants.ApiVersion}."); + continue; + } if (minVersion.IsNewerThan(Constants.ApiVersion)) - yield return new ModMetadata(displayName, modDir.FullName, manifest, compatibility, ModMetadataStatus.Failed, $"it needs SMAPI {minVersion} or later. Please update SMAPI to the latest version to use this mod."); + { + mod.SetStatus(ModMetadataStatus.Failed, $"it needs SMAPI {minVersion} or later. Please update SMAPI to the latest version to use this mod."); + continue; + } } // validate DLL path - string assemblyPath = Path.Combine(modDir.FullName, manifest.EntryDll); + string assemblyPath = Path.Combine(mod.DirectoryPath, mod.Manifest.EntryDll); if (!File.Exists(assemblyPath)) - { - yield return new ModMetadata(displayName, modDir.FullName, manifest, compatibility, ModMetadataStatus.Failed, $"its DLL '{manifest.EntryDll}' doesn't exist."); - continue; - } - - // add mod metadata - yield return new ModMetadata(displayName, modDir.FullName, manifest, compatibility); + mod.SetStatus(ModMetadataStatus.Failed, $"its DLL '{mod.Manifest.EntryDll}' doesn't exist."); } } - /// Sort a set of mods by the order they should be loaded, and remove any mods that can't be loaded due to missing or conflicting dependencies. + /// Sort the given mods by the order they should be loaded. /// The mods to process. - private ModMetadata[] ProcessDependencies(ModMetadata[] mods) + public IEnumerable ProcessDependencies(IEnumerable mods) { var unsortedMods = mods.ToList(); var sortedMods = new Stack(); @@ -126,6 +151,10 @@ namespace StardewModdingAPI.Framework.ModLoading return sortedMods.Reverse().ToArray(); } + + /********* + ** Private methods + *********/ /// Sort a mod's dependencies by the order they should be loaded, and remove any mods that can't be loaded due to missing or conflicting dependencies. /// The index of the mod being processed in the . /// The mods which have been processed. @@ -215,65 +244,5 @@ namespace StardewModdingAPI.Framework.ModLoading yield return directory; } } - - /// Read a manifest file if it's valid, else set a relevant error phrase. - /// The absolute path to the manifest file. - /// The JSON helper with which to read the manifest file. - /// The loaded manifest, if reading succeeded. - /// The read error, if reading failed. - /// Returns whether the manifest was read successfully. - private bool TryReadManifest(string path, JsonHelper jsonHelper, out Manifest manifest, out string errorPhrase) - { - try - { - // validate path - if (!File.Exists(path)) - { - manifest = null; - errorPhrase = "it doesn't have a manifest."; - return false; - } - - // parse manifest - manifest = jsonHelper.ReadJsonFile(path); - if (manifest == null) - { - errorPhrase = "its manifest is invalid."; - return false; - } - - // validate manifest - if (string.IsNullOrWhiteSpace(manifest.EntryDll)) - { - errorPhrase = "its manifest doesn't set an entry DLL."; - return false; - } - - errorPhrase = null; - return true; - } - catch (Exception ex) - { - manifest = null; - errorPhrase = $"parsing its manifest failed:\n{ex.GetLogSummary()}"; - return false; - } - } - - /// Get metadata that indicates whether SMAPI should assume the mod is compatible or broken, regardless of whether it detects incompatible code. - /// The mod manifest. - /// Returns the incompatibility record if applicable, else null. - private ModCompatibility GetCompatibilityRecord(IManifest manifest) - { - string key = !string.IsNullOrWhiteSpace(manifest.UniqueID) ? manifest.UniqueID : manifest.EntryDll; - return ( - from mod in this.CompatibilityRecords - where - mod.ID == key - && (mod.LowerSemanticVersion == null || !manifest.Version.IsOlderThan(mod.LowerSemanticVersion)) - && !manifest.Version.IsNewerThan(mod.UpperSemanticVersion) - select mod - ).FirstOrDefault(); - } } } diff --git a/src/StardewModdingAPI/Program.cs b/src/StardewModdingAPI/Program.cs index c8840538..74a9ff8e 100644 --- a/src/StardewModdingAPI/Program.cs +++ b/src/StardewModdingAPI/Program.cs @@ -313,12 +313,12 @@ namespace StardewModdingAPI // load mods int modsLoaded; { - // get mod metadata (in dependency order) this.Monitor.Log("Loading mod metadata..."); - JsonHelper jsonHelper = new JsonHelper(); - ModMetadata[] mods = new ModResolver(this.Settings.ModCompatibility) - .GetMods(Constants.ModPath, new JsonHelper()) - .ToArray(); + ModResolver resolver = new ModResolver(); + + // load manifests + ModMetadata[] mods = resolver.ReadManifests(Constants.ModPath, new JsonHelper(), this.Settings.ModCompatibility).ToArray(); + resolver.ValidateManifests(mods); // check for deprecated metadata IList deprecationWarnings = new List(); @@ -326,7 +326,7 @@ namespace StardewModdingAPI { // missing unique ID if (string.IsNullOrWhiteSpace(mod.Manifest.UniqueID)) - deprecationWarnings.Add(() => this.Monitor.Log($"{mod.DisplayName} doesn't have specify a {nameof(IManifest.UniqueID)} field in its manifest. This will be required in an upcoming SMAPI release.", LogLevel.Warn)); + deprecationWarnings.Add(() => this.Monitor.Log($"{mod.DisplayName} doesn't have specify a {nameof(IManifest.UniqueID)} field in its manifest. This will be required in an upcoming SMAPI release.", LogLevel.Warn)); // per-save directories if ((mod.Manifest as Manifest)?.PerSaveConfigs == true) @@ -350,8 +350,11 @@ namespace StardewModdingAPI } } + // process dependencies + mods = resolver.ProcessDependencies(mods).ToArray(); + // load mods - modsLoaded = this.LoadMods(mods, jsonHelper, (SContentManager)Game1.content, deprecationWarnings); + modsLoaded = this.LoadMods(mods, new JsonHelper(), (SContentManager)Game1.content, deprecationWarnings); foreach (Action warning in deprecationWarnings) warning(); } @@ -515,7 +518,7 @@ namespace StardewModdingAPI // get basic info IManifest manifest = metadata.Manifest; string assemblyPath = Path.Combine(metadata.DirectoryPath, metadata.Manifest.EntryDll); - + // preprocess & load mod assembly Assembly modAssembly; try -- cgit From 7f368aa8896baa551aa156a8e67e9dd16416022d Mon Sep 17 00:00:00 2001 From: Jesse Plamondon-Willard Date: Sat, 13 May 2017 20:41:00 -0400 Subject: enforce metadata.SetStatus() instead of setting properties directly (#285) --- src/StardewModdingAPI/Framework/ModLoading/ModMetadata.cs | 4 ++-- src/StardewModdingAPI/Framework/ModLoading/ModResolver.cs | 6 ++---- src/StardewModdingAPI/Program.cs | 8 ++------ 3 files changed, 6 insertions(+), 12 deletions(-) (limited to 'src') diff --git a/src/StardewModdingAPI/Framework/ModLoading/ModMetadata.cs b/src/StardewModdingAPI/Framework/ModLoading/ModMetadata.cs index 7be85a83..5ec2d4e0 100644 --- a/src/StardewModdingAPI/Framework/ModLoading/ModMetadata.cs +++ b/src/StardewModdingAPI/Framework/ModLoading/ModMetadata.cs @@ -21,10 +21,10 @@ namespace StardewModdingAPI.Framework.ModLoading public ModCompatibility Compatibility { get; } /// The metadata resolution status. - public ModMetadataStatus Status { get; set; } + public ModMetadataStatus Status { get; private set; } /// The reason the metadata is invalid, if any. - public string Error { get; set; } + public string Error { get; private set; } /********* diff --git a/src/StardewModdingAPI/Framework/ModLoading/ModResolver.cs b/src/StardewModdingAPI/Framework/ModLoading/ModResolver.cs index 829575af..9b26e8b0 100644 --- a/src/StardewModdingAPI/Framework/ModLoading/ModResolver.cs +++ b/src/StardewModdingAPI/Framework/ModLoading/ModResolver.cs @@ -188,8 +188,7 @@ namespace StardewModdingAPI.Framework.ModLoading } if (missingMods != null) { - mod.Status = ModMetadataStatus.Failed; - mod.Error = $"it requires mods which aren't installed ({missingMods.Substring(0, missingMods.Length - 2)})."; + mod.SetStatus(ModMetadataStatus.Failed, $"it requires mods which aren't installed ({missingMods.Substring(0, missingMods.Length - 2)})."); return false; } } @@ -207,8 +206,7 @@ namespace StardewModdingAPI.Framework.ModLoading ModMetadata circularReferenceMod = currentChain.FirstOrDefault(modsToLoadFirst.Contains); if (circularReferenceMod != null) { - mod.Status = ModMetadataStatus.Failed; - mod.Error = $"its dependencies have a circular reference: {string.Join(" => ", currentChain.Select(p => p.DisplayName))} => {circularReferenceMod.DisplayName})."; + mod.SetStatus(ModMetadataStatus.Failed, $"its dependencies have a circular reference: {string.Join(" => ", currentChain.Select(p => p.DisplayName))} => {circularReferenceMod.DisplayName})."); return false; } currentChain.Add(mod); diff --git a/src/StardewModdingAPI/Program.cs b/src/StardewModdingAPI/Program.cs index 74a9ff8e..37e1e000 100644 --- a/src/StardewModdingAPI/Program.cs +++ b/src/StardewModdingAPI/Program.cs @@ -337,15 +337,11 @@ namespace StardewModdingAPI string psDir = Path.Combine(mod.DirectoryPath, "psconfigs"); Directory.CreateDirectory(psDir); if (!Directory.Exists(psDir)) - { - mod.Status = ModMetadataStatus.Failed; - mod.Error = "it requires per-save configuration files ('psconfigs') which couldn't be created for some reason."; - } + mod.SetStatus(ModMetadataStatus.Failed, "it requires per-save configuration files ('psconfigs') which couldn't be created for some reason."); } catch (Exception ex) { - mod.Status = ModMetadataStatus.Failed; - mod.Error = $"it requires per-save configuration files ('psconfigs') which couldn't be created: {ex.GetLogSummary()}"; + mod.SetStatus(ModMetadataStatus.Failed, $"it requires per-save configuration files ('psconfigs') which couldn't be created: {ex.GetLogSummary()}"); } } } -- cgit From 53547a8ca3a5cba45bd0a5a478d0f40daa282888 Mon Sep 17 00:00:00 2001 From: Jesse Plamondon-Willard Date: Sat, 13 May 2017 21:36:50 -0400 Subject: pass API version into mod metadata validation to simplify unit testing (#285) --- .../Framework/ModLoading/IModMetadata.cs | 39 ++++++++++++++++++++++ .../Framework/ModLoading/ModMetadata.cs | 14 ++------ .../Framework/ModLoading/ModMetadataStatus.cs | 12 +++++++ .../Framework/ModLoading/ModResolver.cs | 22 ++++++------ src/StardewModdingAPI/Program.cs | 12 +++---- src/StardewModdingAPI/StardewModdingAPI.csproj | 2 ++ 6 files changed, 72 insertions(+), 29 deletions(-) create mode 100644 src/StardewModdingAPI/Framework/ModLoading/IModMetadata.cs create mode 100644 src/StardewModdingAPI/Framework/ModLoading/ModMetadataStatus.cs (limited to 'src') diff --git a/src/StardewModdingAPI/Framework/ModLoading/IModMetadata.cs b/src/StardewModdingAPI/Framework/ModLoading/IModMetadata.cs new file mode 100644 index 00000000..3771ffdd --- /dev/null +++ b/src/StardewModdingAPI/Framework/ModLoading/IModMetadata.cs @@ -0,0 +1,39 @@ +using StardewModdingAPI.Framework.Models; + +namespace StardewModdingAPI.Framework.ModLoading +{ + /// Metadata for a mod. + internal interface IModMetadata + { + /********* + ** Accessors + *********/ + /// The mod's display name. + string DisplayName { get; } + + /// The mod's full directory path. + string DirectoryPath { get; } + + /// The mod manifest. + IManifest Manifest { get; } + + /// Optional metadata about a mod version that SMAPI should assume is compatible or broken, regardless of whether it detects incompatible code. + ModCompatibility Compatibility { get; } + + /// The metadata resolution status. + ModMetadataStatus Status { get; } + + /// The reason the metadata is invalid, if any. + string Error { get; } + + + /********* + ** Public methods + *********/ + /// Set the mod status. + /// The metadata resolution status. + /// The reason the metadata is invalid, if any. + /// Return the instance for chaining. + IModMetadata SetStatus(ModMetadataStatus status, string error = null); + } +} diff --git a/src/StardewModdingAPI/Framework/ModLoading/ModMetadata.cs b/src/StardewModdingAPI/Framework/ModLoading/ModMetadata.cs index 5ec2d4e0..7b25e090 100644 --- a/src/StardewModdingAPI/Framework/ModLoading/ModMetadata.cs +++ b/src/StardewModdingAPI/Framework/ModLoading/ModMetadata.cs @@ -3,7 +3,7 @@ namespace StardewModdingAPI.Framework.ModLoading { /// Metadata for a mod. - internal class ModMetadata + internal class ModMetadata : IModMetadata { /********* ** Accessors @@ -47,21 +47,11 @@ namespace StardewModdingAPI.Framework.ModLoading /// The metadata resolution status. /// The reason the metadata is invalid, if any. /// Return the instance for chaining. - public ModMetadata SetStatus(ModMetadataStatus status, string error = null) + public IModMetadata SetStatus(ModMetadataStatus status, string error = null) { this.Status = status; this.Error = error; return this; } } - - /// Indicates the status of a mod's metadata resolution. - internal enum ModMetadataStatus - { - /// The mod has been found, but hasn't been processed yet. - Found, - - /// The mod cannot be loaded. - Failed - } } diff --git a/src/StardewModdingAPI/Framework/ModLoading/ModMetadataStatus.cs b/src/StardewModdingAPI/Framework/ModLoading/ModMetadataStatus.cs new file mode 100644 index 00000000..1b2b0b55 --- /dev/null +++ b/src/StardewModdingAPI/Framework/ModLoading/ModMetadataStatus.cs @@ -0,0 +1,12 @@ +namespace StardewModdingAPI.Framework.ModLoading +{ + /// Indicates the status of a mod's metadata resolution. + internal enum ModMetadataStatus + { + /// The mod has been found, but hasn't been processed yet. + Found, + + /// The mod cannot be loaded. + Failed + } +} \ No newline at end of file diff --git a/src/StardewModdingAPI/Framework/ModLoading/ModResolver.cs b/src/StardewModdingAPI/Framework/ModLoading/ModResolver.cs index 9b26e8b0..a3d4ce3e 100644 --- a/src/StardewModdingAPI/Framework/ModLoading/ModResolver.cs +++ b/src/StardewModdingAPI/Framework/ModLoading/ModResolver.cs @@ -18,7 +18,7 @@ namespace StardewModdingAPI.Framework.ModLoading /// The JSON helper with which to read manifests. /// Metadata about mods that SMAPI should assume is compatible or broken, regardless of whether it detects incompatible code. /// Returns the manifests by relative folder. - public IEnumerable ReadManifests(string rootPath, JsonHelper jsonHelper, IEnumerable compatibilityRecords) + public IEnumerable ReadManifests(string rootPath, JsonHelper jsonHelper, IEnumerable compatibilityRecords) { compatibilityRecords = compatibilityRecords.ToArray(); foreach (DirectoryInfo modDir in this.GetModFolders(rootPath)) @@ -75,9 +75,9 @@ namespace StardewModdingAPI.Framework.ModLoading /// Validate manifest metadata. /// The mod manifests to validate. - public void ValidateManifests(IEnumerable mods) + public void ValidateManifests(IEnumerable mods) { - foreach (ModMetadata mod in mods) + foreach (IModMetadata mod in mods) { // skip if already failed if (mod.Status == ModMetadataStatus.Failed) @@ -127,12 +127,12 @@ namespace StardewModdingAPI.Framework.ModLoading /// Sort the given mods by the order they should be loaded. /// The mods to process. - public IEnumerable ProcessDependencies(IEnumerable mods) + public IEnumerable ProcessDependencies(IEnumerable mods) { var unsortedMods = mods.ToList(); - var sortedMods = new Stack(); + var sortedMods = new Stack(); var visitedMods = new bool[unsortedMods.Count]; - var currentChain = new List(); + var currentChain = new List(); bool success = true; for (int index = 0; index < unsortedMods.Count; index++) @@ -162,7 +162,7 @@ namespace StardewModdingAPI.Framework.ModLoading /// The current change of mod dependencies. /// The mods remaining to sort. /// Returns whether the mod can be loaded. - private bool ProcessDependencies(int modIndex, bool[] visitedMods, Stack sortedMods, List currentChain, List unsortedMods) + private bool ProcessDependencies(int modIndex, bool[] visitedMods, Stack sortedMods, List currentChain, List unsortedMods) { // visit mod if (visitedMods[modIndex]) @@ -170,7 +170,7 @@ namespace StardewModdingAPI.Framework.ModLoading visitedMods[modIndex] = true; // mod already failed - ModMetadata mod = unsortedMods[modIndex]; + IModMetadata mod = unsortedMods[modIndex]; if (mod.Status == ModMetadataStatus.Failed) return false; @@ -194,7 +194,7 @@ namespace StardewModdingAPI.Framework.ModLoading } // get mods which should be loaded before this one - ModMetadata[] modsToLoadFirst = + IModMetadata[] modsToLoadFirst = ( from unsorted in unsortedMods where mod.Manifest.Dependencies.Any(required => required.UniqueID == unsorted.Manifest.UniqueID) @@ -203,7 +203,7 @@ namespace StardewModdingAPI.Framework.ModLoading .ToArray(); // detect circular references - ModMetadata circularReferenceMod = currentChain.FirstOrDefault(modsToLoadFirst.Contains); + IModMetadata circularReferenceMod = currentChain.FirstOrDefault(modsToLoadFirst.Contains); if (circularReferenceMod != null) { mod.SetStatus(ModMetadataStatus.Failed, $"its dependencies have a circular reference: {string.Join(" => ", currentChain.Select(p => p.DisplayName))} => {circularReferenceMod.DisplayName})."); @@ -212,7 +212,7 @@ namespace StardewModdingAPI.Framework.ModLoading currentChain.Add(mod); // recursively sort dependencies - foreach (ModMetadata requiredMod in modsToLoadFirst) + foreach (IModMetadata requiredMod in modsToLoadFirst) { int index = unsortedMods.IndexOf(requiredMod); success = this.ProcessDependencies(index, visitedMods, sortedMods, currentChain, unsortedMods); diff --git a/src/StardewModdingAPI/Program.cs b/src/StardewModdingAPI/Program.cs index 37e1e000..9ccb4ddc 100644 --- a/src/StardewModdingAPI/Program.cs +++ b/src/StardewModdingAPI/Program.cs @@ -317,12 +317,12 @@ namespace StardewModdingAPI ModResolver resolver = new ModResolver(); // load manifests - ModMetadata[] mods = resolver.ReadManifests(Constants.ModPath, new JsonHelper(), this.Settings.ModCompatibility).ToArray(); + IModMetadata[] mods = resolver.ReadManifests(Constants.ModPath, new JsonHelper(), this.Settings.ModCompatibility).ToArray(); resolver.ValidateManifests(mods); // check for deprecated metadata IList deprecationWarnings = new List(); - foreach (ModMetadata mod in mods) + foreach (IModMetadata mod in mods) { // missing unique ID if (string.IsNullOrWhiteSpace(mod.Manifest.UniqueID)) @@ -428,7 +428,7 @@ namespace StardewModdingAPI string[] fields = entry.Value.Split('/'); if (fields.Length < SObject.objectInfoDescriptionIndex + 1) { - LogIssue(entry.Key, $"too few fields for an object"); + LogIssue(entry.Key, "too few fields for an object"); issuesFound = true; continue; } @@ -493,16 +493,16 @@ namespace StardewModdingAPI /// The content manager to use for mod content. /// A list to populate with any deprecation warnings. /// Returns the number of mods successfully loaded. - private int LoadMods(ModMetadata[] mods, JsonHelper jsonHelper, SContentManager contentManager, IList deprecationWarnings) + private int LoadMods(IModMetadata[] mods, JsonHelper jsonHelper, SContentManager contentManager, IList deprecationWarnings) { this.Monitor.Log("Loading mods..."); - void LogSkip(ModMetadata mod, string reasonPhrase, LogLevel level = LogLevel.Error) => this.Monitor.Log($"Skipped {mod.DisplayName} because {reasonPhrase}", level); + void LogSkip(IModMetadata mod, string reasonPhrase, LogLevel level = LogLevel.Error) => this.Monitor.Log($"Skipped {mod.DisplayName} because {reasonPhrase}", level); // load mod assemblies int modsLoaded = 0; AssemblyLoader modAssemblyLoader = new AssemblyLoader(Constants.TargetPlatform, this.Monitor); AppDomain.CurrentDomain.AssemblyResolve += (sender, e) => modAssemblyLoader.ResolveAssembly(e.Name); - foreach (ModMetadata metadata in mods) + foreach (IModMetadata metadata in mods) { // validate status if (metadata.Status == ModMetadataStatus.Failed) diff --git a/src/StardewModdingAPI/StardewModdingAPI.csproj b/src/StardewModdingAPI/StardewModdingAPI.csproj index 2424f438..a7362153 100644 --- a/src/StardewModdingAPI/StardewModdingAPI.csproj +++ b/src/StardewModdingAPI/StardewModdingAPI.csproj @@ -121,6 +121,8 @@ + + -- cgit From f03b300b3fc4bcc9844e77e810dcf352a34b9232 Mon Sep 17 00:00:00 2001 From: Jesse Plamondon-Willard Date: Sat, 13 May 2017 21:38:04 -0400 Subject: pass SMAPI version into metadata validation to simplify unit tests (#285) --- src/StardewModdingAPI/Framework/ModLoading/ModResolver.cs | 7 ++++--- src/StardewModdingAPI/Program.cs | 2 +- 2 files changed, 5 insertions(+), 4 deletions(-) (limited to 'src') diff --git a/src/StardewModdingAPI/Framework/ModLoading/ModResolver.cs b/src/StardewModdingAPI/Framework/ModLoading/ModResolver.cs index a3d4ce3e..e3f4fc12 100644 --- a/src/StardewModdingAPI/Framework/ModLoading/ModResolver.cs +++ b/src/StardewModdingAPI/Framework/ModLoading/ModResolver.cs @@ -75,7 +75,8 @@ namespace StardewModdingAPI.Framework.ModLoading /// Validate manifest metadata. /// The mod manifests to validate. - public void ValidateManifests(IEnumerable mods) + /// The current SMAPI version. + public void ValidateManifests(IEnumerable mods, ISemanticVersion apiVersion) { foreach (IModMetadata mod in mods) { @@ -108,10 +109,10 @@ namespace StardewModdingAPI.Framework.ModLoading { if (!SemanticVersion.TryParse(mod.Manifest.MinimumApiVersion, out ISemanticVersion minVersion)) { - mod.SetStatus(ModMetadataStatus.Failed, $"it has an invalid minimum SMAPI version '{mod.Manifest.MinimumApiVersion}'. This should be a semantic version number like {Constants.ApiVersion}."); + mod.SetStatus(ModMetadataStatus.Failed, $"it has an invalid minimum SMAPI version '{mod.Manifest.MinimumApiVersion}'. This should be a semantic version number like {apiVersion}."); continue; } - if (minVersion.IsNewerThan(Constants.ApiVersion)) + if (minVersion.IsNewerThan(apiVersion)) { mod.SetStatus(ModMetadataStatus.Failed, $"it needs SMAPI {minVersion} or later. Please update SMAPI to the latest version to use this mod."); continue; diff --git a/src/StardewModdingAPI/Program.cs b/src/StardewModdingAPI/Program.cs index 9ccb4ddc..743de050 100644 --- a/src/StardewModdingAPI/Program.cs +++ b/src/StardewModdingAPI/Program.cs @@ -318,7 +318,7 @@ namespace StardewModdingAPI // load manifests IModMetadata[] mods = resolver.ReadManifests(Constants.ModPath, new JsonHelper(), this.Settings.ModCompatibility).ToArray(); - resolver.ValidateManifests(mods); + resolver.ValidateManifests(mods, Constants.ApiVersion); // check for deprecated metadata IList deprecationWarnings = new List(); -- cgit From c1fbbf9418179182888d5cfee1f83e9aad4bbcec Mon Sep 17 00:00:00 2001 From: Jesse Plamondon-Willard Date: Sat, 13 May 2017 21:40:53 -0400 Subject: add unit test project (#285) --- .../Properties/AssemblyInfo.cs | 6 +++ .../StardewModdingAPI.Tests.csproj | 63 ++++++++++++++++++++++ src/StardewModdingAPI.Tests/packages.config | 7 +++ src/StardewModdingAPI.sln | 12 +++++ src/StardewModdingAPI/Properties/AssemblyInfo.cs | 3 ++ 5 files changed, 91 insertions(+) create mode 100644 src/StardewModdingAPI.Tests/Properties/AssemblyInfo.cs create mode 100644 src/StardewModdingAPI.Tests/StardewModdingAPI.Tests.csproj create mode 100644 src/StardewModdingAPI.Tests/packages.config (limited to 'src') diff --git a/src/StardewModdingAPI.Tests/Properties/AssemblyInfo.cs b/src/StardewModdingAPI.Tests/Properties/AssemblyInfo.cs new file mode 100644 index 00000000..ee09145b --- /dev/null +++ b/src/StardewModdingAPI.Tests/Properties/AssemblyInfo.cs @@ -0,0 +1,6 @@ +using System.Reflection; +using System.Runtime.InteropServices; + +[assembly: AssemblyTitle("StardewModdingAPI.Tests")] +[assembly: AssemblyDescription("")] +[assembly: Guid("36ccb19e-92eb-48c7-9615-98eefd45109b")] diff --git a/src/StardewModdingAPI.Tests/StardewModdingAPI.Tests.csproj b/src/StardewModdingAPI.Tests/StardewModdingAPI.Tests.csproj new file mode 100644 index 00000000..78dbb281 --- /dev/null +++ b/src/StardewModdingAPI.Tests/StardewModdingAPI.Tests.csproj @@ -0,0 +1,63 @@ + + + + + Debug + x86 + {36CCB19E-92EB-48C7-9615-98EEFD45109B} + Library + Properties + StardewModdingAPI.Tests + StardewModdingAPI.Tests + v4.5 + 512 + + + true + full + false + bin\Debug\ + DEBUG;TRACE + prompt + 4 + + + pdbonly + true + bin\Release\ + TRACE + prompt + 4 + + + + ..\packages\Castle.Core.4.0.0\lib\net45\Castle.Core.dll + + + ..\packages\Moq.4.7.10\lib\net45\Moq.dll + + + ..\packages\Newtonsoft.Json.8.0.3\lib\net45\Newtonsoft.Json.dll + + + ..\packages\NUnit.3.6.1\lib\net45\nunit.framework.dll + + + + + + Properties\GlobalAssemblyInfo.cs + + + + + + + + + {f1a573b0-f436-472c-ae29-0b91ea6b9f8f} + StardewModdingAPI + + + + \ No newline at end of file diff --git a/src/StardewModdingAPI.Tests/packages.config b/src/StardewModdingAPI.Tests/packages.config new file mode 100644 index 00000000..ba954308 --- /dev/null +++ b/src/StardewModdingAPI.Tests/packages.config @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/src/StardewModdingAPI.sln b/src/StardewModdingAPI.sln index 4bc72188..edc299f4 100644 --- a/src/StardewModdingAPI.sln +++ b/src/StardewModdingAPI.sln @@ -28,6 +28,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StardewModdingAPI.Installer EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StardewModdingAPI.AssemblyRewriters", "StardewModdingAPI.AssemblyRewriters\StardewModdingAPI.AssemblyRewriters.csproj", "{10DB0676-9FC1-4771-A2C8-E2519F091E49}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StardewModdingAPI.Tests", "StardewModdingAPI.Tests\StardewModdingAPI.Tests.csproj", "{36CCB19E-92EB-48C7-9615-98EEFD45109B}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -78,6 +80,16 @@ Global {10DB0676-9FC1-4771-A2C8-E2519F091E49}.Release|Mixed Platforms.Build.0 = Release|x86 {10DB0676-9FC1-4771-A2C8-E2519F091E49}.Release|x86.ActiveCfg = Release|x86 {10DB0676-9FC1-4771-A2C8-E2519F091E49}.Release|x86.Build.0 = Release|x86 + {36CCB19E-92EB-48C7-9615-98EEFD45109B}.Debug|Any CPU.ActiveCfg = Debug|x86 + {36CCB19E-92EB-48C7-9615-98EEFD45109B}.Debug|Mixed Platforms.ActiveCfg = Debug|x86 + {36CCB19E-92EB-48C7-9615-98EEFD45109B}.Debug|Mixed Platforms.Build.0 = Debug|x86 + {36CCB19E-92EB-48C7-9615-98EEFD45109B}.Debug|x86.ActiveCfg = Debug|x86 + {36CCB19E-92EB-48C7-9615-98EEFD45109B}.Debug|x86.Build.0 = Debug|x86 + {36CCB19E-92EB-48C7-9615-98EEFD45109B}.Release|Any CPU.ActiveCfg = Release|x86 + {36CCB19E-92EB-48C7-9615-98EEFD45109B}.Release|Mixed Platforms.ActiveCfg = Release|x86 + {36CCB19E-92EB-48C7-9615-98EEFD45109B}.Release|Mixed Platforms.Build.0 = Release|x86 + {36CCB19E-92EB-48C7-9615-98EEFD45109B}.Release|x86.ActiveCfg = Release|x86 + {36CCB19E-92EB-48C7-9615-98EEFD45109B}.Release|x86.Build.0 = Release|x86 EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/src/StardewModdingAPI/Properties/AssemblyInfo.cs b/src/StardewModdingAPI/Properties/AssemblyInfo.cs index 348c2109..b0a065f5 100644 --- a/src/StardewModdingAPI/Properties/AssemblyInfo.cs +++ b/src/StardewModdingAPI/Properties/AssemblyInfo.cs @@ -1,6 +1,9 @@ using System.Reflection; +using System.Runtime.CompilerServices; using System.Runtime.InteropServices; [assembly: AssemblyTitle("Stardew Modding API (SMAPI)")] [assembly: AssemblyDescription("A modding API for Stardew Valley.")] [assembly: Guid("5c3f7f42-fefd-43db-aaea-92ea3bcad531")] +[assembly: InternalsVisibleTo("StardewModdingAPI.Tests")] +[assembly: InternalsVisibleTo("DynamicProxyGenAssembly2")] // Moq for unit testing -- cgit From 725b1f141910ef0e1d294a055a94afe16ac516de Mon Sep 17 00:00:00 2001 From: Jesse Plamondon-Willard Date: Sat, 13 May 2017 21:42:36 -0400 Subject: add unit tests for metadata loading & validation (#285) --- src/StardewModdingAPI.Tests/Framework/Sample.cs | 30 ++++ src/StardewModdingAPI.Tests/ModResolverTests.cs | 191 +++++++++++++++++++++ .../StardewModdingAPI.Tests.csproj | 2 + 3 files changed, 223 insertions(+) create mode 100644 src/StardewModdingAPI.Tests/Framework/Sample.cs create mode 100644 src/StardewModdingAPI.Tests/ModResolverTests.cs (limited to 'src') diff --git a/src/StardewModdingAPI.Tests/Framework/Sample.cs b/src/StardewModdingAPI.Tests/Framework/Sample.cs new file mode 100644 index 00000000..10006f1e --- /dev/null +++ b/src/StardewModdingAPI.Tests/Framework/Sample.cs @@ -0,0 +1,30 @@ +using System; + +namespace StardewModdingAPI.Tests.Framework +{ + /// Provides sample values for unit testing. + internal static class Sample + { + /********* + ** Properties + *********/ + /// A random number generator. + private static readonly Random Random = new Random(); + + + /********* + ** Properties + *********/ + /// Get a sample string. + public static string String() + { + return Guid.NewGuid().ToString("N"); + } + + /// Get a sample integer. + public static int Int() + { + return Sample.Random.Next(); + } + } +} diff --git a/src/StardewModdingAPI.Tests/ModResolverTests.cs b/src/StardewModdingAPI.Tests/ModResolverTests.cs new file mode 100644 index 00000000..37e7c416 --- /dev/null +++ b/src/StardewModdingAPI.Tests/ModResolverTests.cs @@ -0,0 +1,191 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using Moq; +using Newtonsoft.Json; +using NUnit.Framework; +using StardewModdingAPI.Framework.Models; +using StardewModdingAPI.Framework.ModLoading; +using StardewModdingAPI.Framework.Serialisation; +using StardewModdingAPI.Tests.Framework; + +namespace StardewModdingAPI.Tests +{ + [TestFixture] + public class ModResolverTests + { + /********* + ** Unit tests + *********/ + [Test(Description = "Assert that the resolver correctly reads manifest data from a randomised file.")] + public void ReadBasicManifest() + { + // create manifest data + IDictionary originalDependency = new Dictionary + { + [nameof(IManifestDependency.UniqueID)] = Sample.String() + }; + IDictionary original = new Dictionary + { + [nameof(IManifest.Name)] = Sample.String(), + [nameof(IManifest.Author)] = Sample.String(), + [nameof(IManifest.Version)] = new SemanticVersion(Sample.Int(), Sample.Int(), Sample.Int(), Sample.String()), + [nameof(IManifest.Description)] = Sample.String(), + [nameof(IManifest.UniqueID)] = $"{Sample.String()}.{Sample.String()}", + [nameof(IManifest.EntryDll)] = $"{Sample.String()}.dll", + [nameof(IManifest.MinimumApiVersion)] = $"{Sample.Int()}.{Sample.Int()}-{Sample.String()}", + [nameof(IManifest.Dependencies)] = new[] { originalDependency }, + ["ExtraString"] = Sample.String(), + ["ExtraInt"] = Sample.Int() + }; + + // write to filesystem + string rootFolder = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); + string modFolder = Path.Combine(rootFolder, Guid.NewGuid().ToString("N")); + string filename = Path.Combine(modFolder, "manifest.json"); + Directory.CreateDirectory(modFolder); + File.WriteAllText(filename, JsonConvert.SerializeObject(original)); + + // act + IModMetadata[] mods = new ModResolver().ReadManifests(rootFolder, new JsonHelper(), new ModCompatibility[0]).ToArray(); + IModMetadata mod = mods.FirstOrDefault(); + + // assert + Assert.AreEqual(1, mods.Length, 0, $"Expected to find one manifest, found {mods.Length} instead."); + Assert.IsNotNull(mod, "The loaded manifest shouldn't be null."); + Assert.AreEqual(null, mod.Compatibility, "The compatibility record should be null since we didn't provide one."); + Assert.AreEqual(modFolder, mod.DirectoryPath, "The directory path doesn't match."); + Assert.AreEqual(ModMetadataStatus.Found, mod.Status, "The status doesn't match."); + Assert.AreEqual(null, mod.Error, "The error should be null since parsing should have succeeded."); + + Assert.AreEqual(original[nameof(IManifest.Name)], mod.DisplayName, "The display name should use the manifest name."); + Assert.AreEqual(original[nameof(IManifest.Name)], mod.Manifest.Name, "The manifest's name doesn't match."); + Assert.AreEqual(original[nameof(IManifest.Author)], mod.Manifest.Author, "The manifest's author doesn't match."); + Assert.AreEqual(original[nameof(IManifest.Description)], mod.Manifest.Description, "The manifest's description doesn't match."); + Assert.AreEqual(original[nameof(IManifest.EntryDll)], mod.Manifest.EntryDll, "The manifest's entry DLL doesn't match."); + Assert.AreEqual(original[nameof(IManifest.MinimumApiVersion)], mod.Manifest.MinimumApiVersion, "The manifest's minimum API version doesn't match."); + Assert.AreEqual(original[nameof(IManifest.Version)]?.ToString(), mod.Manifest.Version?.ToString(), "The manifest's version doesn't match."); + + Assert.IsNotNull(mod.Manifest.ExtraFields, "The extra fields should not be null."); + Assert.AreEqual(2, mod.Manifest.ExtraFields.Count, "The extra fields should contain two values."); + Assert.AreEqual(original["ExtraString"], mod.Manifest.ExtraFields["ExtraString"], "The manifest's extra fields should contain an 'ExtraString' value."); + Assert.AreEqual(original["ExtraInt"], mod.Manifest.ExtraFields["ExtraInt"], "The manifest's extra fields should contain an 'ExtraInt' value."); + + Assert.IsNotNull(mod.Manifest.Dependencies, "The dependencies field should not be null."); + Assert.AreEqual(1, mod.Manifest.Dependencies.Length, "The dependencies field should contain one value."); + Assert.AreEqual(originalDependency[nameof(IManifestDependency.UniqueID)], mod.Manifest.Dependencies[0].UniqueID, "The first dependency's unique ID doesn't match."); + } + + [Test(Description = "Assert that validation skips manifests that have already failed without calling any other properties.")] + public void ValidateManifest_Skips_Failed() + { + // arrange + Mock mock = new Mock(MockBehavior.Strict); + mock.Setup(p => p.Status).Returns(ModMetadataStatus.Failed); + + // act + new ModResolver().ValidateManifests(new[] { mock.Object }, apiVersion: new SemanticVersion("1.0")); + + // assert + mock.VerifyGet(p => p.Status, Times.Once, "The validation did not check the manifest status."); + } + + [Test(Description = "Assert that validation fails if the mod has 'assume broken' compatibility.")] + public void ValidateManifest_ModCompatibility_AssumeBroken_Fails() + { + // arrange + Mock mock = new Mock(MockBehavior.Strict); + mock.Setup(p => p.Status).Returns(ModMetadataStatus.Found); + mock.Setup(p => p.Compatibility).Returns(new ModCompatibility { Compatibility = ModCompatibilityType.AssumeBroken }); + mock.Setup(p => p.SetStatus(ModMetadataStatus.Failed, It.IsAny())).Returns(() => mock.Object); + + // act + new ModResolver().ValidateManifests(new[] { mock.Object }, apiVersion: new SemanticVersion("1.0")); + + // assert + mock.Verify(p => p.SetStatus(ModMetadataStatus.Failed, It.IsAny()), Times.Once, "The validation did not fail the metadata."); + } + + [Test(Description = "Assert that validation fails when the minimum API version is higher than the current SMAPI version.")] + public void ValidateManifest_MinimumApiVersion_Fails() + { + // arrange + Mock mock = new Mock(MockBehavior.Strict); + mock.Setup(p => p.Status).Returns(ModMetadataStatus.Found); + mock.Setup(p => p.Compatibility).Returns(() => null); + mock.Setup(p => p.Manifest).Returns(this.GetRandomManifest("1.1")); + mock.Setup(p => p.SetStatus(ModMetadataStatus.Failed, It.IsAny())).Returns(() => mock.Object); + + // act + new ModResolver().ValidateManifests(new[] { mock.Object }, apiVersion: new SemanticVersion("1.0")); + + // assert + mock.Verify(p => p.SetStatus(ModMetadataStatus.Failed, It.IsAny()), Times.Once, "The validation did not fail the metadata."); + } + + [Test(Description = "Assert that validation fails when the manifest references a DLL that does not exist.")] + public void ValidateManifest_MissingEntryDLL_Fails() + { + // arrange + Mock mock = new Mock(MockBehavior.Strict); + mock.Setup(p => p.Status).Returns(ModMetadataStatus.Found); + mock.Setup(p => p.Compatibility).Returns(() => null); + mock.Setup(p => p.Manifest).Returns(this.GetRandomManifest()); + mock.Setup(p => p.DirectoryPath).Returns(Path.GetTempPath()); + mock.Setup(p => p.SetStatus(ModMetadataStatus.Failed, It.IsAny())).Returns(() => mock.Object); + + // act + new ModResolver().ValidateManifests(new[] { mock.Object }, apiVersion: new SemanticVersion("1.0")); + + // assert + mock.Verify(p => p.SetStatus(ModMetadataStatus.Failed, It.IsAny()), Times.Once, "The validation did not fail the metadata."); + } + + [Test(Description = "Assert that validation fails when the manifest references a DLL that does not exist.")] + public void ValidateManifest_Valid_Passes() + { + // set up manifest + IManifest manifest = this.GetRandomManifest(); + + // create DLL + string modFolder = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(modFolder); + File.WriteAllText(Path.Combine(modFolder, manifest.EntryDll), ""); + + // arrange + Mock mock = new Mock(MockBehavior.Strict); + mock.Setup(p => p.Status).Returns(ModMetadataStatus.Found); + mock.Setup(p => p.Compatibility).Returns(() => null); + mock.Setup(p => p.Manifest).Returns(manifest); + mock.Setup(p => p.DirectoryPath).Returns(modFolder); + + // act + new ModResolver().ValidateManifests(new[] { mock.Object }, apiVersion: new SemanticVersion("1.0")); + + // assert + // if Moq doesn't throw a method-not-setup exception, the validation didn't override the status. + } + + + + /********* + ** Private methods + *********/ + /// Get a randomised basic manifest. + /// The minimum API version. + private Manifest GetRandomManifest(string minVersion = null) + { + return new Manifest + { + Name = Sample.String(), + Author = Sample.String(), + Version = new SemanticVersion(Sample.Int(), Sample.Int(), Sample.Int(), Sample.String()), + Description = Sample.String(), + UniqueID = $"{Sample.String()}.{Sample.String()}", + EntryDll = $"{Sample.String()}.dll", + MinimumApiVersion = minVersion + }; + } + } +} diff --git a/src/StardewModdingAPI.Tests/StardewModdingAPI.Tests.csproj b/src/StardewModdingAPI.Tests/StardewModdingAPI.Tests.csproj index 78dbb281..c84adbd7 100644 --- a/src/StardewModdingAPI.Tests/StardewModdingAPI.Tests.csproj +++ b/src/StardewModdingAPI.Tests/StardewModdingAPI.Tests.csproj @@ -48,7 +48,9 @@ Properties\GlobalAssemblyInfo.cs + + -- cgit From 317349f3e2469ee031137f14b2c589acbbf09fa6 Mon Sep 17 00:00:00 2001 From: Jesse Plamondon-Willard Date: Sat, 13 May 2017 21:58:13 -0400 Subject: add a few more unit tests for metadata loading & validation (#285) --- src/StardewModdingAPI.Tests/ModResolverTests.cs | 51 +++++++++++++++++++++---- 1 file changed, 44 insertions(+), 7 deletions(-) (limited to 'src') diff --git a/src/StardewModdingAPI.Tests/ModResolverTests.cs b/src/StardewModdingAPI.Tests/ModResolverTests.cs index 37e7c416..8db4c379 100644 --- a/src/StardewModdingAPI.Tests/ModResolverTests.cs +++ b/src/StardewModdingAPI.Tests/ModResolverTests.cs @@ -18,8 +18,40 @@ namespace StardewModdingAPI.Tests /********* ** Unit tests *********/ + [Test(Description = "Assert that the resolver correctly returns an empty list if there are no mods installed.")] + public void ReadBasicManifest_NoMods_ReturnsEmptyList() + { + // arrange + string rootFolder = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(rootFolder); + + // act + IModMetadata[] mods = new ModResolver().ReadManifests(rootFolder, new JsonHelper(), new ModCompatibility[0]).ToArray(); + + // assert + Assert.AreEqual(0, mods.Length, 0, $"Expected to find zero manifests, found {mods.Length} instead."); + } + + [Test(Description = "Assert that the resolver correctly returns a failed metadata if there's an empty mod folder.")] + public void ReadBasicManifest_EmptyModFolder_ReturnsFailedManifest() + { + // arrange + string rootFolder = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); + string modFolder = Path.Combine(rootFolder, Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(modFolder); + + // act + IModMetadata[] mods = new ModResolver().ReadManifests(rootFolder, new JsonHelper(), new ModCompatibility[0]).ToArray(); + IModMetadata mod = mods.FirstOrDefault(); + + // assert + Assert.AreEqual(1, mods.Length, 0, $"Expected to find one manifest, found {mods.Length} instead."); + Assert.AreEqual(ModMetadataStatus.Failed, mod.Status, "The mod metadata was not marked failed."); + Assert.IsNotNull(mod.Error, "The mod metadata did not have an error message set."); + } + [Test(Description = "Assert that the resolver correctly reads manifest data from a randomised file.")] - public void ReadBasicManifest() + public void ReadBasicManifest_CanReadFile() { // create manifest data IDictionary originalDependency = new Dictionary @@ -77,8 +109,14 @@ namespace StardewModdingAPI.Tests Assert.AreEqual(originalDependency[nameof(IManifestDependency.UniqueID)], mod.Manifest.Dependencies[0].UniqueID, "The first dependency's unique ID doesn't match."); } + [Test(Description = "Assert that validation doesn't fail if there are no mods installed.")] + public void ValidateManifests_NoMods_DoesNothing() + { + new ModResolver().ValidateManifests(new ModMetadata[0], apiVersion: new SemanticVersion("1.0")); + } + [Test(Description = "Assert that validation skips manifests that have already failed without calling any other properties.")] - public void ValidateManifest_Skips_Failed() + public void ValidateManifests_Skips_Failed() { // arrange Mock mock = new Mock(MockBehavior.Strict); @@ -92,7 +130,7 @@ namespace StardewModdingAPI.Tests } [Test(Description = "Assert that validation fails if the mod has 'assume broken' compatibility.")] - public void ValidateManifest_ModCompatibility_AssumeBroken_Fails() + public void ValidateManifests_ModCompatibility_AssumeBroken_Fails() { // arrange Mock mock = new Mock(MockBehavior.Strict); @@ -108,7 +146,7 @@ namespace StardewModdingAPI.Tests } [Test(Description = "Assert that validation fails when the minimum API version is higher than the current SMAPI version.")] - public void ValidateManifest_MinimumApiVersion_Fails() + public void ValidateManifests_MinimumApiVersion_Fails() { // arrange Mock mock = new Mock(MockBehavior.Strict); @@ -125,7 +163,7 @@ namespace StardewModdingAPI.Tests } [Test(Description = "Assert that validation fails when the manifest references a DLL that does not exist.")] - public void ValidateManifest_MissingEntryDLL_Fails() + public void ValidateManifests_MissingEntryDLL_Fails() { // arrange Mock mock = new Mock(MockBehavior.Strict); @@ -143,7 +181,7 @@ namespace StardewModdingAPI.Tests } [Test(Description = "Assert that validation fails when the manifest references a DLL that does not exist.")] - public void ValidateManifest_Valid_Passes() + public void ValidateManifests_Valid_Passes() { // set up manifest IManifest manifest = this.GetRandomManifest(); @@ -168,7 +206,6 @@ namespace StardewModdingAPI.Tests } - /********* ** Private methods *********/ -- cgit From f3ff871eb76ca49e298d5418203366fdb46b1dc3 Mon Sep 17 00:00:00 2001 From: Jesse Plamondon-Willard Date: Sat, 13 May 2017 22:47:50 -0400 Subject: add unit tests for basic dependency reordering cases (#285) --- src/StardewModdingAPI.Tests/ModResolverTests.cs | 142 ++++++++++++++++++++++-- 1 file changed, 135 insertions(+), 7 deletions(-) (limited to 'src') diff --git a/src/StardewModdingAPI.Tests/ModResolverTests.cs b/src/StardewModdingAPI.Tests/ModResolverTests.cs index 8db4c379..285c5127 100644 --- a/src/StardewModdingAPI.Tests/ModResolverTests.cs +++ b/src/StardewModdingAPI.Tests/ModResolverTests.cs @@ -18,6 +18,9 @@ namespace StardewModdingAPI.Tests /********* ** Unit tests *********/ + /**** + ** ReadManifests + ****/ [Test(Description = "Assert that the resolver correctly returns an empty list if there are no mods installed.")] public void ReadBasicManifest_NoMods_ReturnsEmptyList() { @@ -84,7 +87,7 @@ namespace StardewModdingAPI.Tests IModMetadata mod = mods.FirstOrDefault(); // assert - Assert.AreEqual(1, mods.Length, 0, $"Expected to find one manifest, found {mods.Length} instead."); + Assert.AreEqual(1, mods.Length, 0, "Expected to find one manifest."); Assert.IsNotNull(mod, "The loaded manifest shouldn't be null."); Assert.AreEqual(null, mod.Compatibility, "The compatibility record should be null since we didn't provide one."); Assert.AreEqual(modFolder, mod.DirectoryPath, "The directory path doesn't match."); @@ -109,6 +112,9 @@ namespace StardewModdingAPI.Tests Assert.AreEqual(originalDependency[nameof(IManifestDependency.UniqueID)], mod.Manifest.Dependencies[0].UniqueID, "The first dependency's unique ID doesn't match."); } + /**** + ** ValidateManifests + ****/ [Test(Description = "Assert that validation doesn't fail if there are no mods installed.")] public void ValidateManifests_NoMods_DoesNothing() { @@ -152,7 +158,7 @@ namespace StardewModdingAPI.Tests Mock mock = new Mock(MockBehavior.Strict); mock.Setup(p => p.Status).Returns(ModMetadataStatus.Found); mock.Setup(p => p.Compatibility).Returns(() => null); - mock.Setup(p => p.Manifest).Returns(this.GetRandomManifest("1.1")); + mock.Setup(p => p.Manifest).Returns(this.GetRandomManifest(m => m.MinimumApiVersion = "1.1")); mock.Setup(p => p.SetStatus(ModMetadataStatus.Failed, It.IsAny())).Returns(() => mock.Object); // act @@ -205,24 +211,146 @@ namespace StardewModdingAPI.Tests // if Moq doesn't throw a method-not-setup exception, the validation didn't override the status. } + /**** + ** ProcessDependencies + ****/ + [Test(Description = "Assert that processing dependencies doesn't fail if there are no mods installed.")] + public void ProcessDependencies_NoMods_DoesNothing() + { + // act + IModMetadata[] mods = new ModResolver().ProcessDependencies(new IModMetadata[0]).ToArray(); + + // assert + Assert.AreEqual(0, mods.Length, 0, "Expected to get an empty list of mods."); + } + + [Test(Description = "Assert that processing dependencies doesn't change the order if there are no mod dependencies.")] + public void ProcessDependencies_NoDependencies_DoesNothing() + { + // arrange + // A B C + Mock modA = this.GetMetadataForDependencyTest("Mod A"); + Mock modB = this.GetMetadataForDependencyTest("Mod B"); + Mock modC = this.GetMetadataForDependencyTest("Mod C"); + + // act + IModMetadata[] mods = new ModResolver().ProcessDependencies(new[] { modA.Object, modB.Object, modC.Object }).ToArray(); + + // assert + Assert.AreEqual(3, mods.Length, 0, "Expected to get the same number of mods input."); + Assert.AreSame(modA.Object, mods[0], "The load order unexpectedly changed with no dependencies."); + Assert.AreSame(modB.Object, mods[1], "The load order unexpectedly changed with no dependencies."); + Assert.AreSame(modC.Object, mods[2], "The load order unexpectedly changed with no dependencies."); + } + + [Test(Description = "Assert that simple dependencies are reordered correctly.")] + public void ProcessDependencies_Reorders_SimpleDependencies() + { + // arrange + // A ◀── B + // ▲ ▲ + // │ │ + // └─ C ─┘ + Mock modA = this.GetMetadataForDependencyTest("Mod A"); + Mock modB = this.GetMetadataForDependencyTest("Mod B", modA); + Mock modC = this.GetMetadataForDependencyTest("Mod C", modA, modB); + + // act + IModMetadata[] mods = new ModResolver().ProcessDependencies(new[] { modC.Object, modA.Object, modB.Object }).ToArray(); + + // assert + Assert.AreEqual(3, mods.Length, 0, "Expected to get the same number of mods input."); + Assert.AreSame(modA.Object, mods[0], "The load order is incorrect: mod A should be first since the other mods depend on it."); + Assert.AreSame(modB.Object, mods[1], "The load order is incorrect: mod B should be second since it needs mod A, and is needed by mod C."); + Assert.AreSame(modC.Object, mods[2], "The load order is incorrect: mod C should be third since it needs both mod A and mod B."); + } + + [Test(Description = "Assert that simple dependency chains are reordered correctly.")] + public void ProcessDependencies_Reorders_DependencyChain() + { + // arrange + // A ◀── B ◀── C ◀── D + Mock modA = this.GetMetadataForDependencyTest("Mod A"); + Mock modB = this.GetMetadataForDependencyTest("Mod B", modA); + Mock modC = this.GetMetadataForDependencyTest("Mod C", modB); + Mock modD = this.GetMetadataForDependencyTest("Mod D", modC); + + // act + IModMetadata[] mods = new ModResolver().ProcessDependencies(new[] { modC.Object, modA.Object, modB.Object, modD.Object }).ToArray(); + + // assert + Assert.AreEqual(4, mods.Length, 0, "Expected to get the same number of mods input."); + Assert.AreSame(modA.Object, mods[0], "The load order is incorrect: mod A should be first since it's needed by mod B."); + Assert.AreSame(modB.Object, mods[1], "The load order is incorrect: mod B should be second since it needs mod A, and is needed by mod C."); + Assert.AreSame(modC.Object, mods[2], "The load order is incorrect: mod C should be third since it needs mod B, and is needed by mod D."); + Assert.AreSame(modD.Object, mods[3], "The load order is incorrect: mod D should be fourth since it needs mod C."); + } + + [Test(Description = "Assert that overlapping dependency chains are reordered correctly.")] + public void ProcessDependencies_Reorders_OverlappingDependencyChain() + { + // arrange + // A ◀── B ◀── C ◀── D + // ▲ ▲ + // │ │ + // E ◀── F + Mock modA = this.GetMetadataForDependencyTest("Mod A"); + Mock modB = this.GetMetadataForDependencyTest("Mod B", modA); + Mock modC = this.GetMetadataForDependencyTest("Mod C", modB); + Mock modD = this.GetMetadataForDependencyTest("Mod D", modC); + Mock modE = this.GetMetadataForDependencyTest("Mod E", modB); + Mock modF = this.GetMetadataForDependencyTest("Mod F", modC, modE); + + // act + IModMetadata[] mods = new ModResolver().ProcessDependencies(new[] { modC.Object, modA.Object, modB.Object, modD.Object, modF.Object, modE.Object }).ToArray(); + + // assert + Assert.AreEqual(6, mods.Length, 0, "Expected to get the same number of mods input."); + Assert.AreSame(modA.Object, mods[0], "The load order is incorrect: mod A should be first since it's needed by mod B."); + Assert.AreSame(modB.Object, mods[1], "The load order is incorrect: mod B should be second since it needs mod A, and is needed by mod C."); + Assert.AreSame(modC.Object, mods[2], "The load order is incorrect: mod C should be third since it needs mod B, and is needed by mod D."); + Assert.AreSame(modD.Object, mods[3], "The load order is incorrect: mod D should be fourth since it needs mod C."); + Assert.AreSame(modE.Object, mods[4], "The load order is incorrect: mod E should be fifth since it needs mod B, but is specified after C which also needs mod B."); + Assert.AreSame(modF.Object, mods[5], "The load order is incorrect: mod F should be last since it needs mods E and C."); + } + /********* ** Private methods *********/ /// Get a randomised basic manifest. - /// The minimum API version. - private Manifest GetRandomManifest(string minVersion = null) + /// Adjust the generated manifest. + private Manifest GetRandomManifest(Action adjust = null) { - return new Manifest + Manifest manifest = new Manifest { Name = Sample.String(), Author = Sample.String(), Version = new SemanticVersion(Sample.Int(), Sample.Int(), Sample.Int(), Sample.String()), Description = Sample.String(), UniqueID = $"{Sample.String()}.{Sample.String()}", - EntryDll = $"{Sample.String()}.dll", - MinimumApiVersion = minVersion + EntryDll = $"{Sample.String()}.dll" }; + adjust?.Invoke(manifest); + return manifest; + } + + /// Get a randomised basic manifest. + /// The mod's name and unique ID. + /// The dependencies this mod requires. + private Mock GetMetadataForDependencyTest(string uniqueID, params Mock[] dependencies) + { + Mock mod = new Mock(MockBehavior.Strict); + mod.Setup(p => p.Status).Returns(ModMetadataStatus.Found); + mod.Setup(p => p.Manifest).Returns( + this.GetRandomManifest(manifest => + { + manifest.Name = uniqueID; + manifest.UniqueID = uniqueID; + manifest.Dependencies = dependencies.Select(p => (IManifestDependency)new ManifestDependency(p.Object.Manifest.UniqueID)).ToArray(); + }) + ); + return mod; } } } -- cgit From 07aadf36126bfadd5df624ccf810828adf679788 Mon Sep 17 00:00:00 2001 From: Jesse Plamondon-Willard Date: Sun, 14 May 2017 18:17:34 -0400 Subject: replace mod indexes with references in dependency-sorting logic (#285) --- .../Framework/ModLoading/ModResolver.cs | 24 ++++++++++------------ 1 file changed, 11 insertions(+), 13 deletions(-) (limited to 'src') diff --git a/src/StardewModdingAPI/Framework/ModLoading/ModResolver.cs b/src/StardewModdingAPI/Framework/ModLoading/ModResolver.cs index e3f4fc12..8efe57d9 100644 --- a/src/StardewModdingAPI/Framework/ModLoading/ModResolver.cs +++ b/src/StardewModdingAPI/Framework/ModLoading/ModResolver.cs @@ -49,7 +49,7 @@ namespace StardewModdingAPI.Framework.ModLoading // get compatibility record ModCompatibility compatibility = null; - if(manifest != null) + if (manifest != null) { string key = !string.IsNullOrWhiteSpace(manifest.UniqueID) ? manifest.UniqueID : manifest.EntryDll; compatibility = ( @@ -132,16 +132,16 @@ namespace StardewModdingAPI.Framework.ModLoading { var unsortedMods = mods.ToList(); var sortedMods = new Stack(); - var visitedMods = new bool[unsortedMods.Count]; + var visitedMods = new HashSet(); var currentChain = new List(); bool success = true; - for (int index = 0; index < unsortedMods.Count; index++) + foreach (IModMetadata mod in unsortedMods) { - if (unsortedMods[index].Status == ModMetadataStatus.Failed) + if (mod.Status == ModMetadataStatus.Failed) continue; - success = this.ProcessDependencies(index, visitedMods, sortedMods, currentChain, unsortedMods); + success = this.ProcessDependencies(mod, visitedMods, sortedMods, currentChain, unsortedMods); if (!success) break; } @@ -157,21 +157,20 @@ namespace StardewModdingAPI.Framework.ModLoading ** Private methods *********/ /// Sort a mod's dependencies by the order they should be loaded, and remove any mods that can't be loaded due to missing or conflicting dependencies. - /// The index of the mod being processed in the . - /// The mods which have been processed. + /// The mod whose dependencies to process. + /// The mods which have been visited. /// The list in which to save mods sorted by dependency order. /// The current change of mod dependencies. /// The mods remaining to sort. /// Returns whether the mod can be loaded. - private bool ProcessDependencies(int modIndex, bool[] visitedMods, Stack sortedMods, List currentChain, List unsortedMods) + private bool ProcessDependencies(IModMetadata mod, HashSet visited, Stack sortedMods, List currentChain, List unsortedMods) { // visit mod - if (visitedMods[modIndex]) + if (visited.Contains(mod)) return true; // already sorted - visitedMods[modIndex] = true; + visited.Add(mod); // mod already failed - IModMetadata mod = unsortedMods[modIndex]; if (mod.Status == ModMetadataStatus.Failed) return false; @@ -215,8 +214,7 @@ namespace StardewModdingAPI.Framework.ModLoading // recursively sort dependencies foreach (IModMetadata requiredMod in modsToLoadFirst) { - int index = unsortedMods.IndexOf(requiredMod); - success = this.ProcessDependencies(index, visitedMods, sortedMods, currentChain, unsortedMods); + success = this.ProcessDependencies(requiredMod, visited, sortedMods, currentChain, unsortedMods); if (!success) break; } -- cgit From 2d9aefebb0991b2e942241bf509eaa98f63b4963 Mon Sep 17 00:00:00 2001 From: Jesse Plamondon-Willard Date: Sun, 14 May 2017 21:19:27 -0400 Subject: rewrite dependency logic to resolve dependency loops by disabling the affected mods (#285) --- src/StardewModdingAPI.Tests/ModResolverTests.cs | 59 ++++++-- .../ModLoading/InvalidModStateException.cs | 14 ++ .../Framework/ModLoading/ModDependencyStatus.cs | 18 +++ .../Framework/ModLoading/ModResolver.cs | 162 ++++++++++++--------- src/StardewModdingAPI/StardewModdingAPI.csproj | 2 + 5 files changed, 178 insertions(+), 77 deletions(-) create mode 100644 src/StardewModdingAPI/Framework/ModLoading/InvalidModStateException.cs create mode 100644 src/StardewModdingAPI/Framework/ModLoading/ModDependencyStatus.cs (limited to 'src') diff --git a/src/StardewModdingAPI.Tests/ModResolverTests.cs b/src/StardewModdingAPI.Tests/ModResolverTests.cs index 285c5127..1142a264 100644 --- a/src/StardewModdingAPI.Tests/ModResolverTests.cs +++ b/src/StardewModdingAPI.Tests/ModResolverTests.cs @@ -252,8 +252,8 @@ namespace StardewModdingAPI.Tests // │ │ // └─ C ─┘ Mock modA = this.GetMetadataForDependencyTest("Mod A"); - Mock modB = this.GetMetadataForDependencyTest("Mod B", modA); - Mock modC = this.GetMetadataForDependencyTest("Mod C", modA, modB); + Mock modB = this.GetMetadataForDependencyTest("Mod B", dependencies: new[] { "Mod A" }); + Mock modC = this.GetMetadataForDependencyTest("Mod C", dependencies: new[] { "Mod A", "Mod B" }); // act IModMetadata[] mods = new ModResolver().ProcessDependencies(new[] { modC.Object, modA.Object, modB.Object }).ToArray(); @@ -271,9 +271,9 @@ namespace StardewModdingAPI.Tests // arrange // A ◀── B ◀── C ◀── D Mock modA = this.GetMetadataForDependencyTest("Mod A"); - Mock modB = this.GetMetadataForDependencyTest("Mod B", modA); - Mock modC = this.GetMetadataForDependencyTest("Mod C", modB); - Mock modD = this.GetMetadataForDependencyTest("Mod D", modC); + Mock modB = this.GetMetadataForDependencyTest("Mod B", dependencies: new[] { "Mod A" }); + Mock modC = this.GetMetadataForDependencyTest("Mod C", dependencies: new[] { "Mod B" }); + Mock modD = this.GetMetadataForDependencyTest("Mod D", dependencies: new[] { "Mod C" }); // act IModMetadata[] mods = new ModResolver().ProcessDependencies(new[] { modC.Object, modA.Object, modB.Object, modD.Object }).ToArray(); @@ -295,11 +295,11 @@ namespace StardewModdingAPI.Tests // │ │ // E ◀── F Mock modA = this.GetMetadataForDependencyTest("Mod A"); - Mock modB = this.GetMetadataForDependencyTest("Mod B", modA); - Mock modC = this.GetMetadataForDependencyTest("Mod C", modB); - Mock modD = this.GetMetadataForDependencyTest("Mod D", modC); - Mock modE = this.GetMetadataForDependencyTest("Mod E", modB); - Mock modF = this.GetMetadataForDependencyTest("Mod F", modC, modE); + Mock modB = this.GetMetadataForDependencyTest("Mod B", dependencies: new[] { "Mod A" }); + Mock modC = this.GetMetadataForDependencyTest("Mod C", dependencies: new[] { "Mod B" }); + Mock modD = this.GetMetadataForDependencyTest("Mod D", dependencies: new[] { "Mod C" }); + Mock modE = this.GetMetadataForDependencyTest("Mod E", dependencies: new[] { "Mod B" }); + Mock modF = this.GetMetadataForDependencyTest("Mod F", dependencies: new[] { "Mod C", "Mod E" }); // act IModMetadata[] mods = new ModResolver().ProcessDependencies(new[] { modC.Object, modA.Object, modB.Object, modD.Object, modF.Object, modE.Object }).ToArray(); @@ -314,6 +314,32 @@ namespace StardewModdingAPI.Tests Assert.AreSame(modF.Object, mods[5], "The load order is incorrect: mod F should be last since it needs mods E and C."); } + [Test(Description = "Assert that mods with circular dependency chains are skipped, but any other mods are loaded in the correct order.")] + public void ProcessDependencies_Skips_CircularDependentMods() + { + // arrange + // A ◀── B ◀── C ──▶ D + // ▲ │ + // │ ▼ + // └──── E + Mock modA = this.GetMetadataForDependencyTest("Mod A"); + Mock modB = this.GetMetadataForDependencyTest("Mod B", dependencies: new[] { "Mod A" }); + Mock modC = this.GetMetadataForDependencyTest("Mod C", dependencies: new[] { "Mod B", "Mod D" }, allowStatusChange: true); + Mock modD = this.GetMetadataForDependencyTest("Mod D", dependencies: new[] { "Mod E" }, allowStatusChange: true); + Mock modE = this.GetMetadataForDependencyTest("Mod E", dependencies: new[] { "Mod C" }, allowStatusChange: true); + + // act + IModMetadata[] mods = new ModResolver().ProcessDependencies(new[] { modC.Object, modA.Object, modB.Object, modD.Object, modE.Object }).ToArray(); + + // assert + Assert.AreEqual(5, mods.Length, 0, "Expected to get the same number of mods input."); + Assert.AreSame(modA.Object, mods[0], "The load order is incorrect: mod A should be first since it's needed by mod B."); + Assert.AreSame(modB.Object, mods[1], "The load order is incorrect: mod B should be second since it needs mod A."); + modC.Verify(p => p.SetStatus(ModMetadataStatus.Failed, It.IsAny()), Times.Once, "Mod C was expected to fail since it's part of a dependency loop."); + modD.Verify(p => p.SetStatus(ModMetadataStatus.Failed, It.IsAny()), Times.Once, "Mod D was expected to fail since it's part of a dependency loop."); + modE.Verify(p => p.SetStatus(ModMetadataStatus.Failed, It.IsAny()), Times.Once, "Mod E was expected to fail since it's part of a dependency loop."); + } + /********* ** Private methods @@ -338,18 +364,27 @@ namespace StardewModdingAPI.Tests /// Get a randomised basic manifest. /// The mod's name and unique ID. /// The dependencies this mod requires. - private Mock GetMetadataForDependencyTest(string uniqueID, params Mock[] dependencies) + /// Whether the code being tested is allowed to change the mod status. + private Mock GetMetadataForDependencyTest(string uniqueID, string[] dependencies = null, bool allowStatusChange = false) { Mock mod = new Mock(MockBehavior.Strict); mod.Setup(p => p.Status).Returns(ModMetadataStatus.Found); + mod.Setup(p => p.DisplayName).Returns(uniqueID); mod.Setup(p => p.Manifest).Returns( this.GetRandomManifest(manifest => { manifest.Name = uniqueID; manifest.UniqueID = uniqueID; - manifest.Dependencies = dependencies.Select(p => (IManifestDependency)new ManifestDependency(p.Object.Manifest.UniqueID)).ToArray(); + manifest.Dependencies = dependencies?.Select(dependencyID => (IManifestDependency)new ManifestDependency(dependencyID)).ToArray(); }) ); + if (allowStatusChange) + { + mod + .Setup(p => p.SetStatus(It.IsAny(), It.IsAny())) + .Callback((status, message) => Console.WriteLine($"<{uniqueID} changed status: [{status}] {message}")) + .Returns(mod.Object); + } return mod; } } diff --git a/src/StardewModdingAPI/Framework/ModLoading/InvalidModStateException.cs b/src/StardewModdingAPI/Framework/ModLoading/InvalidModStateException.cs new file mode 100644 index 00000000..ab11272a --- /dev/null +++ b/src/StardewModdingAPI/Framework/ModLoading/InvalidModStateException.cs @@ -0,0 +1,14 @@ +using System; + +namespace StardewModdingAPI.Framework.ModLoading +{ + /// An exception which indicates that something went seriously wrong while loading mods, and SMAPI should abort outright. + public class InvalidModStateException : Exception + { + /// Construct an instance. + /// The error message. + /// The underlying exception, if any. + public InvalidModStateException(string message, Exception ex = null) + : base(message, ex) { } + } +} diff --git a/src/StardewModdingAPI/Framework/ModLoading/ModDependencyStatus.cs b/src/StardewModdingAPI/Framework/ModLoading/ModDependencyStatus.cs new file mode 100644 index 00000000..0774b487 --- /dev/null +++ b/src/StardewModdingAPI/Framework/ModLoading/ModDependencyStatus.cs @@ -0,0 +1,18 @@ +namespace StardewModdingAPI.Framework.ModLoading +{ + /// The status of a given mod in the dependency-sorting algorithm. + internal enum ModDependencyStatus + { + /// The mod hasn't been visited yet. + Queued, + + /// The mod is currently being analysed as part of a dependency chain. + Checking, + + /// The mod has already been sorted. + Sorted, + + /// The mod couldn't be sorted due to a metadata issue (e.g. missing dependencies). + Failed + } +} diff --git a/src/StardewModdingAPI/Framework/ModLoading/ModResolver.cs b/src/StardewModdingAPI/Framework/ModLoading/ModResolver.cs index 8efe57d9..2b081edc 100644 --- a/src/StardewModdingAPI/Framework/ModLoading/ModResolver.cs +++ b/src/StardewModdingAPI/Framework/ModLoading/ModResolver.cs @@ -130,26 +130,13 @@ namespace StardewModdingAPI.Framework.ModLoading /// The mods to process. public IEnumerable ProcessDependencies(IEnumerable mods) { - var unsortedMods = mods.ToList(); + mods = mods.ToArray(); var sortedMods = new Stack(); - var visitedMods = new HashSet(); - var currentChain = new List(); - bool success = true; - - foreach (IModMetadata mod in unsortedMods) - { - if (mod.Status == ModMetadataStatus.Failed) - continue; - - success = this.ProcessDependencies(mod, visitedMods, sortedMods, currentChain, unsortedMods); - if (!success) - break; - } - - if (!success) - return new ModMetadata[0]; + var states = mods.ToDictionary(mod => mod, mod => ModDependencyStatus.Queued); + foreach (IModMetadata mod in mods) + this.ProcessDependencies(mods.ToArray(), mod, states, sortedMods, new List()); - return sortedMods.Reverse().ToArray(); + return sortedMods.Reverse(); } @@ -157,73 +144,118 @@ namespace StardewModdingAPI.Framework.ModLoading ** Private methods *********/ /// Sort a mod's dependencies by the order they should be loaded, and remove any mods that can't be loaded due to missing or conflicting dependencies. + /// The full list of mods being validated. /// The mod whose dependencies to process. - /// The mods which have been visited. + /// The dependency state for each mod. /// The list in which to save mods sorted by dependency order. /// The current change of mod dependencies. - /// The mods remaining to sort. - /// Returns whether the mod can be loaded. - private bool ProcessDependencies(IModMetadata mod, HashSet visited, Stack sortedMods, List currentChain, List unsortedMods) + /// Returns the mod dependency status. + private ModDependencyStatus ProcessDependencies(IModMetadata[] mods, IModMetadata mod, IDictionary states, Stack sortedMods, ICollection currentChain) { - // visit mod - if (visited.Contains(mod)) - return true; // already sorted - visited.Add(mod); + // check if already visited + switch (states[mod]) + { + // already sorted or failed + case ModDependencyStatus.Sorted: + case ModDependencyStatus.Failed: + return states[mod]; - // mod already failed - if (mod.Status == ModMetadataStatus.Failed) - return false; + // dependency loop + case ModDependencyStatus.Checking: + // This should never happen. The higher-level mod checks if the dependency is + // already being checked, so it can fail without visiting a mod twice. If this + // case is hit, that logic didn't catch the dependency loop for some reason. + throw new InvalidModStateException($"A dependency loop was not caught by the calling iteration ({string.Join(" => ", currentChain.Select(p => p.DisplayName))} => {mod.DisplayName}))."); - // process dependencies - bool success = true; - if (mod.Manifest.Dependencies != null && mod.Manifest.Dependencies.Any()) + // not visited yet, start processing + case ModDependencyStatus.Queued: + break; + + // sanity check + default: + throw new InvalidModStateException($"Unknown dependency status '{states[mod]}'."); + } + + // no dependencies, mark sorted + if (mod.Manifest.Dependencies == null || !mod.Manifest.Dependencies.Any()) { - // validate required dependencies are present + sortedMods.Push(mod); + return states[mod] = ModDependencyStatus.Sorted; + } + + // missing required dependencies, mark failed + { + string[] missingModIDs = + ( + from dependency in mod.Manifest.Dependencies + where mods.All(m => m.Manifest.UniqueID != dependency.UniqueID) + orderby dependency.UniqueID + select dependency.UniqueID + ) + .ToArray(); + if (missingModIDs.Any()) { - string missingMods = null; - foreach (IManifestDependency dependency in mod.Manifest.Dependencies) - { - if (!unsortedMods.Any(m => m.Manifest.UniqueID.Equals(dependency.UniqueID))) - missingMods += $"{dependency.UniqueID}, "; - } - if (missingMods != null) - { - mod.SetStatus(ModMetadataStatus.Failed, $"it requires mods which aren't installed ({missingMods.Substring(0, missingMods.Length - 2)})."); - return false; - } + sortedMods.Push(mod); + mod.SetStatus(ModMetadataStatus.Failed, $"it requires mods which aren't installed ({string.Join(", ", missingModIDs)})."); + return states[mod] = ModDependencyStatus.Failed; } + } - // get mods which should be loaded before this one + // process dependencies + { + states[mod] = ModDependencyStatus.Checking; + + // get mods to load first IModMetadata[] modsToLoadFirst = ( - from unsorted in unsortedMods - where mod.Manifest.Dependencies.Any(required => required.UniqueID == unsorted.Manifest.UniqueID) - select unsorted + from other in mods + where mod.Manifest.Dependencies.Any(required => required.UniqueID == other.Manifest.UniqueID) + select other ) .ToArray(); - // detect circular references - IModMetadata circularReferenceMod = currentChain.FirstOrDefault(modsToLoadFirst.Contains); - if (circularReferenceMod != null) - { - mod.SetStatus(ModMetadataStatus.Failed, $"its dependencies have a circular reference: {string.Join(" => ", currentChain.Select(p => p.DisplayName))} => {circularReferenceMod.DisplayName})."); - return false; - } - currentChain.Add(mod); - // recursively sort dependencies foreach (IModMetadata requiredMod in modsToLoadFirst) { - success = this.ProcessDependencies(requiredMod, visited, sortedMods, currentChain, unsortedMods); - if (!success) - break; + var subchain = new List(currentChain) { mod }; + + // detect dependency loop + if (states[requiredMod] == ModDependencyStatus.Checking) + { + sortedMods.Push(mod); + mod.SetStatus(ModMetadataStatus.Failed, $"its dependencies have a circular reference: {string.Join(" => ", subchain.Select(p => p.DisplayName))} => {requiredMod.DisplayName})."); + return states[mod] = ModDependencyStatus.Failed; + } + + // recursively process each dependency + var substatus = this.ProcessDependencies(mods, requiredMod, states, sortedMods, subchain); + switch (substatus) + { + // sorted successfully + case ModDependencyStatus.Sorted: + break; + + // failed, which means this mod can't be loaded either + case ModDependencyStatus.Failed: + sortedMods.Push(mod); + mod.SetStatus(ModMetadataStatus.Failed, $"it needs the '{requiredMod.DisplayName}' mod, which couldn't be loaded."); + return states[mod] = ModDependencyStatus.Failed; + + // unexpected status + case ModDependencyStatus.Queued: + case ModDependencyStatus.Checking: + throw new InvalidModStateException($"Something went wrong sorting dependencies: mod '{requiredMod.DisplayName}' unexpectedly stayed in the '{substatus}' status."); + + // sanity check + default: + throw new InvalidModStateException($"Unknown dependency status '{states[mod]}'."); + } } - } - // mark mod sorted - sortedMods.Push(mod); - currentChain.Remove(mod); - return success; + // all requirements sorted successfully + sortedMods.Push(mod); + return states[mod] = ModDependencyStatus.Sorted; + } } /// Get all mod folders in a root folder, passing through empty folders as needed. diff --git a/src/StardewModdingAPI/StardewModdingAPI.csproj b/src/StardewModdingAPI/StardewModdingAPI.csproj index a7362153..61b97baa 100644 --- a/src/StardewModdingAPI/StardewModdingAPI.csproj +++ b/src/StardewModdingAPI/StardewModdingAPI.csproj @@ -122,6 +122,8 @@ + + -- cgit