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.
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).
## 1.14

View File

@ -160,7 +160,7 @@ namespace StardewModdingAPI.Tests
Mock<IModMetadata> mock = new Mock<IModMetadata>(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(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);
// act
@ -177,7 +177,7 @@ namespace StardewModdingAPI.Tests
Mock<IModMetadata> mock = new Mock<IModMetadata>(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.Manifest).Returns(this.GetManifest());
mock.Setup(p => p.DirectoryPath).Returns(Path.GetTempPath());
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()
{
// set up manifest
IManifest manifest = this.GetRandomManifest();
IManifest manifest = this.GetManifest();
// create DLL
string modFolder = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"));
@ -231,9 +231,9 @@ namespace StardewModdingAPI.Tests
{
// arrange
// A B C
Mock<IModMetadata> modA = this.GetMetadataForDependencyTest("Mod A");
Mock<IModMetadata> modB = this.GetMetadataForDependencyTest("Mod B");
Mock<IModMetadata> modC = this.GetMetadataForDependencyTest("Mod C");
Mock<IModMetadata> modA = this.GetMetadata("Mod A");
Mock<IModMetadata> modB = this.GetMetadata("Mod B");
Mock<IModMetadata> modC = this.GetMetadata("Mod C");
// act
IModMetadata[] mods = new ModResolver().ProcessDependencies(new[] { modA.Object, modB.Object, modC.Object }).ToArray();
@ -267,9 +267,9 @@ namespace StardewModdingAPI.Tests
// ▲ ▲
// │ │
// └─ C ─┘
Mock<IModMetadata> modA = this.GetMetadataForDependencyTest("Mod A");
Mock<IModMetadata> modB = this.GetMetadataForDependencyTest("Mod B", dependencies: new[] { "Mod A" });
Mock<IModMetadata> modC = this.GetMetadataForDependencyTest("Mod C", dependencies: new[] { "Mod A", "Mod B" });
Mock<IModMetadata> modA = this.GetMetadata("Mod A");
Mock<IModMetadata> modB = this.GetMetadata("Mod B", dependencies: new[] { "Mod A" });
Mock<IModMetadata> modC = this.GetMetadata("Mod C", dependencies: new[] { "Mod A", "Mod B" });
// act
IModMetadata[] mods = new ModResolver().ProcessDependencies(new[] { modC.Object, modA.Object, modB.Object }).ToArray();
@ -286,10 +286,10 @@ namespace StardewModdingAPI.Tests
{
// arrange
// A ◀── B ◀── C ◀── D
Mock<IModMetadata> modA = this.GetMetadataForDependencyTest("Mod A");
Mock<IModMetadata> modB = this.GetMetadataForDependencyTest("Mod B", dependencies: new[] { "Mod A" });
Mock<IModMetadata> modC = this.GetMetadataForDependencyTest("Mod C", dependencies: new[] { "Mod B" });
Mock<IModMetadata> modD = this.GetMetadataForDependencyTest("Mod D", dependencies: new[] { "Mod C" });
Mock<IModMetadata> modA = this.GetMetadata("Mod A");
Mock<IModMetadata> modB = this.GetMetadata("Mod B", dependencies: new[] { "Mod A" });
Mock<IModMetadata> modC = this.GetMetadata("Mod C", dependencies: new[] { "Mod B" });
Mock<IModMetadata> modD = this.GetMetadata("Mod D", dependencies: new[] { "Mod C" });
// act
IModMetadata[] mods = new ModResolver().ProcessDependencies(new[] { modC.Object, modA.Object, modB.Object, modD.Object }).ToArray();
@ -310,12 +310,12 @@ namespace StardewModdingAPI.Tests
// ▲ ▲
// │ │
// E ◀── F
Mock<IModMetadata> modA = this.GetMetadataForDependencyTest("Mod A");
Mock<IModMetadata> modB = this.GetMetadataForDependencyTest("Mod B", dependencies: new[] { "Mod A" });
Mock<IModMetadata> modC = this.GetMetadataForDependencyTest("Mod C", dependencies: new[] { "Mod B" });
Mock<IModMetadata> modD = this.GetMetadataForDependencyTest("Mod D", dependencies: new[] { "Mod C" });
Mock<IModMetadata> modE = this.GetMetadataForDependencyTest("Mod E", dependencies: new[] { "Mod B" });
Mock<IModMetadata> modF = this.GetMetadataForDependencyTest("Mod F", dependencies: new[] { "Mod C", "Mod E" });
Mock<IModMetadata> modA = this.GetMetadata("Mod A");
Mock<IModMetadata> modB = this.GetMetadata("Mod B", dependencies: new[] { "Mod A" });
Mock<IModMetadata> modC = this.GetMetadata("Mod C", dependencies: new[] { "Mod B" });
Mock<IModMetadata> modD = this.GetMetadata("Mod D", dependencies: new[] { "Mod C" });
Mock<IModMetadata> modE = this.GetMetadata("Mod E", dependencies: new[] { "Mod B" });
Mock<IModMetadata> modF = this.GetMetadata("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();
@ -338,11 +338,11 @@ namespace StardewModdingAPI.Tests
// ▲ │
// │ ▼
// └──── E
Mock<IModMetadata> modA = this.GetMetadataForDependencyTest("Mod A");
Mock<IModMetadata> modB = this.GetMetadataForDependencyTest("Mod B", dependencies: new[] { "Mod A" });
Mock<IModMetadata> modC = this.GetMetadataForDependencyTest("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> modE = this.GetMetadataForDependencyTest("Mod E", dependencies: new[] { "Mod C" }, allowStatusChange: true);
Mock<IModMetadata> modA = this.GetMetadata("Mod A");
Mock<IModMetadata> modB = this.GetMetadata("Mod B", dependencies: new[] { "Mod A" });
Mock<IModMetadata> modC = this.GetMetadata("Mod C", dependencies: new[] { "Mod B", "Mod D" }, allowStatusChange: true);
Mock<IModMetadata> modD = this.GetMetadata("Mod D", dependencies: new[] { "Mod E" }, allowStatusChange: true);
Mock<IModMetadata> modE = this.GetMetadata("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();
@ -361,9 +361,9 @@ namespace StardewModdingAPI.Tests
{
// arrange
// A ◀── B ◀── C D (failed)
Mock<IModMetadata> modA = this.GetMetadataForDependencyTest("Mod A");
Mock<IModMetadata> modB = this.GetMetadataForDependencyTest("Mod B", dependencies: new[] { "Mod A" });
Mock<IModMetadata> modC = this.GetMetadataForDependencyTest("Mod C", dependencies: new[] { "Mod B" }, allowStatusChange: true);
Mock<IModMetadata> modA = this.GetMetadata("Mod A");
Mock<IModMetadata> modB = this.GetMetadata("Mod B", dependencies: new[] { "Mod A" });
Mock<IModMetadata> modC = this.GetMetadata("Mod C", dependencies: new[] { "Mod B" }, allowStatusChange: true);
Mock<IModMetadata> modD = new Mock<IModMetadata>(MockBehavior.Strict);
modD.Setup(p => p.Manifest).Returns<IManifest>(null);
modD.Setup(p => p.Status).Returns(ModMetadataStatus.Failed);
@ -378,13 +378,47 @@ namespace StardewModdingAPI.Tests
Assert.AreSame(modB.Object, mods[2], "The load order is incorrect: mod B should be third since it needs mod A, and is needed by mod C.");
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
*********/
/// <summary>Get a randomised basic manifest.</summary>
/// <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
{
@ -399,28 +433,52 @@ namespace StardewModdingAPI.Tests
return manifest;
}
/// <summary>Get a randomised basic manifest.</summary>
/// <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>
private IManifest GetManifest(string uniqueID, string version, params IManifestDependency[] dependencies)
{
return this.GetManifest(manifest =>
{
manifest.Name = uniqueID;
manifest.UniqueID = uniqueID;
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> GetMetadataForDependencyTest(string uniqueID, string[] dependencies = null, bool allowStatusChange = false)
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(uniqueID);
mod.Setup(p => p.Manifest).Returns(
this.GetRandomManifest(manifest =>
{
manifest.Name = uniqueID;
manifest.UniqueID = uniqueID;
manifest.Dependencies = dependencies?.Select(dependencyID => (IManifestDependency)new ManifestDependency(dependencyID)).ToArray();
})
);
mod.Setup(p => p.DisplayName).Returns(manifest.UniqueID);
mod.Setup(p => p.Manifest).Returns(manifest);
if (allowStatusChange)
{
mod
.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);
}
return mod;

View File

@ -205,20 +205,40 @@ namespace StardewModdingAPI.Framework.ModLoading
return states[mod] = ModDependencyStatus.Sorted;
}
// get dependencies
var dependencies =
(
from entry in mod.Manifest.Dependencies
let dependencyMod = mods.FirstOrDefault(m => string.Equals(m.Manifest?.UniqueID, entry.UniqueID, StringComparison.InvariantCultureIgnoreCase))
orderby entry.UniqueID
select (ID: entry.UniqueID, MinVersion: entry.MinimumVersion, Mod: dependencyMod)
)
.ToArray();
// 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[] failedIDs = (from entry in dependencies where entry.Mod == null select entry.ID).ToArray();
if (failedIDs.Any())
{
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;
}
}
@ -227,16 +247,8 @@ namespace StardewModdingAPI.Framework.ModLoading
{
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
IModMetadata[] modsToLoadFirst = dependencies.Select(p => p.Mod).ToArray();
foreach (IModMetadata requiredMod in modsToLoadFirst)
{
var subchain = new List<IModMetadata>(currentChain) { mod };

View File

@ -9,15 +9,22 @@
/// <summary>The unique mod ID to require.</summary>
public string UniqueID { get; set; }
/// <summary>The minimum required version (if any).</summary>
public ISemanticVersion MinimumVersion { get; set; }
/*********
** Public methods
*********/
/// <summary>Construct an instance.</summary>
/// <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.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>())
{
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();
}

View File

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