diff --git a/Install/PatchStep.txt b/Install/PatchStep.txt index b6171e53..fa842768 100644 --- a/Install/PatchStep.txt +++ b/Install/PatchStep.txt @@ -28,46 +28,34 @@ ldnull callvirt System.Void StardewValley.ModHooks::OnGame1_Draw(Microsoft.Xna.Framework.GameTime,Microsoft.Xna.Framework.Graphics.RenderTarget2D) -Optional Section -Fix back button£º +Optional +GamePad Input Modify class StardewValley.Game1, modify method updateAndroidMenus(), modify Instructions at beginning: ldsfld StardewValley.InputState StardewValley.Game1::input callvirt Microsoft.Xna.Framework.Input.GamePadState StardewValley.InputState::GetGamePadState() +Json Asset£º +checkForAction Prefix -Json Asset Support£º + 0 ldsfld StardewValley.ModHooks StardewValley.Game1::hooks + 5 ldarg.0 + 6 callvirt System.Boolean StardewValley.ModHooks::OnObject_checkForAction(StardewValley.Object) + 11 brtrue.s -> (6) ldarg.2 + 13 ldc.i4.0 + 14 ret -Modify class StardewValley.Object, modify method checkForAction,insert instructions at beginning: -ldsfld StardewValley.ModHooks StardewValley.Game1::hooks -ldarg.0 -callvirt System.Boolean StardewValley.ModHooks::OnObject_checkForAction(StardewValley.Object) -brtrue.s -> (6) ldarg.2 -ldc.i4.0 -ret - -modify method isIndexOkForBasicShippedCategory,replace instructions: -ldarg.0 -ldc.i4 434 -bne.un.s -> (5) ldsfld StardewValley.ModHooks StardewValley.Game1::hooks -ldc.i4.0 -ret -ldsfld StardewValley.ModHooks StardewValley.Game1::hooks -ldarg.0 -ldloca.s -> (0) (System.Boolean) -callvirt System.Void StardewValley.ModHooks::OnObject_isIndexOkForBasicShippedCategory(System.Int32,System.Boolean&) -ldloc.0 -ret - -modify method canBePlacedHere insert instructions at beginning: -ldsfld StardewValley.ModHooks StardewValley.Game1::hooks -ldarg.0 -ldarg.1 -ldarg.2 -ldloca.s -> (1) (System.Boolean) -callvirt System.Boolean StardewValley.ModHooks::OnObject_canBePlacedHere(StardewValley.Object,StardewValley.GameLocation,Microsoft.Xna.Framework.Vector2,System.Boolean&) -brtrue.s -> (9) ldarg.1 -ldloc.1 -ret \ No newline at end of file +isIndexOkForBasicShippedCategory Postfix + 0 ldarg.0 + 1 ldc.i4 434 + 6 bne.un.s -> (5) ldsfld StardewValley.ModHooks StardewValley.Game1::hooks + 8 ldc.i4.0 + 9 ret + 10 ldsfld StardewValley.ModHooks StardewValley.Game1::hooks + 15 ldarg.0 + 16 ldloca.s -> (0) (System.Boolean) + 18 callvirt System.Void StardewValley.ModHooks::OnObject_isIndexOkForBasicShippedCategory(System.Int32,System.Boolean&) + 23 ldloc.0 + 24 ret \ No newline at end of file diff --git a/src/Mod.csproj b/src/Mod.csproj index 2ad54b11..3348fe13 100644 --- a/src/Mod.csproj +++ b/src/Mod.csproj @@ -544,6 +544,7 @@ + @@ -600,6 +601,7 @@ + @@ -641,6 +643,7 @@ + diff --git a/src/ModEntry.cs b/src/ModEntry.cs index 79e70e0d..7dccfb7f 100644 --- a/src/ModEntry.cs +++ b/src/ModEntry.cs @@ -4,6 +4,7 @@ using StardewValley; using StardewModdingAPI.Framework; using System.Threading; using Microsoft.Xna.Framework.Graphics; +using System.IO; namespace SMDroid { @@ -19,7 +20,7 @@ namespace SMDroid public ModEntry() { - this.core = new SCore("/sdcard/SMDroid/Mods", false); + this.core = new SCore(Path.Combine(Android.OS.Environment.ExternalStorageDirectory.Path, "SMDroid/Mods"), false); } public override LocalizedContentManager OnGame1_CreateContentManager(IServiceProvider serviceProvider, string rootDirectory) { @@ -30,7 +31,6 @@ namespace SMDroid this.ContentCore = new ContentCoordinator(serviceProvider, rootDirectory, Thread.CurrentThread.CurrentUICulture, SGame.ConstructorHack.Monitor, SGame.ConstructorHack.Reflection, SGame.ConstructorHack.JsonHelper, SGame.OnLoadingFirstAsset ?? SGame.ConstructorHack?.OnLoadingFirstAsset); this.NextContentManagerIsMain = true; this.core.RunInteractively(this.ContentCore); - SGame.printLog("ROOT Directory:" + rootDirectory); return this.ContentCore.CreateGameContentManager("Game1._temporaryContent"); } diff --git a/src/SMAPI/Constants.cs b/src/SMAPI/Constants.cs index 7e260124..bcb0e28d 100644 --- a/src/SMAPI/Constants.cs +++ b/src/SMAPI/Constants.cs @@ -32,16 +32,16 @@ namespace StardewModdingAPI public static GamePlatform TargetPlatform => (GamePlatform)Constants.Platform; /// The path to the game folder. - public static string ExecutionPath { get; } = "/sdcard/SMDroid"; + public static string ExecutionPath { get; } = Path.Combine(Android.OS.Environment.ExternalStorageDirectory.Path , "SMDroid"); /// The directory path containing Stardew Valley's app data. - public static string DataPath { get; } = Path.Combine("/sdcard", "SMDroid"); + public static string DataPath { get; } = Path.Combine(Android.OS.Environment.ExternalStorageDirectory.Path, "SMDroid"); /// The directory path in which error logs should be stored. public static string LogDir { get; } = Path.Combine(Constants.DataPath, "ErrorLogs"); /// The directory path where all saves are stored. - public static string SavesPath { get; } = Path.Combine("/sdcard", "StardewValley"); + public static string SavesPath { get; } = Path.Combine(Android.OS.Environment.ExternalStorageDirectory.Path, "StardewValley"); /// The name of the current save folder (if save info is available, regardless of whether the save file exists yet). public static string SaveFolderName => Constants.GetSaveFolderName(); @@ -56,7 +56,7 @@ namespace StardewModdingAPI internal const string HomePageUrl = "https://smapi.io"; /// The absolute path to the folder containing SMAPI's internal files. - internal static readonly string InternalFilesPath = "/sdcard/SMDroid/smapi-internal"; + internal static readonly string InternalFilesPath = Path.Combine(Android.OS.Environment.ExternalStorageDirectory.Path, "SMDroid/smapi-internal"); /// The file path for the SMAPI configuration file. internal static string ApiConfigPath => Path.Combine(Constants.InternalFilesPath, "StardewModdingAPI.config.json"); diff --git a/src/SMAPI/Framework/Input/SInputState.cs b/src/SMAPI/Framework/Input/SInputState.cs index a15272d5..2315c1f8 100644 --- a/src/SMAPI/Framework/Input/SInputState.cs +++ b/src/SMAPI/Framework/Input/SInputState.cs @@ -81,7 +81,7 @@ namespace StardewModdingAPI.Framework.Input try { // get new states - GamePadState realController = GamePad.GetState(PlayerIndex.One); + GamePadState realController = GamePad.GetState(Game1.playerOneIndex); KeyboardState realKeyboard = Keyboard.GetState(); MouseState realMouse = Mouse.GetState(); var activeButtons = this.DeriveStatuses(this.ActiveButtons, realKeyboard, realMouse, realController); diff --git a/src/SMAPI/Framework/ModHelpers/ModHelper.cs b/src/SMAPI/Framework/ModHelpers/ModHelper.cs index ec2912c0..86e8eb28 100644 --- a/src/SMAPI/Framework/ModHelpers/ModHelper.cs +++ b/src/SMAPI/Framework/ModHelpers/ModHelper.cs @@ -40,7 +40,7 @@ namespace StardewModdingAPI.Framework.ModHelpers public ICommandHelper ConsoleCommands { get; } /// Provides multiplayer utilities. - //public IMultiplayerHelper Multiplayer { get; } + public IMultiplayerHelper Multiplayer { get; } /// An API for reading translations stored in the mod's i18n folder, with one file per locale (like en.json) containing a flat key => value structure. Translations are fetched with locale fallback, so missing translations are filled in from broader locales (like pt-BR.json < pt.json < default.json). public ITranslationHelper Translation { get; } @@ -64,7 +64,7 @@ namespace StardewModdingAPI.Framework.ModHelpers /// An API for reading translations stored in the mod's i18n folder. /// An argument is null or empty. /// The path does not exist on disk. - public ModHelper(string modID, string modDirectory, SInputState inputState, IModEvents events, IContentHelper contentHelper, IContentPackHelper contentPackHelper, ICommandHelper commandHelper, IDataHelper dataHelper, IModRegistry modRegistry, IReflectionHelper reflectionHelper, ITranslationHelper translationHelper) + public ModHelper(string modID, string modDirectory, SInputState inputState, IModEvents events, IContentHelper contentHelper, IContentPackHelper contentPackHelper, ICommandHelper commandHelper, IDataHelper dataHelper, IModRegistry modRegistry, IReflectionHelper reflectionHelper, IMultiplayerHelper multiplayer, ITranslationHelper translationHelper) : base(modID) { // validate directory @@ -82,7 +82,7 @@ namespace StardewModdingAPI.Framework.ModHelpers this.ModRegistry = modRegistry ?? throw new ArgumentNullException(nameof(modRegistry)); this.ConsoleCommands = commandHelper ?? throw new ArgumentNullException(nameof(commandHelper)); this.Reflection = reflectionHelper ?? throw new ArgumentNullException(nameof(reflectionHelper)); - //this.Multiplayer = multiplayer ?? throw new ArgumentNullException(nameof(multiplayer)); + this.Multiplayer = multiplayer ?? throw new ArgumentNullException(nameof(multiplayer)); this.Translation = translationHelper ?? throw new ArgumentNullException(nameof(translationHelper)); this.Events = events; } diff --git a/src/SMAPI/Framework/ModHelpers/MultiplayerHelper.cs b/src/SMAPI/Framework/ModHelpers/MultiplayerHelper.cs new file mode 100644 index 00000000..c62dd121 --- /dev/null +++ b/src/SMAPI/Framework/ModHelpers/MultiplayerHelper.cs @@ -0,0 +1,76 @@ +using System; +using System.Collections.Generic; +using StardewModdingAPI.Framework.Networking; +using StardewValley; + +namespace StardewModdingAPI.Framework.ModHelpers +{ + /// Provides multiplayer utilities. + internal class MultiplayerHelper : BaseHelper, IMultiplayerHelper + { + /********* + ** Fields + *********/ + /// SMAPI's core multiplayer utility. + private readonly SMultiplayer Multiplayer; + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The unique ID of the relevant mod. + /// SMAPI's core multiplayer utility. + public MultiplayerHelper(string modID, SMultiplayer multiplayer) + : base(modID) + { + this.Multiplayer = multiplayer; + } + + /// Get a new multiplayer ID. + public long GetNewID() + { + return this.Multiplayer.getNewID(); + } + + /// Get the locations which are being actively synced from the host. + public IEnumerable GetActiveLocations() + { + return this.Multiplayer.activeLocations(); + } + + /// Get a connected player. + /// The player's unique ID. + /// Returns the connected player, or null if no such player is connected. + public IMultiplayerPeer GetConnectedPlayer(long id) + { + return this.Multiplayer.Peers.TryGetValue(id, out MultiplayerPeer peer) + ? peer + : null; + } + + /// Get all connected players. + public IEnumerable GetConnectedPlayers() + { + return this.Multiplayer.Peers.Values; + } + + /// Send a message to mods installed by connected players. + /// The data type. This can be a class with a default constructor, or a value type. + /// 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 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. + /// The or is null. + public void SendMessage(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/SCore.cs b/src/SMAPI/Framework/SCore.cs index 6abdf99a..9c900e7e 100644 --- a/src/SMAPI/Framework/SCore.cs +++ b/src/SMAPI/Framework/SCore.cs @@ -981,7 +981,7 @@ namespace StardewModdingAPI.Framework IDataHelper dataHelper = new DataHelper(manifest.UniqueID, mod.DirectoryPath, jsonHelper); IReflectionHelper reflectionHelper = new ReflectionHelper(manifest.UniqueID, mod.DisplayName, this.Reflection); IModRegistry modRegistryHelper = new ModRegistryHelper(manifest.UniqueID, this.ModRegistry, proxyFactory, monitor); - //IMultiplayerHelper multiplayerHelper = new MultiplayerHelper(manifest.UniqueID, this.GameInstance.Multiplayer); + IMultiplayerHelper multiplayerHelper = new MultiplayerHelper(manifest.UniqueID, this.GameInstance.Multiplayer); IContentPack CreateFakeContentPack(string packDirPath, IManifest packManifest) { @@ -991,7 +991,7 @@ namespace StardewModdingAPI.Framework return new ContentPack(packDirPath, packManifest, packContentHelper, packTranslationHelper, this.Toolkit.JsonHelper); } - modHelper = new ModHelper(manifest.UniqueID, mod.DirectoryPath, this.GameInstance.Input, events, contentHelper, contentPackHelper, commandHelper, dataHelper, modRegistryHelper, reflectionHelper, translationHelper); + modHelper = new ModHelper(manifest.UniqueID, mod.DirectoryPath, this.GameInstance.Input, events, contentHelper, contentPackHelper, commandHelper, dataHelper, modRegistryHelper, reflectionHelper, multiplayerHelper, translationHelper); } // init mod diff --git a/src/SMAPI/Framework/SGame.cs b/src/SMAPI/Framework/SGame.cs index bb7b4372..dc900890 100644 --- a/src/SMAPI/Framework/SGame.cs +++ b/src/SMAPI/Framework/SGame.cs @@ -72,6 +72,7 @@ namespace StardewModdingAPI.Framework internal bool OnObjectCanBePlacedHere(SObject instance, GameLocation location, Vector2 tile, ref bool result) { ObjectCanBePlacedHereEventArgs args = new ObjectCanBePlacedHereEventArgs(instance, location, tile, result); + args.__result = result; bool run =this.Events.ObjectCanBePlacedHere.RaiseForChainRun(args); result = args.__result; return run; @@ -80,6 +81,7 @@ namespace StardewModdingAPI.Framework internal void OnObjectIsIndexOkForBasicShippedCategory(int index, ref bool result) { ObjectIsIndexOkForBasicShippedCategoryEventArgs args = new ObjectIsIndexOkForBasicShippedCategoryEventArgs(index, result); + args.__result = result; this.Events.ObjectIsIndexOkForBasicShippedCategory.RaiseForChainRun(args); result = args.__result; } @@ -147,8 +149,8 @@ namespace StardewModdingAPI.Framework /// Manages input visible to the game. public SInputState Input => (SInputState)this.Reflection.GetField(typeof(Game1), "input").GetValue(); - ///// The game's core multiplayer utility. - //public SMultiplayer Multiplayer => (SMultiplayer)this.Reflection.GetField(typeof(Game1), "multiplayer").GetValue(); + /// The game's core multiplayer utility. + public SMultiplayer Multiplayer => (SMultiplayer)this.Reflection.GetField(typeof(Game1), "multiplayer").GetValue(); /// A list of queued commands to execute. /// This property must be threadsafe, since it's accessed from a separate console input thread. @@ -198,7 +200,7 @@ namespace StardewModdingAPI.Framework this.OnGameInitialised = onGameInitialised; this.OnGameExiting = onGameExiting; this.Reflection.GetField(typeof(Game1), "input").SetValue(new SInputState()); - //this.Reflection.GetField(typeof(Game1), "multiplayer").SetValue(new SMultiplayer(monitor, eventManager, jsonHelper, modRegistry, reflection, this.OnModMessageReceived)); + this.Reflection.GetField(typeof(Game1), "multiplayer").SetValue(new SMultiplayer(monitor, eventManager, jsonHelper, modRegistry, reflection, this.OnModMessageReceived)); //Game1.hooks = new SModHooks(this.OnNewDayAfterFade); // init observables @@ -500,7 +502,7 @@ namespace StardewModdingAPI.Framework /********* ** Locale changed events *********/ - if (this.Watchers.LocaleWatcher.IsChanged) + if (this.Watchers.LocaleWatcher.IsChanged || SGame.TicksElapsed == 0) { this.Monitor.Log($"Context: locale set to {this.Watchers.LocaleWatcher.CurrentValue}.", LogLevel.Trace); this.OnLocaleChanged(); @@ -1605,6 +1607,7 @@ namespace StardewModdingAPI.Framework string s = Game1.content.LoadString(@"Strings\StringsFromCSFiles:DayTimeMoneyBox.cs.10378"); SpriteText.drawStringWithScrollBackground(Game1.spriteBatch, s, 0x60, 0x20, "", 1f, -1, 0.088f); } + events.Rendered.RaiseEmpty(); _spriteBatchEnd.Invoke(); drawOverlays.Invoke(Game1.spriteBatch); renderScreenBuffer.Invoke(BlendState.Opaque); @@ -1615,12 +1618,12 @@ namespace StardewModdingAPI.Framework SpriteBatchBegin.Invoke(1f); events.RenderingHud.RaiseEmpty(); DrawHUD.Invoke(); - events.RenderedHud.RaiseEmpty(); if (((Game1.currentLocation != null) && !(Game1.activeClickableMenu is GameMenu)) && !(Game1.activeClickableMenu is QuestLog)) { Game1.currentLocation.drawAboveAlwaysFrontLayerText(Game1.spriteBatch); } DrawAfterMap.Invoke(); + events.RenderedHud.RaiseEmpty(); _spriteBatchEnd.Invoke(); if (Game1.tutorialManager != null) { @@ -1658,7 +1661,6 @@ namespace StardewModdingAPI.Framework _spriteBatchEnd.Invoke(); } DrawTutorialUI.Invoke(); - events.Rendered.RaiseEmpty(); } } } diff --git a/src/SMAPI/Framework/SMultiplayer.cs b/src/SMAPI/Framework/SMultiplayer.cs new file mode 100644 index 00000000..6869a23b --- /dev/null +++ b/src/SMAPI/Framework/SMultiplayer.cs @@ -0,0 +1,515 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +//using Galaxy.Api; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using StardewModdingAPI.Events; +using StardewModdingAPI.Framework.Events; +using StardewModdingAPI.Framework.Networking; +using StardewModdingAPI.Framework.Reflection; +using StardewModdingAPI.Toolkit.Serialisation; +using StardewValley; +using StardewValley.Network; +using StardewValley.SDKs; + +namespace StardewModdingAPI.Framework +{ + /// SMAPI's implementation of the game's core multiplayer logic. + /// + /// SMAPI syncs mod context to all players through the host as such: + /// 1. Farmhand sends ModContext + PlayerIntro. + /// 2. If host receives ModContext: it stores the context, replies with known contexts, and forwards it to other farmhands. + /// 3. If host receives PlayerIntro before ModContext: it stores a 'vanilla player' context, and forwards it to other farmhands. + /// 4. If farmhand receives ModContext: it stores it. + /// 5. If farmhand receives ServerIntro without a preceding ModContext: it stores a 'vanilla host' context. + /// 6. If farmhand receives PlayerIntro without a preceding ModContext AND it's not the host peer: it stores a 'vanilla player' context. + /// + /// Once a farmhand/server stored a context, messages can be sent to that player through the SMAPI APIs. + /// + internal class SMultiplayer : Multiplayer + { + /********* + ** Fields + *********/ + /// 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; + + /// A callback to invoke when a mod message is received. + private readonly Action OnModMessageReceived; + + + /********* + ** Accessors + *********/ + /// 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. + /// A callback to invoke when a mod message is received. + public SMultiplayer(IMonitor monitor, EventManager eventManager, JsonHelper jsonHelper, ModRegistry modRegistry, Reflector reflection, Action onModMessageReceived) + { + this.Monitor = monitor; + this.EventManager = eventManager; + this.JsonHelper = jsonHelper; + this.ModRegistry = modRegistry; + this.Reflection = reflection; + this.OnModMessageReceived = onModMessageReceived; + } + + /// Perform cleanup needed when a multiplayer session ends. + public void CleanupOnMultiplayerExit() + { + this.Peers.Clear(); + this.HostPeer = null; + } + + /// Initialise a client before the game connects to a remote server. + /// The client to initialise. + public override Client InitClient(Client client) + { + switch (client) + { + //case LidgrenClient _: + // { + // string address = this.Reflection.GetField(client, "address").GetValue(); + // return new SLidgrenClient(address, this.OnClientProcessingMessage, this.OnClientSendingMessage); + // } + + //case GalaxyNetClient _: + // { + // GalaxyID address = this.Reflection.GetField(client, "lobbyId").GetValue(); + // return new SGalaxyNetClient(address, this.OnClientProcessingMessage, this.OnClientSendingMessage); + // } + + default: + this.Monitor.Log($"Unknown multiplayer client type: {client.GetType().AssemblyQualifiedName}", LogLevel.Trace); + return client; + } + } + + /// Initialise a server before the game connects to an incoming player. + /// The server to initialise. + public override Server InitServer(Server server) + { + switch (server) + { + //case LidgrenServer _: + // { + // IGameServer gameServer = this.Reflection.GetField(server, "gameServer").GetValue(); + // return new SLidgrenServer(gameServer, this, this.OnServerProcessingMessage); + // } + + //case GalaxyNetServer _: + // { + // IGameServer gameServer = this.Reflection.GetField(server, "gameServer").GetValue(); + // return new SGalaxyNetServer(gameServer, this, this.OnServerProcessingMessage); + // } + + default: + this.Monitor.Log($"Unknown multiplayer server type: {server.GetType().AssemblyQualifiedName}", LogLevel.Trace); + return server; + } + } + + /// A callback raised when sending a message as a farmhand. + /// The message being sent. + /// Send an arbitrary message through the client. + /// Resume sending the underlying message. + protected void OnClientSendingMessage(OutgoingMessage message, Action sendMessage, Action resume) + { + if (this.Monitor.IsVerbose) + this.Monitor.Log($"CLIENT SEND {(MessageType)message.MessageType} {message.FarmerID}", LogLevel.Trace); + + switch (message.MessageType) + { + // sync mod context (step 1) + case (byte)MessageType.PlayerIntroduction: + sendMessage(new OutgoingMessage((byte)MessageType.ModContext, Game1.player.UniqueMultiplayerID, this.GetContextSyncMessageFields())); + resume(); + break; + + // run default logic + default: + resume(); + break; + } + } + + /// Process an incoming network message as the host player. + /// The message to process. + /// A method which sends the given message to the client. + /// Process the message using the game's default logic. + public void OnServerProcessingMessage(IncomingMessage message, Action sendMessage, Action resume) + { + if (this.Monitor.IsVerbose) + this.Monitor.Log($"SERVER RECV {(MessageType)message.MessageType} {message.FarmerID}", LogLevel.Trace); + + switch (message.MessageType) + { + // sync mod context (step 2) + case (byte)MessageType.ModContext: + { + // parse message + RemoteContextModel model = this.ReadContext(message.Reader); + this.Monitor.Log($"Received context for farmhand {message.FarmerID} running {(model != null ? $"SMAPI {model.ApiVersion} with {model.Mods.Length} mods" : "vanilla")}.", LogLevel.Trace); + + // store peer + MultiplayerPeer newPeer = new MultiplayerPeer(message.FarmerID, model, sendMessage, isHost: false); + if (this.Peers.ContainsKey(message.FarmerID)) + { + this.Monitor.Log($"Received mod context from farmhand {message.FarmerID}, but the game didn't see them disconnect. This may indicate issues with the network connection.", LogLevel.Info); + this.Peers.Remove(message.FarmerID); + return; + } + this.AddPeer(newPeer, canBeHost: false, raiseEvent: false); + + // reply with own context + this.Monitor.VerboseLog(" Replying with host context..."); + newPeer.SendMessage(new OutgoingMessage((byte)MessageType.ModContext, Game1.player.UniqueMultiplayerID, this.GetContextSyncMessageFields())); + + // reply with other players' context + foreach (MultiplayerPeer otherPeer in this.Peers.Values.Where(p => p.PlayerID != newPeer.PlayerID)) + { + this.Monitor.VerboseLog($" Replying with context for player {otherPeer.PlayerID}..."); + newPeer.SendMessage(new OutgoingMessage((byte)MessageType.ModContext, 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.Monitor.VerboseLog($" Forwarding context to player {otherPeer.PlayerID}..."); + otherPeer.SendMessage(new OutgoingMessage((byte)MessageType.ModContext, newPeer.PlayerID, fields)); + } + } + + // raise event + this.EventManager.PeerContextReceived.Raise(new PeerContextReceivedEventArgs(newPeer)); + } + break; + + // handle player intro + case (byte)MessageType.PlayerIntroduction: + // store peer if new + if (!this.Peers.ContainsKey(message.FarmerID)) + { + this.Monitor.Log($"Received connection for vanilla player {message.FarmerID}.", LogLevel.Trace); + MultiplayerPeer peer = new MultiplayerPeer(message.FarmerID, null, sendMessage, isHost: false); + this.AddPeer(peer, canBeHost: false); + } + + resume(); + break; + + // handle mod message + case (byte)MessageType.ModMessage: + this.ReceiveModMessage(message); + break; + + default: + resume(); + break; + } + } + + /// Process an incoming network message as a farmhand. + /// The message to process. + /// Send an arbitrary message through the client. + /// Resume processing the message using the game's default logic. + /// Returns whether the message was handled. + public void OnClientProcessingMessage(IncomingMessage message, Action sendMessage, Action resume) + { + if (this.Monitor.IsVerbose) + this.Monitor.Log($"CLIENT RECV {(MessageType)message.MessageType} {message.FarmerID}", LogLevel.Trace); + + switch (message.MessageType) + { + // mod context sync (step 4) + case (byte)MessageType.ModContext: + { + // parse message + RemoteContextModel model = this.ReadContext(message.Reader); + this.Monitor.Log($"Received context for {(model?.IsHost == true ? "host" : "farmhand")} {message.FarmerID} running {(model != null ? $"SMAPI {model.ApiVersion} with {model.Mods.Length} mods" : "vanilla")}.", LogLevel.Trace); + + // store peer + MultiplayerPeer peer = new MultiplayerPeer(message.FarmerID, model, sendMessage, isHost: model?.IsHost ?? this.HostPeer == null); + if (peer.IsHost && this.HostPeer != null) + { + this.Monitor.Log($"Rejected mod context from host player {peer.PlayerID}: already received host data from {(peer.PlayerID == this.HostPeer.PlayerID ? "that player" : $"player {peer.PlayerID}")}.", LogLevel.Error); + return; + } + this.AddPeer(peer, canBeHost: true); + } + break; + + // handle server intro + case (byte)MessageType.ServerIntroduction: + { + // store peer + if (!this.Peers.ContainsKey(message.FarmerID) && this.HostPeer == null) + { + this.Monitor.Log($"Received connection for vanilla host {message.FarmerID}.", LogLevel.Trace); + this.AddPeer(new MultiplayerPeer(message.FarmerID, null, sendMessage, isHost: true), canBeHost: false); + } + resume(); + break; + } + + // handle player intro + case (byte)MessageType.PlayerIntroduction: + { + // store peer + if (!this.Peers.TryGetValue(message.FarmerID, out MultiplayerPeer peer)) + { + peer = new MultiplayerPeer(message.FarmerID, null, sendMessage, isHost: this.HostPeer == null); + this.Monitor.Log($"Received connection for vanilla {(peer.IsHost ? "host" : "farmhand")} {message.FarmerID}.", LogLevel.Trace); + this.AddPeer(peer, canBeHost: true); + } + + resume(); + break; + } + + // handle mod message + case (byte)MessageType.ModMessage: + this.ReceiveModMessage(message); + break; + + default: + resume(); + break; + } + } + + /// Remove players who are disconnecting. + protected override void removeDisconnectedFarmers() + { + //foreach (long playerID in this.disconnectingFarmers) + //{ + // if (this.Peers.TryGetValue(playerID, out MultiplayerPeer peer)) + // { + // this.Monitor.Log($"Player quit: {playerID}", LogLevel.Trace); + // this.Peers.Remove(playerID); + // this.EventManager.PeerDisconnected.Raise(new PeerDisconnectedEventArgs(peer)); + // } + //} + + 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.Monitor.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.Monitor.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.Monitor.IsVerbose) + 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((byte)MessageType.ModMessage, peer.PlayerID, data)); + } + } + } + else if (this.HostPeer != null && this.HostPeer.HasSmapi) + this.HostPeer.SendMessage(new OutgoingMessage((byte)MessageType.ModMessage, this.HostPeer.PlayerID, data)); + else + this.Monitor.VerboseLog(" Can't send message because no valid connections were found."); + + } + + + /********* + ** Private methods + *********/ + /// Save a received peer. + /// The peer to add. + /// Whether to track the peer as the host if applicable. + /// Whether to raise the event. + private void AddPeer(MultiplayerPeer peer, bool canBeHost, bool raiseEvent = true) + { + // store + this.Peers[peer.PlayerID] = peer; + if (canBeHost && peer.IsHost) + this.HostPeer = peer; + + // raise event + if (raiseEvent) + this.EventManager.PeerContextReceived.Raise(new PeerContextReceivedEventArgs(peer)); + } + + /// Read the metadata context for a player. + /// The stream reader. + private RemoteContextModel ReadContext(BinaryReader reader) + { + string data = reader.ReadString(); + RemoteContextModel model = this.JsonHelper.Deserialise(data); + return model.ApiVersion != null + ? model + : null; // no data available for unmodded players + } + + /// 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.Monitor.IsVerbose) + this.Monitor.Log($"Received message: {json}.", LogLevel.Trace); + + // 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.Monitor.VerboseLog($" Forwarding message to player {peer.PlayerID}."); + peer.SendMessage(new OutgoingMessage((byte)MessageType.ModMessage, 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) }; + } + } +} diff --git a/src/SMAPI/IModHelper.cs b/src/SMAPI/IModHelper.cs index 589a0ced..cd746e06 100644 --- a/src/SMAPI/IModHelper.cs +++ b/src/SMAPI/IModHelper.cs @@ -35,8 +35,8 @@ namespace StardewModdingAPI /// Metadata about loaded mods. IModRegistry ModRegistry { get; } - ///// Provides multiplayer utilities. - //IMultiplayerHelper Multiplayer { get; } + /// Provides multiplayer utilities. + IMultiplayerHelper Multiplayer { get; } /// Provides translations stored in the mod's i18n folder, with one file per locale (like en.json) containing a flat key => value structure. Translations are fetched with locale fallback, so missing translations are filled in from broader locales (like pt-BR.json < pt.json < default.json). ITranslationHelper Translation { get; } diff --git a/src/SMAPI/IMultiplayerHelper.cs b/src/SMAPI/IMultiplayerHelper.cs new file mode 100644 index 00000000..4067a676 --- /dev/null +++ b/src/SMAPI/IMultiplayerHelper.cs @@ -0,0 +1,33 @@ +using System; +using System.Collections.Generic; +using StardewValley; + +namespace StardewModdingAPI +{ + /// Provides multiplayer utilities. + public interface IMultiplayerHelper : IModLinked + { + /// Get a new multiplayer ID. + long GetNewID(); + + /// Get the locations which are being actively synced from the host. + IEnumerable GetActiveLocations(); + + /// Get a connected player. + /// The player's unique ID. + /// Returns the connected player, or null if no such player is connected. + IMultiplayerPeer GetConnectedPlayer(long id); + + /// Get all connected players. + IEnumerable GetConnectedPlayers(); + + /// Send a message to mods installed by connected players. + /// The data type. This can be a class with a default constructor, or a value type. + /// 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 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. + /// The or is null. + void SendMessage(TMessage message, string messageType, string[] modIDs = null, long[] playerIDs = null); + } +}