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 a6734ef..5b7340e 100644 --- a/PolyMod.csproj +++ b/PolyMod.csproj @@ -11,7 +11,7 @@ IL2CPP PolyMod - 1.2.17 + 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/ModMultiplayer.cs b/src/Multiplayer/ModMultiplayer.cs new file mode 100644 index 0000000..1d6a5c3 --- /dev/null +++ b/src/Multiplayer/ModMultiplayer.cs @@ -0,0 +1,369 @@ +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; +using PolytopiaBackendBase.Common; +using PolytopiaBackendBase.Game; +using PolytopiaBackendBase.Game.BindingModels; +using UnityEngine; + +namespace PolyMod.Multiplayer; + +public class ModMultiplayer +{ + internal static void Init() + { + if (Compatibility.IsClientOnly()) + { + Plugin.logger?.LogInfo($"All loaded mods are client only. Skipping modded multiplayer initialization."); + + return; + } + + Plugin.logger?.LogInfo($"Starting modded multiplayer initialization."); + + Harmony.CreateAndPatchAll(typeof(ModMultiplayer)); + SerializationUtils.Init(); + ModdedClient.Init(); + + Plugin.logger?.LogInfo($"Finished modded multiplayer initialization."); + } + + [HarmonyPrefix] + [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>(); + + _ = HandleCreateLobbyModded(taskCompletionSource, __instance, model); + + __result = taskCompletionSource.Task; + + return false; + } + + private static async System.Threading.Tasks.Task HandleCreateLobbyModded( + Il2CppSystem.Threading.Tasks.TaskCompletionSource> tcs, + BackendAdapter instance, + CreateLobbyBindingModel model) + { + try + { + var payload = JObject.FromObject(model); + payload["IsModded"] = new JValue(true); + payload["Checksum"] = new JValue(Compatibility.checksum); + + 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 HandleCreateLobbyModded: " + ex.Message); + tcs.SetException(new Il2CppSystem.Exception(ex.Message)); + } + } + + [HarmonyPrefix] + [HarmonyPatch(typeof(BackendAdapter), nameof(BackendAdapter.StartLobbyGame))] + private static bool BackendAdapter_StartLobbyGame_Modded( + ref Il2CppSystem.Threading.Tasks.Task> __result, + BackendAdapter __instance, + StartLobbyBindingModel model) + { + Plugin.logger.LogInfo("Multiplayer> BackendAdapter_StartLobbyGame_Modded"); + var taskCompletionSource = new Il2CppSystem.Threading.Tasks.TaskCompletionSource>(); + + _ = HandleStartLobbyGameModded(taskCompletionSource, __instance, model); + + __result = taskCompletionSource.Task; + + return false; + } + + private static async System.Threading.Tasks.Task HandleStartLobbyGameModded( + Il2CppSystem.Threading.Tasks.TaskCompletionSource> tcs, + BackendAdapter instance, + StartLobbyBindingModel model) + { + try + { + var lobbyResponse = await PolytopiaBackendAdapter.Instance.GetLobby(new GetLobbyBindingModel + { + LobbyId = model.LobbyId + }); + + Plugin.logger.LogInfo($"Multiplayer> Lobby processed {lobbyResponse.Success}"); + LobbyGameViewModel lobbyGameViewModel = lobbyResponse.Data; + Plugin.logger.LogInfo("Multiplayer> Lobby received"); + + (byte[] serializedGameState, string gameSettingsJson) = CreateMultiplayerGame( + lobbyGameViewModel, + VersionManager.GameVersion, + VersionManager.GameLogicDataVersion + ); + + Plugin.logger.LogInfo("Multiplayer> GameState and Settings created"); + + 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 setupGameDataViewModel = new SetupGameDataViewModel + { + lobbyId = lobbyGameViewModel.Id.ToString(), + serializedGameState = serializedGameState, + serializedGameSummary = serializedGameSummary, + gameSettingsJson = gameSettingsJson, + initialCommandCount = initialCommandCount, + currentPlayerId = currentPlayerId + }; + + var setupData = System.Text.Json.JsonSerializer.Serialize(setupGameDataViewModel); + + var serverResponse = await instance.HubConnection.InvokeAsync>( + "StartLobbyGameModded", + setupData, + 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) + { + Plugin.logger.LogError("Multiplayer> Error during HandleStartLobbyGameModded: " + ex.Message); + tcs.SetException(new Il2CppSystem.Exception(ex.Message)); + } + } + + /// + /// 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) + { + var lobbyMapSize = lobby.MapSize; + var settings = new GameSettings(); + settings.ApplyLobbySettings(lobby); + if (settings.LiveGamePreset) + { + settings.SetLiveModePreset(); + } + 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 = tribe, + tribeMix = (int)tribe < byte.MaxValue ? tribe : TribeType.None, + skinType = (SkinType)participatorViewModel.SelectedTribeSkin, + defaultName = participatorViewModel.GetNameInternal() + }; + humanPlayer.profile.id = participatorViewModel.UserId; + humanPlayer.profile.SetName(participatorViewModel.GetNameInternal()); + SerializationHelpers.FromByteArray(participatorViewModel.AvatarStateData, out var avatarState); + humanPlayer.profile.avatarState = avatarState; + + settings.AddPlayer(humanPlayer); + } + + foreach (var botDifficulty in lobby.Bots) + { + var botGuid = Il2CppSystem.Guid.NewGuid(); + + var botPlayer = new PlayerData + { + type = PlayerDataType.Bot, + state = PlayerDataFriendshipState.Accepted, + knownTribe = true, + tribe = Enum.GetValues().Where(t => t != TribeType.None) + .OrderBy(x => Il2CppSystem.Guid.NewGuid()).First() + }; + ; + botPlayer.botDifficulty = (BotDifficulty)botDifficulty; + botPlayer.skinType = SkinType.Default; + botPlayer.defaultName = "Bot" + botGuid; + botPlayer.profile.id = botGuid; + + settings.AddPlayer(botPlayer); + } + + GameState gameState = new GameState() + { + Version = gameVersion, + Settings = settings, + PlayerStates = new Il2CppSystem.Collections.Generic.List() + }; + + for (int index = 0; index < settings.GetPlayerCount(); ++index) + { + PlayerData player = settings.GetPlayer(index); + if (player.type != PlayerDataType.Bot) + { + var nullableGuid = new Il2CppSystem.Nullable(player.profile.id); + if (!nullableGuid.HasValue) + { + throw new Exception("GUID was not set properly!"); + } + PlayerState playerState = new PlayerState() + { + Id = (byte)(index + 1), + AccountId = nullableGuid, + AutoPlay = player.type == PlayerDataType.Bot, + UserName = player.GetNameInternal(), + tribe = player.tribe, + tribeMix = player.tribeMix, + hasChosenTribe = true, + skinType = player.skinType + }; + gameState.PlayerStates.Add(playerState); + Plugin.logger.LogInfo($"Multiplayer> Created player: {playerState}"); + } + else + { + GameStateUtils.AddAIOpponent(gameState, GameStateUtils.GetRandomPickableTribe(gameState), + GameSettings.HandicapFromDifficulty(player.botDifficulty), player.skinType); + } + } + + GameStateUtils.SetPlayerColors(gameState); + GameStateUtils.AddNaturePlayer(gameState); + + Plugin.logger.LogInfo("Multiplayer> Creating world..."); + + ushort num = (ushort)Math.Max(lobbyMapSize, + (int)MapDataExtensions.GetMinimumMapSize(gameState.PlayerCount)); + gameState.Map = new MapData(num, num); + MapGeneratorSettings generatorSettings = settings.GetMapGeneratorSettings(); + new MapGenerator().Generate(gameState, generatorSettings); + + Plugin.logger.LogInfo($"Multiplayer> Creating initial state for {gameState.PlayerCount} players..."); + + foreach (PlayerState player in gameState.PlayerStates) + { + foreach (PlayerState otherPlayer in gameState.PlayerStates) + player.aggressions[otherPlayer.Id] = 0; + + if (player.Id != byte.MaxValue && gameState.GameLogicData.TryGetData(player.tribe, out TribeData tribeData)) + { + player.Currency = tribeData.startingStars; + TileData tile = gameState.Map.GetTile(player.startTile); + UnitState unitState = ActionUtils.TrainUnitScored(gameState, player, tile, tribeData.startingUnit); + unitState.attacked = false; + unitState.moved = false; + } + } + + Plugin.logger.LogInfo("Multiplayer> Session created successfully"); + + 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)); + } +} \ 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 new file mode 100644 index 0000000..e6d3da8 --- /dev/null +++ b/src/Multiplayer/SerializationUtils.cs @@ -0,0 +1,42 @@ +using HarmonyLib; +using Polytopia.Data; +using PolytopiaBackendBase.Common; + +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() + { + Harmony.CreateAndPatchAll(typeof(SerializationUtils)); + } + + [HarmonyPrefix] + [HarmonyPatch(typeof(GamePlayerSummary), nameof(GamePlayerSummary.Serialize))] + public static bool GamePlayerSummary_Serialize(GamePlayerSummary __instance) + { + if ((int)__instance.TribeType >= byte.MaxValue) + { + __instance.TribeType = TribeType.None; + } + + return true; + } + + [HarmonyPostfix] + [HarmonyPatch(typeof(PlayerState), nameof(PlayerState.Deserialize))] + 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 new file mode 100644 index 0000000..9fa949b --- /dev/null +++ b/src/Multiplayer/ViewModels/ModdedGameStateViewModel.cs @@ -0,0 +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; } = 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 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 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 96ae942..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; @@ -24,7 +26,9 @@ internal record PolyConfig( bool debug = false, bool autoUpdate = true, bool updatePrerelease = false, - bool allowUnsafeIndexes = false + bool allowUnsafeIndexes = false, + string backendUrl = Multiplayer.Client.DEFAULT_SERVER_URL, + string overrideDeviceId = "" ); /// @@ -132,6 +136,10 @@ public override void Load() Hub.Init(); Main.Init(); + Client.Init(); + ModMultiplayer.Init(); + Dystopia.Init(); + AndroidHandler.Init(); } /// @@ -163,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(); } }