sync SMAPI context between players in multiplayer (#480)

This commit is contained in:
Jesse Plamondon-Willard 2018-10-31 17:29:32 -04:00
parent 688ee69ee6
commit e5e4ce411c
16 changed files with 749 additions and 8 deletions

View File

@ -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>

View File

@ -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;
}
}
}

View File

@ -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);
}
}
}

View File

@ -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;
}
}
}

View File

@ -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; }
}
}

View File

@ -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; }
}
}

View File

@ -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);
}
}
}

View File

@ -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 });
}
}
}

View File

@ -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

View File

@ -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()
{

View File

@ -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) };
}
}
}

View File

@ -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();
*/
}
}

View File

@ -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);
}
}

View File

@ -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; }
}
}

View File

@ -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;
}
}
}

View File

@ -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" />