summaryrefslogtreecommitdiff
path: root/src/SMAPI/Framework
diff options
context:
space:
mode:
authorJesse Plamondon-Willard <github@jplamondonw.com>2018-11-03 01:29:01 -0400
committerJesse Plamondon-Willard <github@jplamondonw.com>2018-11-03 01:29:01 -0400
commit02a46bf13f29ce0dd8ac2f422113083c59dae42d (patch)
tree134a670897ab4fb9b76485b022b19f0cb1167df3 /src/SMAPI/Framework
parent6f23aaf2954f7eeb93b7cc5aad2d3f9b237883f3 (diff)
downloadSMAPI-02a46bf13f29ce0dd8ac2f422113083c59dae42d.tar.gz
SMAPI-02a46bf13f29ce0dd8ac2f422113083c59dae42d.tar.bz2
SMAPI-02a46bf13f29ce0dd8ac2f422113083c59dae42d.zip
add APIs to send/receive messages in multiplayer (#480)
Diffstat (limited to 'src/SMAPI/Framework')
-rw-r--r--src/SMAPI/Framework/Events/EventManager.cs8
-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.cs29
-rw-r--r--src/SMAPI/Framework/ModHelpers/MultiplayerHelper.cs31
-rw-r--r--src/SMAPI/Framework/Networking/ModMessageModel.cs72
-rw-r--r--src/SMAPI/Framework/Networking/MultiplayerPeer.cs14
-rw-r--r--src/SMAPI/Framework/SCore.cs5
-rw-r--r--src/SMAPI/Framework/SGame.cs25
-rw-r--r--src/SMAPI/Framework/SMultiplayer.cs339
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);
+ }
}
}