summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.editorconfig2
-rw-r--r--build/common.targets16
-rw-r--r--docs/README.md2
-rw-r--r--docs/release-notes.md23
-rw-r--r--docs/technical-docs.md4
-rw-r--r--src/SMAPI.Installer/unix-install.sh2
-rw-r--r--src/SMAPI.ModBuildConfig.Analyzer.Tests/SMAPI.ModBuildConfig.Analyzer.Tests.csproj6
-rw-r--r--src/SMAPI.Tests/Core/ModResolverTests.cs3
-rw-r--r--src/SMAPI.Tests/StardewModdingAPI.Tests.csproj4
-rw-r--r--src/SMAPI.Web/Controllers/IndexController.cs7
-rw-r--r--src/SMAPI.Web/Controllers/ModsApiController.cs10
-rw-r--r--src/SMAPI.Web/Framework/ConfigModels/ModUpdateCheckConfig.cs2
-rw-r--r--src/SMAPI.Web/StardewModdingAPI.Web.csproj11
-rw-r--r--src/SMAPI.Web/Startup.cs2
-rw-r--r--src/SMAPI.Web/ViewModels/ModCompatibilityModel.cs2
-rw-r--r--src/SMAPI.Web/ViewModels/ModModel.cs3
-rw-r--r--src/SMAPI.Web/Views/Index/Index.cshtml24
-rw-r--r--src/SMAPI.Web/Views/Index/Privacy.cshtml43
-rw-r--r--src/SMAPI.Web/Views/Mods/Index.cshtml34
-rw-r--r--src/SMAPI.Web/appsettings.json2
-rw-r--r--src/SMAPI.Web/wwwroot/Content/css/index.css5
-rw-r--r--src/SMAPI.Web/wwwroot/Content/css/mods.css47
-rw-r--r--src/SMAPI.Web/wwwroot/Content/css/privacy.css3
-rw-r--r--src/SMAPI.Web/wwwroot/Content/js/mods.js144
-rw-r--r--src/SMAPI.Web/wwwroot/StardewModdingAPI.metadata.json24
-rw-r--r--src/SMAPI/Constants.cs4
-rw-r--r--src/SMAPI/Events/IModEvents.cs3
-rw-r--r--src/SMAPI/Events/IMultiplayerEvents.cs17
-rw-r--r--src/SMAPI/Events/ModMessageReceivedEventArgs.cs46
-rw-r--r--src/SMAPI/Events/PeerContextReceivedEventArgs.cs25
-rw-r--r--src/SMAPI/Events/PeerDisconnectedEventArgs.cs25
-rw-r--r--src/SMAPI/Framework/DeprecationManager.cs49
-rw-r--r--src/SMAPI/Framework/DeprecationWarning.cs38
-rw-r--r--src/SMAPI/Framework/Events/EventManager.cs16
-rw-r--r--src/SMAPI/Framework/Events/ManagedEvent.cs24
-rw-r--r--src/SMAPI/Framework/Events/ManagedEventBase.cs12
-rw-r--r--src/SMAPI/Framework/Events/ModEvents.cs4
-rw-r--r--src/SMAPI/Framework/Events/ModMultiplayerEvents.cs43
-rw-r--r--src/SMAPI/Framework/IModMetadata.cs4
-rw-r--r--src/SMAPI/Framework/ModHelpers/MultiplayerHelper.cs42
-rw-r--r--src/SMAPI/Framework/ModLoading/ModMetadata.cs9
-rw-r--r--src/SMAPI/Framework/ModLoading/ModResolver.cs8
-rw-r--r--src/SMAPI/Framework/ModRegistry.cs2
-rw-r--r--src/SMAPI/Framework/Monitor.cs15
-rw-r--r--src/SMAPI/Framework/Networking/MessageType.cs26
-rw-r--r--src/SMAPI/Framework/Networking/ModMessageModel.cs72
-rw-r--r--src/SMAPI/Framework/Networking/MultiplayerPeer.cs132
-rw-r--r--src/SMAPI/Framework/Networking/MultiplayerPeerMod.cs30
-rw-r--r--src/SMAPI/Framework/Networking/RemoteContextModModel.cs15
-rw-r--r--src/SMAPI/Framework/Networking/RemoteContextModel.cs24
-rw-r--r--src/SMAPI/Framework/Networking/SLidgrenClient.cs49
-rw-r--r--src/SMAPI/Framework/Networking/SLidgrenServer.cs148
-rw-r--r--src/SMAPI/Framework/SCore.cs85
-rw-r--r--src/SMAPI/Framework/SGame.cs56
-rw-r--r--src/SMAPI/Framework/SMultiplayer.cs474
-rw-r--r--src/SMAPI/IMonitor.cs9
-rw-r--r--src/SMAPI/IMultiplayerHelper.cs18
-rw-r--r--src/SMAPI/IMultiplayerPeer.cs41
-rw-r--r--src/SMAPI/IMultiplayerPeerMod.cs15
-rw-r--r--src/SMAPI/Patches/LidgrenServerPatch.cs89
-rw-r--r--src/SMAPI/SemanticVersion.cs19
-rw-r--r--src/SMAPI/StardewModdingAPI.csproj44
-rw-r--r--src/StardewModdingAPI.Toolkit.CoreInterfaces/ISemanticVersion.cs4
-rw-r--r--src/StardewModdingAPI.Toolkit/SemanticVersion.cs19
-rw-r--r--src/StardewModdingAPI.Toolkit/Serialisation/Converters/SemanticVersionConverter.cs2
-rw-r--r--src/StardewModdingAPI.Toolkit/StardewModdingAPI.Toolkit.csproj2
-rw-r--r--src/lib/0Harmony.dllbin99840 -> 0 bytes
-rw-r--r--src/lib/0Harmony.pdbbin323072 -> 0 bytes
68 files changed, 1997 insertions, 187 deletions
diff --git a/.editorconfig b/.editorconfig
index 126fdbd4..5bfc44bd 100644
--- a/.editorconfig
+++ b/.editorconfig
@@ -3,7 +3,7 @@ root: true
##########
## General formatting
-## documentation: http://editorconfig.org
+## documentation: https://editorconfig.org
##########
[*]
indent_style = space
diff --git a/build/common.targets b/build/common.targets
index b5cbbe67..f631633d 100644
--- a/build/common.targets
+++ b/build/common.targets
@@ -54,8 +54,16 @@
<Reference Include="Microsoft.Xna.Framework.Xact, Version=4.0.0.0, Culture=neutral, PublicKeyToken=842cf8be1de50553, processorArchitecture=x86">
<Private>False</Private>
</Reference>
-
+
<!-- game DLLs -->
+ <Reference Include="GalaxyCSharp">
+ <HintPath>$(GamePath)\GalaxyCSharp.dll</HintPath>
+ <Private>False</Private>
+ </Reference>
+ <Reference Include="Lidgren.Network">
+ <HintPath>$(GamePath)\Lidgren.Network.dll</HintPath>
+ <Private>False</Private>
+ </Reference>
<Reference Include="Netcode">
<HintPath>$(GamePath)\Netcode.dll</HintPath>
<Private Condition="'$(MSBuildProjectName)' != 'StardewModdingAPI.Tests'">False</Private>
@@ -79,8 +87,12 @@
<Private>False</Private>
<SpecificVersion>False</SpecificVersion>
</Reference>
-
+
<!-- game DLLs -->
+ <Reference Include="Lidgren.Network">
+ <HintPath>$(GamePath)\Lidgren.Network.dll</HintPath>
+ <Private>False</Private>
+ </Reference>
<Reference Include="StardewValley">
<HintPath>$(GamePath)\StardewValley.exe</HintPath>
<Private>False</Private>
diff --git a/docs/README.md b/docs/README.md
index e7d6d682..b8e3b50b 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -1,4 +1,4 @@
-**SMAPI** is an open-source modding API for [Stardew Valley](http://stardewvalley.net/) that lets
+**SMAPI** is an open-source modding API for [Stardew Valley](https://stardewvalley.net/) that lets
you play the game with mods. It's safely installed alongside the game's executable, and doesn't
change any of your game files. It serves eight main purposes:
diff --git a/docs/release-notes.md b/docs/release-notes.md
index cb83e7ec..9d587ab7 100644
--- a/docs/release-notes.md
+++ b/docs/release-notes.md
@@ -10,7 +10,7 @@
* You can now mark a mod folder ignored by starting the name with a dot (like `.disabled mods`).
* Improved various error messages to be more clear and intuitive.
* SMAPI now prevents a crash caused by mods adding dialogue the game can't parse.
- * When you have an older game version, SMAPI now recommends a compatible SMAPI version in its error.
+ * SMAPI now recommends a compatible SMAPI version if you have an older game version.
* Fixed transparency issues on Linux/Mac for some mod images.
* Fixed error when a mod manifest is corrupted.
* Fixed error when a mod adds an unnamed location.
@@ -21,16 +21,20 @@
* Fixed translation issues not shown as warnings.
* Fixed dependencies not correctly enforced if the dependency is installed but failed to load.
* Fixed some errors logged as SMAPI instead of the affected mod.
+ * Fixed crash log deleted immediately when you relaunch the game.
* Updated compatibility list.
* For the web UI:
* Added a [mod compatibility page](https://mods.smapi.io).
+ * Added [privacy page](https://smapi.io/privacy).
* The log parser now has a separate filter for game messages.
* The log parser now shows content pack authors (thanks to danvolchek!).
- * Corrected log parser instructions for Mac.
+ * Fixed log parser instructions for Mac.
* For modders:
* Added [data API](https://stardewvalleywiki.com/Modding:Modder_Guide/APIs/Data).
+ * Added [multiplayer API](https://stardewvalleywiki.com/Modding:Modder_Guide/APIs/Multiplayer) and [events](https://stardewvalleywiki.com/Modding:Modder_Guide/Apis/Events#Multiplayer_2) to send/receive messages and get connected player info.
+ * Added [verbose logging](https://stardewvalleywiki.com/Modding:Modder_Guide/APIs/Logging#Verbose_logging) feature.
* Added `IContentPack.WriteJsonFile` method.
* Added IntelliSense documentation for the non-developers version of SMAPI.
* Added more events to the prototype `helper.Events` for SMAPI 3.0.
@@ -44,8 +48,13 @@
* Fixed 'no update keys' warning not shown for mods with only invalid update keys.
* Fixed `Context.IsPlayerFree` being true before the player finishes transitioning to a new location in multiplayer.
* Suppressed the game's 'added crickets' debug output.
- * **Breaking change:** `helper.ModRegistry` now returns `IModInfo` instead of `IManifest` directly. This lets SMAPI return more metadata about mods.
- * **Breaking change:** most SMAPI files have been moved into a `smapi-internal` subfolder. This won't affect compiled mod releases, but you'll need to update the build config NuGet package.
+ * Updated dependencies (Harmony 1.0.9.1 → 1.2.0.1, Mono.Cecil 0.10 → 0.10.1).
+ * **Deprecations:**
+ * Non-string manifest versions are now deprecated and will stop working in SMAPI 3.0. Affected mods should use a string version, like `"Version": "1.0.0"`.
+ * `ISemanticVersion.Build` is now deprecated and will be removed in SMAPI 3.0. Affected mods should use `ISemanticVersion.PrereleaseTag` instead.
+ * **Breaking changes:**
+ * `helper.ModRegistry` now returns `IModInfo` instead of `IManifest` directly. This lets SMAPI return more metadata about mods.
+ * Most SMAPI files have been moved into a `smapi-internal` subfolder. This won't affect compiled mod releases, but you'll need to update the build config NuGet package.
* For SMAPI developers:
* Added support for parallel stable/beta unofficial updates in update checks.
@@ -664,7 +673,7 @@ For mod developers:
* The SMAPI log now always uses `\r\n` line endings to simplify crossplatform viewing.
* Fixed `SaveEvents.AfterLoad` being raised during the new-game intro before the player is initialised.
* Fixed SMAPI not recognising `Mod` instances that don't subclass `Mod` directly.
-* Several obsolete APIs have been removed (see [deprecation guide](http://canimod.com/guides/updating-a-smapi-mod)),
+* Several obsolete APIs have been removed (see [migration guides](https://stardewvalleywiki.com/Modding:Index#Migration_guides)),
and all _notice_-level deprecations have been increased to _info_.
* Removed the experimental `IConfigFile`.
@@ -747,7 +756,7 @@ For players:
For developers:
* Deprecated `Version` in favour of `SemanticVersion`.
- _This new implementation is [semver 2.0](http://semver.org/)-compliant, introduces `NewerThan(version)` and `OlderThan(version)` convenience methods, adds support for parsing a version string into a `SemanticVersion`, and fixes various bugs with the former implementation. This also replaces `Manifest` with `IManifest`._
+ _This new implementation is [semver 2.0](https://semver.org/)-compliant, introduces `NewerThan(version)` and `OlderThan(version)` convenience methods, adds support for parsing a version string into a `SemanticVersion`, and fixes various bugs with the former implementation. This also replaces `Manifest` with `IManifest`._
* Increased deprecation levels for `SObject`, `Extensions`, `LogWriter` (not `Log`), `SPlayer`, and `Mod.Entry(ModHelper)` (not `Mod.Entry(IModHelper)`).
## 1.4
@@ -826,7 +835,7 @@ For mod developers:
* Added OS version to log.
* Added zoom-adjusted mouse position to mouse-changed event arguments.
* Added SMAPI code documentation.
- * Switched to [semantic versioning](http://semver.org).
+ * Switched to [semantic versioning](https://semver.org).
* Fixed mod versions not shown correctly in the log.
* Fixed misspelled field in `manifest.json` schema.
* Fixed some events getting wrong data.
diff --git a/docs/technical-docs.md b/docs/technical-docs.md
index be809c3f..08590cba 100644
--- a/docs/technical-docs.md
+++ b/docs/technical-docs.md
@@ -29,7 +29,7 @@ Using an official SMAPI release is recommended for most users.
SMAPI uses some C# 7 code, so you'll need at least
[Visual Studio 2017](https://www.visualstudio.com/vs/community/) on Windows,
-[MonoDevelop 7.0](http://www.monodevelop.com/) on Linux,
+[MonoDevelop 7.0](https://www.monodevelop.com/) on Linux,
[Visual Studio 2017 for Mac](https://www.visualstudio.com/vs/visual-studio-mac/), or an equivalent
IDE to compile it. It uses build configuration derived from the
[crossplatform mod config](https://github.com/Pathoschild/Stardew.ModBuildConfig#readme) to detect
@@ -48,7 +48,7 @@ To prepare a crossplatform SMAPI release, you'll need to compile it on two platf
on the wiki for the first-time setup.
1. Update the version number in `GlobalAssemblyInfo.cs` and `Constants::Version`. Make sure you use a
- [semantic version](http://semver.org). Recommended format:
+ [semantic version](https://semver.org). Recommended format:
build type | format | example
:--------- | :----------------------- | :------
diff --git a/src/SMAPI.Installer/unix-install.sh b/src/SMAPI.Installer/unix-install.sh
index df02bb37..8379ed87 100644
--- a/src/SMAPI.Installer/unix-install.sh
+++ b/src/SMAPI.Installer/unix-install.sh
@@ -16,6 +16,6 @@ fi
if $COMMAND mono >/dev/null 2>&1; then
mono internal/Mono/install.exe
else
- echo "Oops! Looks like Mono isn't installed. Please install Mono from http://mono-project.com, reboot, and run this installer again."
+ echo "Oops! Looks like Mono isn't installed. Please install Mono from https://mono-project.com, reboot, and run this installer again."
read
fi
diff --git a/src/SMAPI.ModBuildConfig.Analyzer.Tests/SMAPI.ModBuildConfig.Analyzer.Tests.csproj b/src/SMAPI.ModBuildConfig.Analyzer.Tests/SMAPI.ModBuildConfig.Analyzer.Tests.csproj
index ca962b6d..4d93df73 100644
--- a/src/SMAPI.ModBuildConfig.Analyzer.Tests/SMAPI.ModBuildConfig.Analyzer.Tests.csproj
+++ b/src/SMAPI.ModBuildConfig.Analyzer.Tests/SMAPI.ModBuildConfig.Analyzer.Tests.csproj
@@ -6,9 +6,9 @@
<ItemGroup>
<PackageReference Include="Microsoft.CodeAnalysis.CSharp.Workspaces" Version="2.8.2" />
- <PackageReference Include="Microsoft.NET.Test.Sdk" Version="15.8.0" />
- <PackageReference Include="NUnit" Version="3.10.1" />
- <PackageReference Include="NUnit3TestAdapter" Version="3.10.0" />
+ <PackageReference Include="Microsoft.NET.Test.Sdk" Version="15.9.0" />
+ <PackageReference Include="NUnit" Version="3.11.0" />
+ <PackageReference Include="NUnit3TestAdapter" Version="3.11.0" />
</ItemGroup>
<ItemGroup>
diff --git a/src/SMAPI.Tests/Core/ModResolverTests.cs b/src/SMAPI.Tests/Core/ModResolverTests.cs
index a38621f8..4a1f04c6 100644
--- a/src/SMAPI.Tests/Core/ModResolverTests.cs
+++ b/src/SMAPI.Tests/Core/ModResolverTests.cs
@@ -145,7 +145,7 @@ namespace StardewModdingAPI.Tests.Core
this.SetupMetadataForValidation(mock, new ModDataRecordVersionedFields
{
Status = ModStatus.AssumeBroken,
- AlternativeUrl = "http://example.org"
+ AlternativeUrl = "https://example.org"
});
// act
@@ -513,6 +513,7 @@ namespace StardewModdingAPI.Tests.Core
mod.Setup(p => p.Status).Returns(ModMetadataStatus.Found);
mod.Setup(p => p.DisplayName).Returns(manifest.UniqueID);
mod.Setup(p => p.Manifest).Returns(manifest);
+ mod.Setup(p => p.HasID(It.IsAny<string>())).Returns((string id) => manifest.UniqueID == id);
if (allowStatusChange)
{
mod
diff --git a/src/SMAPI.Tests/StardewModdingAPI.Tests.csproj b/src/SMAPI.Tests/StardewModdingAPI.Tests.csproj
index e993e914..4ec1a3de 100644
--- a/src/SMAPI.Tests/StardewModdingAPI.Tests.csproj
+++ b/src/SMAPI.Tests/StardewModdingAPI.Tests.csproj
@@ -33,8 +33,8 @@
<PackageReference Include="Castle.Core" Version="4.3.1" />
<PackageReference Include="Moq" Version="4.10.0" />
<PackageReference Include="Newtonsoft.Json" Version="11.0.2" />
- <PackageReference Include="NUnit" Version="3.10.1" />
- <PackageReference Include="System.Runtime.CompilerServices.Unsafe" Version="4.5.1" />
+ <PackageReference Include="NUnit" Version="3.11.0" />
+ <PackageReference Include="System.Runtime.CompilerServices.Unsafe" Version="4.5.2" />
<PackageReference Include="System.Threading.Tasks.Extensions" Version="4.5.1" />
<PackageReference Include="System.ValueTuple" Version="4.5.0" />
</ItemGroup>
diff --git a/src/SMAPI.Web/Controllers/IndexController.cs b/src/SMAPI.Web/Controllers/IndexController.cs
index dadbedd1..d7be664d 100644
--- a/src/SMAPI.Web/Controllers/IndexController.cs
+++ b/src/SMAPI.Web/Controllers/IndexController.cs
@@ -76,6 +76,13 @@ namespace StardewModdingAPI.Web.Controllers
return this.View(model);
}
+ /// <summary>Display the index page.</summary>
+ [HttpGet("/privacy")]
+ public ViewResult Privacy()
+ {
+ return this.View();
+ }
+
/*********
** Private methods
diff --git a/src/SMAPI.Web/Controllers/ModsApiController.cs b/src/SMAPI.Web/Controllers/ModsApiController.cs
index 6e517a97..05568d5e 100644
--- a/src/SMAPI.Web/Controllers/ModsApiController.cs
+++ b/src/SMAPI.Web/Controllers/ModsApiController.cs
@@ -47,8 +47,8 @@ namespace StardewModdingAPI.Web.Controllers
/// <summary>The internal mod metadata list.</summary>
private readonly ModDatabase ModDatabase;
- /// <summary>The web URL for the wiki compatibility list.</summary>
- private readonly string WikiCompatibilityPageUrl;
+ /// <summary>The web URL for the compatibility list.</summary>
+ private readonly string CompatibilityPageUrl;
/*********
@@ -65,7 +65,7 @@ namespace StardewModdingAPI.Web.Controllers
{
this.ModDatabase = new ModToolkit().GetModDatabase(Path.Combine(environment.WebRootPath, "StardewModdingAPI.metadata.json"));
ModUpdateCheckConfig config = configProvider.Value;
- this.WikiCompatibilityPageUrl = config.WikiCompatibilityPageUrl;
+ this.CompatibilityPageUrl = config.CompatibilityPageUrl;
this.Cache = cache;
this.SuccessCacheMinutes = config.SuccessCacheMinutes;
@@ -163,7 +163,7 @@ namespace StardewModdingAPI.Web.Controllers
// get unofficial version
if (wikiEntry?.Compatibility.UnofficialVersion != null && this.IsNewer(wikiEntry.Compatibility.UnofficialVersion, result.Main?.Version) && this.IsNewer(wikiEntry.Compatibility.UnofficialVersion, result.Optional?.Version))
- result.Unofficial = new ModEntryVersionModel(wikiEntry.Compatibility.UnofficialVersion, this.WikiCompatibilityPageUrl);
+ result.Unofficial = new ModEntryVersionModel(wikiEntry.Compatibility.UnofficialVersion, $"{this.CompatibilityPageUrl}/#{wikiEntry.Anchor}");
// get unofficial version for beta
if (wikiEntry?.HasBetaInfo == true)
@@ -174,7 +174,7 @@ namespace StardewModdingAPI.Web.Controllers
if (wikiEntry.BetaCompatibility.UnofficialVersion != null)
{
result.UnofficialForBeta = (wikiEntry.BetaCompatibility.UnofficialVersion != null && this.IsNewer(wikiEntry.BetaCompatibility.UnofficialVersion, result.Main?.Version) && this.IsNewer(wikiEntry.BetaCompatibility.UnofficialVersion, result.Optional?.Version))
- ? new ModEntryVersionModel(wikiEntry.BetaCompatibility.UnofficialVersion, this.WikiCompatibilityPageUrl)
+ ? new ModEntryVersionModel(wikiEntry.BetaCompatibility.UnofficialVersion, $"{this.CompatibilityPageUrl}/#{wikiEntry.Anchor}")
: null;
}
else
diff --git a/src/SMAPI.Web/Framework/ConfigModels/ModUpdateCheckConfig.cs b/src/SMAPI.Web/Framework/ConfigModels/ModUpdateCheckConfig.cs
index 5eef7c55..bde566c0 100644
--- a/src/SMAPI.Web/Framework/ConfigModels/ModUpdateCheckConfig.cs
+++ b/src/SMAPI.Web/Framework/ConfigModels/ModUpdateCheckConfig.cs
@@ -17,6 +17,6 @@ namespace StardewModdingAPI.Web.Framework.ConfigModels
public string SemanticVersionRegex { get; set; }
/// <summary>The web URL for the wiki compatibility list.</summary>
- public string WikiCompatibilityPageUrl { get; set; }
+ public string CompatibilityPageUrl { get; set; }
}
}
diff --git a/src/SMAPI.Web/StardewModdingAPI.Web.csproj b/src/SMAPI.Web/StardewModdingAPI.Web.csproj
index 4814d169..9d1990d9 100644
--- a/src/SMAPI.Web/StardewModdingAPI.Web.csproj
+++ b/src/SMAPI.Web/StardewModdingAPI.Web.csproj
@@ -10,10 +10,10 @@
</ItemGroup>
<ItemGroup>
- <PackageReference Include="HtmlAgilityPack" Version="1.8.7" />
- <PackageReference Include="Markdig" Version="0.15.2" />
- <PackageReference Include="Microsoft.AspNetCore" Version="2.1.3" />
- <PackageReference Include="Microsoft.AspNetCore.Mvc" Version="2.1.2" />
+ <PackageReference Include="HtmlAgilityPack" Version="1.8.9" />
+ <PackageReference Include="Markdig" Version="0.15.4" />
+ <PackageReference Include="Microsoft.AspNetCore" Version="2.1.4" />
+ <PackageReference Include="Microsoft.AspNetCore.Mvc" Version="2.1.3" />
<PackageReference Include="Microsoft.AspNetCore.Rewrite" Version="2.1.1" />
<PackageReference Include="Microsoft.AspNetCore.StaticFiles" Version="2.1.1" />
<PackageReference Include="Microsoft.Extensions.Logging.Debug" Version="2.1.1" />
@@ -27,6 +27,9 @@
<ProjectReference Include="..\StardewModdingAPI.Toolkit\StardewModdingAPI.Toolkit.csproj" />
</ItemGroup>
<ItemGroup>
+ <Content Update="Views\Index\Privacy.cshtml">
+ <Pack>$(IncludeRazorContentInPack)</Pack>
+ </Content>
<Content Update="Views\Mods\Index.cshtml">
<Pack>$(IncludeRazorContentInPack)</Pack>
</Content>
diff --git a/src/SMAPI.Web/Startup.cs b/src/SMAPI.Web/Startup.cs
index 82abf17d..60a16053 100644
--- a/src/SMAPI.Web/Startup.cs
+++ b/src/SMAPI.Web/Startup.cs
@@ -154,7 +154,7 @@ namespace StardewModdingAPI.Web
// shortcut redirects
redirects.Add(new RedirectToUrlRule(@"^/buildmsg(?:/?(.*))$", "https://github.com/Pathoschild/SMAPI/blob/develop/docs/mod-build-config.md#$1"));
- redirects.Add(new RedirectToUrlRule(@"^/compat\.?$", "https://stardewvalleywiki.com/Modding:SMAPI_compatibility"));
+ redirects.Add(new RedirectToUrlRule(@"^/compat\.?$", "https://mods.smapi.io"));
redirects.Add(new RedirectToUrlRule(@"^/docs\.?$", "https://stardewvalleywiki.com/Modding:Index"));
redirects.Add(new RedirectToUrlRule(@"^/install\.?$", "https://stardewvalleywiki.com/Modding:Player_Guide/Getting_Started#Install_SMAPI"));
diff --git a/src/SMAPI.Web/ViewModels/ModCompatibilityModel.cs b/src/SMAPI.Web/ViewModels/ModCompatibilityModel.cs
index 61756176..85bf1e46 100644
--- a/src/SMAPI.Web/ViewModels/ModCompatibilityModel.cs
+++ b/src/SMAPI.Web/ViewModels/ModCompatibilityModel.cs
@@ -29,6 +29,8 @@ namespace StardewModdingAPI.Web.ViewModels
public ModCompatibilityModel(WikiCompatibilityInfo info)
{
this.Status = info.Status.ToString();
+ this.Status = this.Status.Substring(0, 1).ToLower() + this.Status.Substring(1);
+
this.Summary = info.Summary;
this.BrokeIn = info.BrokeIn;
if (info.UnofficialVersion != null)
diff --git a/src/SMAPI.Web/ViewModels/ModModel.cs b/src/SMAPI.Web/ViewModels/ModModel.cs
index 309ed828..0e7d2076 100644
--- a/src/SMAPI.Web/ViewModels/ModModel.cs
+++ b/src/SMAPI.Web/ViewModels/ModModel.cs
@@ -40,6 +40,9 @@ namespace StardewModdingAPI.Web.ViewModels
/// <summary>A unique identifier for the mod that can be used in an anchor URL.</summary>
public string Slug { get; set; }
+ /// <summary>The sites where the mod can be downloaded.</summary>
+ public string[] ModPageSites => this.ModPages.Select(p => p.Text).ToArray();
+
/*********
** Public methods
diff --git a/src/SMAPI.Web/Views/Index/Index.cshtml b/src/SMAPI.Web/Views/Index/Index.cshtml
index a5a82121..01874f50 100644
--- a/src/SMAPI.Web/Views/Index/Index.cshtml
+++ b/src/SMAPI.Web/Views/Index/Index.cshtml
@@ -1,7 +1,10 @@
+@using Microsoft.Extensions.Options
+@using StardewModdingAPI.Web.Framework.ConfigModels
+@inject IOptions<SiteConfig> SiteConfig
+@model StardewModdingAPI.Web.ViewModels.IndexModel
@{
ViewData["Title"] = "SMAPI";
}
-@model StardewModdingAPI.Web.ViewModels.IndexModel
@section Head {
<link rel="stylesheet" href="~/Content/css/index.css?r=20180615" />
<script src="https://cdn.jsdelivr.net/npm/jquery@3.3.1/dist/jquery.min.js" crossorigin="anonymous"></script>
@@ -16,7 +19,7 @@
<div id="call-to-action">
<div class="cta-dropdown">
- <a href="@Model.StableVersion.DownloadUrl" class="main-cta download">Download SMAPI @Model.StableVersion.Version</a><br/>
+ <a href="@Model.StableVersion.DownloadUrl" class="main-cta download">Download SMAPI @Model.StableVersion.Version</a><br />
<div class="dropdown-content">
<a href="https://www.nexusmods.com/stardewvalley/mods/2400"><img src="Content/images/nexus-icon.png" /> Download from Nexus</a>
<a href="@Model.StableVersion.DownloadUrl"><img src="Content/images/direct-download-icon.png" /> Direct download</a>
@@ -30,22 +33,25 @@
Download SMAPI @Model.BetaVersion.Version
@if (!string.IsNullOrWhiteSpace(Model.BetaBlurb))
{
- <br/><small>@Model.BetaBlurb</small>
+ <br /><small>@Model.BetaBlurb</small>
}
- </a><br/>
+ </a><br />
<div class="dropdown-content">
<a href="https://www.nexusmods.com/stardewvalley/mods/2400"><img src="Content/images/nexus-icon.png" /> Download from Nexus</a>
<a href="@Model.BetaVersion.DownloadUrl"><img src="Content/images/direct-download-icon.png" /> Direct download</a>
</div>
</div><br />
}
- <a href="https://stardewvalleywiki.com/Modding:Player_Guide" class="secondary-cta">Player guide</a><br />
+ <div><a href="https://stardewvalleywiki.com/Modding:Player_Guide" class="secondary-cta">Player guide</a></div>
+ <div class="sublinks">
+ <a href="https://github.com/Pathoschild/SMAPI">source code</a> | <a href="@(new UriBuilder(SiteConfig.Value.RootUrl) { Path = "privacy" }.Uri)">privacy</a>
+ </div>
<img id="pufferchick" src="Content/images/pufferchick.png" />
</div>
<h2 id="help">Get help</h2>
<ul>
- <li><a href="https://stardewvalleywiki.com/Modding:SMAPI_compatibility">Mod compatibility list</a></li>
+ <li><a href="@SiteConfig.Value.ModListUrl">Mod compatibility list</a></li>
<li>Get help <a href="https://stardewvalleywiki.com/Modding:Community#Discord">on Discord</a> or <a href="https://community.playstarbound.com/threads/smapi-stardew-modding-api.108375/">in the forums</a></li>
</ul>
@@ -55,7 +61,7 @@
<div class="github-description">
@Html.Raw(Markdig.Markdown.ToHtml(Model.StableVersion.Description))
</div>
- <p>See the <a href="https://github.com/Pathoschild/SMAPI/blob/develop/docs/release-notes.md#release-notes">release notes</a> and <a href="https://stardewvalleywiki.com/Modding:SMAPI_compatibility">mod compatibility list</a> for more info.</p>
+ <p>See the <a href="https://github.com/Pathoschild/SMAPI/blob/develop/docs/release-notes.md#release-notes">release notes</a> and <a href="@SiteConfig.Value.ModListUrl">mod compatibility list</a> for more info.</p>
}
else
{
@@ -64,13 +70,13 @@ else
<div class="github-description">
@Html.Raw(Markdig.Markdown.ToHtml(Model.StableVersion.Description))
</div>
- <p>See the <a href="https://github.com/Pathoschild/SMAPI/blob/develop/docs/release-notes.md#release-notes">release notes</a> and <a href="https://stardewvalleywiki.com/Modding:SMAPI_compatibility">mod compatibility list</a> for more info.</p>
+ <p>See the <a href="https://github.com/Pathoschild/SMAPI/blob/develop/docs/release-notes.md#release-notes">release notes</a> and <a href="@SiteConfig.Value.ModListUrl">mod compatibility list</a> for more info.</p>
<h3>SMAPI @Model.BetaVersion.Version?</h3>
<div class="github-description">
@Html.Raw(Markdig.Markdown.ToHtml(Model.BetaVersion.Description))
</div>
- <p>See the <a href="https://github.com/Pathoschild/SMAPI/blob/develop/docs/release-notes.md#release-notes">release notes</a> and <a href="https://stardewvalleywiki.com/Modding:SMAPI_compatibility">mod compatibility list</a> for more info.</p>
+ <p>See the <a href="https://github.com/Pathoschild/SMAPI/blob/develop/docs/release-notes.md#release-notes">release notes</a> and <a href="@SiteConfig.Value.ModListUrl">mod compatibility list</a> for more info.</p>
}
<h2 id="donate">Donate to support SMAPI ♥</h2>
diff --git a/src/SMAPI.Web/Views/Index/Privacy.cshtml b/src/SMAPI.Web/Views/Index/Privacy.cshtml
new file mode 100644
index 00000000..ca99eef6
--- /dev/null
+++ b/src/SMAPI.Web/Views/Index/Privacy.cshtml
@@ -0,0 +1,43 @@
+@using Microsoft.Extensions.Options
+@using StardewModdingAPI.Web.Framework.ConfigModels
+@inject IOptions<SiteConfig> SiteConfig
+@{
+ ViewData["Title"] = "SMAPI privacy notes";
+}
+@section Head {
+ <link rel="stylesheet" href="~/Content/css/privacy.css" />
+}
+
+&larr; <a href="@SiteConfig.Value.RootUrl">back to SMAPI page</a>
+
+<p>SMAPI is an <a href="https://github.com/Pathoschild/SMAPI">open-source</a> and non-profit project. Your privacy is important, so this page explains what information SMAPI uses and transmits. <strong>This page is informational only, it's not a legal document.</strong></p>
+
+<h2>Principles</h2>
+<ol>
+ <li>SMAPI collects the minimum information needed to enable its features (see below).</li>
+ <li>SMAPI does not collect telemetry, analytics, etc.</li>
+ <li>SMAPI will never sell your information.</li>
+</ol>
+
+<h2>Data collected and transmitted</h2>
+<h3 id="web-logging">Web logging</h3>
+<p>This website and SMAPI's web API are hosted by Amazon Web Services. Their servers may automatically collect diagnostics like your IP address, but this information is not visible to SMAPI's web application or developers. For more information, see the <a href="https://aws.amazon.com/privacy/">Amazon Privacy Notice</a>.</p>
+
+<h3>Update checks</h3>
+<p>SMAPI notifies you when there's a new version of SMAPI or your mods available. To do so, it sends your SMAPI and mod versions to its web API. No personal information is stored by the web application, but see <em><a href="#web-logging">web logging</a></em>.</p>
+
+<p>You can disable update checks, and no information will be transmitted to the web API. To do so:</p>
+<ol>
+ <li><a href="https://stardewvalleywiki.com/Modding:Game_folder">find your game folder</a>;</li>
+ <li>open the <code>smapi-internal/StardewModdingAPI.config.json</code> file in a text editor;</li>
+ <li>change <code>"CheckForUpdates": true</code> to <code>"CheckForUpdates": false</code>.</li>
+</ol>
+
+<h3>Log parser</h3>
+<p>The <a href="https://log.smapi.io/">log parser page</a> lets you store a log file for analysis and sharing. The log data is stored indefinitely in an obfuscated form as unlisted pastes in <a href="https://pastebin.com/">Pastebin</a>. No personal information is stored by the log parser beyond what you choose to upload, but see <em><a href="#web-logging">web logging</a></em> and the <a href="https://pastebin.com/doc_privacy_statement">Pastebin Privacy Statement</a>.</p>
+
+<h3>Multiplayer sync</h3>
+<p>As part of its multiplayer API, SMAPI transmits basic context to players you connect to (mainly your OS, SMAPI version, game version, and installed mods). This is used to enable multiplayer features like inter-mod messages, compatibility checks, etc. Although this information is normally hidden from players, it may be visible due to mods or configuration changes.</p>
+
+<h3>Custom mods</h3>
+<p><strong>Mods may collect and transmit any information. Mods (except those provided as part of the SMAPI download) are not covered by this page. Install third-party mods at your own risk.</strong></p>
diff --git a/src/SMAPI.Web/Views/Mods/Index.cshtml b/src/SMAPI.Web/Views/Mods/Index.cshtml
index b326fd36..372d6706 100644
--- a/src/SMAPI.Web/Views/Mods/Index.cshtml
+++ b/src/SMAPI.Web/Views/Mods/Index.cshtml
@@ -4,11 +4,11 @@
ViewData["Title"] = "SMAPI mod compatibility";
}
@section Head {
- <link rel="stylesheet" href="~/Content/css/mods.css?r=20181021" />
+ <link rel="stylesheet" href="~/Content/css/mods.css?r=20181109" />
<script src="https://cdn.jsdelivr.net/npm/vue@2.5.17/dist/vue.min.js" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/jquery@3.3.1/dist/jquery.min.js" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/tablesorter@2.31.0/dist/js/jquery.tablesorter.combined.min.js" crossorigin="anonymous"></script>
- <script src="~/Content/js/mods.js?r=20181021"></script>
+ <script src="~/Content/js/mods.js?r=20181109"></script>
<script>
$(function() {
var data = @Json.Serialize(Model.Mods, new JsonSerializerSettings { Formatting = Formatting.None });
@@ -29,14 +29,22 @@
</div>
<div id="app">
- <div>
- <label for="search-box">Search: </label>
- <input type="text" id="search-box" v-model="search" v-on:input="applySearch" />
- </div>
- <div id="show-fields-option">
- <input type="checkbox" id="show-all-fields" v-model="showAllFields" />
- <label for="show-all-fields">show advanced fields</label>
+ <div id="options">
+ <div>
+ <label for="search-box">Search: </label>
+ <input type="text" id="search-box" v-model="search" v-on:input="applyFilters" />
+ </div>
+ <div id="filter-area">
+ <input type="checkbox" id="show-advanced" v-model="showAdvanced" />
+ <label for="show-advanced">show detailed options</label>
+ <div id="filters" v-show="showAdvanced">
+ <div v-for="(filterGroup, key) in filters">
+ {{key}}: <span v-for="filter in filterGroup" v-bind:class="{ active: filter.value }"><input type="checkbox" v-bind:id="filter.id" v-model="filter.value" v-on:change="applyFilters" /> <label v-bind:for="filter.id">{{filter.label}}</label></span>
+ </div>
+ </div>
+ </div>
</div>
+ <div id="mod-count" v-show="showAdvanced">{{visibleCount}} mods shown.</div>
<table class="wikitable" id="mod-list">
<thead>
<tr>
@@ -44,8 +52,8 @@
<th>links</th>
<th>author</th>
<th>compatibility</th>
- <th v-show="showAllFields">broke in</th>
- <th v-show="showAllFields">code</th>
+ <th v-show="showAdvanced">broke in</th>
+ <th v-show="showAdvanced">code</th>
<th>&nbsp;</th>
</tr>
</thead>
@@ -72,8 +80,8 @@
</div>
<div v-for="(warning, i) in mod.Warnings">⚠ {{warning}}</div>
</td>
- <td class="mod-broke-in" v-html="mod.BetaCompatibility ? mod.BetaCompatibility.BrokeIn : mod.Compatibility.BrokeIn" v-show="showAllFields"></td>
- <td v-show="showAllFields">
+ <td class="mod-broke-in" v-html="mod.BetaCompatibility ? mod.BetaCompatibility.BrokeIn : mod.Compatibility.BrokeIn" v-show="showAdvanced"></td>
+ <td v-show="showAdvanced">
<span v-if="mod.SourceUrl"><a v-bind:href="mod.SourceUrl">source</a></span>
<span v-else class="mod-closed-source">no source</span>
</td>
diff --git a/src/SMAPI.Web/appsettings.json b/src/SMAPI.Web/appsettings.json
index 401b885f..aba8c448 100644
--- a/src/SMAPI.Web/appsettings.json
+++ b/src/SMAPI.Web/appsettings.json
@@ -48,6 +48,6 @@
"SuccessCacheMinutes": 60,
"ErrorCacheMinutes": 5,
"SemanticVersionRegex": "^(?>(?<major>0|[1-9]\\d*))\\.(?>(?<minor>0|[1-9]\\d*))(?>(?:\\.(?<patch>0|[1-9]\\d*))?)(?:-(?<prerelease>(?>[a-z0-9]+[\\-\\.]?)+))?$",
- "WikiCompatibilityPageUrl": "https://smapi.io/compat"
+ "CompatibilityPageUrl": "https://mods.smapi.io"
}
}
diff --git a/src/SMAPI.Web/wwwroot/Content/css/index.css b/src/SMAPI.Web/wwwroot/Content/css/index.css
index 514e1a5c..979af4af 100644
--- a/src/SMAPI.Web/wwwroot/Content/css/index.css
+++ b/src/SMAPI.Web/wwwroot/Content/css/index.css
@@ -93,6 +93,11 @@ h1 {
display: block;
}
+.sublinks {
+ font-size: 0.9em;
+ margin-bottom: 1em;
+}
+
/*********
** Subsections
*********/
diff --git a/src/SMAPI.Web/wwwroot/Content/css/mods.css b/src/SMAPI.Web/wwwroot/Content/css/mods.css
index 9f82e3e6..730bfc2e 100644
--- a/src/SMAPI.Web/wwwroot/Content/css/mods.css
+++ b/src/SMAPI.Web/wwwroot/Content/css/mods.css
@@ -18,7 +18,6 @@
table.wikitable {
background-color:#f8f9fa;
color:#222;
- margin:1em 0;
border:1px solid #a2a9b1;
border-collapse:collapse
}
@@ -40,10 +39,40 @@ table.wikitable > caption {
font-weight:bold
}
-#show-fields-option {
+#options {
+ margin-bottom: 1em;
+}
+
+#options #filter-area {
opacity: 0.7;
}
+#options #filters {
+ margin-left: 2em;
+ padding-left: 0.5em;
+ border-left: 2px solid gray;
+}
+
+#options #filters span {
+ padding: 2px;
+ margin: 2px;
+ display: inline-block;
+ border-radius: 3px;
+ color: #000;
+ border-color: #880000;
+ background-color: #fcc;
+ font-size: 0.9em;
+}
+
+#options #filters span.active {
+ background: #cfc;
+}
+
+#mod-count {
+ font-size: 0.8em;
+ opacity: 0.5;
+}
+
#mod-list {
font-size: 0.9em;
}
@@ -79,22 +108,22 @@ table.wikitable > caption {
display: block;
}
-#mod-list tr[data-status="Ok"],
-#mod-list tr[data-status="Optional"] {
+#mod-list tr[data-status="ok"],
+#mod-list tr[data-status="optional"] {
background: #BFB;
}
-#mod-list tr[data-status="Workaround"],
-#mod-list tr[data-status="Unofficial"] {
+#mod-list tr[data-status="workaround"],
+#mod-list tr[data-status="unofficial"] {
background: #FFFEC6;
}
-#mod-list tr[data-status="Broken"] {
+#mod-list tr[data-status="broken"] {
background: #FBB;
}
-#mod-list tr[data-status="Obsolete"],
-#mod-list tr[data-status="Abandoned"] {
+#mod-list tr[data-status="obsolete"],
+#mod-list tr[data-status="abandoned"] {
background: #BBB;
opacity: 0.7;
}
diff --git a/src/SMAPI.Web/wwwroot/Content/css/privacy.css b/src/SMAPI.Web/wwwroot/Content/css/privacy.css
new file mode 100644
index 00000000..94bc68a9
--- /dev/null
+++ b/src/SMAPI.Web/wwwroot/Content/css/privacy.css
@@ -0,0 +1,3 @@
+h3 {
+ border: 0;
+}
diff --git a/src/SMAPI.Web/wwwroot/Content/js/mods.js b/src/SMAPI.Web/wwwroot/Content/js/mods.js
index 1af53906..2cff551f 100644
--- a/src/SMAPI.Web/wwwroot/Content/js/mods.js
+++ b/src/SMAPI.Web/wwwroot/Content/js/mods.js
@@ -6,7 +6,76 @@ smapi.modList = function (mods) {
// init data
var data = {
mods: mods,
- showAllFields: false,
+ visibleCount: mods.length,
+ showAdvanced: false,
+ filters: {
+ source: {
+ open: {
+ label: "open",
+ id: "show-open-source",
+ value: true
+ },
+ closed: {
+ label: "closed",
+ id: "show-closed-source",
+ value: true
+ }
+ },
+ status: {
+ ok: {
+ label: "ok",
+ id: "show-status-ok",
+ value: true
+ },
+ optional: {
+ label: "optional",
+ id: "show-status-optional",
+ value: true
+ },
+ unofficial: {
+ label: "unofficial",
+ id: "show-status-unofficial",
+ value: true
+ },
+ workaround: {
+ label: "workaround",
+ id: "show-status-workaround",
+ value: true
+ },
+ broken: {
+ label: "broken",
+ id: "show-status-broken",
+ value: true
+ },
+ abandoned: {
+ label: "abandoned",
+ id: "show-status-abandoned",
+ value: true
+ },
+ obsolete: {
+ label: "obsolete",
+ id: "show-status-obsolete",
+ value: true
+ }
+ },
+ download: {
+ chucklefish: {
+ label: "Chucklefish",
+ id: "show-chucklefish",
+ value: true
+ },
+ nexus: {
+ label: "Nexus",
+ id: "show-nexus",
+ value: true
+ },
+ custom: {
+ label: "custom",
+ id: "show-custom",
+ value: true
+ }
+ }
+ },
search: ""
};
for (var i = 0; i < data.mods.length; i++) {
@@ -54,25 +123,82 @@ smapi.modList = function (mods) {
},
methods: {
/**
- * Update the visibility of all mods based on the current search text.
+ * Update the visibility of all mods based on the current search text and filters.
*/
- applySearch: function () {
+ applyFilters: function () {
// get search terms
var words = data.search.toLowerCase().split(" ");
- // make sure all words match
+ // apply criteria
+ data.visibleCount = data.mods.length;
for (var i = 0; i < data.mods.length; i++) {
var mod = data.mods[i];
- var match = true;
- for (var w = 0; w < words.length; w++) {
- if (mod.SearchableText.indexOf(words[w]) === -1) {
- match = false;
+ mod.Visible = true;
+
+ // check filters
+ if (!this.matchesFilters(mod)) {
+ mod.Visible = false;
+ data.visibleCount--;
+ continue;
+ }
+
+ // check search terms (all search words should match)
+ if (words.length) {
+ for (var w = 0; w < words.length; w++) {
+ if (mod.SearchableText.indexOf(words[w]) === -1) {
+ mod.Visible = false;
+ data.visibleCount--;
+ break;
+ }
+ }
+ }
+ }
+ },
+
+
+ /**
+ * Get whether a mod matches the current filters.
+ * @param {object} mod The mod to check.
+ * @returns {bool} Whether the mod matches the filters.
+ */
+ matchesFilters: function(mod) {
+ var filters = data.filters;
+
+ // check source
+ if (!filters.source.open.value && mod.SourceUrl)
+ return false;
+ if (!filters.source.closed.value && !mod.SourceUrl)
+ return false;
+
+ // check status
+ var status = (mod.BetaCompatibility || mod.Compatibility).Status;
+ if (filters.status[status] && !filters.status[status].value)
+ return false;
+
+ // check download sites
+ var ignoreSites = [];
+
+ if (!filters.download.chucklefish.value)
+ ignoreSites.push("Chucklefish");
+ if (!filters.download.nexus.value)
+ ignoreSites.push("Nexus");
+ if (!filters.download.custom.value)
+ ignoreSites.push("custom");
+
+ if (ignoreSites.length) {
+ var anyLeft = false;
+ for (var i = 0; i < mod.ModPageSites.length; i++) {
+ if (ignoreSites.indexOf(mod.ModPageSites[i]) === -1) {
+ anyLeft = true;
break;
}
}
- mod.Visible = match;
+ if (!anyLeft)
+ return false;
}
+
+ return true;
}
}
});
diff --git a/src/SMAPI.Web/wwwroot/StardewModdingAPI.metadata.json b/src/SMAPI.Web/wwwroot/StardewModdingAPI.metadata.json
index 541dcd91..b16cb99f 100644
--- a/src/SMAPI.Web/wwwroot/StardewModdingAPI.metadata.json
+++ b/src/SMAPI.Web/wwwroot/StardewModdingAPI.metadata.json
@@ -115,6 +115,11 @@
"MapRemoteVersions": { "1.3.1": "1.3" } // manifest not updated
},
+ "BJS Night Sounds": {
+ "ID": "BunnyJumps.BJSNightSounds",
+ "~1.0.0 | Status": "AssumeBroken" // runtime errors with Harmony 1.2.0.1 in SMAPI 2.8+
+ },
+
"Casks Anywhere": {
"ID": "CasksAnywhere",
"MapLocalVersions": { "1.1-alpha": "1.1" }
@@ -194,7 +199,12 @@
"Fishing Adjust": {
"ID": "shuaiz.FishingAdjustMod",
- "~2.0.1 | Status": "AssumeBroken" // Method not found: 'Void Harmony.HarmonyInstance.Patch(System.Reflection.MethodBase, Harmony.HarmonyMethod, Harmony.HarmonyMethod, Harmony.HarmonyMethod)'
+ "~2.0.1 | Status": "AssumeBroken" // Method not found: 'Void Harmony.HarmonyInstance.Patch(System.Reflection.MethodBase, Harmony.HarmonyMethod, Harmony.HarmonyMethod, Harmony.HarmonyMethod)'
+ },
+
+ "Fishing Automaton": {
+ "ID": "Drynwynn.FishingAutomaton",
+ "~1.1 | Status": "AssumeBroken" // runtime errors with Harmony 1.2.0.1 in SMAPI 2.8+
},
"Fix Scythe Exp": {
@@ -245,7 +255,12 @@
"Move Faster": {
"ID": "shuaiz.MoveFasterMod",
- "1.0.1 | Status": "AssumeBroken" // doesn't do anything as of SDV 1.2.33 (bad Harmony patch?)
+ "~1.0.1 | Status": "AssumeBroken" // doesn't do anything as of SDV 1.2.33 (bad Harmony patch?)
+ },
+
+ "MTN": {
+ "ID": "SgtPickles.MTN",
+ "~1.2.5 | Status": "AssumeBroken" // replaces Game1.multiplayer, which breaks SMAPI's multiplayer API.
},
"Multiple Sprites and Portraits On Rotation (File Loading)": {
@@ -258,6 +273,11 @@
"MapLocalVersions": { "2.1": "1.3" } // 1.3 had wrong version in manifest
},
+ "No Added Flying Mine Monsters": {
+ "ID": "Drynwynn.NoAddedFlyingMineMonsters",
+ "~1.1 | Status": "AssumeBroken" // runtime errors with Harmony 1.2.0.1 in SMAPI 2.8+
+ },
+
"No Debug Mode": {
"ID": "NoDebugMode",
"~ | Status": "Obsolete",
diff --git a/src/SMAPI/Constants.cs b/src/SMAPI/Constants.cs
index 83b17401..b9faabdf 100644
--- a/src/SMAPI/Constants.cs
+++ b/src/SMAPI/Constants.cs
@@ -29,7 +29,7 @@ namespace StardewModdingAPI
** Public
****/
/// <summary>SMAPI's current semantic version.</summary>
- public static ISemanticVersion ApiVersion { get; } = new Toolkit.SemanticVersion("2.8.0-beta.5");
+ public static ISemanticVersion ApiVersion { get; } = new Toolkit.SemanticVersion("2.8.0-beta.6");
/// <summary>The minimum supported version of Stardew Valley.</summary>
public static ISemanticVersion MinimumGameVersion { get; } = new GameVersion("1.3.31");
@@ -82,7 +82,7 @@ namespace StardewModdingAPI
/// <summary>The filename extension for SMAPI log files.</summary>
internal static string LogExtension { get; } = "txt";
- /// <summary>A copy of the log leading up to the previous fatal crash, if any.</summary>
+ /// <summary>The file path for the log containing the previous fatal crash, if any.</summary>
internal static string FatalCrashLog => Path.Combine(Constants.LogDir, "SMAPI-crash.txt");
/// <summary>The file path which stores a fatal crash message for the next run.</summary>
diff --git a/src/SMAPI/Events/IModEvents.cs b/src/SMAPI/Events/IModEvents.cs
index 76da7751..bd7ab880 100644
--- a/src/SMAPI/Events/IModEvents.cs
+++ b/src/SMAPI/Events/IModEvents.cs
@@ -12,6 +12,9 @@ namespace StardewModdingAPI.Events
/// <summary>Events raised when the player provides input using a controller, keyboard, or mouse.</summary>
IInputEvents Input { get; }
+ /// <summary>Events raised for multiplayer messages and connections.</summary>
+ IMultiplayerEvents Multiplayer { get; }
+
/// <summary>Events raised when the player data changes.</summary>
IPlayerEvents Player { get; }
diff --git a/src/SMAPI/Events/IMultiplayerEvents.cs b/src/SMAPI/Events/IMultiplayerEvents.cs
new file mode 100644
index 00000000..4a31f48e
--- /dev/null
+++ b/src/SMAPI/Events/IMultiplayerEvents.cs
@@ -0,0 +1,17 @@
+using System;
+
+namespace StardewModdingAPI.Events
+{
+ /// <summary>Events raised for multiplayer messages and connections.</summary>
+ public interface IMultiplayerEvents
+ {
+ /// <summary>Raised after the mod context for a peer is received. This happens before the game approves the connection, so the player doesn't yet exist in the game. This is the earliest point where messages can be sent to the peer via SMAPI.</summary>
+ event EventHandler<PeerContextReceivedEventArgs> PeerContextReceived;
+
+ /// <summary>Raised after a mod message is received over the network.</summary>
+ event EventHandler<ModMessageReceivedEventArgs> ModMessageReceived;
+
+ /// <summary>Raised after the connection with a peer is severed.</summary>
+ event EventHandler<PeerDisconnectedEventArgs> PeerDisconnected;
+ }
+}
diff --git a/src/SMAPI/Events/ModMessageReceivedEventArgs.cs b/src/SMAPI/Events/ModMessageReceivedEventArgs.cs
new file mode 100644
index 00000000..49366ec6
--- /dev/null
+++ b/src/SMAPI/Events/ModMessageReceivedEventArgs.cs
@@ -0,0 +1,46 @@
+using System;
+using StardewModdingAPI.Framework.Networking;
+
+namespace StardewModdingAPI.Events
+{
+ /// <summary>Event arguments for an <see cref="IMultiplayerEvents.ModMessageReceived"/> event.</summary>
+ public class ModMessageReceivedEventArgs : EventArgs
+ {
+ /*********
+ ** Properties
+ *********/
+ /// <summary>The underlying message model.</summary>
+ private readonly ModMessageModel Message;
+
+
+ /*********
+ ** Accessors
+ *********/
+ /// <summary>The unique ID of the player from whose computer the message was sent.</summary>
+ public long FromPlayerID => this.Message.FromPlayerID;
+
+ /// <summary>The unique ID of the mod which sent the message.</summary>
+ public string FromModID => this.Message.FromModID;
+
+ /// <summary>A message type which can be used to decide whether it's the one you want to handle, like <c>SetPlayerLocation</c>. This doesn't need to be globally unique, so mods should check the <see cref="FromModID"/>.</summary>
+ public string Type => this.Message.Type;
+
+
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Construct an instance.</summary>
+ /// <param name="message">The received message.</param>
+ internal ModMessageReceivedEventArgs(ModMessageModel message)
+ {
+ this.Message = message;
+ }
+
+ /// <summary>Read the message data into the given model type.</summary>
+ /// <typeparam name="TModel">The message model type.</typeparam>
+ public TModel ReadAs<TModel>()
+ {
+ return this.Message.Data.ToObject<TModel>();
+ }
+ }
+}
diff --git a/src/SMAPI/Events/PeerContextReceivedEventArgs.cs b/src/SMAPI/Events/PeerContextReceivedEventArgs.cs
new file mode 100644
index 00000000..151a295c
--- /dev/null
+++ b/src/SMAPI/Events/PeerContextReceivedEventArgs.cs
@@ -0,0 +1,25 @@
+using System;
+
+namespace StardewModdingAPI.Events
+{
+ /// <summary>Event arguments for an <see cref="IMultiplayerEvents.PeerContextReceived"/> event.</summary>
+ public class PeerContextReceivedEventArgs : EventArgs
+ {
+ /*********
+ ** Accessors
+ *********/
+ /// <summary>The peer whose metadata was received.</summary>
+ public IMultiplayerPeer Peer { get; }
+
+
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Construct an instance.</summary>
+ /// <param name="peer">The peer whose metadata was received.</param>
+ internal PeerContextReceivedEventArgs(IMultiplayerPeer peer)
+ {
+ this.Peer = peer;
+ }
+ }
+}
diff --git a/src/SMAPI/Events/PeerDisconnectedEventArgs.cs b/src/SMAPI/Events/PeerDisconnectedEventArgs.cs
new file mode 100644
index 00000000..8517988a
--- /dev/null
+++ b/src/SMAPI/Events/PeerDisconnectedEventArgs.cs
@@ -0,0 +1,25 @@
+using System;
+
+namespace StardewModdingAPI.Events
+{
+ /// <summary>Event arguments for an <see cref="IMultiplayerEvents.PeerDisconnected"/> event.</summary>
+ public class PeerDisconnectedEventArgs : EventArgs
+ {
+ /*********
+ ** Accessors
+ *********/
+ /// <summary>The peer who disconnected.</summary>
+ public IMultiplayerPeer Peer { get; }
+
+
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Construct an instance.</summary>
+ /// <param name="peer">The peer who disconnected.</param>
+ internal PeerDisconnectedEventArgs(IMultiplayerPeer peer)
+ {
+ this.Peer = peer;
+ }
+ }
+}
diff --git a/src/SMAPI/Framework/DeprecationManager.cs b/src/SMAPI/Framework/DeprecationManager.cs
index 7a824a05..0fde67ee 100644
--- a/src/SMAPI/Framework/DeprecationManager.cs
+++ b/src/SMAPI/Framework/DeprecationManager.cs
@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
+using System.Linq;
namespace StardewModdingAPI.Framework
{
@@ -18,6 +19,9 @@ namespace StardewModdingAPI.Framework
/// <summary>Tracks the installed mods.</summary>
private readonly ModRegistry ModRegistry;
+ /// <summary>The queued deprecation warnings to display.</summary>
+ private readonly IList<DeprecationWarning> QueuedWarnings = new List<DeprecationWarning>();
+
/*********
** Public methods
@@ -51,29 +55,40 @@ namespace StardewModdingAPI.Framework
if (!this.MarkWarned(source ?? "<unknown>", nounPhrase, version))
return;
- // build message
- string message = $"{source ?? "An unknown mod"} uses deprecated code ({nounPhrase} is deprecated since SMAPI {version}).";
- if (source == null)
- message += $"{Environment.NewLine}{Environment.StackTrace}";
+ // queue warning
+ this.QueuedWarnings.Add(new DeprecationWarning(source, nounPhrase, version, severity));
+ }
- // log message
- switch (severity)
+ /// <summary>Print any queued messages.</summary>
+ public void PrintQueued()
+ {
+ foreach (DeprecationWarning warning in this.QueuedWarnings.OrderBy(p => p.ModName).ThenBy(p => p.NounPhrase))
{
- case DeprecationLevel.Notice:
- this.Monitor.Log(message, LogLevel.Trace);
- break;
+ // build message
+ string message = $"{warning.ModName ?? "An unknown mod"} uses deprecated code ({warning.NounPhrase} is deprecated since SMAPI {warning.Version}).";
+ if (warning.ModName == null)
+ message += $"{Environment.NewLine}{Environment.StackTrace}";
+
+ // log message
+ switch (warning.Level)
+ {
+ case DeprecationLevel.Notice:
+ this.Monitor.Log(message, LogLevel.Trace);
+ break;
- case DeprecationLevel.Info:
- this.Monitor.Log(message, LogLevel.Debug);
- break;
+ case DeprecationLevel.Info:
+ this.Monitor.Log(message, LogLevel.Debug);
+ break;
- case DeprecationLevel.PendingRemoval:
- this.Monitor.Log(message, LogLevel.Warn);
- break;
+ case DeprecationLevel.PendingRemoval:
+ this.Monitor.Log(message, LogLevel.Warn);
+ break;
- default:
- throw new NotSupportedException($"Unknown deprecation level '{severity}'");
+ default:
+ throw new NotSupportedException($"Unknown deprecation level '{warning.Level}'.");
+ }
}
+ this.QueuedWarnings.Clear();
}
/// <summary>Mark a deprecation warning as already logged.</summary>
diff --git a/src/SMAPI/Framework/DeprecationWarning.cs b/src/SMAPI/Framework/DeprecationWarning.cs
new file mode 100644
index 00000000..25415012
--- /dev/null
+++ b/src/SMAPI/Framework/DeprecationWarning.cs
@@ -0,0 +1,38 @@
+namespace StardewModdingAPI.Framework
+{
+ /// <summary>A deprecation warning for a mod.</summary>
+ internal class DeprecationWarning
+ {
+ /*********
+ ** Accessors
+ *********/
+ /// <summary>The affected mod's display name.</summary>
+ public string ModName { get; }
+
+ /// <summary>A noun phrase describing what is deprecated.</summary>
+ public string NounPhrase { get; }
+
+ /// <summary>The SMAPI version which deprecated it.</summary>
+ public string Version { get; }
+
+ /// <summary>The deprecation level for the affected code.</summary>
+ public DeprecationLevel Level { get; }
+
+
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Construct an instance.</summary>
+ /// <param name="modName">The affected mod's display name.</param>
+ /// <param name="nounPhrase">A noun phrase describing what is deprecated.</param>
+ /// <param name="version">The SMAPI version which deprecated it.</param>
+ /// <param name="level">The deprecation level for the affected code.</param>
+ public DeprecationWarning(string modName, string nounPhrase, string version, DeprecationLevel level)
+ {
+ this.ModName = modName;
+ this.NounPhrase = nounPhrase;
+ this.Version = version;
+ this.Level = level;
+ }
+ }
+}
diff --git a/src/SMAPI/Framework/Events/EventManager.cs b/src/SMAPI/Framework/Events/EventManager.cs
index 31b0346a..b9d1c453 100644
--- a/src/SMAPI/Framework/Events/EventManager.cs
+++ b/src/SMAPI/Framework/Events/EventManager.cs
@@ -99,6 +99,18 @@ namespace StardewModdingAPI.Framework.Events
public readonly ManagedEvent<MouseWheelScrolledEventArgs> MouseWheelScrolled;
/****
+ ** Multiplayer
+ ****/
+ /// <summary>Raised after the mod context for a peer is received. This happens before the game approves the connection, so the player doesn't yet exist in the game. This is the earliest point where messages can be sent to the peer via SMAPI.</summary>
+ public readonly ManagedEvent<PeerContextReceivedEventArgs> PeerContextReceived;
+
+ /// <summary>Raised after a mod message is received over the network.</summary>
+ public readonly ManagedEvent<ModMessageReceivedEventArgs> ModMessageReceived;
+
+ /// <summary>Raised after the connection with a peer is severed.</summary>
+ public readonly ManagedEvent<PeerDisconnectedEventArgs> PeerDisconnected;
+
+ /****
** Player
****/
/// <summary>Raised after items are added or removed to a player's inventory.</summary>
@@ -374,6 +386,10 @@ namespace StardewModdingAPI.Framework.Events
this.CursorMoved = ManageEventOf<CursorMovedEventArgs>(nameof(IModEvents.Input), nameof(IInputEvents.CursorMoved));
this.MouseWheelScrolled = ManageEventOf<MouseWheelScrolledEventArgs>(nameof(IModEvents.Input), nameof(IInputEvents.MouseWheelScrolled));
+ this.PeerContextReceived = ManageEventOf<PeerContextReceivedEventArgs>(nameof(IModEvents.Multiplayer), nameof(IMultiplayerEvents.PeerContextReceived));
+ this.ModMessageReceived = ManageEventOf<ModMessageReceivedEventArgs>(nameof(IModEvents.Multiplayer), nameof(IMultiplayerEvents.ModMessageReceived));
+ this.PeerDisconnected = ManageEventOf<PeerDisconnectedEventArgs>(nameof(IModEvents.Multiplayer), nameof(IMultiplayerEvents.PeerDisconnected));
+
this.InventoryChanged = ManageEventOf<InventoryChangedEventArgs>(nameof(IModEvents.Player), nameof(IPlayerEvents.InventoryChanged));
this.LevelChanged = ManageEventOf<LevelChangedEventArgs>(nameof(IModEvents.Player), nameof(IPlayerEvents.LevelChanged));
this.Warped = ManageEventOf<WarpedEventArgs>(nameof(IModEvents.Player), nameof(IPlayerEvents.Warped));
diff --git a/src/SMAPI/Framework/Events/ManagedEvent.cs b/src/SMAPI/Framework/Events/ManagedEvent.cs
index c1ebf6c7..65f6e38e 100644
--- a/src/SMAPI/Framework/Events/ManagedEvent.cs
+++ b/src/SMAPI/Framework/Events/ManagedEvent.cs
@@ -67,6 +67,30 @@ namespace StardewModdingAPI.Framework.Events
}
}
}
+
+ /// <summary>Raise the event and notify all handlers.</summary>
+ /// <param name="args">The event arguments to pass.</param>
+ /// <param name="match">A lambda which returns true if the event should be raised for the given mod.</param>
+ public void RaiseForMods(TEventArgs args, Func<IModMetadata, bool> match)
+ {
+ if (this.Event == null)
+ return;
+
+ foreach (EventHandler<TEventArgs> handler in this.CachedInvocationList)
+ {
+ if (match(this.GetSourceMod(handler)))
+ {
+ try
+ {
+ handler.Invoke(null, args);
+ }
+ catch (Exception ex)
+ {
+ this.LogError(handler, ex);
+ }
+ }
+ }
+ }
}
/// <summary>An event wrapper which intercepts and logs errors in handler code.</summary>
diff --git a/src/SMAPI/Framework/Events/ManagedEventBase.cs b/src/SMAPI/Framework/Events/ManagedEventBase.cs
index f3a278dc..defd903a 100644
--- a/src/SMAPI/Framework/Events/ManagedEventBase.cs
+++ b/src/SMAPI/Framework/Events/ManagedEventBase.cs
@@ -69,12 +69,22 @@ namespace StardewModdingAPI.Framework.Events
this.SourceMods.Remove(handler);
}
+ /// <summary>Get the mod which registered the given event handler, if available.</summary>
+ /// <param name="handler">The event handler.</param>
+ protected IModMetadata GetSourceMod(TEventHandler handler)
+ {
+ return this.SourceMods.TryGetValue(handler, out IModMetadata mod)
+ ? mod
+ : null;
+ }
+
/// <summary>Log an exception from an event handler.</summary>
/// <param name="handler">The event handler instance.</param>
/// <param name="ex">The exception that was raised.</param>
protected void LogError(TEventHandler handler, Exception ex)
{
- if (this.SourceMods.TryGetValue(handler, out IModMetadata mod))
+ IModMetadata mod = this.GetSourceMod(handler);
+ if (mod != null)
mod.LogAsMod($"This mod failed in the {this.EventName} event. Technical details: \n{ex.GetLogSummary()}", LogLevel.Error);
else
this.Monitor.Log($"A mod failed in the {this.EventName} event. Technical details: \n{ex.GetLogSummary()}", LogLevel.Error);
diff --git a/src/SMAPI/Framework/Events/ModEvents.cs b/src/SMAPI/Framework/Events/ModEvents.cs
index 7a318e8b..8ad3936c 100644
--- a/src/SMAPI/Framework/Events/ModEvents.cs
+++ b/src/SMAPI/Framework/Events/ModEvents.cs
@@ -17,6 +17,9 @@ namespace StardewModdingAPI.Framework.Events
/// <summary>Events raised when the player provides input using a controller, keyboard, or mouse.</summary>
public IInputEvents Input { get; }
+ /// <summary>Events raised for multiplayer messages and connections.</summary>
+ public IMultiplayerEvents Multiplayer { get; }
+
/// <summary>Events raised when the player data changes.</summary>
public IPlayerEvents Player { get; }
@@ -38,6 +41,7 @@ namespace StardewModdingAPI.Framework.Events
this.Display = new ModDisplayEvents(mod, eventManager);
this.GameLoop = new ModGameLoopEvents(mod, eventManager);
this.Input = new ModInputEvents(mod, eventManager);
+ this.Multiplayer = new ModMultiplayerEvents(mod, eventManager);
this.Player = new ModPlayerEvents(mod, eventManager);
this.World = new ModWorldEvents(mod, eventManager);
this.Specialised = new ModSpecialisedEvents(mod, eventManager);
diff --git a/src/SMAPI/Framework/Events/ModMultiplayerEvents.cs b/src/SMAPI/Framework/Events/ModMultiplayerEvents.cs
new file mode 100644
index 00000000..152c4e0c
--- /dev/null
+++ b/src/SMAPI/Framework/Events/ModMultiplayerEvents.cs
@@ -0,0 +1,43 @@
+using System;
+using StardewModdingAPI.Events;
+
+namespace StardewModdingAPI.Framework.Events
+{
+ /// <summary>Events raised for multiplayer messages and connections.</summary>
+ internal class ModMultiplayerEvents : ModEventsBase, IMultiplayerEvents
+ {
+ /*********
+ ** Accessors
+ *********/
+ /// <summary>Raised after the mod context for a peer is received. This happens before the game approves the connection, so the player doesn't yet exist in the game. This is the earliest point where messages can be sent to the peer via SMAPI.</summary>
+ public event EventHandler<PeerContextReceivedEventArgs> PeerContextReceived
+ {
+ add => this.EventManager.PeerContextReceived.Add(value);
+ remove => this.EventManager.PeerContextReceived.Remove(value);
+ }
+
+ /// <summary>Raised after a mod message is received over the network.</summary>
+ public event EventHandler<ModMessageReceivedEventArgs> ModMessageReceived
+ {
+ add => this.EventManager.ModMessageReceived.Add(value);
+ remove => this.EventManager.ModMessageReceived.Remove(value);
+ }
+
+ /// <summary>Raised after the connection with a peer is severed.</summary>
+ public event EventHandler<PeerDisconnectedEventArgs> PeerDisconnected
+ {
+ add => this.EventManager.PeerDisconnected.Add(value);
+ remove => this.EventManager.PeerDisconnected.Remove(value);
+ }
+
+
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Construct an instance.</summary>
+ /// <param name="mod">The mod which uses this instance.</param>
+ /// <param name="eventManager">The underlying event manager.</param>
+ internal ModMultiplayerEvents(IModMetadata mod, EventManager eventManager)
+ : base(mod, eventManager) { }
+ }
+}
diff --git a/src/SMAPI/Framework/IModMetadata.cs b/src/SMAPI/Framework/IModMetadata.cs
index bda9429f..7ada7dea 100644
--- a/src/SMAPI/Framework/IModMetadata.cs
+++ b/src/SMAPI/Framework/IModMetadata.cs
@@ -88,6 +88,10 @@ namespace StardewModdingAPI.Framework
/// <summary>Whether the mod has an ID (regardless of whether the ID is valid or the mod itself was loaded).</summary>
bool HasID();
+ /// <summary>Whether the mod has the given ID.</summary>
+ /// <param name="id">The mod ID to check.</param>
+ bool HasID(string id);
+
/// <summary>Get the defined update keys.</summary>
/// <param name="validOnly">Only return valid update keys.</param>
IEnumerable<UpdateKey> GetUpdateKeys(bool validOnly = true);
diff --git a/src/SMAPI/Framework/ModHelpers/MultiplayerHelper.cs b/src/SMAPI/Framework/ModHelpers/MultiplayerHelper.cs
index c449a51b..eedad0bc 100644
--- a/src/SMAPI/Framework/ModHelpers/MultiplayerHelper.cs
+++ b/src/SMAPI/Framework/ModHelpers/MultiplayerHelper.cs
@@ -1,4 +1,6 @@
+using System;
using System.Collections.Generic;
+using StardewModdingAPI.Framework.Networking;
using StardewValley;
namespace StardewModdingAPI.Framework.ModHelpers
@@ -25,16 +27,50 @@ namespace StardewModdingAPI.Framework.ModHelpers
this.Multiplayer = multiplayer;
}
+ /// <summary>Get a new multiplayer ID.</summary>
+ public long GetNewID()
+ {
+ return this.Multiplayer.getNewID();
+ }
+
/// <summary>Get the locations which are being actively synced from the host.</summary>
public IEnumerable<GameLocation> GetActiveLocations()
{
return this.Multiplayer.activeLocations();
}
- /// <summary>Get a new multiplayer ID.</summary>
- public long GetNewID()
+ /// <summary>Get a connected player.</summary>
+ /// <param name="id">The player's unique ID.</param>
+ /// <returns>Returns the connected player, or <c>null</c> if no such player is connected.</returns>
+ public IMultiplayerPeer GetConnectedPlayer(long id)
{
- return this.Multiplayer.getNewID();
+ return this.Multiplayer.Peers.TryGetValue(id, out MultiplayerPeer peer)
+ ? peer
+ : null;
+ }
+
+ /// <summary>Get all connected players.</summary>
+ public IEnumerable<IMultiplayerPeer> GetConnectedPlayers()
+ {
+ return this.Multiplayer.Peers.Values;
+ }
+
+ /// <summary>Send a message to mods installed by connected players.</summary>
+ /// <typeparam name="TMessage">The data type. This can be a class with a default constructor, or a value type.</typeparam>
+ /// <param name="message">The data to send over the network.</param>
+ /// <param name="messageType">A message type which receiving mods can use to decide whether it's the one they want to handle, like <c>SetPlayerLocation</c>. This doesn't need to be globally unique, since mods should check the originating mod ID.</param>
+ /// <param name="modIDs">The mod IDs which should receive the message on the destination computers, or <c>null</c> for all mods. Specifying mod IDs is recommended to improve performance, unless it's a general-purpose broadcast.</param>
+ /// <param name="playerIDs">The <see cref="Farmer.UniqueMultiplayerID" /> values for the players who should receive the message, or <c>null</c> for all players. If you don't need to broadcast to all players, specifying player IDs is recommended to reduce latency.</param>
+ /// <exception cref="ArgumentNullException">The <paramref name="message"/> or <paramref name="messageType" /> is null.</exception>
+ public void SendMessage<TMessage>(TMessage message, string messageType, string[] modIDs = null, long[] playerIDs = null)
+ {
+ this.Multiplayer.BroadcastModMessage(
+ message: message,
+ messageType: messageType,
+ fromModID: this.ModID,
+ toModIDs: modIDs,
+ toPlayerIDs: playerIDs
+ );
}
}
}
diff --git a/src/SMAPI/Framework/ModLoading/ModMetadata.cs b/src/SMAPI/Framework/ModLoading/ModMetadata.cs
index 04aa679b..0cb62a75 100644
--- a/src/SMAPI/Framework/ModLoading/ModMetadata.cs
+++ b/src/SMAPI/Framework/ModLoading/ModMetadata.cs
@@ -153,6 +153,15 @@ namespace StardewModdingAPI.Framework.ModLoading
&& !string.IsNullOrWhiteSpace(this.Manifest.UniqueID);
}
+ /// <summary>Whether the mod has the given ID.</summary>
+ /// <param name="id">The mod ID to check.</param>
+ public bool HasID(string id)
+ {
+ return
+ this.HasID()
+ && string.Equals(this.Manifest.UniqueID.Trim(), id?.Trim(), StringComparison.InvariantCultureIgnoreCase);
+ }
+
/// <summary>Get the defined update keys.</summary>
/// <param name="validOnly">Only return valid update keys.</param>
public IEnumerable<UpdateKey> GetUpdateKeys(bool validOnly = false)
diff --git a/src/SMAPI/Framework/ModLoading/ModResolver.cs b/src/SMAPI/Framework/ModLoading/ModResolver.cs
index 9992cc78..ace84054 100644
--- a/src/SMAPI/Framework/ModLoading/ModResolver.cs
+++ b/src/SMAPI/Framework/ModLoading/ModResolver.cs
@@ -42,7 +42,9 @@ namespace StardewModdingAPI.Framework.ModLoading
? ModMetadataStatus.Found
: ModMetadataStatus.Failed;
string relativePath = PathUtilities.GetRelativePath(rootPath, folder.Directory.FullName);
- yield return new ModMetadata(folder.DisplayName, folder.Directory.FullName, relativePath, manifest, dataRecord, isIgnored: !folder.ShouldBeLoaded).SetStatus(status, folder.ManifestParseError ?? "disabled by dot convention");
+
+ yield return new ModMetadata(folder.DisplayName, folder.Directory.FullName, relativePath, manifest, dataRecord, isIgnored: !folder.ShouldBeLoaded)
+ .SetStatus(status, !folder.ShouldBeLoaded ? "disabled by dot convention" : folder.ManifestParseError);
}
}
@@ -85,7 +87,7 @@ namespace StardewModdingAPI.Framework.ModLoading
updateUrls.Add(mod.DataRecord.AlternativeUrl);
// default update URL
- updateUrls.Add("https://smapi.io/compat");
+ updateUrls.Add("https://mods.smapi.io");
// build error
string error = $"{reasonPhrase}. Please check for a ";
@@ -379,7 +381,7 @@ namespace StardewModdingAPI.Framework.ModLoading
/// <param name="loadedMods">The loaded mods.</param>
private IEnumerable<ModDependency> GetDependenciesFrom(IManifest manifest, IModMetadata[] loadedMods)
{
- IModMetadata FindMod(string id) => loadedMods.FirstOrDefault(m => string.Equals(m.Manifest?.UniqueID, id, StringComparison.InvariantCultureIgnoreCase));
+ IModMetadata FindMod(string id) => loadedMods.FirstOrDefault(m => m.HasID(id));
// yield dependencies
if (manifest.Dependencies != null)
diff --git a/src/SMAPI/Framework/ModRegistry.cs b/src/SMAPI/Framework/ModRegistry.cs
index e7d4f89a..da68fce3 100644
--- a/src/SMAPI/Framework/ModRegistry.cs
+++ b/src/SMAPI/Framework/ModRegistry.cs
@@ -59,7 +59,7 @@ namespace StardewModdingAPI.Framework
uniqueID = uniqueID.Trim();
// find match
- return this.GetAll().FirstOrDefault(p => p.Manifest.UniqueID.Trim().Equals(uniqueID, StringComparison.InvariantCultureIgnoreCase));
+ return this.GetAll().FirstOrDefault(p => p.HasID(uniqueID));
}
/// <summary>Get the mod metadata from one of its assemblies.</summary>
diff --git a/src/SMAPI/Framework/Monitor.cs b/src/SMAPI/Framework/Monitor.cs
index 2812a9cc..a4d92e4b 100644
--- a/src/SMAPI/Framework/Monitor.cs
+++ b/src/SMAPI/Framework/Monitor.cs
@@ -37,6 +37,9 @@ namespace StardewModdingAPI.Framework
/// <summary>Whether SMAPI is aborting. Mods don't need to worry about this unless they have background tasks.</summary>
public bool IsExiting => this.ExitTokenSource.IsCancellationRequested;
+ /// <summary>Whether verbose logging is enabled. This enables more detailed diagnostic messages than are normally needed.</summary>
+ public bool IsVerbose { get; }
+
/// <summary>Whether to show the full log stamps (with time/level/logger) in the console. If false, shows a simplified stamp with only the logger.</summary>
internal bool ShowFullStampInConsole { get; set; }
@@ -56,7 +59,8 @@ namespace StardewModdingAPI.Framework
/// <param name="logFile">The log file to which to write messages.</param>
/// <param name="exitTokenSource">Propagates notification that SMAPI should exit.</param>
/// <param name="colorScheme">The console color scheme to use.</param>
- public Monitor(string source, ConsoleInterceptionManager consoleInterceptor, LogFileManager logFile, CancellationTokenSource exitTokenSource, MonitorColorScheme colorScheme)
+ /// <param name="isVerbose">Whether verbose logging is enabled. This enables more detailed diagnostic messages than are normally needed.</param>
+ public Monitor(string source, ConsoleInterceptionManager consoleInterceptor, LogFileManager logFile, CancellationTokenSource exitTokenSource, MonitorColorScheme colorScheme, bool isVerbose)
{
// validate
if (string.IsNullOrWhiteSpace(source))
@@ -68,6 +72,7 @@ namespace StardewModdingAPI.Framework
this.ConsoleWriter = new ColorfulConsoleWriter(Constants.Platform, colorScheme);
this.ConsoleInterceptor = consoleInterceptor;
this.ExitTokenSource = exitTokenSource;
+ this.IsVerbose = isVerbose;
}
/// <summary>Log a message for the player or developer.</summary>
@@ -78,6 +83,14 @@ namespace StardewModdingAPI.Framework
this.LogImpl(this.Source, message, (ConsoleLogLevel)level);
}
+ /// <summary>Log a message that only appears when <see cref="IMonitor.IsVerbose"/> is enabled.</summary>
+ /// <param name="message">The message to log.</param>
+ public void VerboseLog(string message)
+ {
+ if (this.IsVerbose)
+ this.Log(message, LogLevel.Trace);
+ }
+
/// <summary>Immediately exit the game without saving. This should only be invoked when an irrecoverable fatal error happens that risks save corruption or game-breaking bugs.</summary>
/// <param name="reason">The reason for the shutdown.</param>
public void ExitGameImmediately(string reason)
diff --git a/src/SMAPI/Framework/Networking/MessageType.cs b/src/SMAPI/Framework/Networking/MessageType.cs
new file mode 100644
index 00000000..bd9acfa9
--- /dev/null
+++ b/src/SMAPI/Framework/Networking/MessageType.cs
@@ -0,0 +1,26 @@
+using StardewValley;
+
+namespace StardewModdingAPI.Framework.Networking
+{
+ /// <summary>Network message types recognised by SMAPI and Stardew Valley.</summary>
+ internal enum MessageType : byte
+ {
+ /*********
+ ** SMAPI
+ *********/
+ /// <summary>A data message intended for mods to consume.</summary>
+ ModMessage = 254,
+
+ /// <summary>Metadata context about a player synced by SMAPI.</summary>
+ ModContext = 255,
+
+ /*********
+ ** Vanilla
+ *********/
+ /// <summary>Metadata about the host server sent to a farmhand.</summary>
+ ServerIntroduction = Multiplayer.serverIntroduction,
+
+ /// <summary>Metadata about a player sent to a farmhand or server.</summary>
+ PlayerIntroduction = Multiplayer.playerIntroduction
+ }
+}
diff --git a/src/SMAPI/Framework/Networking/ModMessageModel.cs b/src/SMAPI/Framework/Networking/ModMessageModel.cs
new file mode 100644
index 00000000..7ee39863
--- /dev/null
+++ b/src/SMAPI/Framework/Networking/ModMessageModel.cs
@@ -0,0 +1,72 @@
+using System.Linq;
+using Newtonsoft.Json.Linq;
+
+namespace StardewModdingAPI.Framework.Networking
+{
+ /// <summary>The metadata for a mod message.</summary>
+ internal class ModMessageModel
+ {
+ /*********
+ ** Accessors
+ *********/
+ /****
+ ** Origin
+ ****/
+ /// <summary>The unique ID of the player who broadcast the message.</summary>
+ public long FromPlayerID { get; set; }
+
+ /// <summary>The unique ID of the mod which broadcast the message.</summary>
+ public string FromModID { get; set; }
+
+ /****
+ ** Destination
+ ****/
+ /// <summary>The players who should receive the message, or <c>null</c> for all players.</summary>
+ public long[] ToPlayerIDs { get; set; }
+
+ /// <summary>The mods which should receive the message, or <c>null</c> for all mods.</summary>
+ public string[] ToModIDs { get; set; }
+
+ /// <summary>A message type which receiving mods can use to decide whether it's the one they want to handle, like <c>SetPlayerLocation</c>. This doesn't need to be globally unique, since mods should check the originating mod ID.</summary>
+ public string Type { get; set; }
+
+ /// <summary>The custom mod data being broadcast.</summary>
+ public JToken Data { get; set; }
+
+
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Construct an instance.</summary>
+ public ModMessageModel() { }
+
+ /// <summary>Construct an instance.</summary>
+ /// <param name="fromPlayerID">The unique ID of the player who broadcast the message.</param>
+ /// <param name="fromModID">The unique ID of the mod which broadcast the message.</param>
+ /// <param name="toPlayerIDs">The players who should receive the message, or <c>null</c> for all players.</param>
+ /// <param name="toModIDs">The mods which should receive the message, or <c>null</c> for all mods.</param>
+ /// <param name="type">A message type which receiving mods can use to decide whether it's the one they want to handle, like <c>SetPlayerLocation</c>. This doesn't need to be globally unique, since mods should check the originating mod ID.</param>
+ /// <param name="data">The custom mod data being broadcast.</param>
+ public ModMessageModel(long fromPlayerID, string fromModID, long[] toPlayerIDs, string[] toModIDs, string type, JToken data)
+ {
+ this.FromPlayerID = fromPlayerID;
+ this.FromModID = fromModID;
+ this.ToPlayerIDs = toPlayerIDs;
+ this.ToModIDs = toModIDs;
+ this.Type = type;
+ this.Data = data;
+ }
+
+ /// <summary>Construct an instance.</summary>
+ /// <param name="message">The message to clone.</param>
+ public ModMessageModel(ModMessageModel message)
+ {
+ this.FromPlayerID = message.FromPlayerID;
+ this.FromModID = message.FromModID;
+ this.ToPlayerIDs = message.ToPlayerIDs?.ToArray();
+ this.ToModIDs = message.ToModIDs?.ToArray();
+ this.Type = message.Type;
+ this.Data = message.Data;
+ }
+ }
+}
diff --git a/src/SMAPI/Framework/Networking/MultiplayerPeer.cs b/src/SMAPI/Framework/Networking/MultiplayerPeer.cs
new file mode 100644
index 00000000..7f0fa4f7
--- /dev/null
+++ b/src/SMAPI/Framework/Networking/MultiplayerPeer.cs
@@ -0,0 +1,132 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using Lidgren.Network;
+using StardewValley.Network;
+
+namespace StardewModdingAPI.Framework.Networking
+{
+ /// <summary>Metadata about a connected player.</summary>
+ internal class MultiplayerPeer : IMultiplayerPeer
+ {
+ /*********
+ ** Properties
+ *********/
+ /// <summary>The server through which to send messages, if this is an incoming farmhand.</summary>
+ private readonly SLidgrenServer Server;
+
+ /// <summary>The client through which to send messages, if this is the host player.</summary>
+ private readonly SLidgrenClient Client;
+
+ /// <summary>The network connection to the player.</summary>
+ private readonly NetConnection ServerConnection;
+
+
+ /*********
+ ** Accessors
+ *********/
+ /// <summary>The player's unique ID.</summary>
+ public long PlayerID { get; }
+
+ /// <summary>Whether this is a connection to the host player.</summary>
+ public bool IsHost { get; }
+
+ /// <summary>Whether the player has SMAPI installed.</summary>
+ public bool HasSmapi => this.ApiVersion != null;
+
+ /// <summary>The player's OS platform, if <see cref="HasSmapi"/> is true.</summary>
+ public GamePlatform? Platform { get; }
+
+ /// <summary>The installed version of Stardew Valley, if <see cref="HasSmapi"/> is true.</summary>
+ public ISemanticVersion GameVersion { get; }
+
+ /// <summary>The installed version of SMAPI, if <see cref="HasSmapi"/> is true.</summary>
+ public ISemanticVersion ApiVersion { get; }
+
+ /// <summary>The installed mods, if <see cref="HasSmapi"/> is true.</summary>
+ public IEnumerable<IMultiplayerPeerMod> Mods { get; }
+
+
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Construct an instance.</summary>
+ /// <param name="playerID">The player's unique ID.</param>
+ /// <param name="model">The metadata to copy.</param>
+ /// <param name="server">The server through which to send messages.</param>
+ /// <param name="serverConnection">The server connection through which to send messages.</param>
+ /// <param name="client">The client through which to send messages.</param>
+ /// <param name="isHost">Whether this is a connection to the host player.</param>
+ public MultiplayerPeer(long playerID, RemoteContextModel model, SLidgrenServer server, NetConnection serverConnection, SLidgrenClient client, bool isHost)
+ {
+ this.PlayerID = playerID;
+ this.IsHost = isHost;
+ if (model != null)
+ {
+ this.Platform = model.Platform;
+ this.GameVersion = model.GameVersion;
+ this.ApiVersion = model.ApiVersion;
+ this.Mods = model.Mods.Select(mod => new MultiplayerPeerMod(mod)).ToArray();
+ }
+ this.Server = server;
+ this.ServerConnection = serverConnection;
+ this.Client = client;
+ }
+
+ /// <summary>Construct an instance for a connection to an incoming farmhand.</summary>
+ /// <param name="playerID">The player's unique ID.</param>
+ /// <param name="model">The metadata to copy, if available.</param>
+ /// <param name="server">The server through which to send messages.</param>
+ /// <param name="serverConnection">The server connection through which to send messages.</param>
+ public static MultiplayerPeer ForConnectionToFarmhand(long playerID, RemoteContextModel model, SLidgrenServer server, NetConnection serverConnection)
+ {
+ return new MultiplayerPeer(
+ playerID: playerID,
+ model: model,
+ server: server,
+ serverConnection: serverConnection,
+ client: null,
+ isHost: false
+ );
+ }
+
+ /// <summary>Construct an instance for a connection to the host player.</summary>
+ /// <param name="playerID">The player's unique ID.</param>
+ /// <param name="model">The metadata to copy.</param>
+ /// <param name="client">The client through which to send messages.</param>
+ /// <param name="isHost">Whether this connection is for the host player.</param>
+ public static MultiplayerPeer ForConnectionToHost(long playerID, RemoteContextModel model, SLidgrenClient client, bool isHost)
+ {
+ return new MultiplayerPeer(
+ playerID: playerID,
+ model: model,
+ server: null,
+ serverConnection: null,
+ client: client,
+ isHost: isHost
+ );
+ }
+
+ /// <summary>Get metadata for a mod installed by the player.</summary>
+ /// <param name="id">The unique mod ID.</param>
+ /// <returns>Returns the mod info, or <c>null</c> if the player doesn't have that mod.</returns>
+ public IMultiplayerPeerMod GetMod(string id)
+ {
+ if (string.IsNullOrWhiteSpace(id) || this.Mods == null || !this.Mods.Any())
+ return null;
+
+ id = id.Trim();
+ return this.Mods.FirstOrDefault(mod => mod.ID != null && mod.ID.Equals(id, StringComparison.InvariantCultureIgnoreCase));
+ }
+
+ /// <summary>Send a message to the given peer, bypassing the game's normal validation to allow messages before the connection is approved.</summary>
+ /// <param name="message">The message to send.</param>
+ public void SendMessage(OutgoingMessage message)
+ {
+ if (this.IsHost)
+ this.Client.sendMessage(message);
+ else
+ this.Server.SendMessage(this.ServerConnection, message);
+ }
+ }
+}
diff --git a/src/SMAPI/Framework/Networking/MultiplayerPeerMod.cs b/src/SMAPI/Framework/Networking/MultiplayerPeerMod.cs
new file mode 100644
index 00000000..1b324bcd
--- /dev/null
+++ b/src/SMAPI/Framework/Networking/MultiplayerPeerMod.cs
@@ -0,0 +1,30 @@
+namespace StardewModdingAPI.Framework.Networking
+{
+ internal class MultiplayerPeerMod : IMultiplayerPeerMod
+ {
+ /*********
+ ** Accessors
+ *********/
+ /// <summary>The mod's display name.</summary>
+ public string Name { get; }
+
+ /// <summary>The unique mod ID.</summary>
+ public string ID { get; }
+
+ /// <summary>The mod version.</summary>
+ public ISemanticVersion Version { get; }
+
+
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Construct an instance.</summary>
+ /// <param name="mod">The mod metadata.</param>
+ public MultiplayerPeerMod(RemoteContextModModel mod)
+ {
+ this.Name = mod.Name;
+ this.ID = mod.ID?.Trim();
+ this.Version = mod.Version;
+ }
+ }
+}
diff --git a/src/SMAPI/Framework/Networking/RemoteContextModModel.cs b/src/SMAPI/Framework/Networking/RemoteContextModModel.cs
new file mode 100644
index 00000000..9795d971
--- /dev/null
+++ b/src/SMAPI/Framework/Networking/RemoteContextModModel.cs
@@ -0,0 +1,15 @@
+namespace StardewModdingAPI.Framework.Networking
+{
+ /// <summary>Metadata about an installed mod exchanged with connected computers.</summary>
+ public class RemoteContextModModel
+ {
+ /// <summary>The mod's display name.</summary>
+ public string Name { get; set; }
+
+ /// <summary>The unique mod ID.</summary>
+ public string ID { get; set; }
+
+ /// <summary>The mod version.</summary>
+ public ISemanticVersion Version { get; set; }
+ }
+}
diff --git a/src/SMAPI/Framework/Networking/RemoteContextModel.cs b/src/SMAPI/Framework/Networking/RemoteContextModel.cs
new file mode 100644
index 00000000..7befb151
--- /dev/null
+++ b/src/SMAPI/Framework/Networking/RemoteContextModel.cs
@@ -0,0 +1,24 @@
+namespace StardewModdingAPI.Framework.Networking
+{
+ /// <summary>Metadata about the game, SMAPI, and installed mods exchanged with connected computers.</summary>
+ internal class RemoteContextModel
+ {
+ /*********
+ ** Accessors
+ *********/
+ /// <summary>Whether this player is the host player.</summary>
+ public bool IsHost { get; set; }
+
+ /// <summary>The game's platform version.</summary>
+ public GamePlatform Platform { get; set; }
+
+ /// <summary>The installed version of Stardew Valley.</summary>
+ public ISemanticVersion GameVersion { get; set; }
+
+ /// <summary>The installed version of SMAPI.</summary>
+ public ISemanticVersion ApiVersion { get; set; }
+
+ /// <summary>The installed mods.</summary>
+ public RemoteContextModModel[] Mods { get; set; }
+ }
+}
diff --git a/src/SMAPI/Framework/Networking/SLidgrenClient.cs b/src/SMAPI/Framework/Networking/SLidgrenClient.cs
new file mode 100644
index 00000000..c05e6b76
--- /dev/null
+++ b/src/SMAPI/Framework/Networking/SLidgrenClient.cs
@@ -0,0 +1,49 @@
+using System;
+using StardewValley.Network;
+
+namespace StardewModdingAPI.Framework.Networking
+{
+ /// <summary>A multiplayer client used to connect to a hosted server. This is an implementation of <see cref="LidgrenClient"/> with callbacks for SMAPI functionality.</summary>
+ internal class SLidgrenClient : LidgrenClient
+ {
+ /*********
+ ** Properties
+ *********/
+ /// <summary>A callback to raise when receiving a message. This receives the client instance, incoming message, and a callback to run the default logic.</summary>
+ private readonly Action<SLidgrenClient, IncomingMessage, Action> OnProcessingMessage;
+
+ /// <summary>A callback to raise when sending a message. This receives the client instance, outgoing message, and a callback to run the default logic.</summary>
+ private readonly Action<SLidgrenClient, OutgoingMessage, Action> OnSendingMessage;
+
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Construct an instance.</summary>
+ /// <param name="address">The remote address being connected.</param>
+ /// <param name="onProcessingMessage">A callback to raise when receiving a message. This receives the client instance, incoming message, and a callback to run the default logic.</param>
+ /// <param name="onSendingMessage">A callback to raise when sending a message. This receives the client instance, outgoing message, and a callback to run the default logic.</param>
+ public SLidgrenClient(string address, Action<SLidgrenClient, IncomingMessage, Action> onProcessingMessage, Action<SLidgrenClient, OutgoingMessage, Action> onSendingMessage)
+ : base(address)
+ {
+ this.OnProcessingMessage = onProcessingMessage;
+ this.OnSendingMessage = onSendingMessage;
+ }
+
+ /// <summary>Send a message to the connected peer.</summary>
+ public override void sendMessage(OutgoingMessage message)
+ {
+ this.OnSendingMessage(this, message, () => base.sendMessage(message));
+ }
+
+
+ /*********
+ ** Protected methods
+ *********/
+ /// <summary>Process an incoming network message.</summary>
+ /// <param name="message">The message to process.</param>
+ protected override void processIncomingMessage(IncomingMessage message)
+ {
+ this.OnProcessingMessage(this, message, () => base.processIncomingMessage(message));
+ }
+ }
+}
diff --git a/src/SMAPI/Framework/Networking/SLidgrenServer.cs b/src/SMAPI/Framework/Networking/SLidgrenServer.cs
new file mode 100644
index 00000000..060b433b
--- /dev/null
+++ b/src/SMAPI/Framework/Networking/SLidgrenServer.cs
@@ -0,0 +1,148 @@
+using System;
+using System.Diagnostics.CodeAnalysis;
+using System.IO;
+using System.Reflection;
+using Lidgren.Network;
+using StardewModdingAPI.Framework.Reflection;
+using StardewModdingAPI.Patches;
+using StardewValley;
+using StardewValley.Network;
+
+namespace StardewModdingAPI.Framework.Networking
+{
+ /// <summary>A multiplayer server used to connect to an incoming player. This is an implementation of <see cref="LidgrenServer"/> that adds support for SMAPI's metadata context exchange.</summary>
+ internal class SLidgrenServer : LidgrenServer
+ {
+ /*********
+ ** Properties
+ *********/
+
+ /// <summary>The constructor for the internal <c>NetBufferReadStream</c> type.</summary>
+ private readonly ConstructorInfo NetBufferReadStreamConstructor = SLidgrenServer.GetNetBufferReadStreamConstructor();
+
+ /// <summary>The constructor for the internal <c>NetBufferWriteStream</c> type.</summary>
+ private readonly ConstructorInfo NetBufferWriteStreamConstructor = SLidgrenServer.GetNetBufferWriteStreamConstructor();
+
+ /// <summary>A method which reads farmer data from the given binary reader.</summary>
+ private readonly Func<BinaryReader, NetFarmerRoot> ReadFarmer;
+
+ /// <summary>A callback to raise when receiving a message. This receives the server instance, raw/parsed incoming message, and a callback to run the default logic.</summary>
+ private readonly Action<SLidgrenServer, NetIncomingMessage, IncomingMessage, Action> OnProcessingMessage;
+
+ /// <summary>A callback to raise when sending a message. This receives the server instance, outgoing connection, outgoing message, target player ID, and a callback to run the default logic.</summary>
+ private readonly Action<SLidgrenServer, NetConnection, OutgoingMessage, Action> OnSendingMessage;
+
+ /// <summary>The peer connections.</summary>
+ private readonly Bimap<long, NetConnection> Peers;
+
+ /// <summary>The underlying net server.</summary>
+ private readonly IReflectedField<NetServer> Server;
+
+
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Construct an instance.</summary>
+ /// <param name="gameServer">The underlying game server.</param>
+ /// <param name="reflection">Simplifies access to private code.</param>
+ /// <param name="readFarmer">A method which reads farmer data from the given binary reader.</param>
+ /// <param name="onProcessingMessage">A callback to raise when receiving a message. This receives the server instance, raw/parsed incoming message, and a callback to run the default logic.</param>
+ /// <param name="onSendingMessage">A callback to raise when sending a message. This receives the server instance, outgoing connection, outgoing message, and a callback to run the default logic.</param>
+ public SLidgrenServer(IGameServer gameServer, Reflector reflection, Func<BinaryReader, NetFarmerRoot> readFarmer, Action<SLidgrenServer, NetIncomingMessage, IncomingMessage, Action> onProcessingMessage, Action<SLidgrenServer, NetConnection, OutgoingMessage, Action> onSendingMessage)
+ : base(gameServer)
+ {
+ this.ReadFarmer = readFarmer;
+ this.OnProcessingMessage = onProcessingMessage;
+ this.OnSendingMessage = onSendingMessage;
+ this.Peers = reflection.GetField<Bimap<long, NetConnection>>(this, "peers").GetValue();
+ this.Server = reflection.GetField<NetServer>(this, "server");
+ }
+
+ /// <summary>Send a message to a remote server.</summary>
+ /// <param name="connection">The network connection.</param>
+ /// <param name="message">The message to send.</param>
+ /// <remarks>This is an implementation of <see cref="LidgrenServer.sendMessage(NetConnection, OutgoingMessage)"/> which calls <see cref="OnSendingMessage"/>. This method is invoked via <see cref="LidgrenServerPatch.Prefix_LidgrenServer_SendMessage"/>.</remarks>
+ public void SendMessage(NetConnection connection, OutgoingMessage message)
+ {
+ this.OnSendingMessage(this, connection, message, () =>
+ {
+ NetServer server = this.Server.GetValue();
+ NetOutgoingMessage netMessage = server.CreateMessage();
+ using (Stream bufferWriteStream = (Stream)this.NetBufferWriteStreamConstructor.Invoke(new object[] { netMessage }))
+ using (BinaryWriter writer = new BinaryWriter(bufferWriteStream))
+ message.Write(writer);
+
+ server.SendMessage(netMessage, connection, NetDeliveryMethod.ReliableOrdered);
+ });
+ }
+
+ /// <summary>Parse a data message from a client.</summary>
+ /// <param name="rawMessage">The raw network message to parse.</param>
+ /// <remarks>This is an implementation of <see cref="LidgrenServer.parseDataMessageFromClient"/> which calls <see cref="OnProcessingMessage"/>. This method is invoked via <see cref="LidgrenServerPatch.Prefix_LidgrenServer_ParseDataMessageFromClient"/>.</remarks>
+ [SuppressMessage("ReSharper", "AccessToDisposedClosure", Justification = "The callback is invoked synchronously.")]
+ public bool ParseDataMessageFromClient(NetIncomingMessage rawMessage)
+ {
+ // add hook to call multiplayer core
+ NetConnection peer = rawMessage.SenderConnection;
+ using (IncomingMessage message = new IncomingMessage())
+ using (Stream readStream = (Stream)this.NetBufferReadStreamConstructor.Invoke(new object[] { rawMessage }))
+ using (BinaryReader reader = new BinaryReader(readStream))
+ {
+ while (rawMessage.LengthBits - rawMessage.Position >= 8)
+ {
+ message.Read(reader);
+ this.OnProcessingMessage(this, rawMessage, message, () =>
+ {
+ if (this.Peers.ContainsLeft(message.FarmerID) && this.Peers[message.FarmerID] == peer)
+ this.gameServer.processIncomingMessage(message);
+ else if (message.MessageType == Multiplayer.playerIntroduction)
+ {
+ NetFarmerRoot farmer = this.ReadFarmer(message.Reader);
+ this.gameServer.checkFarmhandRequest("", farmer, msg => this.SendMessage(peer, msg), () => this.Peers[farmer.Value.UniqueMultiplayerID] = peer);
+ }
+ });
+ }
+ }
+
+ return false;
+ }
+
+
+ /*********
+ ** Private methods
+ *********/
+ /// <summary>Get the constructor for the internal <c>NetBufferReadStream</c> type.</summary>
+ private static ConstructorInfo GetNetBufferReadStreamConstructor()
+ {
+ // get type
+ string typeName = $"StardewValley.Network.NetBufferReadStream, {Constants.GameAssemblyName}";
+ Type type = Type.GetType(typeName);
+ if (type == null)
+ throw new InvalidOperationException($"Can't find type: {typeName}");
+
+ // get constructor
+ ConstructorInfo constructor = type.GetConstructor(new[] { typeof(NetBuffer) });
+ if (constructor == null)
+ throw new InvalidOperationException($"Can't find constructor for type: {typeName}");
+
+ return constructor;
+ }
+
+ /// <summary>Get the constructor for the internal <c>NetBufferWriteStream</c> type.</summary>
+ private static ConstructorInfo GetNetBufferWriteStreamConstructor()
+ {
+ // get type
+ string typeName = $"StardewValley.Network.NetBufferWriteStream, {Constants.GameAssemblyName}";
+ Type type = Type.GetType(typeName);
+ if (type == null)
+ throw new InvalidOperationException($"Can't find type: {typeName}");
+
+ // get constructor
+ ConstructorInfo constructor = type.GetConstructor(new[] { typeof(NetBuffer) });
+ if (constructor == null)
+ throw new InvalidOperationException($"Can't find constructor for type: {typeName}");
+
+ return constructor;
+ }
+ }
+}
diff --git a/src/SMAPI/Framework/SCore.cs b/src/SMAPI/Framework/SCore.cs
index a17af91e..890058b0 100644
--- a/src/SMAPI/Framework/SCore.cs
+++ b/src/SMAPI/Framework/SCore.cs
@@ -77,7 +77,7 @@ namespace StardewModdingAPI.Framework
/// <summary>Manages deprecation warnings.</summary>
/// <remarks>This is initialised after the game starts.</remarks>
- private DeprecationManager DeprecationManager;
+ private readonly DeprecationManager DeprecationManager;
/// <summary>Manages SMAPI events for mods.</summary>
private readonly EventManager EventManager;
@@ -120,13 +120,13 @@ namespace StardewModdingAPI.Framework
this.ModsPath = modsPath;
// init log file
- this.PurgeLogFiles();
+ this.PurgeNormalLogs();
string logPath = this.GetLogPath();
// init basics
this.Settings = JsonConvert.DeserializeObject<SConfig>(File.ReadAllText(Constants.ApiConfigPath));
this.LogFile = new LogFileManager(logPath);
- this.Monitor = new Monitor("SMAPI", this.ConsoleManager, this.LogFile, this.CancellationTokenSource, this.Settings.ColorScheme)
+ this.Monitor = new Monitor("SMAPI", this.ConsoleManager, this.LogFile, this.CancellationTokenSource, this.Settings.ColorScheme, this.Settings.VerboseLogging)
{
WriteToConsole = writeToConsole,
ShowTraceInConsole = this.Settings.DeveloperMode,
@@ -134,6 +134,14 @@ namespace StardewModdingAPI.Framework
};
this.MonitorForGame = this.GetSecondaryMonitor("game");
this.EventManager = new EventManager(this.Monitor, this.ModRegistry);
+ this.DeprecationManager = new DeprecationManager(this.Monitor, this.ModRegistry);
+
+ // redirect direct console output
+ if (this.MonitorForGame.WriteToConsole)
+ this.ConsoleManager.OnMessageIntercepted += message => this.HandleConsoleMessage(this.MonitorForGame, message);
+
+ // inject deprecation managers
+ SemanticVersion.DeprecationManager = this.DeprecationManager;
// init logging
this.Monitor.Log($"SMAPI {Constants.ApiVersion} with Stardew Valley {Constants.GameVersion} on {EnvironmentUtility.GetFriendlyPlatformName(Constants.Platform)}", LogLevel.Info);
@@ -161,7 +169,8 @@ namespace StardewModdingAPI.Framework
// apply game patches
new GamePatcher(this.Monitor).Apply(
- new DialoguePatch(this.MonitorForGame, this.Reflection)
+ new DialogueErrorPatch(this.MonitorForGame, this.Reflection),
+ new LidgrenServerPatch()
);
}
@@ -208,7 +217,7 @@ namespace StardewModdingAPI.Framework
// override game
SGame.ConstructorHack = new SGameConstructorHack(this.Monitor, this.Reflection, this.Toolkit.JsonHelper);
- this.GameInstance = new SGame(this.Monitor, this.MonitorForGame, this.Reflection, this.EventManager, this.InitialiseAfterGameStart, this.Dispose);
+ this.GameInstance = new SGame(this.Monitor, this.MonitorForGame, this.Reflection, this.EventManager, this.Toolkit.JsonHelper, this.ModRegistry, this.DeprecationManager, this.InitialiseAfterGameStart, this.Dispose);
StardewValley.Program.gamePtr = this.GameInstance;
// add exit handler
@@ -265,7 +274,7 @@ namespace StardewModdingAPI.Framework
// show details if game crashed during last session
if (File.Exists(Constants.FatalCrashMarker))
{
- this.Monitor.Log("The game crashed last time you played. That can be due to bugs in the game, but if it happens repeatedly you can ask for help here: http://community.playstarbound.com/threads/108375/.", LogLevel.Error);
+ this.Monitor.Log("The game crashed last time you played. That can be due to bugs in the game, but if it happens repeatedly you can ask for help here: https://community.playstarbound.com/threads/108375/.", LogLevel.Error);
this.Monitor.Log("If you ask for help, make sure to share your SMAPI log: https://log.smapi.io.", LogLevel.Error);
this.Monitor.Log("Press any key to delete the crash data and continue playing.", LogLevel.Info);
Console.ReadKey();
@@ -339,16 +348,6 @@ namespace StardewModdingAPI.Framework
/// <summary>Initialise SMAPI and mods after the game starts.</summary>
private void InitialiseAfterGameStart()
{
- // load settings
- this.GameInstance.VerboseLogging = this.Settings.VerboseLogging;
-
- // load core components
- this.DeprecationManager = new DeprecationManager(this.Monitor, this.ModRegistry);
-
- // redirect direct console output
- if (this.MonitorForGame.WriteToConsole)
- this.ConsoleManager.OnMessageIntercepted += message => this.HandleConsoleMessage(this.MonitorForGame, message);
-
// add headers
if (this.Settings.DeveloperMode)
this.Monitor.Log($"You configured SMAPI to run in developer mode. The console may be much more verbose. You can disable developer mode by installing the non-developer version of SMAPI, or by editing {Constants.ApiConfigPath}.", LogLevel.Info);
@@ -356,7 +355,7 @@ namespace StardewModdingAPI.Framework
this.Monitor.Log($"You configured SMAPI to not check for updates. Running an old version of SMAPI is not recommended. You can enable update checks by reinstalling SMAPI or editing {Constants.ApiConfigPath}.", LogLevel.Warn);
if (!this.Monitor.WriteToConsole)
this.Monitor.Log("Writing to the terminal is disabled because the --no-terminal argument was received. This usually means launching the terminal failed.", LogLevel.Warn);
- this.VerboseLog("Verbose logging enabled.");
+ this.Monitor.VerboseLog("Verbose logging enabled.");
// validate XNB integrity
if (!this.ValidateContentIntegrity())
@@ -749,7 +748,7 @@ namespace StardewModdingAPI.Framework
// log loaded content packs
if (loadedContentPacks.Any())
{
- string GetModDisplayName(string id) => loadedMods.FirstOrDefault(p => id != null && id.Equals(p.Manifest?.UniqueID, StringComparison.InvariantCultureIgnoreCase))?.DisplayName;
+ string GetModDisplayName(string id) => loadedMods.FirstOrDefault(p => p.HasID(id))?.DisplayName;
this.Monitor.Log($"Loaded {loadedContentPacks.Length} content packs:", LogLevel.Info);
foreach (IModMetadata metadata in loadedContentPacks.OrderBy(p => p.DisplayName))
@@ -897,6 +896,12 @@ namespace StardewModdingAPI.Framework
return false;
}
+ // add deprecation warning for old version format
+ {
+ if (mod.Manifest?.Version is Toolkit.SemanticVersion version && version.IsLegacyFormat)
+ this.DeprecationManager.Warn(mod.DisplayName, "non-string manifest version", "2.8", DeprecationLevel.Notice);
+ }
+
// validate dependencies
// Although dependences are validated before mods are loaded, a dependency may have failed to load.
if (mod.Manifest.Dependencies?.Any() == true)
@@ -906,7 +911,7 @@ namespace StardewModdingAPI.Framework
if (this.ModRegistry.Get(dependency.UniqueID) == null)
{
string dependencyName = mods
- .FirstOrDefault(otherMod => otherMod.HasID() && dependency.UniqueID.Equals(otherMod.Manifest.UniqueID, StringComparison.InvariantCultureIgnoreCase))
+ .FirstOrDefault(otherMod => otherMod.HasID(dependency.UniqueID))
?.DisplayName ?? dependency.UniqueID;
errorReasonPhrase = $"it needs the '{dependencyName}' mod, which couldn't be loaded.";
return false;
@@ -944,7 +949,7 @@ namespace StardewModdingAPI.Framework
}
catch (IncompatibleInstructionException) // details already in trace logs
{
- string[] updateUrls = new[] { modDatabase.GetModPageUrlFor(manifest.UniqueID), "https://smapi.io/compat" }.Where(p => p != null).ToArray();
+ string[] updateUrls = new[] { modDatabase.GetModPageUrlFor(manifest.UniqueID), "https://mods.smapi.io" }.Where(p => p != null).ToArray();
errorReasonPhrase = $"it's no longer compatible. Please check for a new version at {string.Join(" or ", updateUrls)}.";
return false;
}
@@ -970,7 +975,7 @@ namespace StardewModdingAPI.Framework
// get content packs
IContentPack[] contentPacks = this.ModRegistry
.GetAll(assemblyMods: false)
- .Where(p => p.IsContentPack && mod.Manifest.UniqueID.Equals(p.Manifest.ContentPackFor.UniqueID, StringComparison.InvariantCultureIgnoreCase))
+ .Where(p => p.IsContentPack && mod.HasID(p.Manifest.ContentPackFor.UniqueID))
.Select(p => p.ContentPack)
.ToArray();
@@ -1293,7 +1298,7 @@ namespace StardewModdingAPI.Framework
/// <param name="name">The name of the module which will log messages with this instance.</param>
private Monitor GetSecondaryMonitor(string name)
{
- return new Monitor(name, this.ConsoleManager, this.LogFile, this.CancellationTokenSource, this.Settings.ColorScheme)
+ return new Monitor(name, this.ConsoleManager, this.LogFile, this.CancellationTokenSource, this.Settings.ColorScheme, this.Settings.VerboseLogging)
{
WriteToConsole = this.Monitor.WriteToConsole,
ShowTraceInConsole = this.Settings.DeveloperMode,
@@ -1301,14 +1306,6 @@ namespace StardewModdingAPI.Framework
};
}
- /// <summary>Log a message if verbose mode is enabled.</summary>
- /// <param name="message">The message to log.</param>
- private void VerboseLog(string message)
- {
- if (this.Settings.VerboseLogging)
- this.Monitor.Log(message, LogLevel.Trace);
- }
-
/// <summary>Get the absolute path to the next available log file.</summary>
private string GetLogPath()
{
@@ -1331,8 +1328,8 @@ namespace StardewModdingAPI.Framework
throw new InvalidOperationException("Could not find an available log path.");
}
- /// <summary>Delete all log files created by SMAPI.</summary>
- private void PurgeLogFiles()
+ /// <summary>Delete normal (non-crash) log files created by SMAPI.</summary>
+ private void PurgeNormalLogs()
{
DirectoryInfo logsDir = new DirectoryInfo(Constants.LogDir);
if (!logsDir.Exists)
@@ -1340,16 +1337,22 @@ namespace StardewModdingAPI.Framework
foreach (FileInfo logFile in logsDir.EnumerateFiles())
{
- if (logFile.Name.StartsWith(Constants.LogNamePrefix, StringComparison.InvariantCultureIgnoreCase))
+ // skip non-SMAPI file
+ if (!logFile.Name.StartsWith(Constants.LogNamePrefix, StringComparison.InvariantCultureIgnoreCase))
+ continue;
+
+ // skip crash log
+ if (logFile.FullName == Constants.FatalCrashLog)
+ continue;
+
+ // delete file
+ try
{
- try
- {
- FileUtilities.ForceDelete(logFile);
- }
- catch (IOException)
- {
- // ignore file if it's in use
- }
+ FileUtilities.ForceDelete(logFile);
+ }
+ catch (IOException)
+ {
+ // ignore file if it's in use
}
}
}
diff --git a/src/SMAPI/Framework/SGame.cs b/src/SMAPI/Framework/SGame.cs
index 57f48d11..3de97aea 100644
--- a/src/SMAPI/Framework/SGame.cs
+++ b/src/SMAPI/Framework/SGame.cs
@@ -15,9 +15,11 @@ using StardewModdingAPI.Enums;
using StardewModdingAPI.Events;
using StardewModdingAPI.Framework.Events;
using StardewModdingAPI.Framework.Input;
+using StardewModdingAPI.Framework.Networking;
using StardewModdingAPI.Framework.Reflection;
using StardewModdingAPI.Framework.StateTracking;
using StardewModdingAPI.Framework.Utilities;
+using StardewModdingAPI.Toolkit.Serialisation;
using StardewValley;
using StardewValley.BellsAndWhistles;
using StardewValley.Buildings;
@@ -49,6 +51,12 @@ namespace StardewModdingAPI.Framework
/// <summary>Manages SMAPI events for mods.</summary>
private readonly EventManager Events;
+ /// <summary>Tracks the installed mods.</summary>
+ private readonly ModRegistry ModRegistry;
+
+ /// <summary>Manages deprecation warnings.</summary>
+ private readonly DeprecationManager DeprecationManager;
+
/// <summary>The maximum number of consecutive attempts SMAPI should make to recover from a draw error.</summary>
private readonly Countdown DrawCrashTimer = new Countdown(60); // 60 ticks = roughly one second
@@ -114,9 +122,6 @@ namespace StardewModdingAPI.Framework
/// <summary>The game's core multiplayer utility.</summary>
public SMultiplayer Multiplayer => (SMultiplayer)Game1.multiplayer;
- /// <summary>Whether SMAPI should log more information about the game context.</summary>
- public bool VerboseLogging { get; set; }
-
/// <summary>A list of queued commands to execute.</summary>
/// <remarks>This property must be threadsafe, since it's accessed from a separate console input thread.</remarks>
public ConcurrentQueue<string> CommandQueue { get; } = new ConcurrentQueue<string>();
@@ -130,9 +135,12 @@ namespace StardewModdingAPI.Framework
/// <param name="monitorForGame">Encapsulates monitoring and logging on the game's behalf.</param>
/// <param name="reflection">Simplifies access to private game code.</param>
/// <param name="eventManager">Manages SMAPI events for mods.</param>
+ /// <param name="jsonHelper">Encapsulates SMAPI's JSON file parsing.</param>
+ /// <param name="modRegistry">Tracks the installed mods.</param>
+ /// <param name="deprecationManager">Manages deprecation warnings.</param>
/// <param name="onGameInitialised">A callback to invoke after the game finishes initialising.</param>
/// <param name="onGameExiting">A callback to invoke when the game exits.</param>
- internal SGame(IMonitor monitor, IMonitor monitorForGame, Reflector reflection, EventManager eventManager, Action onGameInitialised, Action onGameExiting)
+ internal SGame(IMonitor monitor, IMonitor monitorForGame, Reflector reflection, EventManager eventManager, JsonHelper jsonHelper, ModRegistry modRegistry, DeprecationManager deprecationManager, Action onGameInitialised, Action onGameExiting)
{
SGame.ConstructorHack = null;
@@ -147,11 +155,13 @@ namespace StardewModdingAPI.Framework
this.Monitor = monitor;
this.MonitorForGame = monitorForGame;
this.Events = eventManager;
+ this.ModRegistry = modRegistry;
this.Reflection = reflection;
+ this.DeprecationManager = deprecationManager;
this.OnGameInitialised = onGameInitialised;
this.OnGameExiting = onGameExiting;
Game1.input = new SInputState();
- Game1.multiplayer = new SMultiplayer(monitor, eventManager);
+ Game1.multiplayer = new SMultiplayer(monitor, eventManager, jsonHelper, modRegistry, reflection, this.OnModMessageReceived);
Game1.hooks = new SModHooks(this.OnNewDayAfterFade);
// init observables
@@ -181,15 +191,21 @@ namespace StardewModdingAPI.Framework
this.OnGameExiting?.Invoke();
}
- /****
- ** Intercepted methods & events
- ****/
/// <summary>A callback invoked before <see cref="Game1.newDayAfterFade"/> runs.</summary>
protected void OnNewDayAfterFade()
{
this.Events.DayEnding.RaiseEmpty();
}
+ /// <summary>A callback invoked when a mod message is received.</summary>
+ /// <param name="message">The message to deliver to applicable mods.</param>
+ private void OnModMessageReceived(ModMessageModel message)
+ {
+ // raise events for applicable mods
+ HashSet<string> modIDs = new HashSet<string>(message.ToModIDs ?? this.ModRegistry.GetAll().Select(p => p.Manifest.UniqueID), StringComparer.InvariantCultureIgnoreCase);
+ this.Events.ModMessageReceived.RaiseForMods(new ModMessageReceivedEventArgs(message), mod => mod != null && modIDs.Contains(mod.Manifest.UniqueID));
+ }
+
/// <summary>Constructor a content manager to read XNB files.</summary>
/// <param name="serviceProvider">The service provider to use to locate services.</param>
/// <param name="rootDirectory">The root directory to search for content.</param>
@@ -221,6 +237,8 @@ namespace StardewModdingAPI.Framework
{
try
{
+ this.DeprecationManager.PrintQueued();
+
/*********
** Special cases
*********/
@@ -445,7 +463,7 @@ namespace StardewModdingAPI.Framework
// since the game adds & removes its own handler on the fly.
if (this.Watchers.WindowSizeWatcher.IsChanged)
{
- if (this.VerboseLogging)
+ if (this.Monitor.IsVerbose)
this.Monitor.Log($"Events: window size changed to {this.Watchers.WindowSizeWatcher.CurrentValue}.", LogLevel.Trace);
Point oldSize = this.Watchers.WindowSizeWatcher.PreviousValue;
@@ -484,7 +502,7 @@ namespace StardewModdingAPI.Framework
int now = this.Watchers.MouseWheelScrollWatcher.CurrentValue;
this.Watchers.MouseWheelScrollWatcher.Reset();
- if (this.VerboseLogging)
+ if (this.Monitor.IsVerbose)
this.Monitor.Log($"Events: mouse wheel scrolled to {now}.", LogLevel.Trace);
this.Events.MouseWheelScrolled.Raise(new MouseWheelScrolledEventArgs(cursor, was, now));
}
@@ -497,7 +515,7 @@ namespace StardewModdingAPI.Framework
if (status == InputStatus.Pressed)
{
- if (this.VerboseLogging)
+ if (this.Monitor.IsVerbose)
this.Monitor.Log($"Events: button {button} pressed.", LogLevel.Trace);
this.Events.ButtonPressed.Raise(new ButtonPressedEventArgs(button, cursor, inputState));
@@ -519,7 +537,7 @@ namespace StardewModdingAPI.Framework
}
else if (status == InputStatus.Released)
{
- if (this.VerboseLogging)
+ if (this.Monitor.IsVerbose)
this.Monitor.Log($"Events: button {button} released.", LogLevel.Trace);
this.Events.ButtonReleased.Raise(new ButtonReleasedEventArgs(button, cursor, inputState));
@@ -558,7 +576,7 @@ namespace StardewModdingAPI.Framework
IClickableMenu now = this.Watchers.ActiveMenuWatcher.CurrentValue;
this.Watchers.ActiveMenuWatcher.Reset(); // reset here so a mod changing the menu will be raised as a new event afterwards
- if (this.VerboseLogging)
+ if (this.Monitor.IsVerbose)
this.Monitor.Log($"Context: menu changed from {was?.GetType().FullName ?? "none"} to {now?.GetType().FullName ?? "none"}.", LogLevel.Trace);
// raise menu events
@@ -586,7 +604,7 @@ namespace StardewModdingAPI.Framework
GameLocation[] removed = this.Watchers.LocationsWatcher.Removed.ToArray();
this.Watchers.LocationsWatcher.ResetLocationList();
- if (this.VerboseLogging)
+ if (this.Monitor.IsVerbose)
{
string addedText = this.Watchers.LocationsWatcher.Added.Any() ? string.Join(", ", added.Select(p => p.Name)) : "none";
string removedText = this.Watchers.LocationsWatcher.Removed.Any() ? string.Join(", ", removed.Select(p => p.Name)) : "none";
@@ -682,7 +700,7 @@ namespace StardewModdingAPI.Framework
int now = this.Watchers.TimeWatcher.CurrentValue;
this.Watchers.TimeWatcher.Reset();
- if (this.VerboseLogging)
+ if (this.Monitor.IsVerbose)
this.Monitor.Log($"Events: time changed from {was} to {now}.", LogLevel.Trace);
this.Events.TimeChanged.Raise(new TimeChangedEventArgs(was, now));
@@ -699,7 +717,7 @@ namespace StardewModdingAPI.Framework
// raise current location changed
if (playerTracker.TryGetNewLocation(out GameLocation newLocation))
{
- if (this.VerboseLogging)
+ if (this.Monitor.IsVerbose)
this.Monitor.Log($"Context: set location to {newLocation.Name}.", LogLevel.Trace);
GameLocation oldLocation = playerTracker.LocationWatcher.PreviousValue;
@@ -710,7 +728,7 @@ namespace StardewModdingAPI.Framework
// raise player leveled up a skill
foreach (KeyValuePair<SkillType, IValueWatcher<int>> pair in playerTracker.GetChangedSkills())
{
- if (this.VerboseLogging)
+ if (this.Monitor.IsVerbose)
this.Monitor.Log($"Events: player skill '{pair.Key}' changed from {pair.Value.PreviousValue} to {pair.Value.CurrentValue}.", LogLevel.Trace);
this.Events.LevelChanged.Raise(new LevelChangedEventArgs(playerTracker.Player, pair.Key, pair.Value.PreviousValue, pair.Value.CurrentValue));
@@ -721,7 +739,7 @@ namespace StardewModdingAPI.Framework
ItemStackChange[] changedItems = playerTracker.GetInventoryChanges().ToArray();
if (changedItems.Any())
{
- if (this.VerboseLogging)
+ if (this.Monitor.IsVerbose)
this.Monitor.Log("Events: player inventory changed.", LogLevel.Trace);
this.Events.InventoryChanged.Raise(new InventoryChangedEventArgs(playerTracker.Player, changedItems));
this.Events.Legacy_InventoryChanged.Raise(new EventArgsInventoryChanged(Game1.player.Items, changedItems));
@@ -730,7 +748,7 @@ namespace StardewModdingAPI.Framework
// raise mine level changed
if (playerTracker.TryGetNewMineLevel(out int mineLevel))
{
- if (this.VerboseLogging)
+ if (this.Monitor.IsVerbose)
this.Monitor.Log($"Context: mine level changed to {mineLevel}.", LogLevel.Trace);
this.Events.Legacy_MineLevelChanged.Raise(new EventArgsMineLevelChanged(playerTracker.MineLevelWatcher.PreviousValue, mineLevel));
}
diff --git a/src/SMAPI/Framework/SMultiplayer.cs b/src/SMAPI/Framework/SMultiplayer.cs
index 4923a202..dc9b8b68 100644
--- a/src/SMAPI/Framework/SMultiplayer.cs
+++ b/src/SMAPI/Framework/SMultiplayer.cs
@@ -1,9 +1,32 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using Lidgren.Network;
+using Newtonsoft.Json;
+using Newtonsoft.Json.Linq;
+using StardewModdingAPI.Events;
using StardewModdingAPI.Framework.Events;
+using StardewModdingAPI.Framework.Networking;
+using StardewModdingAPI.Framework.Reflection;
+using StardewModdingAPI.Toolkit.Serialisation;
using StardewValley;
+using StardewValley.Network;
namespace StardewModdingAPI.Framework
{
/// <summary>SMAPI's implementation of the game's core multiplayer logic.</summary>
+ /// <remarks>
+ /// SMAPI syncs mod context to all players through the host as such:
+ /// 1. Farmhand sends ModContext + PlayerIntro.
+ /// 2. If host receives ModContext: it stores the context, replies with known contexts, and forwards it to other farmhands.
+ /// 3. If host receives PlayerIntro before ModContext: it stores a 'vanilla player' context, and forwards it to other farmhands.
+ /// 4. If farmhand receives ModContext: it stores it.
+ /// 5. If farmhand receives ServerIntro without a preceding ModContext: it stores a 'vanilla host' context.
+ /// 6. If farmhand receives PlayerIntro without a preceding ModContext AND it's not the host peer: it stores a 'vanilla player' context.
+ ///
+ /// Once a farmhand/server stored a context, messages can be sent to that player through the SMAPI APIs.
+ /// </remarks>
internal class SMultiplayer : Multiplayer
{
/*********
@@ -12,9 +35,34 @@ namespace StardewModdingAPI.Framework
/// <summary>Encapsulates monitoring and logging.</summary>
private readonly IMonitor Monitor;
+ /// <summary>Tracks the installed mods.</summary>
+ private readonly ModRegistry ModRegistry;
+
+ /// <summary>Encapsulates SMAPI's JSON file parsing.</summary>
+ private readonly JsonHelper JsonHelper;
+
+ /// <summary>Simplifies access to private code.</summary>
+ private readonly Reflector Reflection;
+
/// <summary>Manages SMAPI events.</summary>
private readonly EventManager EventManager;
+ /// <summary>The players who are currently disconnecting.</summary>
+ private readonly IList<long> DisconnectingFarmers;
+
+ /// <summary>A callback to invoke when a mod message is received.</summary>
+ private readonly Action<ModMessageModel> OnModMessageReceived;
+
+
+ /*********
+ ** Accessors
+ *********/
+ /// <summary>The metadata for each connected peer.</summary>
+ public IDictionary<long, MultiplayerPeer> Peers { get; } = new Dictionary<long, MultiplayerPeer>();
+
+ /// <summary>The metadata for the host player, if the current player is a farmhand.</summary>
+ public MultiplayerPeer HostPeer;
+
/*********
** Public methods
@@ -22,10 +70,20 @@ namespace StardewModdingAPI.Framework
/// <summary>Construct an instance.</summary>
/// <param name="monitor">Encapsulates monitoring and logging.</param>
/// <param name="eventManager">Manages SMAPI events.</param>
- public SMultiplayer(IMonitor monitor, EventManager eventManager)
+ /// <param name="jsonHelper">Encapsulates SMAPI's JSON file parsing.</param>
+ /// <param name="modRegistry">Tracks the installed mods.</param>
+ /// <param name="reflection">Simplifies access to private code.</param>
+ /// <param name="onModMessageReceived">A callback to invoke when a mod message is received.</param>
+ public SMultiplayer(IMonitor monitor, EventManager eventManager, JsonHelper jsonHelper, ModRegistry modRegistry, Reflector reflection, Action<ModMessageModel> onModMessageReceived)
{
this.Monitor = monitor;
this.EventManager = eventManager;
+ this.JsonHelper = jsonHelper;
+ this.ModRegistry = modRegistry;
+ this.Reflection = reflection;
+ this.OnModMessageReceived = onModMessageReceived;
+
+ this.DisconnectingFarmers = reflection.GetField<List<long>>(this, "disconnectingFarmers").GetValue();
}
/// <summary>Handle sync messages from other players and perform other initial sync logic.</summary>
@@ -43,5 +101,419 @@ namespace StardewModdingAPI.Framework
base.UpdateLate(forceSync);
this.EventManager.Legacy_AfterMainBroadcast.Raise();
}
+
+ /// <summary>Initialise a client before the game connects to a remote server.</summary>
+ /// <param name="client">The client to initialise.</param>
+ public override Client InitClient(Client client)
+ {
+ if (client is LidgrenClient)
+ {
+ string address = this.Reflection.GetField<string>(client, "address").GetValue();
+ return new SLidgrenClient(address, this.OnClientProcessingMessage, this.OnClientSendingMessage);
+ }
+
+ return client;
+ }
+
+ /// <summary>Initialise a server before the game connects to an incoming player.</summary>
+ /// <param name="server">The server to initialise.</param>
+ public override Server InitServer(Server server)
+ {
+ if (server is LidgrenServer)
+ {
+ IGameServer gameServer = this.Reflection.GetField<IGameServer>(server, "gameServer").GetValue();
+ return new SLidgrenServer(gameServer, this.Reflection, this.readFarmer, this.OnServerProcessingMessage, this.OnServerSendingMessage);
+ }
+
+ return server;
+ }
+
+ /// <summary>A callback raised when sending a network message as the host player.</summary>
+ /// <param name="server">The server sending the message.</param>
+ /// <param name="connection">The connection to which a message is being sent.</param>
+ /// <param name="message">The message being sent.</param>
+ /// <param name="resume">Send the underlying message.</param>
+ protected void OnServerSendingMessage(SLidgrenServer server, NetConnection connection, OutgoingMessage message, Action resume)
+ {
+ if (this.Monitor.IsVerbose)
+ this.Monitor.Log($"SERVER SEND {(MessageType)message.MessageType} {message.FarmerID}", LogLevel.Trace);
+
+ resume();
+ }
+
+ /// <summary>A callback raised when sending a message as a farmhand.</summary>
+ /// <param name="client">The client sending the message.</param>
+ /// <param name="message">The message being sent.</param>
+ /// <param name="resume">Send the underlying message.</param>
+ protected void OnClientSendingMessage(SLidgrenClient client, OutgoingMessage message, Action resume)
+ {
+ if (this.Monitor.IsVerbose)
+ this.Monitor.Log($"CLIENT SEND {(MessageType)message.MessageType} {message.FarmerID}", LogLevel.Trace);
+
+ switch (message.MessageType)
+ {
+ // sync mod context (step 1)
+ case (byte)MessageType.PlayerIntroduction:
+ client.sendMessage((byte)MessageType.ModContext, this.GetContextSyncMessageFields());
+ resume();
+ break;
+
+ // run default logic
+ default:
+ resume();
+ break;
+ }
+ }
+
+ /// <summary>Process an incoming network message as the host player.</summary>
+ /// <param name="server">The server instance that received the connection.</param>
+ /// <param name="rawMessage">The raw network message that was received.</param>
+ /// <param name="message">The message to process.</param>
+ /// <param name="resume">Process the message using the game's default logic.</param>
+ public void OnServerProcessingMessage(SLidgrenServer server, NetIncomingMessage rawMessage, IncomingMessage message, Action resume)
+ {
+ if (this.Monitor.IsVerbose)
+ this.Monitor.Log($"SERVER RECV {(MessageType)message.MessageType} {message.FarmerID}", LogLevel.Trace);
+
+ switch (message.MessageType)
+ {
+ // sync mod context (step 2)
+ case (byte)MessageType.ModContext:
+ {
+ // parse message
+ RemoteContextModel model = this.ReadContext(message.Reader);
+ this.Monitor.Log($"Received context for farmhand {message.FarmerID} running {(model != null ? $"SMAPI {model.ApiVersion} with {model.Mods.Length} mods" : "vanilla")}.", LogLevel.Trace);
+
+ // store peer
+ MultiplayerPeer newPeer = MultiplayerPeer.ForConnectionToFarmhand(message.FarmerID, model, server, rawMessage.SenderConnection);
+ if (this.Peers.ContainsKey(message.FarmerID))
+ {
+ this.Monitor.Log($"Rejected mod context from farmhand {message.FarmerID}: already received context for that player.", LogLevel.Error);
+ return;
+ }
+ this.AddPeer(newPeer, canBeHost: false, raiseEvent: false);
+
+ // reply with own context
+ this.Monitor.VerboseLog(" Replying with host context...");
+ newPeer.SendMessage(new OutgoingMessage((byte)MessageType.ModContext, Game1.player.UniqueMultiplayerID, this.GetContextSyncMessageFields()));
+
+ // reply with other players' context
+ foreach (MultiplayerPeer otherPeer in this.Peers.Values.Where(p => p.PlayerID != newPeer.PlayerID))
+ {
+ this.Monitor.VerboseLog($" Replying with context for player {otherPeer.PlayerID}...");
+ newPeer.SendMessage(new OutgoingMessage((byte)MessageType.ModContext, otherPeer.PlayerID, this.GetContextSyncMessageFields(otherPeer)));
+ }
+
+ // forward to other peers
+ if (this.Peers.Count > 1)
+ {
+ object[] fields = this.GetContextSyncMessageFields(newPeer);
+ foreach (MultiplayerPeer otherPeer in this.Peers.Values.Where(p => p.PlayerID != newPeer.PlayerID))
+ {
+ this.Monitor.VerboseLog($" Forwarding context to player {otherPeer.PlayerID}...");
+ otherPeer.SendMessage(new OutgoingMessage((byte)MessageType.ModContext, newPeer.PlayerID, fields));
+ }
+ }
+
+ // raise event
+ this.EventManager.PeerContextReceived.Raise(new PeerContextReceivedEventArgs(newPeer));
+ }
+ break;
+
+ // handle player intro
+ case (byte)MessageType.PlayerIntroduction:
+ // store peer if new
+ if (!this.Peers.ContainsKey(message.FarmerID))
+ {
+ this.Monitor.Log($"Received connection for vanilla player {message.FarmerID}.", LogLevel.Trace);
+ MultiplayerPeer peer = MultiplayerPeer.ForConnectionToFarmhand(message.FarmerID, null, server, rawMessage.SenderConnection);
+ this.AddPeer(peer, canBeHost: false);
+ }
+
+ resume();
+ break;
+
+ // handle mod message
+ case (byte)MessageType.ModMessage:
+ this.ReceiveModMessage(message);
+ break;
+
+ default:
+ resume();
+ break;
+ }
+ }
+
+ /// <summary>Process an incoming network message as a farmhand.</summary>
+ /// <param name="client">The client instance that received the connection.</param>
+ /// <param name="message">The message to process.</param>
+ /// <param name="resume">Process the message using the game's default logic.</param>
+ /// <returns>Returns whether the message was handled.</returns>
+ public void OnClientProcessingMessage(SLidgrenClient client, IncomingMessage message, Action resume)
+ {
+ if (this.Monitor.IsVerbose)
+ this.Monitor.Log($"CLIENT RECV {(MessageType)message.MessageType} {message.FarmerID}", LogLevel.Trace);
+
+ switch (message.MessageType)
+ {
+ // mod context sync (step 4)
+ case (byte)MessageType.ModContext:
+ {
+ // parse message
+ RemoteContextModel model = this.ReadContext(message.Reader);
+ this.Monitor.Log($"Received context for {(model?.IsHost == true ? "host" : "farmhand")} {message.FarmerID} running {(model != null ? $"SMAPI {model.ApiVersion} with {model.Mods.Length} mods" : "vanilla")}.", LogLevel.Trace);
+
+ // store peer
+ MultiplayerPeer peer = MultiplayerPeer.ForConnectionToHost(message.FarmerID, model, client, model?.IsHost ?? this.HostPeer == null);
+ if (peer.IsHost && this.HostPeer != null)
+ {
+ this.Monitor.Log($"Rejected mod context from host player {peer.PlayerID}: already received host data from {(peer.PlayerID == this.HostPeer.PlayerID ? "that player" : $"player {peer.PlayerID}")}.", LogLevel.Error);
+ return;
+ }
+ this.AddPeer(peer, canBeHost: true);
+ }
+ break;
+
+ // handle server intro
+ case (byte)MessageType.ServerIntroduction:
+ {
+ // store peer
+ if (!this.Peers.ContainsKey(message.FarmerID) && this.HostPeer == null)
+ {
+ this.Monitor.Log($"Received connection for vanilla host {message.FarmerID}.", LogLevel.Trace);
+ this.AddPeer(MultiplayerPeer.ForConnectionToHost(message.FarmerID, null, client, isHost: true), canBeHost: false);
+ }
+ resume();
+ break;
+ }
+
+ // handle player intro
+ case (byte)MessageType.PlayerIntroduction:
+ {
+ // store peer
+ if (!this.Peers.TryGetValue(message.FarmerID, out MultiplayerPeer peer))
+ {
+ peer = MultiplayerPeer.ForConnectionToHost(message.FarmerID, null, client, isHost: this.HostPeer == null);
+ this.Monitor.Log($"Received connection for vanilla {(peer.IsHost ? "host" : "farmhand")} {message.FarmerID}.", LogLevel.Trace);
+ this.AddPeer(peer, canBeHost: true);
+ }
+
+ resume();
+ break;
+ }
+
+ // handle mod message
+ case (byte)MessageType.ModMessage:
+ this.ReceiveModMessage(message);
+ break;
+
+ default:
+ resume();
+ break;
+ }
+ }
+
+ /// <summary>Remove players who are disconnecting.</summary>
+ protected override void removeDisconnectedFarmers()
+ {
+ foreach (long playerID in this.DisconnectingFarmers)
+ {
+ if (this.Peers.TryGetValue(playerID, out MultiplayerPeer peer))
+ {
+ this.Monitor.Log($"Player quit: {playerID}", LogLevel.Trace);
+ this.Peers.Remove(playerID);
+ this.EventManager.PeerDisconnected.Raise(new PeerDisconnectedEventArgs(peer));
+ }
+ }
+
+ base.removeDisconnectedFarmers();
+ }
+
+ /// <summary>Broadcast a mod message to matching players.</summary>
+ /// <param name="message">The data to send over the network.</param>
+ /// <param name="messageType">A message type which receiving mods can use to decide whether it's the one they want to handle, like <c>SetPlayerLocation</c>. This doesn't need to be globally unique, since mods should check the originating mod ID.</param>
+ /// <param name="fromModID">The unique ID of the mod sending the message.</param>
+ /// <param name="toModIDs">The mod IDs which should receive the message on the destination computers, or <c>null</c> for all mods. Specifying mod IDs is recommended to improve performance, unless it's a general-purpose broadcast.</param>
+ /// <param name="toPlayerIDs">The <see cref="Farmer.UniqueMultiplayerID" /> values for the players who should receive the message, or <c>null</c> for all players. If you don't need to broadcast to all players, specifying player IDs is recommended to reduce latency.</param>
+ public void BroadcastModMessage<TMessage>(TMessage message, string messageType, string fromModID, string[] toModIDs, long[] toPlayerIDs)
+ {
+ // validate
+ if (message == null)
+ throw new ArgumentNullException(nameof(message));
+ if (string.IsNullOrWhiteSpace(messageType))
+ throw new ArgumentNullException(nameof(messageType));
+ if (string.IsNullOrWhiteSpace(fromModID))
+ throw new ArgumentNullException(nameof(fromModID));
+ if (!this.Peers.Any())
+ {
+ this.Monitor.VerboseLog($"Ignored '{messageType}' broadcast from mod {fromModID}: not connected to any players.");
+ return;
+ }
+
+ // filter player IDs
+ HashSet<long> playerIDs = null;
+ if (toPlayerIDs != null && toPlayerIDs.Any())
+ {
+ playerIDs = new HashSet<long>(toPlayerIDs);
+ playerIDs.RemoveWhere(id => !this.Peers.ContainsKey(id));
+ if (!playerIDs.Any())
+ {
+ this.Monitor.VerboseLog($"Ignored '{messageType}' broadcast from mod {fromModID}: none of the specified player IDs are connected.");
+ return;
+ }
+ }
+
+ // get data to send
+ ModMessageModel model = new ModMessageModel(
+ fromPlayerID: Game1.player.UniqueMultiplayerID,
+ fromModID: fromModID,
+ toModIDs: toModIDs,
+ toPlayerIDs: playerIDs?.ToArray(),
+ type: messageType,
+ data: JToken.FromObject(message)
+ );
+ string data = JsonConvert.SerializeObject(model, Formatting.None);
+
+ // log message
+ if (this.Monitor.IsVerbose)
+ this.Monitor.Log($"Broadcasting '{messageType}' message: {data}.", LogLevel.Trace);
+
+ // send message
+ if (Context.IsMainPlayer)
+ {
+ foreach (MultiplayerPeer peer in this.Peers.Values)
+ {
+ if (playerIDs == null || playerIDs.Contains(peer.PlayerID))
+ {
+ model.ToPlayerIDs = new[] { peer.PlayerID };
+ peer.SendMessage(new OutgoingMessage((byte)MessageType.ModMessage, peer.PlayerID, data));
+ }
+ }
+ }
+ else if (this.HostPeer != null && this.HostPeer.HasSmapi)
+ this.HostPeer.SendMessage(new OutgoingMessage((byte)MessageType.ModMessage, this.HostPeer.PlayerID, data));
+ else
+ this.Monitor.VerboseLog(" Can't send message because no valid connections were found.");
+
+ }
+
+
+ /*********
+ ** Private methods
+ *********/
+ /// <summary>Save a received peer.</summary>
+ /// <param name="peer">The peer to add.</param>
+ /// <param name="canBeHost">Whether to track the peer as the host if applicable.</param>
+ /// <param name="raiseEvent">Whether to raise the <see cref="Events.EventManager.PeerContextReceived"/> event.</param>
+ private void AddPeer(MultiplayerPeer peer, bool canBeHost, bool raiseEvent = true)
+ {
+ // store
+ this.Peers[peer.PlayerID] = peer;
+ if (canBeHost && peer.IsHost)
+ this.HostPeer = peer;
+
+ // raise event
+ if (raiseEvent)
+ this.EventManager.PeerContextReceived.Raise(new PeerContextReceivedEventArgs(peer));
+ }
+
+ /// <summary>Read the metadata context for a player.</summary>
+ /// <param name="reader">The stream reader.</param>
+ private RemoteContextModel ReadContext(BinaryReader reader)
+ {
+ string data = reader.ReadString();
+ RemoteContextModel model = this.JsonHelper.Deserialise<RemoteContextModel>(data);
+ return model.ApiVersion != null
+ ? model
+ : null; // no data available for unmodded players
+ }
+
+ /// <summary>Receive a mod message sent from another player's mods.</summary>
+ /// <param name="message">The raw message to parse.</param>
+ private void ReceiveModMessage(IncomingMessage message)
+ {
+ // parse message
+ string json = message.Reader.ReadString();
+ ModMessageModel model = this.JsonHelper.Deserialise<ModMessageModel>(json);
+ HashSet<long> playerIDs = new HashSet<long>(model.ToPlayerIDs ?? this.GetKnownPlayerIDs());
+ if (this.Monitor.IsVerbose)
+ this.Monitor.Log($"Received message: {json}.");
+
+ // notify local mods
+ if (playerIDs.Contains(Game1.player.UniqueMultiplayerID))
+ this.OnModMessageReceived(model);
+
+ // forward to other players
+ if (Context.IsMainPlayer && playerIDs.Any(p => p != Game1.player.UniqueMultiplayerID))
+ {
+ ModMessageModel newModel = new ModMessageModel(model);
+ foreach (long playerID in playerIDs)
+ {
+ if (playerID != Game1.player.UniqueMultiplayerID && playerID != model.FromPlayerID && this.Peers.TryGetValue(playerID, out MultiplayerPeer peer))
+ {
+ newModel.ToPlayerIDs = new[] { peer.PlayerID };
+ this.Monitor.VerboseLog($" Forwarding message to player {peer.PlayerID}.");
+ peer.SendMessage(new OutgoingMessage((byte)MessageType.ModMessage, peer.PlayerID, this.JsonHelper.Serialise(newModel, Formatting.None)));
+ }
+ }
+ }
+ }
+
+ /// <summary>Get all connected player IDs, including the current player.</summary>
+ private IEnumerable<long> GetKnownPlayerIDs()
+ {
+ yield return Game1.player.UniqueMultiplayerID;
+ foreach (long peerID in this.Peers.Keys)
+ yield return peerID;
+ }
+
+ /// <summary>Get the fields to include in a context sync message sent to other players.</summary>
+ private object[] GetContextSyncMessageFields()
+ {
+ RemoteContextModel model = new RemoteContextModel
+ {
+ IsHost = Context.IsWorldReady && Context.IsMainPlayer,
+ Platform = Constants.TargetPlatform,
+ ApiVersion = Constants.ApiVersion,
+ GameVersion = Constants.GameVersion,
+ Mods = this.ModRegistry
+ .GetAll()
+ .Select(mod => new RemoteContextModModel
+ {
+ ID = mod.Manifest.UniqueID,
+ Name = mod.Manifest.Name,
+ Version = mod.Manifest.Version
+ })
+ .ToArray()
+ };
+
+ return new object[] { this.JsonHelper.Serialise(model, Formatting.None) };
+ }
+
+ /// <summary>Get the fields to include in a context sync message sent to other players.</summary>
+ /// <param name="peer">The peer whose data to represent.</param>
+ private object[] GetContextSyncMessageFields(IMultiplayerPeer peer)
+ {
+ if (!peer.HasSmapi)
+ return new object[] { "{}" };
+
+ RemoteContextModel model = new RemoteContextModel
+ {
+ IsHost = peer.IsHost,
+ Platform = peer.Platform.Value,
+ ApiVersion = peer.ApiVersion,
+ GameVersion = peer.GameVersion,
+ Mods = peer.Mods
+ .Select(mod => new RemoteContextModModel
+ {
+ ID = mod.ID,
+ Name = mod.Name,
+ Version = mod.Version
+ })
+ .ToArray()
+ };
+
+ return new object[] { this.JsonHelper.Serialise(model, Formatting.None) };
+ }
}
}
diff --git a/src/SMAPI/IMonitor.cs b/src/SMAPI/IMonitor.cs
index 62c479bc..0f153e10 100644
--- a/src/SMAPI/IMonitor.cs
+++ b/src/SMAPI/IMonitor.cs
@@ -1,4 +1,4 @@
-namespace StardewModdingAPI
+namespace StardewModdingAPI
{
/// <summary>Encapsulates monitoring and logging for a given module.</summary>
public interface IMonitor
@@ -9,6 +9,9 @@
/// <summary>Whether SMAPI is aborting. Mods don't need to worry about this unless they have background tasks.</summary>
bool IsExiting { get; }
+ /// <summary>Whether verbose logging is enabled. This enables more detailed diagnostic messages than are normally needed.</summary>
+ bool IsVerbose { get; }
+
/*********
** Methods
@@ -18,6 +21,10 @@
/// <param name="level">The log severity level.</param>
void Log(string message, LogLevel level = LogLevel.Debug);
+ /// <summary>Log a message that only appears when <see cref="IsVerbose"/> is enabled.</summary>
+ /// <param name="message">The message to log.</param>
+ void VerboseLog(string message);
+
/// <summary>Immediately exit the game without saving. This should only be invoked when an irrecoverable fatal error happens that risks save corruption or game-breaking bugs.</summary>
/// <param name="reason">The reason for the shutdown.</param>
void ExitGameImmediately(string reason);
diff --git a/src/SMAPI/IMultiplayerHelper.cs b/src/SMAPI/IMultiplayerHelper.cs
index 43a0ac95..4067a676 100644
--- a/src/SMAPI/IMultiplayerHelper.cs
+++ b/src/SMAPI/IMultiplayerHelper.cs
@@ -1,3 +1,4 @@
+using System;
using System.Collections.Generic;
using StardewValley;
@@ -11,5 +12,22 @@ namespace StardewModdingAPI
/// <summary>Get the locations which are being actively synced from the host.</summary>
IEnumerable<GameLocation> GetActiveLocations();
+
+ /// <summary>Get a connected player.</summary>
+ /// <param name="id">The player's unique ID.</param>
+ /// <returns>Returns the connected player, or <c>null</c> if no such player is connected.</returns>
+ IMultiplayerPeer GetConnectedPlayer(long id);
+
+ /// <summary>Get all connected players.</summary>
+ IEnumerable<IMultiplayerPeer> GetConnectedPlayers();
+
+ /// <summary>Send a message to mods installed by connected players.</summary>
+ /// <typeparam name="TMessage">The data type. This can be a class with a default constructor, or a value type.</typeparam>
+ /// <param name="message">The data to send over the network.</param>
+ /// <param name="messageType">A message type which receiving mods can use to decide whether it's the one they want to handle, like <c>SetPlayerLocation</c>. This doesn't need to be globally unique, since mods should check the originating mod ID.</param>
+ /// <param name="modIDs">The mod IDs which should receive the message on the destination computers, or <c>null</c> for all mods. Specifying mod IDs is recommended to improve performance, unless it's a general-purpose broadcast.</param>
+ /// <param name="playerIDs">The <see cref="Farmer.UniqueMultiplayerID" /> values for the players who should receive the message, or <c>null</c> for all players. If you don't need to broadcast to all players, specifying player IDs is recommended to reduce latency.</param>
+ /// <exception cref="ArgumentNullException">The <paramref name="message"/> or <paramref name="messageType" /> is null.</exception>
+ void SendMessage<TMessage>(TMessage message, string messageType, string[] modIDs = null, long[] playerIDs = null);
}
}
diff --git a/src/SMAPI/IMultiplayerPeer.cs b/src/SMAPI/IMultiplayerPeer.cs
new file mode 100644
index 00000000..0d4d3261
--- /dev/null
+++ b/src/SMAPI/IMultiplayerPeer.cs
@@ -0,0 +1,41 @@
+using System.Collections.Generic;
+
+namespace StardewModdingAPI
+{
+ /// <summary>Metadata about a connected player.</summary>
+ public interface IMultiplayerPeer
+ {
+ /*********
+ ** Accessors
+ *********/
+ /// <summary>The player's unique ID.</summary>
+ long PlayerID { get; }
+
+ /// <summary>Whether this is a connection to the host player.</summary>
+ bool IsHost { get; }
+
+ /// <summary>Whether the player has SMAPI installed.</summary>
+ bool HasSmapi { get; }
+
+ /// <summary>The player's OS platform, if <see cref="HasSmapi"/> is true.</summary>
+ GamePlatform? Platform { get; }
+
+ /// <summary>The installed version of Stardew Valley, if <see cref="HasSmapi"/> is true.</summary>
+ ISemanticVersion GameVersion { get; }
+
+ /// <summary>The installed version of SMAPI, if <see cref="HasSmapi"/> is true.</summary>
+ ISemanticVersion ApiVersion { get; }
+
+ /// <summary>The installed mods, if <see cref="HasSmapi"/> is true.</summary>
+ IEnumerable<IMultiplayerPeerMod> Mods { get; }
+
+
+ /*********
+ ** Methods
+ *********/
+ /// <summary>Get metadata for a mod installed by the player.</summary>
+ /// <param name="id">The unique mod ID.</param>
+ /// <returns>Returns the mod info, or <c>null</c> if the player doesn't have that mod.</returns>
+ IMultiplayerPeerMod GetMod(string id);
+ }
+}
diff --git a/src/SMAPI/IMultiplayerPeerMod.cs b/src/SMAPI/IMultiplayerPeerMod.cs
new file mode 100644
index 00000000..005408b1
--- /dev/null
+++ b/src/SMAPI/IMultiplayerPeerMod.cs
@@ -0,0 +1,15 @@
+namespace StardewModdingAPI
+{
+ /// <summary>Metadata about a mod installed by a connected player.</summary>
+ public interface IMultiplayerPeerMod
+ {
+ /// <summary>The mod's display name.</summary>
+ string Name { get; }
+
+ /// <summary>The unique mod ID.</summary>
+ string ID { get; }
+
+ /// <summary>The mod version.</summary>
+ ISemanticVersion Version { get; }
+ }
+}
diff --git a/src/SMAPI/Patches/LidgrenServerPatch.cs b/src/SMAPI/Patches/LidgrenServerPatch.cs
new file mode 100644
index 00000000..6f937665
--- /dev/null
+++ b/src/SMAPI/Patches/LidgrenServerPatch.cs
@@ -0,0 +1,89 @@
+using System;
+using System.Diagnostics.CodeAnalysis;
+using System.IO;
+using System.Reflection;
+using Harmony;
+using Lidgren.Network;
+using StardewModdingAPI.Framework;
+using StardewModdingAPI.Framework.Networking;
+using StardewModdingAPI.Framework.Patching;
+using StardewValley;
+using StardewValley.Network;
+
+namespace StardewModdingAPI.Patches
+{
+ /// <summary>A Harmony patch to let SMAPI override <see cref="LidgrenServer"/> methods.</summary>
+ internal class LidgrenServerPatch : IHarmonyPatch
+ {
+ /*********
+ ** Accessors
+ *********/
+ /// <summary>A unique name for this patch.</summary>
+ public string Name => $"{nameof(LidgrenServerPatch)}";
+
+
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Apply the Harmony patch.</summary>
+ /// <param name="harmony">The Harmony instance.</param>
+ public void Apply(HarmonyInstance harmony)
+ {
+ // override parseDataMessageFromClient
+ {
+ MethodInfo method = AccessTools.Method(typeof(LidgrenServer), "parseDataMessageFromClient");
+ MethodInfo prefix = AccessTools.Method(this.GetType(), nameof(LidgrenServerPatch.Prefix_LidgrenServer_ParseDataMessageFromClient));
+ harmony.Patch(method, new HarmonyMethod(prefix), null);
+ }
+
+ // override sendMessage
+ {
+ MethodInfo method = typeof(LidgrenServer).GetMethod("sendMessage", BindingFlags.NonPublic | BindingFlags.Instance, null, new [] { typeof(NetConnection), typeof(OutgoingMessage) }, null);
+ MethodInfo prefix = AccessTools.Method(this.GetType(), nameof(LidgrenServerPatch.Prefix_LidgrenServer_SendMessage));
+ harmony.Patch(method, new HarmonyMethod(prefix), null);
+ }
+ }
+
+
+ /*********
+ ** Private methods
+ *********/
+ /// <summary>The method to call instead of the <see cref="LidgrenServer.parseDataMessageFromClient"/> method.</summary>
+ /// <param name="__instance">The instance being patched.</param>
+ /// <param name="dataMsg">The raw network message to parse.</param>
+ /// <param name="___peers">The private <c>peers</c> field on the <paramref name="__instance"/> instance.</param>
+ /// <param name="___gameServer">The private <c>gameServer</c> field on the <paramref name="__instance"/> instance.</param>
+ /// <returns>Returns whether to execute the original method.</returns>
+ /// <remarks>This method must be static for Harmony to work correctly. See the Harmony documentation before renaming arguments.</remarks>
+ [SuppressMessage("ReSharper", "InconsistentNaming", Justification = "Argument names are defined by Harmony.")]
+ private static bool Prefix_LidgrenServer_ParseDataMessageFromClient(LidgrenServer __instance, NetIncomingMessage dataMsg, Bimap<long, NetConnection> ___peers, IGameServer ___gameServer)
+ {
+ if (__instance is SLidgrenServer smapiServer)
+ {
+ smapiServer.ParseDataMessageFromClient(dataMsg);
+ return false;
+ }
+
+ return true;
+ }
+
+ /// <summary>The method to call instead of the <see cref="LidgrenServer.sendMessage"/> method.</summary>
+ /// <param name="__instance">The instance being patched.</param>
+ /// <param name="connection">The connection to which to send the message.</param>
+ /// <param name="___peers">The private <c>peers</c> field on the <paramref name="__instance"/> instance.</param>
+ /// <param name="___gameServer">The private <c>gameServer</c> field on the <paramref name="__instance"/> instance.</param>
+ /// <returns>Returns whether to execute the original method.</returns>
+ /// <remarks>This method must be static for Harmony to work correctly. See the Harmony documentation before renaming arguments.</remarks>
+ [SuppressMessage("ReSharper", "InconsistentNaming", Justification = "Argument names are defined by Harmony.")]
+ private static bool Prefix_LidgrenServer_SendMessage(LidgrenServer __instance, NetConnection connection, OutgoingMessage message, Bimap<long, NetConnection> ___peers, IGameServer ___gameServer)
+ {
+ if (__instance is SLidgrenServer smapiServer)
+ {
+ smapiServer.SendMessage(connection, message);
+ return false;
+ }
+
+ return true;
+ }
+ }
+}
diff --git a/src/SMAPI/SemanticVersion.cs b/src/SMAPI/SemanticVersion.cs
index 587ff286..401f62c2 100644
--- a/src/SMAPI/SemanticVersion.cs
+++ b/src/SMAPI/SemanticVersion.cs
@@ -1,5 +1,6 @@
using System;
using Newtonsoft.Json;
+using StardewModdingAPI.Framework;
namespace StardewModdingAPI
{
@@ -12,6 +13,9 @@ namespace StardewModdingAPI
/// <summary>The underlying semantic version implementation.</summary>
private readonly ISemanticVersion Version;
+ /// <summary>Manages deprecation warnings.</summary>
+ internal static DeprecationManager DeprecationManager { get; set; }
+
/*********
** Accessors
@@ -26,7 +30,18 @@ namespace StardewModdingAPI
public int PatchVersion => this.Version.PatchVersion;
/// <summary>An optional build tag.</summary>
- public string Build => this.Version.Build;
+ [Obsolete("Use " + nameof(ISemanticVersion.PrereleaseTag) + " instead")]
+ public string Build
+ {
+ get
+ {
+ SemanticVersion.DeprecationManager?.Warn($"{nameof(ISemanticVersion)}.{nameof(ISemanticVersion.Build)}", "2.8", DeprecationLevel.Notice);
+ return this.Version.PrereleaseTag;
+ }
+ }
+
+ /// <summary>An optional prerelease tag.</summary>
+ public string PrereleaseTag => this.Version.PrereleaseTag;
/*********
@@ -70,7 +85,7 @@ namespace StardewModdingAPI
/// <summary>Get an integer indicating whether this version precedes (less than 0), supercedes (more than 0), or is equivalent to (0) the specified version.</summary>
/// <param name="other">The version to compare with this instance.</param>
/// <exception cref="ArgumentNullException">The <paramref name="other"/> value is null.</exception>
- /// <remarks>The implementation is defined by Semantic Version 2.0 (http://semver.org/).</remarks>
+ /// <remarks>The implementation is defined by Semantic Version 2.0 (https://semver.org/).</remarks>
public int CompareTo(ISemanticVersion other)
{
return this.Version.CompareTo(other);
diff --git a/src/SMAPI/StardewModdingAPI.csproj b/src/SMAPI/StardewModdingAPI.csproj
index 4ce0892e..7f6444d1 100644
--- a/src/SMAPI/StardewModdingAPI.csproj
+++ b/src/SMAPI/StardewModdingAPI.csproj
@@ -55,14 +55,13 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="LargeAddressAware" Version="1.0.3" />
- <PackageReference Include="Mono.Cecil" Version="0.10.0" />
+ <PackageReference Include="Lib.Harmony">
+ <Version>1.2.0.1</Version>
+ </PackageReference>
+ <PackageReference Include="Mono.Cecil" Version="0.10.1" />
<PackageReference Include="Newtonsoft.Json" Version="11.0.2" />
</ItemGroup>
<ItemGroup>
- <Reference Include="0Harmony, Version=1.0.9.1, Culture=neutral, processorArchitecture=MSIL">
- <SpecificVersion>False</SpecificVersion>
- <HintPath>..\lib\0Harmony.dll</HintPath>
- </Reference>
<Reference Include="System" />
<Reference Include="System.Core" />
<Reference Include="System.Drawing" />
@@ -116,6 +115,7 @@
<Compile Include="Events\IGameLoopEvents.cs" />
<Compile Include="Events\IInputEvents.cs" />
<Compile Include="Events\IModEvents.cs" />
+ <Compile Include="Events\IMultiplayerEvents.cs" />
<Compile Include="Events\InputEvents.cs" />
<Compile Include="Events\InventoryChangedEventArgs.cs" />
<Compile Include="Events\IPlayerEvents.cs" />
@@ -130,10 +130,13 @@
<Compile Include="Events\MenuChangedEventArgs.cs" />
<Compile Include="Events\MenuEvents.cs" />
<Compile Include="Events\MineEvents.cs" />
+ <Compile Include="Events\ModMessageReceivedEventArgs.cs" />
<Compile Include="Events\MouseWheelScrolledEventArgs.cs" />
<Compile Include="Events\MultiplayerEvents.cs" />
<Compile Include="Events\NpcListChangedEventArgs.cs" />
<Compile Include="Events\ObjectListChangedEventArgs.cs" />
+ <Compile Include="Events\PeerContextReceivedEventArgs.cs" />
+ <Compile Include="Events\PeerDisconnectedEventArgs.cs" />
<Compile Include="Events\PlayerEvents.cs" />
<Compile Include="Events\RenderedActiveMenuEventArgs.cs" />
<Compile Include="Events\RenderedEventArgs.cs" />
@@ -160,10 +163,28 @@
<Compile Include="Events\UpdateTickingEventArgs.cs" />
<Compile Include="Events\WarpedEventArgs.cs" />
<Compile Include="Events\WindowResizedEventArgs.cs" />
- <Compile Include="Framework\Events\ModPlayerEvents.cs" />
+ <Compile Include="Framework\DeprecationWarning.cs" />
+ <Compile Include="Framework\Events\EventManager.cs" />
+ <Compile Include="Framework\Events\ManagedEvent.cs" />
+ <Compile Include="Framework\Events\ManagedEventBase.cs" />
<Compile Include="Framework\Events\ModDisplayEvents.cs" />
+ <Compile Include="Framework\Events\ModEvents.cs" />
+ <Compile Include="Framework\Events\ModEventsBase.cs" />
+ <Compile Include="Framework\Events\ModGameLoopEvents.cs" />
+ <Compile Include="Framework\Events\ModInputEvents.cs" />
+ <Compile Include="Framework\Events\ModMultiplayerEvents.cs" />
+ <Compile Include="Framework\Events\ModPlayerEvents.cs" />
<Compile Include="Framework\Events\ModSpecialisedEvents.cs" />
+ <Compile Include="Framework\Events\ModWorldEvents.cs" />
+ <Compile Include="Framework\Networking\MessageType.cs" />
<Compile Include="Framework\ModHelpers\DataHelper.cs" />
+ <Compile Include="Framework\Networking\ModMessageModel.cs" />
+ <Compile Include="Framework\Networking\MultiplayerPeer.cs" />
+ <Compile Include="Framework\Networking\MultiplayerPeerMod.cs" />
+ <Compile Include="Framework\Networking\RemoteContextModel.cs" />
+ <Compile Include="Framework\Networking\RemoteContextModModel.cs" />
+ <Compile Include="Framework\Networking\SLidgrenClient.cs" />
+ <Compile Include="Framework\Networking\SLidgrenServer.cs" />
<Compile Include="Framework\SCore.cs" />
<Compile Include="Framework\SGameConstructorHack.cs" />
<Compile Include="Framework\ContentManagers\BaseContentManager.cs" />
@@ -177,15 +198,8 @@
<Compile Include="Framework\Serialisation\ColorConverter.cs" />
<Compile Include="Framework\Serialisation\PointConverter.cs" />
<Compile Include="Framework\Serialisation\RectangleConverter.cs" />
- <Compile Include="Framework\Events\ModEventsBase.cs" />
- <Compile Include="Framework\Events\EventManager.cs" />
- <Compile Include="Framework\Events\ManagedEvent.cs" />
<Compile Include="Framework\ContentPack.cs" />
<Compile Include="Framework\Content\ContentCache.cs" />
- <Compile Include="Framework\Events\ManagedEventBase.cs" />
- <Compile Include="Framework\Events\ModEvents.cs" />
- <Compile Include="Framework\Events\ModGameLoopEvents.cs" />
- <Compile Include="Framework\Events\ModInputEvents.cs" />
<Compile Include="Framework\Input\GamePadStateBuilder.cs" />
<Compile Include="Framework\ModHelpers\InputHelper.cs" />
<Compile Include="Framework\SModHooks.cs" />
@@ -218,7 +232,6 @@
<Compile Include="Framework\ModLoading\Rewriters\TypeReferenceRewriter.cs" />
<Compile Include="Framework\Exceptions\SAssemblyLoadFailedException.cs" />
<Compile Include="Framework\ModLoading\AssemblyLoadStatus.cs" />
- <Compile Include="Framework\Events\ModWorldEvents.cs" />
<Compile Include="Framework\Reflection\InterfaceProxyBuilder.cs" />
<Compile Include="Framework\Reflection\InterfaceProxyFactory.cs" />
<Compile Include="Framework\RewriteFacades\SpriteBatchMethods.cs" />
@@ -243,9 +256,11 @@
<Compile Include="IContentPack.cs" />
<Compile Include="IModInfo.cs" />
<Compile Include="IMultiplayerHelper.cs" />
+ <Compile Include="IMultiplayerPeer.cs" />
<Compile Include="IReflectedField.cs" />
<Compile Include="IReflectedMethod.cs" />
<Compile Include="IReflectedProperty.cs" />
+ <Compile Include="IMultiplayerPeerMod.cs" />
<Compile Include="Metadata\CoreAssetPropagator.cs" />
<Compile Include="ContentSource.cs" />
<Compile Include="Framework\Content\AssetInfo.cs" />
@@ -310,6 +325,7 @@
<Compile Include="Metadata\InstructionMetadata.cs" />
<Compile Include="Mod.cs" />
<Compile Include="Patches\DialogueErrorPatch.cs" />
+ <Compile Include="Patches\LidgrenServerPatch.cs" />
<Compile Include="PatchMode.cs" />
<Compile Include="GamePlatform.cs" />
<Compile Include="Program.cs" />
diff --git a/src/StardewModdingAPI.Toolkit.CoreInterfaces/ISemanticVersion.cs b/src/StardewModdingAPI.Toolkit.CoreInterfaces/ISemanticVersion.cs
index 961ef777..6631b01d 100644
--- a/src/StardewModdingAPI.Toolkit.CoreInterfaces/ISemanticVersion.cs
+++ b/src/StardewModdingAPI.Toolkit.CoreInterfaces/ISemanticVersion.cs
@@ -18,8 +18,12 @@ namespace StardewModdingAPI
int PatchVersion { get; }
/// <summary>An optional build tag.</summary>
+ [Obsolete("Use " + nameof(ISemanticVersion.PrereleaseTag) + " instead")]
string Build { get; }
+ /// <summary>An optional prerelease tag.</summary>
+ string PrereleaseTag { get; }
+
/*********
** Accessors
diff --git a/src/StardewModdingAPI.Toolkit/SemanticVersion.cs b/src/StardewModdingAPI.Toolkit/SemanticVersion.cs
index 2a78d2f0..a7990d13 100644
--- a/src/StardewModdingAPI.Toolkit/SemanticVersion.cs
+++ b/src/StardewModdingAPI.Toolkit/SemanticVersion.cs
@@ -5,7 +5,7 @@ namespace StardewModdingAPI.Toolkit
{
/// <summary>A semantic version with an optional release tag.</summary>
/// <remarks>
- /// The implementation is defined by Semantic Version 2.0 (http://semver.org/), with a few deviations:
+ /// The implementation is defined by Semantic Version 2.0 (https://semver.org/), with a few deviations:
/// - short-form "x.y" versions are supported (equivalent to "x.y.0");
/// - hyphens are synonymous with dots in prerelease tags (like "-unofficial.3-pathoschild");
/// - +build suffixes are not supported;
@@ -40,7 +40,14 @@ namespace StardewModdingAPI.Toolkit
public int PatchVersion { get; }
/// <summary>An optional prerelease tag.</summary>
- public string Build { get; }
+ [Obsolete("Use " + nameof(ISemanticVersion.PrereleaseTag) + " instead")]
+ public string Build => this.PrereleaseTag;
+
+ /// <summary>An optional prerelease tag.</summary>
+ public string PrereleaseTag { get; }
+
+ /// <summary>Whether the version was parsed from the legacy object format.</summary>
+ public bool IsLegacyFormat { get; }
/*********
@@ -51,12 +58,14 @@ namespace StardewModdingAPI.Toolkit
/// <param name="minor">The minor version incremented for backwards-compatible changes.</param>
/// <param name="patch">The patch version for backwards-compatible fixes.</param>
/// <param name="tag">An optional prerelease tag.</param>
- public SemanticVersion(int major, int minor, int patch, string tag = null)
+ /// <param name="isLegacyFormat">Whether the version was parsed from the legacy object format.</param>
+ public SemanticVersion(int major, int minor, int patch, string tag = null, bool isLegacyFormat = false)
{
this.MajorVersion = major;
this.MinorVersion = minor;
this.PatchVersion = patch;
- this.Build = this.GetNormalisedTag(tag);
+ this.PrereleaseTag = this.GetNormalisedTag(tag);
+ this.IsLegacyFormat = isLegacyFormat;
this.AssertValid();
}
@@ -93,7 +102,7 @@ namespace StardewModdingAPI.Toolkit
this.MajorVersion = int.Parse(match.Groups["major"].Value);
this.MinorVersion = match.Groups["minor"].Success ? int.Parse(match.Groups["minor"].Value) : 0;
this.PatchVersion = match.Groups["patch"].Success ? int.Parse(match.Groups["patch"].Value) : 0;
- this.Build = match.Groups["prerelease"].Success ? this.GetNormalisedTag(match.Groups["prerelease"].Value) : null;
+ this.PrereleaseTag = match.Groups["prerelease"].Success ? this.GetNormalisedTag(match.Groups["prerelease"].Value) : null;
this.AssertValid();
}
diff --git a/src/StardewModdingAPI.Toolkit/Serialisation/Converters/SemanticVersionConverter.cs b/src/StardewModdingAPI.Toolkit/Serialisation/Converters/SemanticVersionConverter.cs
index 070f2c97..e0e185c9 100644
--- a/src/StardewModdingAPI.Toolkit/Serialisation/Converters/SemanticVersionConverter.cs
+++ b/src/StardewModdingAPI.Toolkit/Serialisation/Converters/SemanticVersionConverter.cs
@@ -70,7 +70,7 @@ namespace StardewModdingAPI.Toolkit.Serialisation.Converters
if (build == "0")
build = null; // '0' from incorrect examples in old SMAPI documentation
- return new SemanticVersion(major, minor, patch, build);
+ return new SemanticVersion(major, minor, patch, build, isLegacyFormat: true);
}
/// <summary>Read a JSON string.</summary>
diff --git a/src/StardewModdingAPI.Toolkit/StardewModdingAPI.Toolkit.csproj b/src/StardewModdingAPI.Toolkit/StardewModdingAPI.Toolkit.csproj
index 91aae3e6..3fa28d19 100644
--- a/src/StardewModdingAPI.Toolkit/StardewModdingAPI.Toolkit.csproj
+++ b/src/StardewModdingAPI.Toolkit/StardewModdingAPI.Toolkit.csproj
@@ -12,7 +12,7 @@
</ItemGroup>
<ItemGroup>
- <PackageReference Include="HtmlAgilityPack" Version="1.8.7" />
+ <PackageReference Include="HtmlAgilityPack" Version="1.8.9" />
<PackageReference Include="Newtonsoft.Json" Version="11.0.2" />
<PackageReference Include="Pathoschild.Http.FluentClient" Version="3.2.0" />
</ItemGroup>
diff --git a/src/lib/0Harmony.dll b/src/lib/0Harmony.dll
deleted file mode 100644
index 63619429..00000000
--- a/src/lib/0Harmony.dll
+++ /dev/null
Binary files differ
diff --git a/src/lib/0Harmony.pdb b/src/lib/0Harmony.pdb
deleted file mode 100644
index d7a4c67c..00000000
--- a/src/lib/0Harmony.pdb
+++ /dev/null
Binary files differ