diff options
Diffstat (limited to 'src/SMAPI/Framework')
-rw-r--r-- | src/SMAPI/Framework/Events/EventManager.cs | 8 | ||||
-rw-r--r-- | src/SMAPI/Framework/Events/ManagedEvent.cs | 24 | ||||
-rw-r--r-- | src/SMAPI/Framework/Events/ManagedEventBase.cs | 12 | ||||
-rw-r--r-- | src/SMAPI/Framework/Events/ModEvents.cs | 4 | ||||
-rw-r--r-- | src/SMAPI/Framework/Events/ModMultiplayerEvents.cs | 29 | ||||
-rw-r--r-- | src/SMAPI/Framework/ModHelpers/MultiplayerHelper.cs | 31 | ||||
-rw-r--r-- | src/SMAPI/Framework/Networking/ModMessageModel.cs | 72 | ||||
-rw-r--r-- | src/SMAPI/Framework/Networking/MultiplayerPeer.cs | 14 | ||||
-rw-r--r-- | src/SMAPI/Framework/SCore.cs | 5 | ||||
-rw-r--r-- | src/SMAPI/Framework/SGame.cs | 25 | ||||
-rw-r--r-- | src/SMAPI/Framework/SMultiplayer.cs | 339 |
11 files changed, 460 insertions, 103 deletions
diff --git a/src/SMAPI/Framework/Events/EventManager.cs b/src/SMAPI/Framework/Events/EventManager.cs index 31b0346a..519cf48a 100644 --- a/src/SMAPI/Framework/Events/EventManager.cs +++ b/src/SMAPI/Framework/Events/EventManager.cs @@ -99,6 +99,12 @@ namespace StardewModdingAPI.Framework.Events public readonly ManagedEvent<MouseWheelScrolledEventArgs> MouseWheelScrolled; /**** + ** Multiplayer + ****/ + /// <summary>Raised after a mod message is received over the network.</summary> + public readonly ManagedEvent<ModMessageReceivedEventArgs> ModMessageReceived; + + /**** ** Player ****/ /// <summary>Raised after items are added or removed to a player's inventory.</summary> @@ -374,6 +380,8 @@ 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.ModMessageReceived = ManageEventOf<ModMessageReceivedEventArgs>(nameof(IModEvents.Multiplayer), nameof(IMultiplayerEvents.ModMessageReceived)); + 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..a830a54a --- /dev/null +++ b/src/SMAPI/Framework/Events/ModMultiplayerEvents.cs @@ -0,0 +1,29 @@ +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 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); + } + + + /********* + ** 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/ModHelpers/MultiplayerHelper.cs b/src/SMAPI/Framework/ModHelpers/MultiplayerHelper.cs index 86f8e012..eedad0bc 100644 --- a/src/SMAPI/Framework/ModHelpers/MultiplayerHelper.cs +++ b/src/SMAPI/Framework/ModHelpers/MultiplayerHelper.cs @@ -1,3 +1,4 @@ +using System; using System.Collections.Generic; using StardewModdingAPI.Framework.Networking; using StardewValley; @@ -26,18 +27,18 @@ namespace StardewModdingAPI.Framework.ModHelpers this.Multiplayer = multiplayer; } - /// <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() { 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 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> @@ -53,5 +54,23 @@ namespace StardewModdingAPI.Framework.ModHelpers { 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/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 index e97e36bc..c7f8ffad 100644 --- a/src/SMAPI/Framework/Networking/MultiplayerPeer.cs +++ b/src/SMAPI/Framework/Networking/MultiplayerPeer.cs @@ -30,7 +30,7 @@ namespace StardewModdingAPI.Framework.Networking public long PlayerID { get; } /// <summary>Whether this is a connection to the host player.</summary> - public bool IsHostPlayer => this.PlayerID == Game1.MasterPlayer.UniqueMultiplayerID; + public bool IsHost { get; } /// <summary>Whether the player has SMAPI installed.</summary> public bool HasSmapi => this.ApiVersion != null; @@ -57,9 +57,11 @@ namespace StardewModdingAPI.Framework.Networking /// <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) + /// <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; @@ -84,7 +86,8 @@ namespace StardewModdingAPI.Framework.Networking model: model, server: server, serverConnection: serverConnection, - client: null + client: null, + isHost: false ); } @@ -99,7 +102,8 @@ namespace StardewModdingAPI.Framework.Networking model: model, server: null, serverConnection: null, - client: client + client: client, + isHost: true ); } @@ -119,7 +123,7 @@ namespace StardewModdingAPI.Framework.Networking /// <param name="message">The message to send.</param> public void SendMessage(OutgoingMessage message) { - if (this.IsHostPlayer) + if (this.IsHost) this.Client.sendMessage(message); else this.Server.SendMessage(this.ServerConnection, message); diff --git a/src/SMAPI/Framework/SCore.cs b/src/SMAPI/Framework/SCore.cs index 69b33699..ca343389 100644 --- a/src/SMAPI/Framework/SCore.cs +++ b/src/SMAPI/Framework/SCore.cs @@ -209,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.Toolkit.JsonHelper, this.ModRegistry, 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 @@ -340,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); diff --git a/src/SMAPI/Framework/SGame.cs b/src/SMAPI/Framework/SGame.cs index 6b19f538..c7f5962f 100644 --- a/src/SMAPI/Framework/SGame.cs +++ b/src/SMAPI/Framework/SGame.cs @@ -51,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 @@ -116,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>(); @@ -136,7 +139,8 @@ namespace StardewModdingAPI.Framework /// <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, JsonHelper jsonHelper, ModRegistry modRegistry, 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; @@ -151,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, jsonHelper, modRegistry, reflection, this.VerboseLogging); + Game1.multiplayer = new SMultiplayer(monitor, eventManager, jsonHelper, modRegistry, reflection, this.VerboseLogging, this.OnModMessageReceived); Game1.hooks = new SModHooks(this.OnNewDayAfterFade); // init observables @@ -191,6 +197,15 @@ namespace StardewModdingAPI.Framework 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 a151272e..e4f912d2 100644 --- a/src/SMAPI/Framework/SMultiplayer.cs +++ b/src/SMAPI/Framework/SMultiplayer.cs @@ -1,7 +1,9 @@ +using System; using System.Collections.Generic; using System.Linq; using Lidgren.Network; using Newtonsoft.Json; +using Newtonsoft.Json.Linq; using StardewModdingAPI.Framework.Events; using StardewModdingAPI.Framework.Networking; using StardewModdingAPI.Framework.Reflection; @@ -38,6 +40,9 @@ namespace StardewModdingAPI.Framework /// <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 @@ -45,9 +50,15 @@ namespace StardewModdingAPI.Framework /// <summary>The message ID for a SMAPI message containing context about a player.</summary> public const byte ContextSyncMessageID = 255; + /// <summary>The message ID for a mod message.</summary> + public const byte ModMessageID = 254; + /// <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 @@ -59,7 +70,8 @@ namespace StardewModdingAPI.Framework /// <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) + /// <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; @@ -67,6 +79,7 @@ namespace StardewModdingAPI.Framework this.ModRegistry = modRegistry; this.Reflection = reflection; this.VerboseLogging = verboseLogging; + this.OnModMessageReceived = onModMessageReceived; this.DisconnectingFarmers = reflection.GetField<List<long>>(this, "disconnectingFarmers").GetValue(); } @@ -113,7 +126,7 @@ namespace StardewModdingAPI.Framework return server; } - /// <summary>Process an incoming network message from an unknown farmhand.</summary> + /// <summary>Process an incoming network message from an unknown farmhand, usually a player whose connection hasn't been approved yet.</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> @@ -123,69 +136,99 @@ namespace StardewModdingAPI.Framework if (!Game1.IsMasterGame) return; - // sync SMAPI context with connected instances - if (message.MessageType == SMultiplayer.ContextSyncMessageID) + switch (message.MessageType) { - // 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))); - } + // sync SMAPI context with connected instances + case 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 + this.VerboseLog(" Replying with context for current player..."); + newPeer.SendMessage(new OutgoingMessage(SMultiplayer.ContextSyncMessageID, Game1.player.UniqueMultiplayerID, this.GetContextSyncMessageFields())); + 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(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)) + { + this.VerboseLog($" Forwarding context to player {otherPeer.PlayerID}..."); + otherPeer.SendMessage(new OutgoingMessage(SMultiplayer.ContextSyncMessageID, newPeer.PlayerID, fields)); + } + } + } + break; - // 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)) + // handle intro from unmodded player + case Multiplayer.playerIntroduction: + if (!this.Peers.ContainsKey(message.FarmerID)) { - if (this.VerboseLogging) - this.Monitor.Log($" Forwarding context to player {otherPeer.PlayerID}...", LogLevel.Trace); - otherPeer.SendMessage(new OutgoingMessage(SMultiplayer.ContextSyncMessageID, newPeer.PlayerID, fields)); + // 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); + var peer = MultiplayerPeer.ForConnectionToFarmhand(message.FarmerID, null, customServer, rawMessage.SenderConnection); + this.Peers[message.FarmerID] = peer; + if (peer.IsHost) + this.HostPeer = peer; } - } + break; + + // handle mod message + case SMultiplayer.ModMessageID: + this.ReceiveModMessage(message); + break; } + } - // handle intro from unmodded player - else if (message.MessageType == Multiplayer.playerIntroduction && !this.Peers.ContainsKey(message.FarmerID)) + /// <summary>Process an incoming message from an approved connection.</summary> + /// <param name="message">The message to process.</param> + public override void processIncomingMessage(IncomingMessage message) + { + switch (message.MessageType) { - // 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); + // handle mod message + case SMultiplayer.ModMessageID: + this.ReceiveModMessage(message); + break; + + // let game process message + default: + base.processIncomingMessage(message); + break; } + } /// <summary>Process an incoming network message from the server.</summary> @@ -194,33 +237,50 @@ namespace StardewModdingAPI.Framework /// <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) + switch (message.MessageType) { - // 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; - } + // receive SMAPI context from a connected player + case 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 + MultiplayerPeer peer = MultiplayerPeer.ForConnectionToHost(message.FarmerID, model, client); + this.Peers[message.FarmerID] = peer; + if (peer.IsHost) + this.HostPeer = peer; + } + 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); - } + // handle intro from unmodded player + case Multiplayer.playerIntroduction: + if (!this.Peers.ContainsKey(message.FarmerID)) + { + // store peer + this.Monitor.Log($"Received connection for vanilla player {message.FarmerID}.", LogLevel.Trace); + var peer = MultiplayerPeer.ForConnectionToHost(message.FarmerID, null, client); + this.Peers[message.FarmerID] = peer; + if (peer.IsHost) + this.HostPeer = peer; + } + return false; + + // handle mod message + case SMultiplayer.ModMessageID: + this.ReceiveModMessage(message); + return true; - return false; + default: + return false; + } } /// <summary>Remove players who are disconnecting.</summary> @@ -235,10 +295,117 @@ namespace StardewModdingAPI.Framework 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(SMultiplayer.ModMessageID, peer.PlayerID, data)); + } + } + } + else if (this.HostPeer != null && this.HostPeer.HasSmapi) + this.HostPeer.SendMessage(new OutgoingMessage(SMultiplayer.ModMessageID, this.HostPeer.PlayerID, data)); + else + this.VerboseLog(" Can't send message because no valid connections were found."); + + } + /********* ** Private methods *********/ + /// <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(SMultiplayer.ModMessageID, 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() { @@ -271,7 +438,7 @@ namespace StardewModdingAPI.Framework RemoteContextModel model = new RemoteContextModel { - IsHost = peer.IsHostPlayer, + IsHost = peer.IsHost, Platform = peer.Platform.Value, ApiVersion = peer.ApiVersion, GameVersion = peer.GameVersion, @@ -287,5 +454,13 @@ namespace StardewModdingAPI.Framework 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); + } } } |