diff --git a/.gitignore b/.gitignore index a42fb76..3a4ab03 100644 --- a/.gitignore +++ b/.gitignore @@ -406,4 +406,6 @@ dist/ # End of https://www.toptal.com/developers/gitignore/api/csharp -run.bat \ No newline at end of file +run.bat + +.idea/ diff --git a/PolyMod.csproj b/PolyMod.csproj index 2c165a4..5b7340e 100644 --- a/PolyMod.csproj +++ b/PolyMod.csproj @@ -11,7 +11,7 @@ IL2CPP PolyMod - 1.3.0-pre + 1.3.0-pre-android-multi-1 2.17.2.16299 PolyModdingTeam The Battle of Polytopia's mod loader. diff --git a/resources/dystopia_icon.png b/resources/dystopia_icon.png new file mode 100644 index 0000000..512162a Binary files /dev/null and b/resources/dystopia_icon.png differ diff --git a/resources/localization.json b/resources/localization.json index 3b19f1e..4b1ca28 100644 --- a/resources/localization.json +++ b/resources/localization.json @@ -1,4 +1,44 @@ { + "polymod_dystopia": { + "English": "Dystopia", + "German (Germany)": "Dystopia" + }, + "polymod_dystopia_server": { + "English": "Server", + "German (Germany)": "Server" + }, + "polymod_dystopia_connected": { + "English": "connected", + "German (Germany)": "verbunden" + }, + "polymod_dystopia_disconnected": { + "English": "disconnected", + "German (Germany)": "getrennt" + }, + "polymod_dystopia_modded": { + "English": "modded", + "German (Germany)": "gemoddet" + }, + "polymod_dystopia_vanilla": { + "English": "vanilla", + "German (Germany)": "vanilla" + }, + "polymod_dystopia_modded_description": { + "English": "Gameplay Mods active\nChecksum: {0}\n\nOnly players with the exact same mods can join your games. The official backend is unavailable while gameplay mods are loaded.", + "German (Germany)": "Gameplay-Mods aktiv\nPrüfsumme: {0}\n\nNur Spieler mit exakt denselben Mods können deinen Spielen beitreten. Der offizielle Server ist mit geladenen Gameplay-Mods nicht verfügbar." + }, + "polymod_dystopia_vanilla_description": { + "English": "No gameplay mods loaded — vanilla multiplayer.", + "German (Germany)": "Keine Gameplay-Mods geladen — Vanilla-Multiplayer." + }, + "polymod_dystopia_connect": { + "English": "Connect to {0}", + "German (Germany)": "Mit {0} verbinden" + }, + "polymod_dystopia_custom": { + "English": "Custom server...", + "German (Germany)": "Eigener Server..." + }, "polymod_hub": { "English": "PolyMod Hub", "Russian": "Центр PolyMod", diff --git a/src/Android/AndroidHandler.cs b/src/Android/AndroidHandler.cs new file mode 100644 index 0000000..0e3730d --- /dev/null +++ b/src/Android/AndroidHandler.cs @@ -0,0 +1,90 @@ +using HarmonyLib; +using PolytopiaBackendBase; +using PolytopiaBackendBase.Auth; +using UnityEngine; + +namespace PolyMod.Android; + +public static class AndroidHandler +{ + internal static void Init() + { + if (Application.platform != RuntimePlatform.Android) return; + + Harmony.CreateAndPatchAll(typeof(AndroidHandler)); + } + + /// + /// On Android, bypass multiplayer requirements that depend on + /// Google Play login, push notifications, and purchases — none of which work + /// when running as a wrapper app with a different package identity. + /// + [HarmonyPrefix] + [HarmonyPatch(typeof(GameManager), nameof(GameManager.IsMultiplayerEnabled), MethodType.Getter)] + public static bool GameManager_IsMultiplayerEnabled(ref bool __result) + { + __result = true; + return false; + } + + /// + /// Replace the Android login flow to skip Google Play Games SDK entirely. + /// Uses deviceUniqueIdentifier as the auth code for the Polydystopia backend. + /// + [HarmonyPrefix] + [HarmonyPatch(typeof(PolytopiaBackendAdapter), "LoginPlatformAndroid")] + public static bool LoginPlatformAndroid_Prefix( + ref Il2CppSystem.Threading.Tasks.Task> __result, + PolytopiaBackendAdapter __instance) + { + // Mark social login as cached so the post-login flow doesn't bail out + __instance.HasSocialLoginCached = true; + + var model = new LoginGooglePlayBindingModel(); + model.AuthCode = SystemInfo.deviceUniqueIdentifier; + model.DeviceId = SystemInfo.deviceUniqueIdentifier; + model.GameVersion = new Il2CppSystem.Nullable(VersionManager.GameVersion); + + Plugin.logger.LogInfo($"Multiplayer> Android login with DeviceId: {model.DeviceId}"); + __result = __instance.LoginGooglePlay(model); + return false; + } + + /// + /// On android Firebase cannot initialize inside the launcher process (its config lives in the game APK's resources, and the native lib may be unreachable there). + /// We try to skip Firebase completely. + /// + [HarmonyPrefix] + [HarmonyPatch(typeof(AnalyticsManager), nameof(AnalyticsManager.IsAnalyticsEnabled))] + private static bool AnalyticsManager_IsAnalyticsEnabled(ref bool __result) + { + __result = false; + return false; + } + + /// + /// On android Firebase cannot initialize inside the launcher process (its config lives in the game APK's resources, and the native lib may be unreachable there). + /// We try to skip Firebase completely. isFirebaseInitialized deliberately stays false: + /// pretending Firebase is up could wake isFirebaseInitialized-guarded code paths + /// (e.g. HandleOpenedThroughNotification on every app resume). + /// + [HarmonyPrefix] + [HarmonyPatch(typeof(FirebaseMessagingManager), nameof(FirebaseMessagingManager.Init))] + private static bool FirebaseMessagingManager_Init() + { + return false; + } + + /// + /// RequestPushNotificationPermissions (the push-notification row in LoginDetails) calls + /// InitAsync directly, bypassing Init — with isFirebaseInitialized kept false that would + /// still reach Firebase, so hand back a completed task instead. + /// + [HarmonyPrefix] + [HarmonyPatch(typeof(FirebaseMessagingManager), nameof(FirebaseMessagingManager.InitAsync))] + private static bool FirebaseMessagingManager_InitAsync(ref Il2CppSystem.Threading.Tasks.Task __result) + { + __result = Il2CppSystem.Threading.Tasks.Task.CompletedTask; + return false; + } +} \ No newline at end of file diff --git a/src/Managers/Compatibility.cs b/src/Managers/Compatibility.cs index 1d55bb7..4d39d70 100644 --- a/src/Managers/Compatibility.cs +++ b/src/Managers/Compatibility.cs @@ -21,6 +21,16 @@ internal static class Compatibility internal static bool shouldResetSettings = false; private static bool sawSignatureWarning; + /// + /// Whether all loaded mods are client only. If at least one non client only mod exists this returns false. + /// + /// + public static bool IsClientOnly() + { + return Registry.mods.Select(modPair => modPair.Value) + .All(mod => mod.client || mod.id == "polytopia" || mod.status != Mod.Status.Success); + } + /// /// Hashes the signatures of all loaded mods to create a checksum. /// @@ -152,6 +162,25 @@ private static bool StartScreen_OnResumeButtonClick(StartScreen_UI2 __instance) return CheckSignatures(__instance.OnResumeButtonLongPress, LocalSaveFileUtils.GetSaveFiles(PolytopiaBackendBase.Game.GameType.SinglePlayer)[0]); } + /// + /// Checks the signature of a multiplayer game before opening it. + /// Blocks on mismatch. + /// + [HarmonyPrefix] + [HarmonyPatch(typeof(GameManager), nameof(GameManager.OpenMultiplayerGame))] + private static bool GameManager_OpenMultiplayerGame( + ref Il2CppSystem.Threading.Tasks.Task __result, + Il2CppSystem.Guid gameId) + { + if (CheckSignatures(null!, gameId)) return true; + + var taskCompletionSource = new Il2CppSystem.Threading.Tasks.TaskCompletionSource(); + taskCompletionSource.SetResult(false); + __result = taskCompletionSource.Task; + + return false; + } + /// /// Deletes the signature file of a pass-and-play game when it is deleted. /// diff --git a/src/Managers/Visual.cs b/src/Managers/Visual.cs index bccc5fc..d786dec 100644 --- a/src/Managers/Visual.cs +++ b/src/Managers/Visual.cs @@ -55,6 +55,10 @@ public record SkinInfo(int idx, string id, SkinData? skinData); /// A dictionary of custom widths for basic popups. public static Dictionary basicPopupWidths = new(); + /// Original font sizes of popup buttons, so relayouts rescale from the prefab size. + private static readonly Dictionary popupButtonFontSizes = new(); + /// Original scroll viewport bottom offsets of popups, so relayouts don't compound. + private static readonly Dictionary popupScrollBottoms = new(); /// Represents information about a unit prefab. public struct UnitPrefabInfo { @@ -778,7 +782,86 @@ private static void UpdateWidth(PopupBase __instance) { int id = __instance.GetInstanceID(); if (basicPopupWidths.ContainsKey(id)) - __instance.rectTransform.SetWidth(basicPopupWidths[id]); + { + float maxWidth = UIManager.GetUIWidth() - 40f; + float width = Mathf.Min(basicPopupWidths[id], maxWidth); + __instance.rectTransform.SetWidth(width); + LayoutPopupButtons(__instance, width); + } + } + + private const float BUTTON_GAP = 10f; + private const float BUTTON_ROW_PADDING = 40f; + + private static void LayoutPopupButtons(PopupBase popup, float popupWidth) + { + var legacy = popup.TryCast(); + if (legacy == null || legacy.buttonContainer == null) return; + var buttons = legacy.buttonContainer.Buttons; + if (buttons == null || buttons.Length < 2) return; + + float rowWidth = popupWidth - BUTTON_ROW_PADDING; + + foreach (UITextButton button in buttons) + { + int id = button.GetInstanceID(); + if (!popupButtonFontSizes.ContainsKey(id)) + popupButtonFontSizes[id] = button.FontSize; + else + button.FontSize = popupButtonFontSizes[id]; + button.UpdateSize(); + float width = button.rectTransform.GetWidth(); + if (width > rowWidth) + { + button.FontSize *= rowWidth / width; + button.UpdateSize(); + } + } + + List> rows = new(); + float cursor = 0f; + foreach (UITextButton button in buttons) + { + float width = button.rectTransform.GetWidth(); + if (rows.Count == 0 || cursor + width > rowWidth) + { + rows.Add(new()); + cursor = 0f; + } + rows[^1].Add(button); + cursor += width + BUTTON_GAP; + } + + float rowHeight = buttons[0].rectTransform.GetHeight() + BUTTON_GAP; + for (int r = 0; r < rows.Count; r++) + { + float total = -BUTTON_GAP; + foreach (UITextButton button in rows[r]) + total += button.rectTransform.GetWidth() + BUTTON_GAP; + float x = -total / 2f; + float y = (rows.Count - 1 - r) * rowHeight; + foreach (UITextButton button in rows[r]) + { + button.rectTransform.anchorMin = new Vector2(0.5f, 0.5f); + button.rectTransform.anchorMax = new Vector2(0.5f, 0.5f); + button.rectTransform.pivot = new Vector2(0f, 0.5f); + button.rectTransform.anchoredPosition = new Vector2(x, y); + x += button.rectTransform.GetWidth() + BUTTON_GAP; + } + } + + float extra = (rows.Count - 1) * rowHeight; + float maxHeight = (ScreenManager.SafeHeight - 20f) * UICanvasScalerHelper.GetInvertedUIScale(); + popup.rectTransform.SetHeight(Mathf.Min(popup.rectTransform.GetHeight() + extra, maxHeight)); + + if (popup.scrollRect != null) + { + var viewport = popup.scrollRect.GetComponent(); + int popupId = popup.GetInstanceID(); + if (!popupScrollBottoms.ContainsKey(popupId)) + popupScrollBottoms[popupId] = viewport.offsetMin.y; + viewport.offsetMin = new Vector2(viewport.offsetMin.x, popupScrollBottoms[popupId] + extra); + } } /// Sets the attacker's tribe before a unit attacks. @@ -809,12 +892,19 @@ private static void WeaponGFX_SetSkin(WeaponGFX __instance, SkinType skinType) } } - /// Removes a popup's custom width when it is hidden. + /// Removes a popup's custom width and cached button font sizes when it is hidden. [HarmonyPostfix] [HarmonyPatch(typeof(PopupBase), nameof(PopupBase.Hide))] private static void PopupBase_Hide(PopupBase __instance) { basicPopupWidths.Remove(__instance.GetInstanceID()); + popupScrollBottoms.Remove(__instance.GetInstanceID()); + var legacy = __instance.TryCast(); + if (legacy != null && legacy.buttonContainer != null && legacy.buttonContainer.Buttons != null) + { + foreach (UITextButton button in legacy.buttonContainer.Buttons) + popupButtonFontSizes.Remove(button.GetInstanceID()); + } } [HarmonyPrefix] diff --git a/src/Multiplayer/Dystopia.cs b/src/Multiplayer/Dystopia.cs new file mode 100644 index 0000000..3d288fc --- /dev/null +++ b/src/Multiplayer/Dystopia.cs @@ -0,0 +1,264 @@ +using HarmonyLib; +using Il2CppInterop.Runtime; +using PolyMod.Managers; +using PolytopiaBackendBase; +using TMPro; + +namespace PolyMod.Multiplayer; + +/// +/// Integrates the Dystopia backend switcher natively into the multiplayer tab as a section at the bottom of the game list +/// +public static class Dystopia +{ + internal const string OFFICIAL_SERVER_URL = "https://polytopia-prod.net/"; + + private record ServerEntry(string name, string url, bool official = false, bool disabled = false); + + private static readonly ServerEntry[] SERVERS = + { + new("Official", OFFICIAL_SERVER_URL, official: true), + // Disabled until the production Dystopia server goes online. + new("Dystopia", "https://polydystopia.xyz", disabled: true), + new("Dystopia Dev", "https://dev.polydystopia.xyz"), + }; + + internal static void Init() + { + Harmony.CreateAndPatchAll(typeof(Dystopia)); + } + + [HarmonyPostfix] + [HarmonyPatch(typeof(MultiplayerScreen), nameof(MultiplayerScreen.AddHotSeatGames))] + private static void MultiplayerScreen_AddHotSeatGames(MultiplayerScreen __instance) + { + try + { + AddServerSection(__instance); + } + catch (System.Exception e) + { + Plugin.logger.LogWarning($"Dystopia> Failed to add server section: {e}"); + } + } + + private static void AddServerSection(MultiplayerScreen screen) + { + string current = Normalize(Plugin.config.backendUrl); + + screen.AddHeader("polymod.dystopia", useExtraSpacer: true); + + string connectionState = Localization.Get(PolytopiaBackendAdapter.Instance.IsConnected + ? "polymod.dystopia.connected" + : "polymod.dystopia.disconnected"); + + MultiplayerInfoRow infoRow = screen.AddInfoRow(); + infoRow.header.text = $"{Localization.Get("polymod.dystopia.server")}: {CurrentServerName()} ({connectionState})"; + infoRow.description.text = Compatibility.IsClientOnly() + ? Localization.Get("polymod.dystopia.vanilla.description") + : string.Format(Localization.Get("polymod.dystopia.modded.description"), ShortChecksum()); + + foreach (ServerEntry server in SERVERS) + { + // The active server is named in the info row; the official backend stays blocked + // (the info row explains why). + if (server.official || server.disabled || Normalize(server.url) == current) continue; + + string url = server.url; + AddServerButton(screen, + string.Format(Localization.Get("polymod.dystopia.connect"), server.name), + () => SwitchServer(url)); + } + + AddServerButton(screen, Localization.Get("polymod.dystopia.custom"), ShowCustomServerPopup); + } + + private static void AddServerButton(MultiplayerScreen screen, string text, System.Action action) + { + // The private no-arg AddButtonRow returns the actual row (the public string overload + // returns the prefab by mistake) and clears callbacks on reused rows. + ButtonRow row = screen.AddButtonRow(); + row.buttonComp.text = text; + row.buttonComp.OnClickedSignal.Add(DelegateSupport.ConvertDelegate(action)); + } + + [HarmonyPostfix] + [HarmonyPatch(typeof(MultiplayerSelectionScreen), nameof(MultiplayerSelectionScreen.OnEnable))] + private static void MultiplayerSelectionScreen_OnEnable(MultiplayerSelectionScreen __instance) + { + UpdateStatusHeader(__instance); + } + + [HarmonyPostfix] + [HarmonyPatch(typeof(MultiplayerSelectionScreen), nameof(MultiplayerSelectionScreen.OnBackendConnectionChanged))] + private static void MultiplayerSelectionScreen_OnBackendConnectionChanged(MultiplayerSelectionScreen __instance) + { + UpdateStatusHeader(__instance); + } + + private static void UpdateStatusHeader(MultiplayerSelectionScreen screen) + { + try + { + UIHorizontalList list = screen.ScreenSelectionList; + if (list == null) return; + + if (string.IsNullOrEmpty(list.HeaderKey)) + { + // Activates and sizes the header slot; the text itself is overridden below. + list.HeaderKey = "polymod.dystopia"; + } + + string moddedState = Compatibility.IsClientOnly() + ? Localization.Get("polymod.dystopia.vanilla") + : $"{Localization.Get("polymod.dystopia.modded")} {ShortChecksum()}"; + list.header.Text = $"{CurrentServerName()} · {moddedState}"; + } + catch (System.Exception e) + { + Plugin.logger.LogWarning($"Dystopia> Failed to update status header: {e}"); + } + } + + private static string CurrentServerName() + { + string current = Normalize(Plugin.config.backendUrl); + foreach (ServerEntry server in SERVERS) + { + if (Normalize(server.url) == current) return server.name; + } + + return Plugin.config.backendUrl; + } + + private static string ShortChecksum() + { + return Compatibility.checksum.Length >= 8 ? Compatibility.checksum[..8] : Compatibility.checksum; + } + + private static void ShowCustomServerPopup() + { + SearchFriendCodePopup popup = PopupManager.GetPopup("dystopiaCustomServerPopup"); + TMP_InputField input = popup.inputfield; + + input.onSubmit = new TMP_InputField.SubmitEvent(); + input.onEndEdit = new TMP_InputField.SubmitEvent(); + input.onValueChanged = new TMP_InputField.OnChangeEvent(); + input.onSelect = new TMP_InputField.SelectionEvent(); + input.onDeselect = new TMP_InputField.SelectionEvent(); + input.contentType = TMP_InputField.ContentType.Standard; + input.characterLimit = 200; + + var placeholder = input.placeholder != null ? input.placeholder.TryCast() : null; + if (placeholder != null) + { + placeholder.text = "Server URL or IP"; + } + + void OnConnect() + { + string url = input.text?.Trim() ?? ""; + if (url.Length == 0) + { + return; + } + if (!url.StartsWith("http://") && !url.StartsWith("https://")) + { + url = "https://" + url; + } + if (!System.Uri.TryCreate(url, System.UriKind.Absolute, out _)) + { + NotificationManager.Notify("Invalid server URL"); + return; + } + popup.Hide(); + SwitchServer(url); + } + + popup.Show(); + popup.Header = "Custom server"; + popup.Description = "Enter the server URL or IP:"; + popup.buttonContainer.ResetContainer(); + popup.buttonData = new PopupBase.PopupButtonData[] + { + new("buttons.back"), + new( + "Connect", + callback: DelegateSupport.ConvertDelegate(OnConnect), + closesPopup: false + ), + }; + input.SetTextWithoutNotify(Plugin.config.backendUrl); + } + + private static async void SwitchServer(string url) + { + Plugin.logger.LogInfo($"Dystopia> Switching server to {url}"); + Plugin.config = Plugin.config with { backendUrl = url }; + Plugin.WriteConfig(); + + PolytopiaBackendAdapter adapter = PolytopiaBackendAdapter.Instance; + + await adapter.CloseConnection(); + + BuildConfig buildConfig = BuildConfigHelper.GetSelectedBuildConfig(); + buildConfig.buildServerURL = BuildServerURL.Custom; + buildConfig.customServerURL = url; + + adapter.UseBackendUri(new Il2CppSystem.Uri(url)); + adapter.UseHttpClient(); + + PurgeServerCaches(); + + adapter.ConnectionStatus = ConnectionStatus.None; + BackendEvents.BackendConnectionChanged(ConnectionStatus.Disconnected, false); + + GameManager.GetLoginManager().Login(false, true); + Plugin.logger.LogInfo($"Dystopia> Reconnect to {url} initiated"); + } + + private static void PurgeServerCaches() + { + try + { + var remote = GameManager.GetRemoteGameDataManager(); + remote.gameDataCache.Clear(); + remote.matchmakingGameDataCache?.Clear(); + remote.gameIdCache.Clear(); + remote.hasLoadedGameDataCache = false; + + var lobbies = GameManager.GetLobbyManager(); + lobbies.cachedLobbies.Clear(); + lobbies.hasCachedLobbies = false; + + AccountManager.currentPlayerData = null; + AccountManager.friends = null; + AccountManager.friendViewModels = null; + AccountManager.playersStatuses?.Clear(); + AccountManager.ClearCachedPlayerId(); + + DeleteIfExists(Paths.GetUserProfileCachePath()); + DeleteIfExists(Paths.GetStartupDataPath()); + + GameManager.GetWeeklyChallengeModel().ClearData(); + GameManager.ActionableGamesCount = 0; + } + catch (System.Exception e) + { + Plugin.logger.LogWarning($"Dystopia> Failed to purge some server caches: {e}"); + } + } + + private static void DeleteIfExists(string path) + { + if (File.Exists(path)) + { + File.Delete(path); + } + } + + private static string Normalize(string url) + { + return url.TrimEnd('/').ToLowerInvariant(); + } +} diff --git a/src/Multiplayer/Client.cs b/src/Multiplayer/ModMultiplayer.cs similarity index 59% rename from src/Multiplayer/Client.cs rename to src/Multiplayer/ModMultiplayer.cs index add1b28..1d6a5c3 100644 --- a/src/Multiplayer/Client.cs +++ b/src/Multiplayer/ModMultiplayer.cs @@ -1,5 +1,8 @@ using HarmonyLib; using Il2CppMicrosoft.AspNetCore.SignalR.Client; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using PolyMod.Managers; using PolyMod.Multiplayer.ViewModels; using Polytopia.Data; using PolytopiaBackendBase; @@ -7,138 +10,68 @@ using PolytopiaBackendBase.Game; using PolytopiaBackendBase.Game.BindingModels; using UnityEngine; -using Newtonsoft.Json; namespace PolyMod.Multiplayer; -public static class Client +public class ModMultiplayer { - internal const string DEFAULT_SERVER_URL = "https://dev.polydystopia.xyz"; - internal const string LOCAL_SERVER_URL = "http://localhost:5051/"; - private const string GldMarker = "##GLD:"; - internal static bool allowGldMods = false; - - // Cache parsed GLD by game Seed to handle rewinds/reloads - private static readonly Dictionary _gldCache = new(); - private static readonly Dictionary _versionCache = new(); // Seed -> modGldVersion - internal static void Init() { - Harmony.CreateAndPatchAll(typeof(Client)); - BuildConfig buildConfig = BuildConfigHelper.GetSelectedBuildConfig(); - buildConfig.buildServerURL = BuildServerURL.Custom; - buildConfig.customServerURL = LOCAL_SERVER_URL; + if (Compatibility.IsClientOnly()) + { + Plugin.logger?.LogInfo($"All loaded mods are client only. Skipping modded multiplayer initialization."); - Plugin.logger.LogInfo($"Multiplayer> Server URL set to: {Plugin.config.backendUrl}"); - Plugin.logger.LogInfo("Multiplayer> GLD patches applied"); - } + return; + } - [HarmonyPostfix] - [HarmonyPatch(typeof(MultiplayerSelectionScreen), nameof(MultiplayerSelectionScreen.Show))] - public static void MultiplayerScreen_Show(MultiplayerSelectionScreen __instance) - { - __instance.TournamentsButton.gameObject.SetActive(false); - } + Plugin.logger?.LogInfo($"Starting modded multiplayer initialization."); - [HarmonyPostfix] - [HarmonyPatch(typeof(StartScreen_UI2), nameof(StartScreen_UI2.RunLayout))] - private static void StartScreen_UI2_RunLayout(StartScreen_UI2 __instance) - { - __instance.highscoreButton.gameObject.SetActive(false); - __instance.weeklyChallengeButton.gameObject.SetActive(false); - } + Harmony.CreateAndPatchAll(typeof(ModMultiplayer)); + SerializationUtils.Init(); + ModdedClient.Init(); - [HarmonyPostfix] - [HarmonyPatch(typeof(SystemInfo), nameof(SystemInfo.deviceUniqueIdentifier), MethodType.Getter)] - public static void SteamClient_get_SteamId(ref string __result) - { - if (Plugin.config.overrideDeviceId != string.Empty) - { - __result = Plugin.config.overrideDeviceId; - } + Plugin.logger?.LogInfo($"Finished modded multiplayer initialization."); } - [HarmonyPrefix] - [HarmonyPatch(typeof(ClientBase), nameof(ClientBase.SendCommand))] - private static bool ClientBase_SendCommand( - ClientBase __instance, - CommandBase command) + [HarmonyPatch(typeof(BackendAdapter), nameof(BackendAdapter.CreateLobby))] + private static bool BackendAdapter_CreateLobby( + ref Il2CppSystem.Threading.Tasks.Task> __result, + BackendAdapter __instance, + CreateLobbyBindingModel model) { + Plugin.logger.LogInfo("Multiplayer> BackendAdapter_CreateLobby"); + var taskCompletionSource = new Il2CppSystem.Threading.Tasks.TaskCompletionSource>(); - Plugin.logger.LogInfo("Multiplayer> ClientBase_SendCommand"); - Il2CppSystem.Threading.Tasks.Task> task = new(); - var taskCompletionSource = new Il2CppSystem.Threading.Tasks.TaskCompletionSource>(); - - _ = ClientBase_SendCommand_Async(taskCompletionSource, __instance, command); + _ = HandleCreateLobbyModded(taskCompletionSource, __instance, model); - task = taskCompletionSource.Task; + __result = taskCompletionSource.Task; return false; } - private static async System.Threading.Tasks.Task ClientBase_SendCommand_Async( - Il2CppSystem.Threading.Tasks.TaskCompletionSource> tcs, - ClientBase client, - CommandBase command) + private static async System.Threading.Tasks.Task HandleCreateLobbyModded( + Il2CppSystem.Threading.Tasks.TaskCompletionSource> tcs, + BackendAdapter instance, + CreateLobbyBindingModel model) { try { - if (!client.CurrentGameId.HasValue) - { - Console.Write("Tried to perform and send command but no GameId was set"); - return; - } - if (!ClientActionManager.CanReceiveCommand(command, client.GameState)) - { - Console.Write("Tried to send invalid command"); - return; - } - uint currentResetId = client.resets; - int count = client.GameState.CommandStack.Count; - var list = new Il2CppSystem.Collections.Generic.List(); - list.Add(command); - client.ActionManager.ExecuteCommands(list); - await client.SendCommandToServer(command, count); - - var serializedGameState = SerializationHelpers.ToByteArray(client.GameState, client.GameState.Version); - - var succ = GameStateSummary.FromGameStateByteArray(serializedGameState, - out GameStateSummary stateSummary, out var gameState); - - var serializedGameSummary = SerializationHelpers.ToByteArray(stateSummary, gameState.Version); - - - client.GameState.TryGetPlayer(client.GameState.CurrentPlayer, out PlayerState playerState); - var currentPlayerId = ""; - if(playerState.AccountId.HasValue) - { - currentPlayerId = playerState.AccountId.Value.ToString(); - } - var setupGameDataViewModel = new ModdedGameStateViewModel - { - gameId = client.gameId.ToString(), - serializedGameState = serializedGameState, - serializedGameSummary = serializedGameSummary, - gameSettingsJson = "", - currentPlayerId = currentPlayerId, - IsEndTurnCommand = command.GetCommandType() == CommandType.EndTurn - }; + var payload = JObject.FromObject(model); + payload["IsModded"] = new JValue(true); + payload["Checksum"] = new JValue(Compatibility.checksum); - - - var setupData = System.Text.Json.JsonSerializer.Serialize(setupGameDataViewModel); - - var serverResponse = await PolytopiaBackendAdapter.Instance.HubConnection.InvokeAsync>( - "UpdateGameStateModded", - setupData, + var serverResponse = await instance.HubConnection.InvokeAsync>( + "CreateLobby", + payload, Il2CppSystem.Threading.CancellationToken.None ); + Plugin.logger.LogInfo("Multiplayer> Invoked CreateLobby with mod info"); tcs.SetResult(serverResponse); } catch (Exception ex) { - Plugin.logger.LogError("Multiplayer> Error during HandleSendCommandModded: " + ex.Message); + Plugin.logger.LogError("Multiplayer> Error during HandleCreateLobbyModded: " + ex.Message); tcs.SetException(new Il2CppSystem.Exception(ex.Message)); } } @@ -153,14 +86,14 @@ private static bool BackendAdapter_StartLobbyGame_Modded( Plugin.logger.LogInfo("Multiplayer> BackendAdapter_StartLobbyGame_Modded"); var taskCompletionSource = new Il2CppSystem.Threading.Tasks.TaskCompletionSource>(); - _ = BackendAdapter_StartLobbyGame_Async(taskCompletionSource, __instance, model); + _ = HandleStartLobbyGameModded(taskCompletionSource, __instance, model); __result = taskCompletionSource.Task; return false; } - private static async System.Threading.Tasks.Task BackendAdapter_StartLobbyGame_Async( + private static async System.Threading.Tasks.Task HandleStartLobbyGameModded( Il2CppSystem.Threading.Tasks.TaskCompletionSource> tcs, BackendAdapter instance, StartLobbyBindingModel model) @@ -182,18 +115,31 @@ private static async System.Threading.Tasks.Task BackendAdapter_StartLobbyGame_A VersionManager.GameLogicDataVersion ); - Plugin.logger.LogInfo("Multiplayer> GameState and Settiings created"); + Plugin.logger.LogInfo("Multiplayer> GameState and Settings created"); - var succ = GameStateSummary.FromGameStateByteArray(serializedGameState, - out GameStateSummary stateSummary, out var gameState); + var serializedGameSummary = Array.Empty(); + var initialCommandCount = -1; + string? currentPlayerId = null; + if (GameStateSummary.FromGameStateByteArray(serializedGameState, out GameStateSummary stateSummary, + out GameState initialState)) + { + serializedGameSummary = SerializationHelpers.ToByteArray(stateSummary, initialState.Version); + initialCommandCount = initialState.CommandStack.Count; + currentPlayerId = GameStateUtils.GetCurrentPlayerAccountId(initialState).ToString(); + if (currentPlayerId == "00000000-0000-0000-0000-000000000000") + { + currentPlayerId = null; + } + } - var serializedGameSummary = SerializationHelpers.ToByteArray(stateSummary, gameState.Version); - var setupGameDataViewModel = new ModdedGameStateViewModel + var setupGameDataViewModel = new SetupGameDataViewModel { lobbyId = lobbyGameViewModel.Id.ToString(), serializedGameState = serializedGameState, serializedGameSummary = serializedGameSummary, - gameSettingsJson = gameSettingsJson + gameSettingsJson = gameSettingsJson, + initialCommandCount = initialCommandCount, + currentPlayerId = currentPlayerId }; var setupData = System.Text.Json.JsonSerializer.Serialize(setupGameDataViewModel); @@ -204,6 +150,19 @@ private static async System.Threading.Tasks.Task BackendAdapter_StartLobbyGame_A Il2CppSystem.Threading.CancellationToken.None ); Plugin.logger.LogInfo("Multiplayer> Invoked StartLobbyGameModded"); + + if (serverResponse == null) + { + tcs.SetException(new Il2CppSystem.Exception("No response from StartLobbyGameModded.")); + return; + } + + if (serverResponse.Success) + { + ModdedClient.RegisterModdedGame(lobbyGameViewModel.Id.ToString(), Compatibility.checksum); + ModdedClient.SetShadowState(lobbyGameViewModel.Id.ToString(), serializedGameState); + } + tcs.SetResult(serverResponse); } catch (Exception ex) @@ -213,6 +172,69 @@ private static async System.Threading.Tasks.Task BackendAdapter_StartLobbyGame_A } } + /// + /// Joining a modded lobby must carry the local mod checksum so the server can block mismatched mod sets before they corrupt a game. + /// + [HarmonyPrefix] + [HarmonyPatch(typeof(BackendAdapter), nameof(BackendAdapter.RespondToLobbyInvitation))] + private static bool BackendAdapter_RespondToLobbyInvitation( + ref Il2CppSystem.Threading.Tasks.Task> __result, + BackendAdapter __instance, + RespondToLobbyInvitation model) + { + Plugin.logger.LogInfo("Multiplayer> BackendAdapter_RespondToLobbyInvitation"); + var taskCompletionSource = new Il2CppSystem.Threading.Tasks.TaskCompletionSource>(); + + _ = HandleRespondToLobbyInvitationModded(taskCompletionSource, __instance, model); + + __result = taskCompletionSource.Task; + + return false; + } + + private static async System.Threading.Tasks.Task HandleRespondToLobbyInvitationModded( + Il2CppSystem.Threading.Tasks.TaskCompletionSource> tcs, + BackendAdapter instance, + RespondToLobbyInvitation model) + { + try + { + var payload = JObject.FromObject(model); + payload["Checksum"] = new JValue(Compatibility.checksum); + + var serverResponse = await instance.HubConnection.InvokeAsync>( + "RespondToLobbyInvitation", + payload, + Il2CppSystem.Threading.CancellationToken.None + ); + + if (serverResponse == null) + { + tcs.SetException(new Il2CppSystem.Exception("No response from RespondToLobbyInvitation.")); + return; + } + + if (!serverResponse.Success && serverResponse.ErrorCode == ErrorCode.StateProhibitsOperation) + { + Plugin.logger.LogWarning("Multiplayer> Lobby join blocked: mod set mismatch"); + PopupManager.GetBasicPopupWithData(new( + Localization.Get("polymod.signature.mismatch"), + Localization.Get("polymod.signature.incompatible"), + new(new PopupBase.PopupButtonData[] { + new("OK") + }) + )).Show(); + } + + tcs.SetResult(serverResponse); + } + catch (Exception ex) + { + Plugin.logger.LogError("Multiplayer> Error during HandleRespondToLobbyInvitationModded: " + ex.Message); + tcs.SetException(new Il2CppSystem.Exception(ex.Message)); + } + } + public static (byte[] serializedGameState, string gameSettingsJson) CreateMultiplayerGame(LobbyGameViewModel lobby, int gameVersion, int gameLogicVersion) { @@ -225,13 +247,16 @@ public static (byte[] serializedGameState, string gameSettingsJson) CreateMultip } foreach (var participatorViewModel in lobby.Participators) { + if (participatorViewModel.InvitationState != PlayerInvitationState.Accepted) continue; + + var tribe = (TribeType)participatorViewModel.SelectedTribe; var humanPlayer = new PlayerData { type = PlayerDataType.LocalUser, state = PlayerDataFriendshipState.Accepted, knownTribe = true, - tribe = (TribeType)participatorViewModel.SelectedTribe, - tribeMix = TribeType.None, // TribeMix is byte too + tribe = tribe, + tribeMix = (int)tribe < byte.MaxValue ? tribe : TribeType.None, skinType = (SkinType)participatorViewModel.SelectedTribeSkin, defaultName = participatorViewModel.GetNameInternal() }; @@ -334,41 +359,11 @@ public static (byte[] serializedGameState, string gameSettingsJson) CreateMultip gameState.CommandStack.Add((CommandBase)new StartMatchCommand((byte)1)); + new ActionManager(gameState).Update(); + var serializedGameState = SerializationHelpers.ToByteArray(gameState, gameState.Version); return (serializedGameState, JsonConvert.SerializeObject(gameState.Settings)); } - - // FIX FOR NATURE PLAYER. BOTS ARENT IMPLEMENTED YET - - [HarmonyPrefix] - [HarmonyPatch(typeof(GameState), nameof(GameState.EndPlayerTurn))] - private static bool GameState_EndPlayerTurn(GameState __instance, bool newTurn = false) - { - Console.Write("GameState_EndPlayerTurn"); - __instance.CurrentPlayerIndex++; - if (__instance.CurrentPlayerIndex >=__instance. PlayerStates.Count) - { - __instance.CurrentPlayerIndex = 0; - newTurn = true; - } - - var currentPlayer = __instance.PlayerStates[__instance.CurrentPlayerIndex]; - if (!currentPlayer.IsAlive(__instance)) - { - __instance.EndPlayerTurn(newTurn); - } - else if (newTurn) - { - __instance.CurrentTurn++; - } - - if(currentPlayer.AutoPlay) - { - __instance.CommandStack.Add(new EndTurnCommand(currentPlayer.Id)); - } - Console.Write("finished"); - return false; - } -} +} \ No newline at end of file diff --git a/src/Multiplayer/ModdedClient.cs b/src/Multiplayer/ModdedClient.cs new file mode 100644 index 0000000..8120104 --- /dev/null +++ b/src/Multiplayer/ModdedClient.cs @@ -0,0 +1,616 @@ +using HarmonyLib; +using Il2CppMicrosoft.AspNetCore.SignalR.Client; +using PolyMod.Managers; +using PolyMod.Multiplayer.ViewModels; +using Polytopia.Data; +using PolytopiaBackendBase; +using PolytopiaBackendBase.Game; +using PolytopiaBackendBase.Game.BindingModels; +using UnityEngine; + +namespace PolyMod.Multiplayer; + +/// +/// Client-authoritative command flow for modded games. +/// The server never evaluates modded game state. The acting client executes commands (plus auto-play follow-up turns) the way the vanilla server would, on a shadow copy of the authoritative state and uploads the result via UpdateGameStateModded. Other clients receive the commands through the vanilla OnCommand relay. +/// +public static class ModdedClient +{ + private static readonly HashSet _moddedGameIds = new(); + private static readonly Dictionary _shadowStates = new(); + private static readonly SemaphoreSlim _sendLock = new(1, 1); + private const string EmptyGuid = "00000000-0000-0000-0000-000000000000"; + + internal static void Init() + { + Harmony.CreateAndPatchAll(typeof(ModdedClient)); + } + + internal static void RegisterModdedGame(string gameId, string? checksum) + { + lock (_moddedGameIds) + { + _moddedGameIds.Add(gameId.ToLowerInvariant()); + } + + try + { + File.WriteAllText(SignaturesPath(gameId), checksum ?? Compatibility.checksum); + } + catch (Exception e) + { + Plugin.logger.LogWarning($"Multiplayer> Could not write signatures for {gameId}: {e.Message}"); + } + } + + internal static bool IsModdedGame(string gameId) + { + lock (_moddedGameIds) + { + if (_moddedGameIds.Contains(gameId.ToLowerInvariant())) return true; + } + + return File.Exists(SignaturesPath(gameId)); + } + + internal static void SetShadowState(string gameId, byte[] stateBytes) + { + lock (_shadowStates) + { + _shadowStates[gameId.ToLowerInvariant()] = stateBytes; + } + } + + private static byte[]? GetShadowState(string gameId) + { + lock (_shadowStates) + { + return _shadowStates.TryGetValue(gameId.ToLowerInvariant(), out var bytes) ? bytes : null; + } + } + + private static void DropShadowState(string gameId) + { + lock (_shadowStates) + { + _shadowStates.Remove(gameId.ToLowerInvariant()); + } + } + + private static string SignaturesPath(string gameId) => + Path.Combine(Application.persistentDataPath, $"{gameId}.signatures"); + + /// + /// Whenever the client subscribes to a game, ask the server whether it is modded so the send path can route accordingly. + /// Also covers games created/joined on another device. + /// + [HarmonyPostfix] + [HarmonyPatch(typeof(BackendAdapter), nameof(BackendAdapter.SubscribeToGame))] + private static void BackendAdapter_SubscribeToGame(BackendAdapter __instance, SubscribeToGameBindingModel model) + { + _ = FetchModdedGameInfo(__instance, model.GameId); + } + + private static async System.Threading.Tasks.Task FetchModdedGameInfo(BackendAdapter adapter, Il2CppSystem.Guid gameIdGuid) + { + var gameId = gameIdGuid.ToString(); + try + { + var json = await adapter.HubConnection.InvokeAsync( + "GetModdedGameInfo", + gameId, + Il2CppSystem.Threading.CancellationToken.None + ); + + using var doc = System.Text.Json.JsonDocument.Parse(json); + var root = doc.RootElement; + if (!root.TryGetProperty("isModded", out var isModdedProperty) || !isModdedProperty.GetBoolean()) + { + return; + } + + string? checksum = root.TryGetProperty("checksum", out var checksumProperty) + ? checksumProperty.GetString() + : null; + + RegisterModdedGame(gameId, checksum); + Plugin.logger.LogInfo($"Multiplayer> Game {gameId} is modded"); + + if (checksum != null && checksum != Compatibility.checksum) + { + Plugin.logger.LogWarning($"Multiplayer> Mod checksum mismatch for game {gameId}"); + PopupManager.GetBasicPopupWithData(new( + Localization.Get("polymod.signature.mismatch"), + Localization.Get("polymod.signature.incompatible"), + new(new PopupBase.PopupButtonData[] { + new("OK") + }) + )).Show(); + return; + } + + if (GetShadowState(gameId) == null) + { + await FetchShadowState(gameIdGuid); + } + } + catch (Exception e) + { + Plugin.logger.LogWarning($"Multiplayer> Could not fetch modded game info for {gameId}: {e.Message}"); + } + } + + /// + /// Fetches the server's stored authoritative state into the shadow cache. + /// + private static async System.Threading.Tasks.Task FetchShadowState(Il2CppSystem.Guid gameIdGuid) + { + try + { + var gameResponse = await PolytopiaBackendAdapter.Instance.JoinGameHttp(new JoinGameBindingModel + { + GameId = gameIdGuid + }); + + byte[]? stateBytes = gameResponse?.Data?.CurrentGameStateData ?? gameResponse?.Data?.InitialGameStateData; + if (stateBytes == null) + { + Plugin.logger.LogWarning($"Multiplayer> Could not fetch state for game {gameIdGuid}"); + return null; + } + + SetShadowState(gameIdGuid.ToString(), stateBytes); + return stateBytes; + } + catch (Exception e) + { + Plugin.logger.LogWarning($"Multiplayer> Could not fetch state for game {gameIdGuid}: {e.Message}"); + return null; + } + } + + /// + /// The modded replacement for the vanilla send. T + /// he client has already executed the command locally, recompute it (plus auto-play follow-ups) on the shadow state and upload the result instead of calling the vanilla SendCommand hub method. + /// + [HarmonyPrefix] + [HarmonyPatch(typeof(BackendAdapter), nameof(BackendAdapter.SendCommand))] + private static bool BackendAdapter_SendCommand( + ref Il2CppSystem.Threading.Tasks.Task> __result, + SendCommandBindingModel model) + { + if (!IsModdedGame(model.GameId.ToString())) return true; + + var taskCompletionSource = + new Il2CppSystem.Threading.Tasks.TaskCompletionSource>(); + + _ = HandleModdedSendCommand(taskCompletionSource, model); + + __result = taskCompletionSource.Task; + + return false; + } + + private static async System.Threading.Tasks.Task HandleModdedSendCommand( + Il2CppSystem.Threading.Tasks.TaskCompletionSource> tcs, + SendCommandBindingModel model) + { + await _sendLock.WaitAsync(); + try + { + var gameIdGuid = model.GameId; + var gameId = gameIdGuid.ToString(); + int expectedIndex = model.Command.CommandIndex; + + if (!CommandBase.FromByteArray(model.Command.SerializedData, out var command, out _)) + { + Plugin.logger.LogError("Multiplayer> Could not deserialize own command"); + tcs.SetResult(FailedResponse()); + return; + } + + var shadow = GetShadowState(gameId); + if (shadow == null || CountCommands(shadow) != expectedIndex) + { + shadow = await FetchShadowState(gameIdGuid); + } + + if (shadow == null) + { + tcs.SetResult(FailedResponse()); + return; + } + + var (update, followUps, newStateBytes) = + ComputeModdedUpdate(shadow, gameId, command, null, expectedIndex); + if (update == null) + { + DropShadowState(gameId); + tcs.SetResult(FailedResponse()); + return; + } + + var uploaded = await UploadModdedUpdate(update); + if (!uploaded) + { + DropShadowState(gameId); + tcs.SetResult(FailedResponse()); + return; + } + + SetShadowState(gameId, newStateBytes!); + FeedFollowUpsToLiveClient(gameId, followUps); + tcs.SetResult(SuccessResponse()); + } + catch (Exception ex) + { + Plugin.logger.LogError("Multiplayer> Error during HandleModdedSendCommand: " + ex.Message); + tcs.SetException(new Il2CppSystem.Exception(ex.Message)); + } + finally + { + _sendLock.Release(); + } + } + + /// + /// Auto-play follow-up commands (resigned players' turns) computed on the shadow are fed to the live client exactly like a vanilla server push. + /// + private static void FeedFollowUpsToLiveClient(string gameId, List followUps) + { + if (followUps.Count == 0) return; + + var client = GameManager.Client; + if (client == null || !client.CurrentGameId.HasValue || + client.CurrentGameId.Value.ToString() != gameId) + { + return; + } + + var followUpCommands = new Il2CppSystem.Collections.Generic.List(); + foreach (var serialized in followUps) + { + if (CommandBase.FromByteArray(serialized, out var followUpCommand, out _)) + { + followUpCommands.Add(followUpCommand); + } + } + + if (followUpCommands.Count > 0) + { + _ = client.ReceiveCommand(followUpCommands); + } + } + + private static int CountCommands(byte[] stateBytes) + { + if (!SerializationHelpers.FromByteArray(stateBytes, out GameState state)) + { + return -1; + } + + return state.CommandStack.Count; + } + + /// + /// Runs the command on a copy of the authoritative state. + /// Returns the serialized follow-up commands and the new state separately. + /// + internal static (ModdedGameStateViewModel? update, List followUps, byte[]? newStateBytes) + ComputeModdedUpdate(byte[] preStateBytes, string gameId, CommandBase command, + string? resignedAccountId, int expectedFirstIndex = -1) + { + var noFollowUps = new List(); + + if (!SerializationHelpers.FromByteArray(preStateBytes, out GameState stateCopy)) + { + Plugin.logger.LogError("Multiplayer> Could not deserialize shadow state"); + return (null, noFollowUps, null); + } + + new ActionManager(stateCopy).Update(); + + int version = stateCopy.Version; + int firstIndex = stateCopy.CommandStack.Count; + if (expectedFirstIndex >= 0 && firstIndex != expectedFirstIndex) + { + Plugin.logger.LogWarning( + $"Multiplayer> Shadow state out of sync ({firstIndex} commands, expected {expectedFirstIndex})"); + return (null, noFollowUps, null); + } + + var commandList = new Il2CppSystem.Collections.Generic.List(); + commandList.Add(command); + var result = GameStateUtils.PerformCommands(stateCopy, commandList, out _, out _); + if (result == null || !result.Success) + { + Plugin.logger.LogError("Multiplayer> Command execution failed on shadow state"); + return (null, noFollowUps, null); + } + + int newCount = stateCopy.CommandStack.Count; + var uploadCommands = new List(); + var followUps = new List(); + bool isEndTurn = false; + + for (int i = firstIndex; i < newCount; i++) + { + var executed = stateCopy.CommandStack[i]; + if (executed.GetCommandType() == CommandType.EndTurn) + { + isEndTurn = true; + } + + var serialized = CommandBase.ToByteArray(executed, version); + uploadCommands.Add(new ModdedCommandViewModel { serializedData = serialized, commandIndex = i }); + if (i > firstIndex) + { + followUps.Add(serialized); + } + } + + string? currentPlayerId = GameStateUtils.GetCurrentPlayerAccountId(stateCopy).ToString(); + if (currentPlayerId == EmptyGuid) + { + currentPlayerId = null; + } + + var newStateBytes = SerializationHelpers.ToByteArray(stateCopy, stateCopy.Version); + + var summaryBytes = Array.Empty(); + if (GameStateSummary.FromGameStateByteArray(newStateBytes, out GameStateSummary summary, + out GameState summaryState)) + { + summaryBytes = SerializationHelpers.ToByteArray(summary, summaryState.Version); + } + + var update = new ModdedGameStateViewModel + { + gameId = gameId, + commands = uploadCommands, + serializedGameState = newStateBytes, + serializedGameSummary = summaryBytes, + newCommandCount = newCount, + currentPlayerId = currentPlayerId, + isEndTurn = isEndTurn, + isGameEnded = stateCopy.CurrentState == GameState.State.Ended, + resignedPlayerId = resignedAccountId + }; + + return (update, followUps, newStateBytes); + } + + internal static async System.Threading.Tasks.Task UploadModdedUpdate(ModdedGameStateViewModel update) + { + try + { + var payload = System.Text.Json.JsonSerializer.Serialize(update); + var response = await PolytopiaBackendAdapter.Instance.HubConnection + .InvokeAsync>( + "UpdateGameStateModded", + payload, + Il2CppSystem.Threading.CancellationToken.None + ); + + if (response != null && response.Success) + { + return true; + } + + Plugin.logger.LogWarning($"Multiplayer> UpdateGameStateModded rejected: {response?.ErrorMessage}"); + } + catch (Exception e) + { + Plugin.logger.LogError($"Multiplayer> UpdateGameStateModded failed: {e.Message}"); + } + + return false; + } + + /// + /// Relayed commands from other players are applied to the shadow so it tracks the authoritative state without refetching. + /// + [HarmonyPrefix] + [HarmonyPatch(typeof(PolytopiaBackendAdapter), nameof(PolytopiaBackendAdapter.OnCommand))] + private static void PolytopiaBackendAdapter_OnCommand(CommandArrayViewModel model) + { + try + { + var gameId = model.GameId.ToString(); + if (!IsModdedGame(gameId)) return; + + var shadow = GetShadowState(gameId); + if (shadow == null) return; + + if (!SerializationHelpers.FromByteArray(shadow, out GameState state)) + { + DropShadowState(gameId); + return; + } + + var actionManager = new ActionManager(state); + actionManager.Update(); + foreach (var commandViewModel in model.Commands) + { + int index = commandViewModel.CommandIndex; + if (index >= 0 && index < state.CommandStack.Count) + { + continue; + } + + if (index > state.CommandStack.Count) + { + DropShadowState(gameId); + return; + } + + if (!CommandBase.FromByteArray(commandViewModel.SerializedData, out var command, out _) || + !actionManager.ExecuteCommand(command, out _)) + { + DropShadowState(gameId); + return; + } + } + + SetShadowState(gameId, SerializationHelpers.ToByteArray(state, state.Version)); + } + catch (Exception e) + { + Plugin.logger.LogWarning($"Multiplayer> Could not apply relayed commands to shadow: {e.Message}"); + DropShadowState(model.GameId.ToString()); + } + } + + /// + /// Vanilla resign asks the server to build the resign command, which the relay server cannot do for modded games. Build and execute it on the shadow state instead. + /// + [HarmonyPrefix] + [HarmonyPatch(typeof(BackendAdapter), nameof(BackendAdapter.Resign))] + private static bool BackendAdapter_Resign( + ref Il2CppSystem.Threading.Tasks.Task> __result, + ResignBindingModel model) + { + if (!IsModdedGame(model.GameId.ToString())) return true; + + var taskCompletionSource = + new Il2CppSystem.Threading.Tasks.TaskCompletionSource>(); + + _ = HandleModdedResign(taskCompletionSource, model.GameId); + + __result = taskCompletionSource.Task; + + return false; + } + + private static async System.Threading.Tasks.Task HandleModdedResign( + Il2CppSystem.Threading.Tasks.TaskCompletionSource> tcs, + Il2CppSystem.Guid gameIdGuid) + { + await _sendLock.WaitAsync(); + try + { + var gameId = gameIdGuid.ToString(); + var ownAccountId = AccountManager.PlayerAccountId; + + var shadow = GetShadowState(gameId) ?? await FetchShadowState(gameIdGuid); + if (shadow == null || + !SerializationHelpers.FromByteArray(shadow, out GameState state) || + !state.TryGetPlayer(ownAccountId, out PlayerState ownPlayer)) + { + tcs.SetResult(FailedResponse()); + return; + } + + var resignCommand = new ResignCommand(state.CurrentPlayer, ownPlayer.Id, 0, false); + var (update, followUps, newStateBytes) = + ComputeModdedUpdate(shadow, gameId, resignCommand, ownAccountId.ToString()); + if (update == null) + { + DropShadowState(gameId); + tcs.SetResult(FailedResponse()); + return; + } + + var uploaded = await UploadModdedUpdate(update); + if (!uploaded) + { + DropShadowState(gameId); + tcs.SetResult(FailedResponse()); + return; + } + + SetShadowState(gameId, newStateBytes!); + + var client = GameManager.Client; + if (client != null && client.CurrentGameId.HasValue && + client.CurrentGameId.Value.ToString() == gameId) + { + var commands = new Il2CppSystem.Collections.Generic.List(); + commands.Add(resignCommand); + foreach (var serialized in followUps) + { + if (CommandBase.FromByteArray(serialized, out var followUpCommand, out _)) + { + commands.Add(followUpCommand); + } + } + + _ = client.ReceiveCommand(commands); + } + + tcs.SetResult(SuccessResponse()); + } + catch (Exception ex) + { + Plugin.logger.LogError("Multiplayer> Error during HandleModdedResign: " + ex.Message); + tcs.SetException(new Il2CppSystem.Exception(ex.Message)); + } + finally + { + _sendLock.Release(); + } + } + + /// + /// Skipping is not supported yet for modded games. + /// Returns true to suppress errors. + /// + [HarmonyPrefix] + [HarmonyPatch(typeof(BackendAdapter), nameof(BackendAdapter.SkipTurn))] + private static bool BackendAdapter_SkipTurn( + ref Il2CppSystem.Threading.Tasks.Task> __result, + SkipTurnBindingModel model) + { + if (!IsModdedGame(model.GameId.ToString())) return true; + + Plugin.logger.LogInfo("Multiplayer> SkipTurn is not supported for modded games yet"); + + var taskCompletionSource = + new Il2CppSystem.Threading.Tasks.TaskCompletionSource>(); + taskCompletionSource.SetResult(SuccessResponse()); + __result = taskCompletionSource.Task; + + return false; + } + + /// + ///Matchmaking is blocked while gameplay mods are loaded. + /// + [HarmonyPrefix] + [HarmonyPatch(typeof(BackendAdapter), nameof(BackendAdapter.SubmitMatchmakingRequest))] + private static bool BackendAdapter_SubmitMatchmakingRequest( + ref Il2CppSystem.Threading.Tasks.Task> __result) + { + Plugin.logger.LogWarning("Multiplayer> Matchmaking blocked: gameplay mods are loaded"); + PopupManager.GetBasicPopupWithData(new( + "Matchmaking disabled", + "Matchmaking is unavailable while gameplay mods are loaded.", + new(new PopupBase.PopupButtonData[] { + new("OK") + }) + )).Show(); + + var taskCompletionSource = + new Il2CppSystem.Threading.Tasks.TaskCompletionSource>(); + taskCompletionSource.SetResult(new ServerResponse + { + Success = false, + ErrorCode = ErrorCode.StateProhibitsOperation, + ErrorMessage = "Matchmaking is disabled while gameplay mods are loaded." + }); + __result = taskCompletionSource.Task; + + return false; + } + + private static ServerResponse SuccessResponse() => + new() { Success = true, Data = new ResponseViewModel() }; + + private static ServerResponse FailedResponse() => + new() + { + Success = false, + ErrorCode = ErrorCode.StateProhibitsOperation, + ErrorMessage = "Modded game operation failed." + }; +} diff --git a/src/Multiplayer/Multiplayer.cs b/src/Multiplayer/Multiplayer.cs new file mode 100644 index 0000000..1520742 --- /dev/null +++ b/src/Multiplayer/Multiplayer.cs @@ -0,0 +1,208 @@ +using HarmonyLib; +using Il2CppMicrosoft.AspNetCore.SignalR.Client; +using PolyMod.Multiplayer.ViewModels; +using Polytopia.Data; +using PolytopiaBackendBase; +using PolytopiaBackendBase.Common; +using PolytopiaBackendBase.Game; +using PolytopiaBackendBase.Game.BindingModels; +using UnityEngine; +using Newtonsoft.Json; +using PolytopiaBackendBase.Auth; + +namespace PolyMod.Multiplayer; + +public static class Client +{ + internal const string DEFAULT_SERVER_URL = "https://dev.polydystopia.xyz"; + internal const string LOCAL_SERVER_URL = "http://localhost:5051/"; + private const string GldMarker = "##GLD:"; + internal static bool allowGldMods = false; + + // Cache parsed GLD by game Seed to handle rewinds/reloads + private static readonly Dictionary _gldCache = new(); + private static readonly Dictionary _versionCache = new(); // Seed -> modGldVersion + + internal static void Init() + { + Harmony.CreateAndPatchAll(typeof(Client)); + BuildConfig buildConfig = BuildConfigHelper.GetSelectedBuildConfig(); + buildConfig.buildServerURL = BuildServerURL.Custom; + buildConfig.customServerURL = Plugin.config.backendUrl; + + // Update BackendUri and HttpClient.BaseAddress since PolytopiaBackendAdapter.Instance + // was statically initialized before plugins load, so it still points to polytopia-prod.net + var uri = new Il2CppSystem.Uri(Plugin.config.backendUrl); + PolytopiaBackendAdapter.Instance.UseBackendUri(uri); + PolytopiaBackendAdapter.Instance.BackendHttpClient.BaseAddress = uri; + + Plugin.logger.LogInfo($"Multiplayer> Server URL set to: {Plugin.config.backendUrl}"); + } + + [HarmonyPostfix] + [HarmonyPatch(typeof(MultiplayerSelectionScreen), nameof(MultiplayerSelectionScreen.Awake))] + public static void MultiplayerSelectionScreen_Awake(MultiplayerSelectionScreen __instance) + { + __instance.TournamentsButton.gameObject.SetActive(false); + } + + [HarmonyPostfix] + [HarmonyPatch(typeof(StartScreen_UI2), nameof(StartScreen_UI2.Init))] + private static void StartScreen_UI2_HideButtons(StartScreen_UI2 __instance) + { + __instance.highscoreButton.gameObject.SetActive(false); + __instance.weeklyChallengeButton.gameObject.SetActive(false); + } + + [HarmonyPostfix] + [HarmonyPatch(typeof(StartScreen_UI2), nameof(StartScreen_UI2.RunLayout))] + private static void StartScreen_UI2_ReflowRoundButtons(StartScreen_UI2 __instance, ScreenBase_UI2.ScreenSize screenSize) + { + // RunLayout adds all four round buttons to a UITable unconditionally, so hiding the highscore button leaves a gap. Re-run the row without it so the rest recenter. + UITable table = new(); + table.AddCell(__instance.settingsButton.Cast()); + table.AddCell(__instance.throneRoomButton.Cast()); + table.AddCell(__instance.aboutButton.Cast()); + table.SetBottom(screenSize.safeRect.Bottom + __instance.settingsButton.GetHalfHeight() + 15f); + table.margin = 20f; + table.RunLayout(); + } + + [HarmonyPostfix] + [HarmonyPatch(typeof(SystemInfo), nameof(SystemInfo.deviceUniqueIdentifier), MethodType.Getter)] + public static void SteamClient_get_SteamId(ref string __result) + { + if (Plugin.config.overrideDeviceId != string.Empty) + { + __result = Plugin.config.overrideDeviceId; + } + } + + /// + /// After GameState deserialization, check for trailing GLD version ID and set mockedGameLogicData. + /// The server appends "##GLD:" + modGldVersion (int) after the normal serialized data. + /// + [HarmonyPostfix] + [HarmonyPatch(typeof(GameState), nameof(GameState.Deserialize))] + [Obsolete("This will be succeeded by ModMultiplayer in the future.")] + private static void Deserialize_Postfix(GameState __instance, BinaryReader __0) + { + if(!allowGldMods) return; + + Plugin.logger?.LogDebug("Deserialize_Postfix: Entered"); + + try + { + var reader = __0; + if (reader == null) + { + Plugin.logger?.LogWarning("Deserialize_Postfix: reader is null"); + return; + } + + var position = reader.BaseStream.Position; + var length = reader.BaseStream.Length; + var remaining = length - position; + + Plugin.logger?.LogDebug($"Deserialize_Postfix: Stream position={position}, length={length}, remaining={remaining}"); + + // Check if there's more data after normal deserialization + if (position >= length) + { + Plugin.logger?.LogDebug("Deserialize_Postfix: No trailing data (position >= length)"); + + var sd = __instance.Seed; + if (_gldCache.TryGetValue(sd, out var cachedGld)) + { + __instance.mockedGameLogicData = cachedGld; + var cachedVersion = _versionCache.GetValueOrDefault(sd, -1); + Plugin.logger?.LogInfo($"Deserialize_Postfix: Applied cached GLD for Seed={sd}, ModGldVersion={cachedVersion}"); + } + return; + } + + Plugin.logger?.LogDebug($"Deserialize_Postfix: Found {remaining} bytes of trailing data, attempting to read marker"); + + var marker = reader.ReadString(); + Plugin.logger?.LogDebug($"Deserialize_Postfix: Read marker string: '{marker}'"); + + if (marker != GldMarker) + { + Plugin.logger?.LogDebug($"Deserialize_Postfix: Marker mismatch - expected '{GldMarker}', got '{marker}'"); + return; + } + + Plugin.logger?.LogInfo($"Deserialize_Postfix: Found GLD marker '{GldMarker}'"); + + var modGldVersion = reader.ReadInt32(); + Plugin.logger?.LogInfo($"Deserialize_Postfix: Found embedded ModGldVersion: {modGldVersion}"); + + Plugin.logger?.LogDebug($"Deserialize_Postfix: Fetching GLD from server for version {modGldVersion}"); + var gldJson = FetchGldById(modGldVersion); + if (string.IsNullOrEmpty(gldJson)) + { + Plugin.logger?.LogError($"Deserialize_Postfix: Failed to fetch GLD for ModGldVersion: {modGldVersion}"); + return; + } + + Plugin.logger?.LogDebug($"Deserialize_Postfix: Parsing GLD JSON ({gldJson.Length} chars)"); + + var customGld = new GameLogicData(); + customGld.Parse(gldJson); + __instance.mockedGameLogicData = customGld; + + // Cache for subsequent deserializations (rewinds, reloads) + var seed = __instance.Seed; + _gldCache[seed] = customGld; + _versionCache[seed] = modGldVersion; + + Plugin.logger?.LogInfo($"Deserialize_Postfix: Successfully set mockedGameLogicData from ModGldVersion: {modGldVersion}, cached for Seed={seed}"); + } + catch (EndOfStreamException) + { + Plugin.logger?.LogDebug("Deserialize_Postfix: EndOfStreamException - no trailing data"); + } + catch (Exception ex) + { + Plugin.logger?.LogError($"Deserialize_Postfix: Exception: {ex.GetType().Name}: {ex.Message}"); + Plugin.logger?.LogDebug($"Deserialize_Postfix: Stack trace: {ex.StackTrace}"); + } + } + + /// + /// Fetch GLD from server using ModGldVersion ID + /// + [Obsolete("This will be succeeded by ModMultiplayer in the future.")] + private static string? FetchGldById(int modGldVersion) + { + if(!allowGldMods) return null; + try + { + using var client = new HttpClient(); + var url = $"{Plugin.config.backendUrl.TrimEnd('/')}/api/mods/gld/{modGldVersion}"; + Plugin.logger?.LogDebug($"FetchGldById: Requesting URL: {url}"); + + var response = client.GetAsync(url).Result; + Plugin.logger?.LogDebug($"FetchGldById: Response status: {response.StatusCode}"); + + if (response.IsSuccessStatusCode) + { + var gld = response.Content.ReadAsStringAsync().Result; + Plugin.logger?.LogInfo($"FetchGldById: Successfully fetched mod GLD ({gld.Length} chars)"); + return gld; + } + + var errorContent = response.Content.ReadAsStringAsync().Result; + Plugin.logger?.LogError($"FetchGldById: Failed with status {response.StatusCode}: {errorContent}"); + } + catch (Exception ex) + { + Plugin.logger?.LogError($"FetchGldById: Exception: {ex.GetType().Name}: {ex.Message}"); + if (ex.InnerException != null) + { + Plugin.logger?.LogError($"FetchGldById: Inner exception: {ex.InnerException.Message}"); + } + } + return null; + } +} diff --git a/src/Multiplayer/SerializationUtils.cs b/src/Multiplayer/SerializationUtils.cs index e91a46b..e6d3da8 100644 --- a/src/Multiplayer/SerializationUtils.cs +++ b/src/Multiplayer/SerializationUtils.cs @@ -4,6 +4,13 @@ namespace PolyMod.Multiplayer; +/// +/// Serialization fixes for custom (modded) content. +/// +/// GamePlayerSummary stores the tribe as a single byte, which custom tribe ids (>= 1000) overflow into garbage. +/// Rewriting the record in managed code is not an option. Reading Il2CppSystem.Nullable members (PolytopiaId) from managed patches returns corrupted guids. +/// Instead the tribe is clamped to None before the untouched native serializer runs and menu summaries show a generic icon for custom tribes, nothing more. +/// public static class SerializationUtils { internal static void Init() @@ -13,62 +20,23 @@ internal static void Init() [HarmonyPrefix] [HarmonyPatch(typeof(GamePlayerSummary), nameof(GamePlayerSummary.Serialize))] - public static bool GamePlayerSummary_Serialize(GamePlayerSummary __instance, Il2CppSystem.IO.BinaryWriter writer, int version) - { - Plugin.logger.LogInfo("Multiplayer> GamePlayerSummary_Serialize"); - var memoryStream = new Il2CppSystem.IO.MemoryStream(); - var binaryWriter = new Il2CppSystem.IO.BinaryWriter(memoryStream); - binaryWriter.Write(__instance.Id); - binaryWriter.Write(__instance.PolytopiaId.ToString()); - binaryWriter.Write(__instance.UserName ?? ""); - binaryWriter.Write((int)__instance.TribeType); - binaryWriter.Write(__instance.AutoPlay); - binaryWriter.Write(__instance.HasChosenTribe); - binaryWriter.Write(__instance.Handicap); - binaryWriter.Write(__instance.IsDead); - if (version >= 86) - { - binaryWriter.Write((int)__instance.SkinType); - } - writer.Write((int)memoryStream.Length); - memoryStream.WriteTo(writer.BaseStream); - return false; - } + public static bool GamePlayerSummary_Serialize(GamePlayerSummary __instance) + { + if ((int)__instance.TribeType >= byte.MaxValue) + { + __instance.TribeType = TribeType.None; + } - [HarmonyPrefix] - [HarmonyPatch(typeof(GamePlayerSummary), nameof(GamePlayerSummary.Deserialize))] - public static bool GamePlayerSummary_Deserialize(GamePlayerSummary __instance, Il2CppSystem.IO.BinaryReader reader, int version) - { - Plugin.logger.LogInfo("Multiplayer> GamePlayerSummary_Deserialize"); - int num = reader.ReadInt32(); - long position = reader.BaseStream.Position; - __instance.Id = reader.ReadByte(); - string g = reader.ReadString(); - Il2CppSystem.Guid parsed; - Il2CppSystem.Nullable nullableGuid; - if (Il2CppSystem.Guid.TryParse(g, out parsed)) - nullableGuid = new Il2CppSystem.Nullable(parsed); - else - nullableGuid = new Il2CppSystem.Nullable(); - __instance.PolytopiaId = nullableGuid; - __instance.UserName = reader.ReadString(); - __instance.TribeType = (TribeType)reader.ReadInt32(); - __instance.AutoPlay = reader.ReadBoolean(); - __instance.HasChosenTribe = reader.ReadBoolean(); - __instance.Handicap = reader.ReadInt32(); - __instance.IsDead = reader.ReadBoolean(); - if (version >= 86) - { - __instance.SkinType = (SkinType)reader.ReadInt32(); - } - reader.BaseStream.Position = position + num; - return false; - } + return true; + } [HarmonyPostfix] [HarmonyPatch(typeof(PlayerState), nameof(PlayerState.Deserialize))] - public static void PlayerState_Deserialize(PlayerState __instance, Il2CppSystem.IO.BinaryReader reader, int version) - { - __instance.climate = __instance.tribe; - } -} \ No newline at end of file + public static void PlayerState_Deserialize(PlayerState __instance, Il2CppSystem.IO.BinaryReader reader, int version) + { + if ((int)__instance.tribe >= Plugin.AUTOIDX_STARTS_FROM) + { + __instance.climate = __instance.tribe; + } + } +} diff --git a/src/Multiplayer/ViewModels/IMonoServerResponseData.cs b/src/Multiplayer/ViewModels/IMonoServerResponseData.cs new file mode 100644 index 0000000..3b0a835 --- /dev/null +++ b/src/Multiplayer/ViewModels/IMonoServerResponseData.cs @@ -0,0 +1,5 @@ +namespace PolyMod.Multiplayer.ViewModels; + +public interface IMonoServerResponseData +{ +} \ No newline at end of file diff --git a/src/Multiplayer/ViewModels/ModdedGameStateViewModel.cs b/src/Multiplayer/ViewModels/ModdedGameStateViewModel.cs index 176a997..9fa949b 100644 --- a/src/Multiplayer/ViewModels/ModdedGameStateViewModel.cs +++ b/src/Multiplayer/ViewModels/ModdedGameStateViewModel.cs @@ -1,13 +1,33 @@ namespace PolyMod.Multiplayer.ViewModels; + +/// +/// Payload of the UpdateGameStateModded hub method. +/// The locally executed command batch plus everything the server would normally compute itself (new state, summary, turn metadata). +/// public class ModdedGameStateViewModel { - public string gameId { get; set; } = ""; - public string lobbyId { get; set; } = ""; + public string gameId { get; set; } = string.Empty; + + public List commands { get; set; } = new(); + + public byte[] serializedGameState { get; set; } = Array.Empty(); + + public byte[] serializedGameSummary { get; set; } = Array.Empty(); + + public int newCommandCount { get; set; } = -1; - public byte[] serializedGameState { get; set; } = new byte[0]; - public byte[] serializedGameSummary { get; set; } = new byte[0]; + public string? currentPlayerId { get; set; } + + public bool isEndTurn { get; set; } + + public bool isGameEnded { get; set; } + + public string? resignedPlayerId { get; set; } +} + +public class ModdedCommandViewModel +{ + public byte[] serializedData { get; set; } = Array.Empty(); - public string gameSettingsJson { get; set; } = ""; - public string currentPlayerId { get; set; } = ""; - public bool IsEndTurnCommand { get; set; } = false; -} \ No newline at end of file + public int commandIndex { get; set; } = -1; +} diff --git a/src/Multiplayer/ViewModels/SetupGameDataViewModel.cs b/src/Multiplayer/ViewModels/SetupGameDataViewModel.cs new file mode 100644 index 0000000..689d29e --- /dev/null +++ b/src/Multiplayer/ViewModels/SetupGameDataViewModel.cs @@ -0,0 +1,16 @@ + +namespace PolyMod.Multiplayer.ViewModels; +public class SetupGameDataViewModel : IMonoServerResponseData +{ + public string lobbyId { get; set; } = string.Empty; + + public byte[] serializedGameState { get; set; } = Array.Empty(); + + public byte[] serializedGameSummary { get; set; } = Array.Empty(); + + public string gameSettingsJson { get; set; } = string.Empty; + + public int initialCommandCount { get; set; } = -1; + + public string? currentPlayerId { get; set; } +} diff --git a/src/Plugin.cs b/src/Plugin.cs index 597609c..8cba2b9 100644 --- a/src/Plugin.cs +++ b/src/Plugin.cs @@ -3,7 +3,9 @@ using BepInEx; using BepInEx.Configuration; using BepInEx.Logging; +using PolyMod.Android; using PolyMod.Managers; +using PolyMod.Multiplayer; using UnityEngine; namespace PolyMod; @@ -134,8 +136,10 @@ public override void Load() Hub.Init(); Main.Init(); - Multiplayer.SerializationUtils.Init(); - Multiplayer.Client.Init(); + Client.Init(); + ModMultiplayer.Init(); + Dystopia.Init(); + AndroidHandler.Init(); } /// @@ -167,8 +171,9 @@ internal static void UpdateConsole() { ConsoleManager.CreateConsole(); } - else + else if (OperatingSystem.IsWindows()) { + // BepInEx's Unix console driver throws unsupported on detach. Off-Windows there is no separate console window, so there is nothing to detach. ConsoleManager.DetachConsole(); } }