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();
}
}