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..783658f 100644 --- a/PolyMod.csproj +++ b/PolyMod.csproj @@ -11,7 +11,7 @@ IL2CPP PolyMod - 1.2.17 + 1.3.0-pre-android-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/src/Android/AndroidHandler.cs b/src/Android/AndroidHandler.cs new file mode 100644 index 0000000..18a8d9f --- /dev/null +++ b/src/Android/AndroidHandler.cs @@ -0,0 +1,89 @@ +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; + + 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..9359df3 100644 --- a/src/Managers/Compatibility.cs +++ b/src/Managers/Compatibility.cs @@ -21,6 +21,15 @@ 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); + } + /// /// Hashes the signatures of all loaded mods to create a checksum. /// 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..9848f3d --- /dev/null +++ b/src/Multiplayer/Dystopia.cs @@ -0,0 +1,234 @@ +using Cpp2IL.Core.Extensions; +using HarmonyLib; +using Il2CppInterop.Runtime; +using PolyMod.Managers; +using PolytopiaBackendBase; +using TMPro; +using UnityEngine; + +namespace PolyMod.Multiplayer; + +/// +/// Adds a "Dystopia" start-screen button with a popup to switch the backend server at runtime. +/// +public static class Dystopia +{ + private const int POPUP_WIDTH = 1400; + + internal const string OFFICIAL_SERVER_URL = "https://polytopia-prod.net/"; + + private record ServerEntry(string name, string url, bool official = false); + + private static readonly ServerEntry[] SERVERS = + { + new("Official", OFFICIAL_SERVER_URL, official: true), + new("Dystopia", "https://polydystopia.xyz"), + new("Dystopia Dev", "https://dev.polydystopia.xyz"), + }; + + /// + /// Default backend: the official server, except on Android + /// + internal static string DefaultServerUrl() + { + return Application.platform == RuntimePlatform.Android + ? Client.DEFAULT_SERVER_URL + : OFFICIAL_SERVER_URL; + } + + private static UIRoundButton_UI2? dystopiaButton = null; + + internal static void Init() + { + Harmony.CreateAndPatchAll(typeof(Dystopia)); + } + + [HarmonyPostfix] + [HarmonyPatch(typeof(StartScreen_UI2), nameof(StartScreen_UI2.Init))] + private static void StartScreen_UI2_Init(StartScreen_UI2 __instance, RectTransform transform) + { + if (dystopiaButton != null) + { + UnityEngine.Object.Destroy(dystopiaButton.gameObject); + } + dystopiaButton = UILibrary.NewRoundButton(transform).SetStyle(UIButtonBase_UI2.ButtonStyle.Suggested); + dystopiaButton.bg.sprite = Visual.BuildSprite(Plugin.GetResource("dystopia_icon.png").ReadBytes()); + dystopiaButton.OnClickedSignal.Add(DelegateSupport.ConvertDelegate(ShowDystopiaPopup)); + } + + [HarmonyPostfix] + [HarmonyPatch(typeof(StartScreen_UI2), nameof(StartScreen_UI2.RunLayout))] + private static void StartScreen_UI2_RunLayout(StartScreen_UI2 __instance, ScreenBase_UI2.ScreenSize screenSize) + { + if (dystopiaButton == null) + { + Plugin.logger.LogWarning("Dystopia button is null when running layout!"); + return; + } + dystopiaButton.iconContainer.gameObject.SetActive(false); + dystopiaButton.outline.gameObject.SetActive(false); + dystopiaButton.bg.color = Color.white; + dystopiaButton.Text = "Dystopia"; + float num = 50f; + + dystopiaButton.SetPosition(screenSize.safeRect.Right - (num * 4.0f), screenSize.safeRect.Top - num); + } + + internal static void ShowDystopiaPopup() + { + string current = Normalize(Plugin.config.backendUrl); + + BasicPopupLegacy popup = Visual.GetBasicPopupLegacy(); + popup.Header = "Dystopia"; + popup.Description = $"Current server:\n{Plugin.config.backendUrl}"; + + List buttons = new() + { + new("buttons.back"), + }; + foreach (ServerEntry server in SERVERS) + { + bool isActive = Normalize(server.url) == current; + // The official server rejects our DeviceId login, so it stays greyed out on Android. + bool isDisabled = isActive || (server.official && Application.platform == RuntimePlatform.Android); + string url = server.url; + buttons.Add(new( + isActive ? server.name + " (active)" : server.name, + isDisabled ? PopupBase.PopupButtonData.States.Disabled : PopupBase.PopupButtonData.States.None, + DelegateSupport.ConvertDelegate((System.Action)(() => SwitchServer(url))) + )); + } + buttons.Add(new( + "Custom...", + callback: DelegateSupport.ConvertDelegate(ShowCustomServerPopup) + )); + popup.buttonData = buttons.ToArray(); + popup.ShowSetWidth(POPUP_WIDTH); + } + + 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..115c09f --- /dev/null +++ b/src/Multiplayer/ModMultiplayer.cs @@ -0,0 +1,272 @@ +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)); + + + 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) + { + // On Android, let the game's original StartLobbyGame handle it + if (Application.platform == RuntimePlatform.Android) return true; + + 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 setupGameDataViewModel = new SetupGameDataViewModel + { + lobbyId = lobbyGameViewModel.Id.ToString(), + serializedGameState = serializedGameState, + gameSettingsJson = gameSettingsJson + }; + + 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"); + tcs.SetResult(serverResponse); + } + catch (Exception ex) + { + Plugin.logger.LogError("Multiplayer> Error during HandleStartLobbyGameModded: " + 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) + { + var humanPlayer = new PlayerData + { + type = PlayerDataType.LocalUser, + state = PlayerDataFriendshipState.Accepted, + knownTribe = true, + tribe = (TribeType)participatorViewModel.SelectedTribe, + tribeMix = (TribeType)participatorViewModel.SelectedTribe, + 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)); + + var serializedGameState = SerializationHelpers.ToByteArray(gameState, gameState.Version); + + return (serializedGameState, + JsonConvert.SerializeObject(gameState.Settings)); + } +} \ No newline at end of file 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/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/SetupGameDataViewModel.cs b/src/Multiplayer/ViewModels/SetupGameDataViewModel.cs new file mode 100644 index 0000000..41274bd --- /dev/null +++ b/src/Multiplayer/ViewModels/SetupGameDataViewModel.cs @@ -0,0 +1,10 @@ + +namespace PolyMod.Multiplayer.ViewModels; +public class SetupGameDataViewModel : IMonoServerResponseData +{ + public string lobbyId { get; set; } = string.Empty; + + public byte[] serializedGameState { get; set; } = Array.Empty(); + + public string gameSettingsJson { get; set; } = string.Empty; +} \ No newline at end of file diff --git a/src/Plugin.cs b/src/Plugin.cs index 96ae942..ee514f8 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 = "" ); /// @@ -115,7 +119,7 @@ public override void Load() } catch { - config = new(); + config = new(backendUrl: Multiplayer.Dystopia.DefaultServerUrl()); } WriteConfig(); UpdateConsole(); @@ -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(); } }