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;
using StardewModdingAPI.Toolkit.Serialisation;
using StardewValley;
using StardewValley.Network;
namespace StardewModdingAPI.Framework
{
/// SMAPI's implementation of the game's core multiplayer logic.
internal class SMultiplayer : Multiplayer
{
/*********
** Properties
*********/
/// Encapsulates monitoring and logging.
private readonly IMonitor Monitor;
/// Tracks the installed mods.
private readonly ModRegistry ModRegistry;
/// Encapsulates SMAPI's JSON file parsing.
private readonly JsonHelper JsonHelper;
/// Simplifies access to private code.
private readonly Reflector Reflection;
/// Manages SMAPI events.
private readonly EventManager EventManager;
/// The players who are currently disconnecting.
private readonly IList DisconnectingFarmers;
/// Whether SMAPI should log more detailed information.
private readonly bool VerboseLogging;
/// A callback to invoke when a mod message is received.
private readonly Action OnModMessageReceived;
/*********
** Accessors
*********/
/// The message ID for a SMAPI message containing context about a player.
public const byte ContextSyncMessageID = 255;
/// The message ID for a mod message.
public const byte ModMessageID = 254;
/// The metadata for each connected peer.
public IDictionary Peers { get; } = new Dictionary();
/// The metadata for the host player, if the current player is a farmhand.
public MultiplayerPeer HostPeer;
/*********
** Public methods
*********/
/// Construct an instance.
/// Encapsulates monitoring and logging.
/// Manages SMAPI events.
/// Encapsulates SMAPI's JSON file parsing.
/// Tracks the installed mods.
/// Simplifies access to private code.
/// Whether SMAPI should log more detailed information.
/// A callback to invoke when a mod message is received.
public SMultiplayer(IMonitor monitor, EventManager eventManager, JsonHelper jsonHelper, ModRegistry modRegistry, Reflector reflection, bool verboseLogging, Action onModMessageReceived)
{
this.Monitor = monitor;
this.EventManager = eventManager;
this.JsonHelper = jsonHelper;
this.ModRegistry = modRegistry;
this.Reflection = reflection;
this.VerboseLogging = verboseLogging;
this.OnModMessageReceived = onModMessageReceived;
this.DisconnectingFarmers = reflection.GetField>(this, "disconnectingFarmers").GetValue();
}
/// Handle sync messages from other players and perform other initial sync logic.
public override void UpdateEarly()
{
this.EventManager.Legacy_BeforeMainSync.Raise();
base.UpdateEarly();
this.EventManager.Legacy_AfterMainSync.Raise();
}
/// Broadcast sync messages to other players and perform other final sync logic.
public override void UpdateLate(bool forceSync = false)
{
this.EventManager.Legacy_BeforeMainBroadcast.Raise();
base.UpdateLate(forceSync);
this.EventManager.Legacy_AfterMainBroadcast.Raise();
}
/// Initialise a client before the game connects to a remote server.
/// The client to initialise.
public override Client InitClient(Client client)
{
if (client is LidgrenClient)
{
string address = this.Reflection.GetField(client, "address").GetValue();
return new SLidgrenClient(address, this.GetContextSyncMessageFields, this.TryProcessMessageFromServer);
}
return client;
}
/// Initialise a server before the game connects to an incoming player.
/// The server to initialise.
public override Server InitServer(Server server)
{
if (server is LidgrenServer)
{
IGameServer gameServer = this.Reflection.GetField(server, "gameServer").GetValue();
return new SLidgrenServer(gameServer);
}
return server;
}
/// Process an incoming network message from an unknown farmhand, usually a player whose connection hasn't been approved yet.
/// The server instance that received the connection.
/// The raw network message that was received.
/// The message to process.
public void ProcessMessageFromUnknownFarmhand(Server server, NetIncomingMessage rawMessage, IncomingMessage message)
{
// ignore invalid message (farmhands should only receive messages from the server)
if (!Game1.IsMasterGame)
return;
switch (message.MessageType)
{
// 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(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;
// handle intro from unmodded player
case Multiplayer.playerIntroduction:
if (!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);
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;
}
}
/// Process an incoming message from an approved connection.
/// The message to process.
public override void processIncomingMessage(IncomingMessage message)
{
switch (message.MessageType)
{
// handle mod message
case SMultiplayer.ModMessageID:
this.ReceiveModMessage(message);
break;
// let game process message
default:
base.processIncomingMessage(message);
break;
}
}
/// Process an incoming network message from the server.
/// The client instance that received the connection.
/// The message to process.
/// Returns whether the message was handled.
public bool TryProcessMessageFromServer(SLidgrenClient client, IncomingMessage message)
{
switch (message.MessageType)
{
// receive SMAPI context from a connected player
case SMultiplayer.ContextSyncMessageID:
{
// parse message
string data = message.Reader.ReadString();
RemoteContextModel model = this.JsonHelper.Deserialise(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
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;
default:
return false;
}
}
/// Remove players who are disconnecting.
protected override void removeDisconnectedFarmers()
{
foreach (long playerID in this.DisconnectingFarmers)
{
this.Monitor.Log($"Player quit: {playerID}", LogLevel.Trace);
this.Peers.Remove(playerID);
}
base.removeDisconnectedFarmers();
}
/// Broadcast a mod message to matching players.
/// The data to send over the network.
/// A message type which receiving mods can use to decide whether it's the one they want to handle, like SetPlayerLocation. This doesn't need to be globally unique, since mods should check the originating mod ID.
/// The unique ID of the mod sending the message.
/// The mod IDs which should receive the message on the destination computers, or null for all mods. Specifying mod IDs is recommended to improve performance, unless it's a general-purpose broadcast.
/// The values for the players who should receive the message, or null for all players. If you don't need to broadcast to all players, specifying player IDs is recommended to reduce latency.
public void BroadcastModMessage(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 playerIDs = null;
if (toPlayerIDs != null && toPlayerIDs.Any())
{
playerIDs = new HashSet(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
*********/
/// Receive a mod message sent from another player's mods.
/// The raw message to parse.
private void ReceiveModMessage(IncomingMessage message)
{
// parse message
string json = message.Reader.ReadString();
ModMessageModel model = this.JsonHelper.Deserialise(json);
HashSet playerIDs = new HashSet(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)));
}
}
}
}
/// Get all connected player IDs, including the current player.
private IEnumerable GetKnownPlayerIDs()
{
yield return Game1.player.UniqueMultiplayerID;
foreach (long peerID in this.Peers.Keys)
yield return peerID;
}
/// Get the fields to include in a context sync message sent to other players.
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) };
}
/// Get the fields to include in a context sync message sent to other players.
/// The peer whose data to represent.
private object[] GetContextSyncMessageFields(IMultiplayerPeer peer)
{
if (!peer.HasSmapi)
return new object[] { "{}" };
RemoteContextModel model = new RemoteContextModel
{
IsHost = peer.IsHost,
Platform = peer.Platform.Value,
ApiVersion = peer.ApiVersion,
GameVersion = peer.GameVersion,
Mods = peer.Mods
.Select(mod => new RemoteContextModModel
{
ID = mod.ID,
Name = mod.Name,
Version = mod.Version
})
.ToArray()
};
return new object[] { this.JsonHelper.Serialise(model, Formatting.None) };
}
/// Log a trace message if is enabled.
/// The message to log.
private void VerboseLog(string message)
{
if (this.VerboseLogging)
this.Monitor.Log(message, LogLevel.Trace);
}
}
}