add support for minimum dependency versions (#286)

This commit is contained in:
Jesse Plamondon-Willard 2017-06-18 18:18:04 -04:00
parent cdac6dad7d
commit 3c3953a7fd
6 changed files with 142 additions and 60 deletions

View File

@ -17,6 +17,7 @@ For players:
* SMAPI will no longer load mods known to be obsolete or unneeded. * SMAPI will no longer load mods known to be obsolete or unneeded.
For modders: For modders:
* You can now specify minimum dependency versions in `manifest.json`.
* Added `System.ValueTuple.dll` to the SMAPI install package so mods can use [C# 7 value tuples](https://docs.microsoft.com/en-us/dotnet/csharp/tuples). * Added `System.ValueTuple.dll` to the SMAPI install package so mods can use [C# 7 value tuples](https://docs.microsoft.com/en-us/dotnet/csharp/tuples).
## 1.14 ## 1.14

View File

@ -160,7 +160,7 @@ namespace StardewModdingAPI.Tests
Mock<IModMetadata> mock = new Mock<IModMetadata>(MockBehavior.Strict); Mock<IModMetadata> mock = new Mock<IModMetadata>(MockBehavior.Strict);
mock.Setup(p => p.Status).Returns(ModMetadataStatus.Found); mock.Setup(p => p.Status).Returns(ModMetadataStatus.Found);
mock.Setup(p => p.Compatibility).Returns(() => null); mock.Setup(p => p.Compatibility).Returns(() => null);
mock.Setup(p => p.Manifest).Returns(this.GetRandomManifest(m => m.MinimumApiVersion = "1.1")); mock.Setup(p => p.Manifest).Returns(this.GetManifest(m => m.MinimumApiVersion = "1.1"));
mock.Setup(p => p.SetStatus(ModMetadataStatus.Failed, It.IsAny<string>())).Returns(() => mock.Object); mock.Setup(p => p.SetStatus(ModMetadataStatus.Failed, It.IsAny<string>())).Returns(() => mock.Object);
// act // act
@ -177,7 +177,7 @@ namespace StardewModdingAPI.Tests
Mock<IModMetadata> mock = new Mock<IModMetadata>(MockBehavior.Strict); Mock<IModMetadata> mock = new Mock<IModMetadata>(MockBehavior.Strict);
mock.Setup(p => p.Status).Returns(ModMetadataStatus.Found); mock.Setup(p => p.Status).Returns(ModMetadataStatus.Found);
mock.Setup(p => p.Compatibility).Returns(() => null); mock.Setup(p => p.Compatibility).Returns(() => null);
mock.Setup(p => p.Manifest).Returns(this.GetRandomManifest()); mock.Setup(p => p.Manifest).Returns(this.GetManifest());
mock.Setup(p => p.DirectoryPath).Returns(Path.GetTempPath()); mock.Setup(p => p.DirectoryPath).Returns(Path.GetTempPath());
mock.Setup(p => p.SetStatus(ModMetadataStatus.Failed, It.IsAny<string>())).Returns(() => mock.Object); mock.Setup(p => p.SetStatus(ModMetadataStatus.Failed, It.IsAny<string>())).Returns(() => mock.Object);
@ -192,7 +192,7 @@ namespace StardewModdingAPI.Tests
public void ValidateManifests_Valid_Passes() public void ValidateManifests_Valid_Passes()
{ {
// set up manifest // set up manifest
IManifest manifest = this.GetRandomManifest(); IManifest manifest = this.GetManifest();
// create DLL // create DLL
string modFolder = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); string modFolder = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"));
@ -231,9 +231,9 @@ namespace StardewModdingAPI.Tests
{ {
// arrange // arrange
// A B C // A B C
Mock<IModMetadata> modA = this.GetMetadataForDependencyTest("Mod A"); Mock<IModMetadata> modA = this.GetMetadata("Mod A");
Mock<IModMetadata> modB = this.GetMetadataForDependencyTest("Mod B"); Mock<IModMetadata> modB = this.GetMetadata("Mod B");
Mock<IModMetadata> modC = this.GetMetadataForDependencyTest("Mod C"); Mock<IModMetadata> modC = this.GetMetadata("Mod C");
// act // act
IModMetadata[] mods = new ModResolver().ProcessDependencies(new[] { modA.Object, modB.Object, modC.Object }).ToArray(); IModMetadata[] mods = new ModResolver().ProcessDependencies(new[] { modA.Object, modB.Object, modC.Object }).ToArray();
@ -267,9 +267,9 @@ namespace StardewModdingAPI.Tests
// ▲ ▲ // ▲ ▲
// │ │ // │ │
// └─ C ─┘ // └─ C ─┘
Mock<IModMetadata> modA = this.GetMetadataForDependencyTest("Mod A"); Mock<IModMetadata> modA = this.GetMetadata("Mod A");
Mock<IModMetadata> modB = this.GetMetadataForDependencyTest("Mod B", dependencies: new[] { "Mod A" }); Mock<IModMetadata> modB = this.GetMetadata("Mod B", dependencies: new[] { "Mod A" });
Mock<IModMetadata> modC = this.GetMetadataForDependencyTest("Mod C", dependencies: new[] { "Mod A", "Mod B" }); Mock<IModMetadata> modC = this.GetMetadata("Mod C", dependencies: new[] { "Mod A", "Mod B" });
// act // act
IModMetadata[] mods = new ModResolver().ProcessDependencies(new[] { modC.Object, modA.Object, modB.Object }).ToArray(); IModMetadata[] mods = new ModResolver().ProcessDependencies(new[] { modC.Object, modA.Object, modB.Object }).ToArray();
@ -286,10 +286,10 @@ namespace StardewModdingAPI.Tests
{ {
// arrange // arrange
// A ◀── B ◀── C ◀── D // A ◀── B ◀── C ◀── D
Mock<IModMetadata> modA = this.GetMetadataForDependencyTest("Mod A"); Mock<IModMetadata> modA = this.GetMetadata("Mod A");
Mock<IModMetadata> modB = this.GetMetadataForDependencyTest("Mod B", dependencies: new[] { "Mod A" }); Mock<IModMetadata> modB = this.GetMetadata("Mod B", dependencies: new[] { "Mod A" });
Mock<IModMetadata> modC = this.GetMetadataForDependencyTest("Mod C", dependencies: new[] { "Mod B" }); Mock<IModMetadata> modC = this.GetMetadata("Mod C", dependencies: new[] { "Mod B" });
Mock<IModMetadata> modD = this.GetMetadataForDependencyTest("Mod D", dependencies: new[] { "Mod C" }); Mock<IModMetadata> modD = this.GetMetadata("Mod D", dependencies: new[] { "Mod C" });
// act // act
IModMetadata[] mods = new ModResolver().ProcessDependencies(new[] { modC.Object, modA.Object, modB.Object, modD.Object }).ToArray(); IModMetadata[] mods = new ModResolver().ProcessDependencies(new[] { modC.Object, modA.Object, modB.Object, modD.Object }).ToArray();
@ -310,12 +310,12 @@ namespace StardewModdingAPI.Tests
// ▲ ▲ // ▲ ▲
// │ │ // │ │
// E ◀── F // E ◀── F
Mock<IModMetadata> modA = this.GetMetadataForDependencyTest("Mod A"); Mock<IModMetadata> modA = this.GetMetadata("Mod A");
Mock<IModMetadata> modB = this.GetMetadataForDependencyTest("Mod B", dependencies: new[] { "Mod A" }); Mock<IModMetadata> modB = this.GetMetadata("Mod B", dependencies: new[] { "Mod A" });
Mock<IModMetadata> modC = this.GetMetadataForDependencyTest("Mod C", dependencies: new[] { "Mod B" }); Mock<IModMetadata> modC = this.GetMetadata("Mod C", dependencies: new[] { "Mod B" });
Mock<IModMetadata> modD = this.GetMetadataForDependencyTest("Mod D", dependencies: new[] { "Mod C" }); Mock<IModMetadata> modD = this.GetMetadata("Mod D", dependencies: new[] { "Mod C" });
Mock<IModMetadata> modE = this.GetMetadataForDependencyTest("Mod E", dependencies: new[] { "Mod B" }); Mock<IModMetadata> modE = this.GetMetadata("Mod E", dependencies: new[] { "Mod B" });
Mock<IModMetadata> modF = this.GetMetadataForDependencyTest("Mod F", dependencies: new[] { "Mod C", "Mod E" }); Mock<IModMetadata> modF = this.GetMetadata("Mod F", dependencies: new[] { "Mod C", "Mod E" });
// act // act
IModMetadata[] mods = new ModResolver().ProcessDependencies(new[] { modC.Object, modA.Object, modB.Object, modD.Object, modF.Object, modE.Object }).ToArray(); IModMetadata[] mods = new ModResolver().ProcessDependencies(new[] { modC.Object, modA.Object, modB.Object, modD.Object, modF.Object, modE.Object }).ToArray();
@ -338,11 +338,11 @@ namespace StardewModdingAPI.Tests
// ▲ │ // ▲ │
// │ ▼ // │ ▼
// └──── E // └──── E
Mock<IModMetadata> modA = this.GetMetadataForDependencyTest("Mod A"); Mock<IModMetadata> modA = this.GetMetadata("Mod A");
Mock<IModMetadata> modB = this.GetMetadataForDependencyTest("Mod B", dependencies: new[] { "Mod A" }); Mock<IModMetadata> modB = this.GetMetadata("Mod B", dependencies: new[] { "Mod A" });
Mock<IModMetadata> modC = this.GetMetadataForDependencyTest("Mod C", dependencies: new[] { "Mod B", "Mod D" }, allowStatusChange: true); Mock<IModMetadata> modC = this.GetMetadata("Mod C", dependencies: new[] { "Mod B", "Mod D" }, allowStatusChange: true);
Mock<IModMetadata> modD = this.GetMetadataForDependencyTest("Mod D", dependencies: new[] { "Mod E" }, allowStatusChange: true); Mock<IModMetadata> modD = this.GetMetadata("Mod D", dependencies: new[] { "Mod E" }, allowStatusChange: true);
Mock<IModMetadata> modE = this.GetMetadataForDependencyTest("Mod E", dependencies: new[] { "Mod C" }, allowStatusChange: true); Mock<IModMetadata> modE = this.GetMetadata("Mod E", dependencies: new[] { "Mod C" }, allowStatusChange: true);
// act // act
IModMetadata[] mods = new ModResolver().ProcessDependencies(new[] { modC.Object, modA.Object, modB.Object, modD.Object, modE.Object }).ToArray(); IModMetadata[] mods = new ModResolver().ProcessDependencies(new[] { modC.Object, modA.Object, modB.Object, modD.Object, modE.Object }).ToArray();
@ -361,9 +361,9 @@ namespace StardewModdingAPI.Tests
{ {
// arrange // arrange
// A ◀── B ◀── C D (failed) // A ◀── B ◀── C D (failed)
Mock<IModMetadata> modA = this.GetMetadataForDependencyTest("Mod A"); Mock<IModMetadata> modA = this.GetMetadata("Mod A");
Mock<IModMetadata> modB = this.GetMetadataForDependencyTest("Mod B", dependencies: new[] { "Mod A" }); Mock<IModMetadata> modB = this.GetMetadata("Mod B", dependencies: new[] { "Mod A" });
Mock<IModMetadata> modC = this.GetMetadataForDependencyTest("Mod C", dependencies: new[] { "Mod B" }, allowStatusChange: true); Mock<IModMetadata> modC = this.GetMetadata("Mod C", dependencies: new[] { "Mod B" }, allowStatusChange: true);
Mock<IModMetadata> modD = new Mock<IModMetadata>(MockBehavior.Strict); Mock<IModMetadata> modD = new Mock<IModMetadata>(MockBehavior.Strict);
modD.Setup(p => p.Manifest).Returns<IManifest>(null); modD.Setup(p => p.Manifest).Returns<IManifest>(null);
modD.Setup(p => p.Status).Returns(ModMetadataStatus.Failed); modD.Setup(p => p.Status).Returns(ModMetadataStatus.Failed);
@ -379,12 +379,46 @@ namespace StardewModdingAPI.Tests
Assert.AreSame(modC.Object, mods[3], "The load order is incorrect: mod C should be fourth since it needs mod B, and is needed by mod D."); Assert.AreSame(modC.Object, mods[3], "The load order is incorrect: mod C should be fourth since it needs mod B, and is needed by mod D.");
} }
[Test(Description = "Assert that dependencies are failed if they don't meet the minimum version.")]
public void ProcessDependencies_WithMinVersions_FailsIfNotMet()
{
// arrange
// A 1.0 ◀── B (need A 1.1)
Mock<IModMetadata> modA = this.GetMetadata(this.GetManifest("Mod A", "1.0"));
Mock<IModMetadata> modB = this.GetMetadata(this.GetManifest("Mod B", "1.0", new ManifestDependency("Mod A", "1.1")), allowStatusChange: true);
// act
IModMetadata[] mods = new ModResolver().ProcessDependencies(new[] { modA.Object, modB.Object }).ToArray();
// assert
Assert.AreEqual(2, mods.Length, 0, "Expected to get the same number of mods input.");
modB.Verify(p => p.SetStatus(ModMetadataStatus.Failed, It.IsAny<string>()), Times.Once, "Mod B unexpectedly didn't fail even though it needs a newer version of Mod A.");
}
[Test(Description = "Assert that dependencies are accepted if they meet the minimum version.")]
public void ProcessDependencies_WithMinVersions_SucceedsIfMet()
{
// arrange
// A 1.0 ◀── B (need A 1.0-beta)
Mock<IModMetadata> modA = this.GetMetadata(this.GetManifest("Mod A", "1.0"));
Mock<IModMetadata> modB = this.GetMetadata(this.GetManifest("Mod B", "1.0", new ManifestDependency("Mod A", "1.0-beta")), allowStatusChange: false);
// act
IModMetadata[] mods = new ModResolver().ProcessDependencies(new[] { modA.Object, modB.Object }).ToArray();
// assert
Assert.AreEqual(2, 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.");
}
/********* /*********
** Private methods ** Private methods
*********/ *********/
/// <summary>Get a randomised basic manifest.</summary> /// <summary>Get a randomised basic manifest.</summary>
/// <param name="adjust">Adjust the generated manifest.</param> /// <param name="adjust">Adjust the generated manifest.</param>
private Manifest GetRandomManifest(Action<Manifest> adjust = null) private Manifest GetManifest(Action<Manifest> adjust = null)
{ {
Manifest manifest = new Manifest Manifest manifest = new Manifest
{ {
@ -401,26 +435,50 @@ namespace StardewModdingAPI.Tests
/// <summary>Get a randomised basic manifest.</summary> /// <summary>Get a randomised basic manifest.</summary>
/// <param name="uniqueID">The mod's name and unique ID.</param> /// <param name="uniqueID">The mod's name and unique ID.</param>
/// <param name="version">The mod version.</param>
/// <param name="dependencies">The dependencies this mod requires.</param> /// <param name="dependencies">The dependencies this mod requires.</param>
/// <param name="allowStatusChange">Whether the code being tested is allowed to change the mod status.</param> private IManifest GetManifest(string uniqueID, string version, params IManifestDependency[] dependencies)
private Mock<IModMetadata> GetMetadataForDependencyTest(string uniqueID, string[] dependencies = null, bool allowStatusChange = false)
{ {
Mock<IModMetadata> mod = new Mock<IModMetadata>(MockBehavior.Strict); return this.GetManifest(manifest =>
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.Name = uniqueID;
manifest.UniqueID = uniqueID; manifest.UniqueID = uniqueID;
manifest.Dependencies = dependencies?.Select(dependencyID => (IManifestDependency)new ManifestDependency(dependencyID)).ToArray(); manifest.Version = new SemanticVersion(version);
}) manifest.Dependencies = dependencies;
); });
}
/// <summary>Get a randomised basic manifest.</summary>
/// <param name="uniqueID">The mod's name and unique ID.</param>
private Mock<IModMetadata> GetMetadata(string uniqueID)
{
return this.GetMetadata(this.GetManifest(uniqueID, "1.0"));
}
/// <summary>Get a randomised basic manifest.</summary>
/// <param name="uniqueID">The mod's name and unique ID.</param>
/// <param name="dependencies">The dependencies this mod requires.</param>
/// <param name="allowStatusChange">Whether the code being tested is allowed to change the mod status.</param>
private Mock<IModMetadata> GetMetadata(string uniqueID, string[] dependencies, bool allowStatusChange = false)
{
IManifest manifest = this.GetManifest(uniqueID, "1.0", dependencies?.Select(dependencyID => (IManifestDependency)new ManifestDependency(dependencyID, null)).ToArray());
return this.GetMetadata(manifest, allowStatusChange);
}
/// <summary>Get a randomised basic manifest.</summary>
/// <param name="manifest">The mod manifest.</param>
/// <param name="allowStatusChange">Whether the code being tested is allowed to change the mod status.</param>
private Mock<IModMetadata> GetMetadata(IManifest manifest, bool allowStatusChange = false)
{
Mock<IModMetadata> mod = new Mock<IModMetadata>(MockBehavior.Strict);
mod.Setup(p => p.Status).Returns(ModMetadataStatus.Found);
mod.Setup(p => p.DisplayName).Returns(manifest.UniqueID);
mod.Setup(p => p.Manifest).Returns(manifest);
if (allowStatusChange) if (allowStatusChange)
{ {
mod mod
.Setup(p => p.SetStatus(It.IsAny<ModMetadataStatus>(), It.IsAny<string>())) .Setup(p => p.SetStatus(It.IsAny<ModMetadataStatus>(), It.IsAny<string>()))
.Callback<ModMetadataStatus, string>((status, message) => Console.WriteLine($"<{uniqueID} changed status: [{status}] {message}")) .Callback<ModMetadataStatus, string>((status, message) => Console.WriteLine($"<{manifest.UniqueID} changed status: [{status}] {message}"))
.Returns(mod.Object); .Returns(mod.Object);
} }
return mod; return mod;

View File

@ -205,20 +205,40 @@ namespace StardewModdingAPI.Framework.ModLoading
return states[mod] = ModDependencyStatus.Sorted; return states[mod] = ModDependencyStatus.Sorted;
} }
// missing required dependencies, mark failed // get dependencies
{ var dependencies =
string[] missingModIDs =
( (
from dependency in mod.Manifest.Dependencies from entry in mod.Manifest.Dependencies
where mods.All(m => m.Manifest?.UniqueID != dependency.UniqueID) let dependencyMod = mods.FirstOrDefault(m => string.Equals(m.Manifest?.UniqueID, entry.UniqueID, StringComparison.InvariantCultureIgnoreCase))
orderby dependency.UniqueID orderby entry.UniqueID
select dependency.UniqueID select (ID: entry.UniqueID, MinVersion: entry.MinimumVersion, Mod: dependencyMod)
) )
.ToArray(); .ToArray();
if (missingModIDs.Any())
// missing required dependencies, mark failed
{
string[] failedIDs = (from entry in dependencies where entry.Mod == null select entry.ID).ToArray();
if (failedIDs.Any())
{ {
sortedMods.Push(mod); sortedMods.Push(mod);
mod.SetStatus(ModMetadataStatus.Failed, $"it requires mods which aren't installed ({string.Join(", ", missingModIDs)})."); mod.SetStatus(ModMetadataStatus.Failed, $"it requires mods which aren't installed ({string.Join(", ", failedIDs)}).");
return states[mod] = ModDependencyStatus.Failed;
}
}
// dependency min version not met, mark failed
{
string[] failedLabels =
(
from entry in dependencies
where entry.MinVersion != null && entry.MinVersion.IsNewerThan(entry.Mod.Manifest.Version)
select $"{entry.Mod.DisplayName} (needs {entry.MinVersion} or later)"
)
.ToArray();
if (failedLabels.Any())
{
sortedMods.Push(mod);
mod.SetStatus(ModMetadataStatus.Failed, $"it needs newer versions of some mods: {string.Join(", ", failedLabels)}.");
return states[mod] = ModDependencyStatus.Failed; return states[mod] = ModDependencyStatus.Failed;
} }
} }
@ -227,16 +247,8 @@ namespace StardewModdingAPI.Framework.ModLoading
{ {
states[mod] = ModDependencyStatus.Checking; states[mod] = ModDependencyStatus.Checking;
// get mods to load first
IModMetadata[] modsToLoadFirst =
(
from other in mods
where mod.Manifest.Dependencies.Any(required => required.UniqueID == other.Manifest?.UniqueID)
select other
)
.ToArray();
// recursively sort dependencies // recursively sort dependencies
IModMetadata[] modsToLoadFirst = dependencies.Select(p => p.Mod).ToArray();
foreach (IModMetadata requiredMod in modsToLoadFirst) foreach (IModMetadata requiredMod in modsToLoadFirst)
{ {
var subchain = new List<IModMetadata>(currentChain) { mod }; var subchain = new List<IModMetadata>(currentChain) { mod };

View File

@ -9,15 +9,22 @@
/// <summary>The unique mod ID to require.</summary> /// <summary>The unique mod ID to require.</summary>
public string UniqueID { get; set; } public string UniqueID { get; set; }
/// <summary>The minimum required version (if any).</summary>
public ISemanticVersion MinimumVersion { get; set; }
/********* /*********
** Public methods ** Public methods
*********/ *********/
/// <summary>Construct an instance.</summary> /// <summary>Construct an instance.</summary>
/// <param name="uniqueID">The unique mod ID to require.</param> /// <param name="uniqueID">The unique mod ID to require.</param>
public ManifestDependency(string uniqueID) /// <param name="minimumVersion">The minimum required version (if any).</param>
public ManifestDependency(string uniqueID, string minimumVersion)
{ {
this.UniqueID = uniqueID; this.UniqueID = uniqueID;
this.MinimumVersion = !string.IsNullOrWhiteSpace(minimumVersion)
? new SemanticVersion(minimumVersion)
: null;
} }
} }
} }

View File

@ -51,7 +51,8 @@ namespace StardewModdingAPI.Framework.Serialisation
foreach (JObject obj in JArray.Load(reader).Children<JObject>()) foreach (JObject obj in JArray.Load(reader).Children<JObject>())
{ {
string uniqueID = obj.Value<string>(nameof(IManifestDependency.UniqueID)); string uniqueID = obj.Value<string>(nameof(IManifestDependency.UniqueID));
result.Add(new ManifestDependency(uniqueID)); string minVersion = obj.Value<string>(nameof(IManifestDependency.MinimumVersion));
result.Add(new ManifestDependency(uniqueID, minVersion));
} }
return result.ToArray(); return result.ToArray();
} }

View File

@ -8,5 +8,8 @@
*********/ *********/
/// <summary>The unique mod ID to require.</summary> /// <summary>The unique mod ID to require.</summary>
string UniqueID { get; } string UniqueID { get; }
/// <summary>The minimum required version (if any).</summary>
ISemanticVersion MinimumVersion { get; }
} }
} }