diff --git a/PolyMod.csproj b/PolyMod.csproj index a09c089..b22dd4e 100644 --- a/PolyMod.csproj +++ b/PolyMod.csproj @@ -11,7 +11,7 @@ IL2CPP PolyMod - 1.2.13 + 1.3.0-pre 2.16.8.15757 PolyModdingTeam The Battle of Polytopia's mod loader. diff --git a/src/Multiplayer/Client.cs b/src/Multiplayer/Client.cs new file mode 100644 index 0000000..aba7195 --- /dev/null +++ b/src/Multiplayer/Client.cs @@ -0,0 +1,500 @@ +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; + +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 = LOCAL_SERVER_URL; + + Plugin.logger.LogInfo($"Multiplayer> Server URL set to: {Plugin.config.backendUrl}"); + Plugin.logger.LogInfo("Multiplayer> GLD patches applied"); + } + + [HarmonyPostfix] + [HarmonyPatch(typeof(MultiplayerScreen), nameof(MultiplayerScreen.Show))] + public static void MultiplayerScreen_Show(MultiplayerScreen __instance) + { + __instance.multiplayerSelectionScreen.TournamentsButton.gameObject.SetActive(false); + } + + [HarmonyPostfix] + [HarmonyPatch(typeof(StartScreen), nameof(StartScreen.Start))] + private static void StartScreen_Start(StartScreen __instance) + { + __instance.highscoreButton.gameObject.SetActive(false); + __instance.weeklyChallengesButton.gameObject.SetActive(false); + } + + [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))] + // 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 + // /// + // 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; + // } + + [HarmonyPrefix] + [HarmonyPatch(typeof(ClientBase), nameof(ClientBase.SendCommand))] + private static bool ClientBase_SendCommand( + ClientBase __instance, + CommandBase command) + { + + Plugin.logger.LogInfo("Multiplayer> ClientBase_SendCommand"); + Il2CppSystem.Threading.Tasks.Task> task = new(); + var taskCompletionSource = new Il2CppSystem.Threading.Tasks.TaskCompletionSource>(); + + _ = HandleSendCommandModded(taskCompletionSource, __instance, command); + + task = taskCompletionSource.Task; + + return false; + } + + private static async System.Threading.Tasks.Task HandleSendCommandModded( + Il2CppSystem.Threading.Tasks.TaskCompletionSource> tcs, + ClientBase client, + CommandBase command) + { + try + { + if (!client.CurrentGameId.HasValue) + { + Console.Write("Tried to perform and send command but no GameId was set"); + return; + } + if (!ClientActionManager.CanReceiveCommand(command, client.GameState)) + { + Console.Write("Tried to send invalid command"); + return; + } + uint currentResetId = client.resets; + int count = client.GameState.CommandStack.Count; + var list = new Il2CppSystem.Collections.Generic.List(); + list.Add(command); + client.ActionManager.ExecuteCommands(list); + await client.SendCommandToServer(command, count); + + var serializedGameState = SerializationHelpers.ToByteArray(client.GameState, client.GameState.Version); + + var succ = GameStateSummary.FromGameStateByteArray(serializedGameState, + out GameStateSummary stateSummary, out var gameState); + + var serializedGameSummary = SerializationHelpers.ToByteArray(stateSummary, gameState.Version); + + + client.GameState.TryGetPlayer(client.GameState.CurrentPlayer, out PlayerState playerState); + var currentPlayerId = ""; + if(playerState.AccountId.HasValue) + { + currentPlayerId = playerState.AccountId.Value.ToString(); + } + var setupGameDataViewModel = new ModdedGameStateViewModel + { + gameId = client.gameId.ToString(), + serializedGameState = serializedGameState, + serializedGameSummary = serializedGameSummary, + gameSettingsJson = "", + currentPlayerId = currentPlayerId, + IsEndTurnCommand = command.GetCommandType() == CommandType.EndTurn + }; + + + + var setupData = System.Text.Json.JsonSerializer.Serialize(setupGameDataViewModel); + + var serverResponse = await PolytopiaBackendAdapter.Instance.HubConnection.InvokeAsync>( + "UpdateGameStateModded", + setupData, + Il2CppSystem.Threading.CancellationToken.None + ); + tcs.SetResult(serverResponse); + } + catch (Exception ex) + { + Plugin.logger.LogError("Multiplayer> Error during HandleSendCommandModded: " + 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 Settiings created"); + + var succ = GameStateSummary.FromGameStateByteArray(serializedGameState, + out GameStateSummary stateSummary, out var gameState); + + var serializedGameSummary = SerializationHelpers.ToByteArray(stateSummary, gameState.Version); + var setupGameDataViewModel = new ModdedGameStateViewModel + { + lobbyId = lobbyGameViewModel.Id.ToString(), + serializedGameState = serializedGameState, + serializedGameSummary = serializedGameSummary, + 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.None, // TribeMix is byte too + 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)); + } + + // FIX FOR NATURE PLAYER. BOTS ARENT IMPLEMENTED YET + + [HarmonyPrefix] + [HarmonyPatch(typeof(GameState), nameof(GameState.EndPlayerTurn))] + private static bool GameState_EndPlayerTurn(GameState __instance, bool newTurn = false) + { + Console.Write("GameState_EndPlayerTurn"); + __instance.CurrentPlayerIndex++; + if (__instance.CurrentPlayerIndex >=__instance. PlayerStates.Count) + { + __instance.CurrentPlayerIndex = 0; + newTurn = true; + } + + var currentPlayer = __instance.PlayerStates[__instance.CurrentPlayerIndex]; + if (!currentPlayer.IsAlive(__instance)) + { + __instance.EndPlayerTurn(newTurn); + } + else if (newTurn) + { + __instance.CurrentTurn++; + } + + if(currentPlayer.AutoPlay) + { + __instance.CommandStack.Add(new EndTurnCommand(currentPlayer.Id)); + } + Console.Write("finished"); + return false; + } +} diff --git a/src/Multiplayer/SerializationUtils.cs b/src/Multiplayer/SerializationUtils.cs new file mode 100644 index 0000000..e91a46b --- /dev/null +++ b/src/Multiplayer/SerializationUtils.cs @@ -0,0 +1,74 @@ +using HarmonyLib; +using Polytopia.Data; +using PolytopiaBackendBase.Common; + +namespace PolyMod.Multiplayer; + +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, Il2CppSystem.IO.BinaryWriter writer, int version) + { + Plugin.logger.LogInfo("Multiplayer> GamePlayerSummary_Serialize"); + var memoryStream = new Il2CppSystem.IO.MemoryStream(); + var binaryWriter = new Il2CppSystem.IO.BinaryWriter(memoryStream); + binaryWriter.Write(__instance.Id); + binaryWriter.Write(__instance.PolytopiaId.ToString()); + binaryWriter.Write(__instance.UserName ?? ""); + binaryWriter.Write((int)__instance.TribeType); + binaryWriter.Write(__instance.AutoPlay); + binaryWriter.Write(__instance.HasChosenTribe); + binaryWriter.Write(__instance.Handicap); + binaryWriter.Write(__instance.IsDead); + if (version >= 86) + { + binaryWriter.Write((int)__instance.SkinType); + } + writer.Write((int)memoryStream.Length); + memoryStream.WriteTo(writer.BaseStream); + return false; + } + + [HarmonyPrefix] + [HarmonyPatch(typeof(GamePlayerSummary), nameof(GamePlayerSummary.Deserialize))] + public static bool GamePlayerSummary_Deserialize(GamePlayerSummary __instance, Il2CppSystem.IO.BinaryReader reader, int version) + { + Plugin.logger.LogInfo("Multiplayer> GamePlayerSummary_Deserialize"); + int num = reader.ReadInt32(); + long position = reader.BaseStream.Position; + __instance.Id = reader.ReadByte(); + string g = reader.ReadString(); + Il2CppSystem.Guid parsed; + Il2CppSystem.Nullable nullableGuid; + if (Il2CppSystem.Guid.TryParse(g, out parsed)) + nullableGuid = new Il2CppSystem.Nullable(parsed); + else + nullableGuid = new Il2CppSystem.Nullable(); + __instance.PolytopiaId = nullableGuid; + __instance.UserName = reader.ReadString(); + __instance.TribeType = (TribeType)reader.ReadInt32(); + __instance.AutoPlay = reader.ReadBoolean(); + __instance.HasChosenTribe = reader.ReadBoolean(); + __instance.Handicap = reader.ReadInt32(); + __instance.IsDead = reader.ReadBoolean(); + if (version >= 86) + { + __instance.SkinType = (SkinType)reader.ReadInt32(); + } + reader.BaseStream.Position = position + num; + return false; + } + + [HarmonyPostfix] + [HarmonyPatch(typeof(PlayerState), nameof(PlayerState.Deserialize))] + public static void PlayerState_Deserialize(PlayerState __instance, Il2CppSystem.IO.BinaryReader reader, int version) + { + __instance.climate = __instance.tribe; + } +} \ No newline at end of file diff --git a/src/Multiplayer/ViewModels/ModdedGameStateViewModel.cs b/src/Multiplayer/ViewModels/ModdedGameStateViewModel.cs new file mode 100644 index 0000000..176a997 --- /dev/null +++ b/src/Multiplayer/ViewModels/ModdedGameStateViewModel.cs @@ -0,0 +1,13 @@ +namespace PolyMod.Multiplayer.ViewModels; +public class ModdedGameStateViewModel +{ + public string gameId { get; set; } = ""; + public string lobbyId { get; set; } = ""; + + public byte[] serializedGameState { get; set; } = new byte[0]; + public byte[] serializedGameSummary { get; set; } = new byte[0]; + + public string gameSettingsJson { get; set; } = ""; + public string currentPlayerId { get; set; } = ""; + public bool IsEndTurnCommand { get; set; } = false; +} \ No newline at end of file diff --git a/src/Plugin.cs b/src/Plugin.cs index 96ae942..597609c 100644 --- a/src/Plugin.cs +++ b/src/Plugin.cs @@ -24,7 +24,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 +134,8 @@ public override void Load() Hub.Init(); Main.Init(); + Multiplayer.SerializationUtils.Init(); + Multiplayer.Client.Init(); } ///