summaryrefslogtreecommitdiff
path: root/src/SMAPI/Framework/SMultiplayer.cs
blob: a151272edb502bfa476d72f25a287832df5238a0 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
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
{
    /// <summary>SMAPI's implementation of the game's core multiplayer logic.</summary>
    internal class SMultiplayer : Multiplayer
    {
        /*********
        ** Properties
        *********/
        /// <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
        *********/
        /// <summary>Construct an instance.</summary>
        /// <param name="monitor">Encapsulates monitoring and logging.</param>
        /// <param name="eventManager">Manages SMAPI events.</param>
        /// <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>
        public override void UpdateEarly()
        {
            this.EventManager.Legacy_BeforeMainSync.Raise();
            base.UpdateEarly();
            this.EventManager.Legacy_AfterMainSync.Raise();
        }

        /// <summary>Broadcast sync messages to other players and perform other final sync logic.</summary>
        public override void UpdateLate(bool forceSync = false)
        {
            this.EventManager.Legacy_BeforeMainBroadcast.Raise();
            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) };
        }
    }
}