summaryrefslogtreecommitdiff
path: root/src/SMAPI/Framework
diff options
context:
space:
mode:
Diffstat (limited to 'src/SMAPI/Framework')
-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.cs2
-rw-r--r--src/SMAPI/Framework/ModRegistry.cs2
-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.cs14
-rw-r--r--src/SMAPI/Framework/SGame.cs32
-rw-r--r--src/SMAPI/Framework/SMultiplayer.cs487
21 files changed, 1164 insertions, 23 deletions
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..3ff70d64 100644
--- a/src/SMAPI/Framework/ModLoading/ModResolver.cs
+++ b/src/SMAPI/Framework/ModLoading/ModResolver.cs
@@ -379,7 +379,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/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..e703dbb1
--- /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))
+ 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..f078acba 100644
--- a/src/SMAPI/Framework/SCore.cs
+++ b/src/SMAPI/Framework/SCore.cs
@@ -161,7 +161,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 +209,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.InitialiseAfterGameStart, this.Dispose, this.Settings.VerboseLogging);
StardewValley.Program.gamePtr = this.GameInstance;
// add exit handler
@@ -339,9 +340,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);
@@ -749,7 +747,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))
@@ -906,7 +904,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;
@@ -970,7 +968,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();
diff --git a/src/SMAPI/Framework/SGame.cs b/src/SMAPI/Framework/SGame.cs
index 57f48d11..c7f5962f 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>Whether SMAPI should log more information about the game context.</summary>
+ private readonly bool VerboseLogging;
+
/// <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="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)
+ /// <param name="verboseLogging">Whether SMAPI should log more information about the game context.</param>
+ internal SGame(IMonitor monitor, IMonitor monitorForGame, Reflector reflection, EventManager eventManager, JsonHelper jsonHelper, ModRegistry modRegistry, Action onGameInitialised, Action onGameExiting, bool verboseLogging)
{
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.OnGameInitialised = onGameInitialised;
this.OnGameExiting = onGameExiting;
+ this.VerboseLogging = verboseLogging;
Game1.input = new SInputState();
- Game1.multiplayer = new SMultiplayer(monitor, eventManager);
+ Game1.multiplayer = new SMultiplayer(monitor, eventManager, jsonHelper, modRegistry, reflection, this.VerboseLogging, 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>
diff --git a/src/SMAPI/Framework/SMultiplayer.cs b/src/SMAPI/Framework/SMultiplayer.cs
index 4923a202..2d0f8b9b 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,37 @@ 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>Whether SMAPI should log more detailed information.</summary>
+ private readonly bool VerboseLogging;
+
+ /// <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 +73,22 @@ 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="verboseLogging">Whether SMAPI should log more detailed information.</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, bool verboseLogging, Action<ModMessageModel> onModMessageReceived)
{
this.Monitor = monitor;
this.EventManager = eventManager;
+ this.JsonHelper = jsonHelper;
+ this.ModRegistry = modRegistry;
+ this.Reflection = reflection;
+ this.VerboseLogging = verboseLogging;
+ 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 +106,427 @@ 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.VerboseLogging)
+ 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.VerboseLogging)
+ 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.VerboseLogging)
+ 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.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.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.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.VerboseLogging)
+ 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.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.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.VerboseLogging)
+ 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.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.VerboseLogging)
+ 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.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) };
+ }
+
+ /// <summary>Log a trace message if <see cref="VerboseLogging"/> is enabled.</summary>
+ /// <param name="message">The message to log.</param>
+ private void VerboseLog(string message)
+ {
+ if (this.VerboseLogging)
+ this.Monitor.Log(message, LogLevel.Trace);
+ }
}
}