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 +++ .../Frame