diff options
-rw-r--r-- | build/common.targets | 8 | ||||
-rw-r--r-- | src/SMAPI/Framework/ModHelpers/MultiplayerHelper.cs | 17 | ||||
-rw-r--r-- | src/SMAPI/Framework/Networking/MultiplayerPeer.cs | 128 | ||||
-rw-r--r-- | src/SMAPI/Framework/Networking/MultiplayerPeerMod.cs | 30 | ||||
-rw-r--r-- | src/SMAPI/Framework/Networking/RemoteContextModModel.cs | 15 | ||||
-rw-r--r-- | src/SMAPI/Framework/Networking/RemoteContextModel.cs | 24 | ||||
-rw-r--r-- | src/SMAPI/Framework/Networking/SLidgrenClient.cs | 58 | ||||
-rw-r--r-- | src/SMAPI/Framework/Networking/SLidgrenServer.cs | 36 | ||||
-rw-r--r-- | src/SMAPI/Framework/SCore.cs | 5 | ||||
-rw-r--r-- | src/SMAPI/Framework/SGame.cs | 11 | ||||
-rw-r--r-- | src/SMAPI/Framework/SMultiplayer.cs | 246 | ||||
-rw-r--r-- | src/SMAPI/IMultiplayerHelper.cs | 11 | ||||
-rw-r--r-- | src/SMAPI/IMultiplayerPeer.cs | 41 | ||||
-rw-r--r-- | src/SMAPI/IMultiplayerPeerMod.cs | 15 | ||||
-rw-r--r-- | src/SMAPI/Patches/NetworkingPatch.cs | 103 | ||||
-rw-r--r-- | src/SMAPI/StardewModdingAPI.csproj | 9 |
16 files changed, 749 insertions, 8 deletions
diff --git a/build/common.targets b/build/common.targets index b5cbbe67..e646e62c 100644 --- a/build/common.targets +++ b/build/common.targets @@ -56,6 +56,14 @@ </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> diff --git a/src/SMAPI/Framework/ModHelpers/MultiplayerHelper.cs b/src/SMAPI/Framework/ModHelpers/MultiplayerHelper.cs index c449a51b..86f8e012 100644 --- a/src/SMAPI/Framework/ModHelpers/MultiplayerHelper.cs +++ b/src/SMAPI/Framework/ModHelpers/MultiplayerHelper.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using StardewModdingAPI.Framework.Networking; using StardewValley; namespace StardewModdingAPI.Framework.ModHelpers @@ -36,5 +37,21 @@ namespace StardewModdingAPI.Framework.ModHelpers { return this.Multiplayer.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.Peers.TryGetValue(id, out MultiplayerPeer peer) + ? peer + : null; + } + + /// <summary>Get all connected players.</summary> + public IEnumerable<IMultiplayerPeer> GetConnectedPlayers() + { + return this.Multiplayer.Peers.Values; + } } } diff --git a/src/SMAPI/Framework/Networking/MultiplayerPeer.cs b/src/SMAPI/Framework/Networking/MultiplayerPeer.cs new file mode 100644 index 00000000..e97e36bc --- /dev/null +++ b/src/SMAPI/Framework/Networking/MultiplayerPeer.cs @@ -0,0 +1,128 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Lidgren.Network; +using StardewValley; +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 IsHostPlayer => this.PlayerID == Game1.MasterPlayer.UniqueMultiplayerID; + + /// <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> + public MultiplayerPeer(long playerID, RemoteContextModel model, SLidgrenServer server, NetConnection serverConnection, SLidgrenClient client) + { + this.PlayerID = playerID; + 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 + ); + } + + /// <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> + public static MultiplayerPeer ForConnectionToHost(long playerID, RemoteContextModel model, SLidgrenClient client) + { + return new MultiplayerPeer( + playerID: playerID, + model: model, + server: null, + serverConnection: null, + client: client + ); + } + + /// <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.IsHostPlayer) + 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..9dfdba15 --- /dev/null +++ b/src/SMAPI/Framework/Networking/SLidgrenClient.cs @@ -0,0 +1,58 @@ +using System; +using StardewValley; +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"/> that adds support for SMAPI's metadata context exchange.</summary> + internal class SLidgrenClient : LidgrenClient + { + /********* + ** Properties + *********/ + /// <summary>Get the metadata to include in a metadata message sent to other players.</summary> + private readonly Func<object[]> GetMetadataMessageFields; + + /// <summary>The method to call when receiving a custom SMAPI message from the server, which returns whether the message was processed.</summary> + private readonly Func<SLidgrenClient, IncomingMessage, bool> TryProcessMessage; + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="address">The remote address being connected.</param> + /// <param name="getMetadataMessageFields">Get the metadata to include in a metadata message sent to other players.</param> + /// <param name="tryProcessMessage">The method to call when receiving a custom SMAPI message from the server, which returns whether the message was processed..</param> + public SLidgrenClient(string address, Func<object[]> getMetadataMessageFields, Func<SLidgrenClient, IncomingMessage, bool> tryProcessMessage) + : base(address) + { + this.GetMetadataMessageFields = getMetadataMessageFields; + this.TryProcessMessage = tryProcessMessage; + } + + /// <summary>Send the metadata needed to connect with a remote server.</summary> + public override void sendPlayerIntroduction() + { + // send custom intro + if (this.getUserID() != "") + Game1.player.userID.Value = this.getUserID(); + this.sendMessage(SMultiplayer.ContextSyncMessageID, this.GetMetadataMessageFields()); + base.sendPlayerIntroduction(); + } + + + /********* + ** Protected methods + *********/ + /// <summary>Process an incoming network message.</summary> + /// <param name="message">The message to process.</param> + protected override void processIncomingMessage(IncomingMessage message) + { + if (this.TryProcessMessage(this, message)) + return; + + 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..971eb66d --- /dev/null +++ b/src/SMAPI/Framework/Networking/SLidgrenServer.cs @@ -0,0 +1,36 @@ +using System.Reflection; +using Lidgren.Network; +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>A method which sends a message through a specific connection.</summary> + private readonly MethodInfo SendMessageToConnectionMethod; + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="gameServer">The underlying game server.</param> + public SLidgrenServer(IGameServer gameServer) + : base(gameServer) + { + this.SendMessageToConnectionMethod = typeof(LidgrenServer).GetMethod(nameof(LidgrenServer.sendMessage), BindingFlags.NonPublic | BindingFlags.Instance, null, new[] { typeof(NetConnection), typeof(OutgoingMessage) }, null); + } + + /// <summary>Send a message to a remote server.</summary> + /// <param name="connection">The network connection.</param> + /// <param name="message">The message to send.</param> + public void SendMessage(NetConnection connection, OutgoingMessage message) + { + this.SendMessageToConnectionMethod.Invoke(this, new object[] { connection, message }); + } + } +} diff --git a/src/SMAPI/Framework/SCore.cs b/src/SMAPI/Framework/SCore.cs index a17af91e..d59051fa 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 NetworkingPatch() ); } @@ -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); StardewValley.Program.gamePtr = this.GameInstance; // add exit handler diff --git a/src/SMAPI/Framework/SGame.cs b/src/SMAPI/Framework/SGame.cs index 57f48d11..6b19f538 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; @@ -130,9 +132,11 @@ 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) + internal SGame(IMonitor monitor, IMonitor monitorForGame, Reflector reflection, EventManager eventManager, JsonHelper jsonHelper, ModRegistry modRegistry, Action onGameInitialised, Action onGameExiting) { SGame.ConstructorHack = null; @@ -151,7 +155,7 @@ namespace StardewModdingAPI.Framework 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.VerboseLogging); Game1.hooks = new SModHooks(this.OnNewDayAfterFade); // init observables @@ -181,9 +185,6 @@ namespace StardewModdingAPI.Framework this.OnGameExiting?.Invoke(); } - /**** - ** Intercepted methods & events - ****/ /// <summary>A callback invoked before <see cref="Game1.newDayAfterFade"/> runs.</summary> protected void OnNewDayAfterFade() { diff --git a/src/SMAPI/Framework/SMultiplayer.cs b/src/SMAPI/Framework/SMultiplayer.cs index 4923a202..a151272e 100644 --- a/src/SMAPI/Framework/SMultiplayer.cs +++ b/src/SMAPI/Framework/SMultiplayer.cs @@ -1,5 +1,13 @@ +using System.Collections.Generic; +using System.Linq; +using Lidgren.Network; +using Newtonsoft.Json; using StardewModdingAPI.Framework.Events; +using StardewModdingAPI.Framework.Networking; +using StardewModdingAPI.Framework.Reflection; +using StardewModdingAPI.Toolkit.Serialisation; using StardewValley; +using StardewValley.Network; namespace StardewModdingAPI.Framework { @@ -12,9 +20,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>Whether SMAPI should log more detailed information.</summary> + private readonly bool VerboseLogging; + + + /********* + ** Accessors + *********/ + /// <summary>The message ID for a SMAPI message containing context about a player.</summary> + public const byte ContextSyncMessageID = 255; + + /// <summary>The metadata for each connected peer.</summary> + public IDictionary<long, MultiplayerPeer> Peers { get; } = new Dictionary<long, MultiplayerPeer>(); + /********* ** Public methods @@ -22,10 +55,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="verboseLogging">Whether SMAPI should log more detailed information.</param> + public SMultiplayer(IMonitor monitor, EventManager eventManager, JsonHelper jsonHelper, ModRegistry modRegistry, Reflector reflection, bool verboseLogging) { this.Monitor = monitor; this.EventManager = eventManager; + this.JsonHelper = jsonHelper; + this.ModRegistry = modRegistry; + this.Reflection = reflection; + this.VerboseLogging = verboseLogging; + + 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 +86,206 @@ 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.GetContextSyncMessageFields, this.TryProcessMessageFromServer); + } + + 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); + } + + return server; + } + + /// <summary>Process an incoming network message from an unknown farmhand.</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> + public void ProcessMessageFromUnknownFarmhand(Server server, NetIncomingMessage rawMessage, IncomingMessage message) + { + // ignore invalid message (farmhands should only receive messages from the server) + if (!Game1.IsMasterGame) + return; + + // sync SMAPI context with connected instances + if (message.MessageType == SMultiplayer.ContextSyncMessageID) + { + // get server + if (!(server is SLidgrenServer customServer)) + { + this.Monitor.Log($"Received context from farmhand {message.FarmerID} via unknown client {server.GetType().FullName}. Mods will not be able to sync data to that player.", LogLevel.Warn); + return; + } + + // parse message + string data = message.Reader.ReadString(); + RemoteContextModel model = this.JsonHelper.Deserialise<RemoteContextModel>(data); + if (model.ApiVersion == null) + model = null; // no data available for unmodded players + + // log info + if (model != null) + this.Monitor.Log($"Received context for farmhand {message.FarmerID} running SMAPI {model.ApiVersion} with {model.Mods.Length} mods{(this.VerboseLogging ? $": {data}" : "")}.", LogLevel.Trace); + else + this.Monitor.Log($"Received context for farmhand {message.FarmerID} running vanilla{(this.VerboseLogging ? $": {data}" : "")}.", LogLevel.Trace); + + // store peer + MultiplayerPeer newPeer = this.Peers[message.FarmerID] = MultiplayerPeer.ForConnectionToFarmhand(message.FarmerID, model, customServer, rawMessage.SenderConnection); + + // reply with known contexts + if (this.VerboseLogging) + this.Monitor.Log(" Replying with context for current player...", LogLevel.Trace); + newPeer.SendMessage(new OutgoingMessage(SMultiplayer.ContextSyncMessageID, Game1.player.UniqueMultiplayerID, this.GetContextSyncMessageFields())); + foreach (MultiplayerPeer otherPeer in this.Peers.Values.Where(p => p.PlayerID != newPeer.PlayerID)) + { + if (this.VerboseLogging) + this.Monitor.Log($" Replying with context for player {otherPeer.PlayerID}...", LogLevel.Trace); + newPeer.SendMessage(new OutgoingMessage(SMultiplayer.ContextSyncMessageID, 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)) + { + if (this.VerboseLogging) + this.Monitor.Log($" Forwarding context to player {otherPeer.PlayerID}...", LogLevel.Trace); + otherPeer.SendMessage(new OutgoingMessage(SMultiplayer.ContextSyncMessageID, newPeer.PlayerID, fields)); + } + } + } + + // handle intro from unmodded player + else if (message.MessageType == Multiplayer.playerIntroduction && !this.Peers.ContainsKey(message.FarmerID)) + { + // get server + if (!(server is SLidgrenServer customServer)) + { + this.Monitor.Log($"Received connection from farmhand {message.FarmerID} with unknown client {server.GetType().FullName}. Mods will not be able to sync data to that player.", LogLevel.Warn); + return; + } + + // store peer + this.Monitor.Log($"Received connection for vanilla player {message.FarmerID}.", LogLevel.Trace); + this.Peers[message.FarmerID] = MultiplayerPeer.ForConnectionToFarmhand(message.FarmerID, null, customServer, rawMessage.SenderConnection); + } + } + + /// <summary>Process an incoming network message from the server.</summary> + /// <param name="client">The client instance that received the connection.</param> + /// <param name="message">The message to process.</param> + /// <returns>Returns whether the message was handled.</returns> + public bool TryProcessMessageFromServer(SLidgrenClient client, IncomingMessage message) + { + // receive SMAPI context from a connected player + if (message.MessageType == SMultiplayer.ContextSyncMessageID) + { + // parse message + string data = message.Reader.ReadString(); + RemoteContextModel model = this.JsonHelper.Deserialise<RemoteContextModel>(data); + + // log info + if (model != null) + this.Monitor.Log($"Received context for {(model.IsHost ? "host" : "farmhand")} {message.FarmerID} running SMAPI {model.ApiVersion} with {model.Mods.Length} mods{(this.VerboseLogging ? $": {data}" : "")}.", LogLevel.Trace); + else + this.Monitor.Log($"Received context for player {message.FarmerID} running vanilla{(this.VerboseLogging ? $": {data}" : "")}.", LogLevel.Trace); + + // store peer + this.Peers[message.FarmerID] = MultiplayerPeer.ForConnectionToHost(message.FarmerID, model, client); + return true; + } + + // handle intro from unmodded player + if (message.MessageType == Multiplayer.playerIntroduction && !this.Peers.ContainsKey(message.FarmerID)) + { + // store peer + this.Monitor.Log($"Received connection for vanilla player {message.FarmerID}.", LogLevel.Trace); + this.Peers[message.FarmerID] = MultiplayerPeer.ForConnectionToHost(message.FarmerID, null, client); + } + + return false; + } + + /// <summary>Remove players who are disconnecting.</summary> + protected override void removeDisconnectedFarmers() + { + foreach (long playerID in this.DisconnectingFarmers) + { + this.Monitor.Log($"Player quit: {playerID}", LogLevel.Trace); + this.Peers.Remove(playerID); + } + + base.removeDisconnectedFarmers(); + } + + + /********* + ** Private methods + *********/ + /// <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.IsHostPlayer, + 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/IMultiplayerHelper.cs b/src/SMAPI/IMultiplayerHelper.cs index 43a0ac95..b01a7bed 100644 --- a/src/SMAPI/IMultiplayerHelper.cs +++ b/src/SMAPI/IMultiplayerHelper.cs @@ -11,5 +11,16 @@ namespace StardewModdingAPI /// <summary>Get the locations which are being actively synced from the host.</summary> IEnumerable<GameLocation> GetActiveLocations(); + + /* disable until ready for release: + + /// <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(); + */ } } diff --git a/src/SMAPI/IMultiplayerPeer.cs b/src/SMAPI/IMultiplayerPeer.cs new file mode 100644 index 00000000..e314eba5 --- /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 IsHostPlayer { 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/NetworkingPatch.cs b/src/SMAPI/Patches/NetworkingPatch.cs new file mode 100644 index 00000000..12ccf84c --- /dev/null +++ b/src/SMAPI/Patches/NetworkingPatch.cs @@ -0,0 +1,103 @@ +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 enable the SMAPI multiplayer metadata handshake.</summary> + internal class NetworkingPatch : IHarmonyPatch + { + /********* + ** Properties + *********/ + /// <summary>The constructor for the internal <c>NetBufferReadStream</c> type.</summary> + private static readonly ConstructorInfo NetBufferReadStreamConstructor = NetworkingPatch.GetNetBufferReadStreamConstructor(); + + + /********* + ** Accessors + *********/ + /// <summary>A unique name for this patch.</summary> + public string Name => $"{nameof(NetworkingPatch)}"; + + + /********* + ** Public methods + *********/ + /// <summary>Apply the Harmony patch.</summary> + /// <param name="harmony">The Harmony instance.</param> + public void Apply(HarmonyInstance harmony) + { + MethodInfo method = AccessTools.Method(typeof(LidgrenServer), "parseDataMessageFromClient"); + MethodInfo prefix = AccessTools.Method(this.GetType(), nameof(NetworkingPatch.Prefix_LidgrenServer_ParseDataMessageFromClient)); + 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) + { + // get SMAPI overrides + SMultiplayer multiplayer = ((SGame)Game1.game1).Multiplayer; + SLidgrenServer server = (SLidgrenServer)__instance; + + // add hook to call multiplayer core + NetConnection peer = dataMsg.SenderConnection; + using (IncomingMessage message = new IncomingMessage()) + using (Stream readStream = (Stream)NetworkingPatch.NetBufferReadStreamConstructor.Invoke(new object[] { dataMsg })) + using (BinaryReader reader = new BinaryReader(readStream)) + { + while (dataMsg.LengthBits - dataMsg.Position >= 8) + { + message.Read(reader); + if (___peers.ContainsLeft(message.FarmerID) && ___peers[message.FarmerID] == peer) + ___gameServer.processIncomingMessage(message); + else if (message.MessageType == Multiplayer.playerIntroduction) + { + NetFarmerRoot farmer = multiplayer.readFarmer(message.Reader); + ___gameServer.checkFarmhandRequest("", farmer, msg => server.SendMessage(peer, msg), () => ___peers[farmer.Value.UniqueMultiplayerID] = peer); + } + else + multiplayer.ProcessMessageFromUnknownFarmhand(__instance, dataMsg, message); // added hook + } + } + + return false; + } + + /// <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; + } + } +} diff --git a/src/SMAPI/StardewModdingAPI.csproj b/src/SMAPI/StardewModdingAPI.csproj index 4ce0892e..2fdf4d97 100644 --- a/src/SMAPI/StardewModdingAPI.csproj +++ b/src/SMAPI/StardewModdingAPI.csproj @@ -164,6 +164,12 @@ <Compile Include="Framework\Events\ModDisplayEvents.cs" /> <Compile Include="Framework\Events\ModSpecialisedEvents.cs" /> <Compile Include="Framework\ModHelpers\DataHelper.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" /> @@ -243,9 +249,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 +318,7 @@ <Compile Include="Metadata\InstructionMetadata.cs" /> <Compile Include="Mod.cs" /> <Compile Include="Patches\DialogueErrorPatch.cs" /> + <Compile Include="Patches\NetworkingPatch.cs" /> <Compile Include="PatchMode.cs" /> <Compile Include="GamePlatform.cs" /> <Compile Include="Program.cs" /> |