Skip to content
Draft

v1.3 #139

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -404,4 +404,6 @@ build/
dist/
*.spec

# End of https://www.toptal.com/developers/gitignore/api/csharp
# End of https://www.toptal.com/developers/gitignore/api/csharp

run.bat
2 changes: 1 addition & 1 deletion PolyMod.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
</RestoreAdditionalProjectSources>
<Configurations>IL2CPP</Configurations>
<RootNamespace>PolyMod</RootNamespace>
<Version>1.2.11</Version>
<Version>1.3.0-pre</Version>
<PolytopiaVersion>2.16.4.15698</PolytopiaVersion>
<Authors>PolyModdingTeam</Authors>
<Description>The Battle of Polytopia's mod loader.</Description>
Expand Down
379 changes: 379 additions & 0 deletions src/Multiplayer/Client.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,379 @@
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<int, GameLogicData> _gldCache = new();
private static readonly Dictionary<int, int> _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;

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


/// <summary>
/// After GameState deserialization, check for trailing GLD version ID and set mockedGameLogicData.
/// The server appends "##GLD:" + modGldVersion (int) after the normal serialized data.
/// </summary>
[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}");
}
}

/// <summary>
/// Fetch GLD from server using ModGldVersion ID
/// </summary>
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}");
Comment on lines +160 to +175
Copy link
Copy Markdown

@coderabbitai coderabbitai bot Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Synchronous blocking calls on HttpClient can cause deadlocks and thread pool starvation.

Using .Result on GetAsync() and ReadAsStringAsync() blocks the calling thread. In UI contexts (Unity), this can deadlock. Additionally, creating a new HttpClient per request can exhaust sockets.

♻️ Proposed fix: reuse HttpClient and make method async
+    private static readonly HttpClient _httpClient = new();
+
-    private static string? FetchGldById(int modGldVersion)
+    private static async System.Threading.Tasks.Task<string?> FetchGldByIdAsync(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;
+            var response = await _httpClient.GetAsync(url);
             Plugin.logger?.LogDebug($"FetchGldById: Response status: {response.StatusCode}");

             if (response.IsSuccessStatusCode)
             {
-                var gld = response.Content.ReadAsStringAsync().Result;
+                var gld = await response.Content.ReadAsStringAsync();
                 Plugin.logger?.LogInfo($"FetchGldById: Successfully fetched mod GLD ({gld.Length} chars)");
                 return gld;
             }

-            var errorContent = response.Content.ReadAsStringAsync().Result;
+            var errorContent = await response.Content.ReadAsStringAsync();
             Plugin.logger?.LogError($"FetchGldById: Failed with status {response.StatusCode}: {errorContent}");
         }

Note: This would require refactoring Deserialize_Postfix to handle async, which may be complex for a Harmony postfix. If synchronous calls are necessary, consider using ConfigureAwait(false) and wrapping in Task.Run().

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/Multiplayer/Multiplayer.cs` around lines 160 - 175, FetchGldById
currently blocks on HttpClient by calling GetAsync().Result and
ReadAsStringAsync().Result and instantiates a new HttpClient per call; change
FetchGldById to an async method (e.g. Task<string> FetchGldByIdAsync) that
awaits client.GetAsync(url).ConfigureAwait(false) and await
response.Content.ReadAsStringAsync().ConfigureAwait(false), and use a single
reused HttpClient instance (e.g. a static readonly HttpClient on the class)
instead of creating one per call; update callers (including Deserialize_Postfix)
to call the async variant (propagate async/Task where possible) or, if changing
the call chain is impossible, wrap the async work in Task.Run and use
ConfigureAwait(false) to avoid deadlocks.

Copy link
Copy Markdown
Contributor

@johnklipi johnklipi Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not even my code btw

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@johnklipi, thanks for the heads-up! Regardless of authorship, since this code is part of the PR it's worth flagging to whoever owns it. The concern around blocking .Result calls on HttpClient and socket exhaustion from per-request HttpClient instantiation is worth addressing before merging. Feel free to pass this along to the original author if needed! 😊

}
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(BackendAdapter), nameof(BackendAdapter.StartLobbyGame))]
private static bool BackendAdapter_StartLobbyGame_Modded(
ref Il2CppSystem.Threading.Tasks.Task<ServerResponse<LobbyGameViewModel>> __result,
BackendAdapter __instance,
StartLobbyBindingModel model)
{
Plugin.logger.LogInfo("Multiplayer> BackendAdapter_StartLobbyGame_Modded");
var taskCompletionSource = new Il2CppSystem.Threading.Tasks.TaskCompletionSource<ServerResponse<LobbyGameViewModel>>();

_ = HandleStartLobbyGameModded(taskCompletionSource, __instance, model);

__result = taskCompletionSource.Task;

return false;
}

private static async System.Threading.Tasks.Task HandleStartLobbyGameModded(
Il2CppSystem.Threading.Tasks.TaskCompletionSource<ServerResponse<LobbyGameViewModel>> 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");
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Typo: "Settiings" should be "Settings".

✏️ Fix typo
-            Plugin.logger.LogInfo("Multiplayer> GameState and Settiings created");
+            Plugin.logger.LogInfo("Multiplayer> GameState and Settings created");
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
Plugin.logger.LogInfo("Multiplayer> GameState and Settiings created");
Plugin.logger.LogInfo("Multiplayer> GameState and Settings created");
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/Multiplayer/Multiplayer.cs` at line 227, Update the log message passed to
Plugin.logger.LogInfo in Multiplayer.cs to fix the typo: change "Multiplayer>
GameState and Settiings created" to "Multiplayer> GameState and Settings
created" so the log reads correctly; locate the call to Plugin.logger.LogInfo
and edit the string literal accordingly.


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<ServerResponse<LobbyGameViewModel>>(
"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<AvatarState>(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<TribeType>().Where(t => t != TribeType.None)
.OrderBy(x => Il2CppSystem.Guid.NewGuid()).First()
};
;
Comment on lines +292 to +295
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Stray semicolon and non-deterministic random tribe selection.

  1. Line 295 has a stray semicolon.
  2. Using OrderBy(x => Il2CppSystem.Guid.NewGuid()) for shuffling is unreliable — GUIDs are generated per comparison, not per element, leading to inconsistent ordering. Use a proper random source.
🐛 Proposed fix
             var botPlayer = new PlayerData
             {
                 type = PlayerDataType.Bot,
                 state = PlayerDataFriendshipState.Accepted,
                 knownTribe = true,
-                tribe = Enum.GetValues<TribeType>().Where(t => t != TribeType.None)
-                    .OrderBy(x => Il2CppSystem.Guid.NewGuid()).First()
+                tribe = Enum.GetValues<TribeType>()
+                    .Where(t => t != TribeType.None)
+                    .OrderBy(_ => System.Random.Shared.Next())
+                    .First()
             };
-            ;
             botPlayer.botDifficulty = (BotDifficulty)botDifficulty;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
tribe = Enum.GetValues<TribeType>().Where(t => t != TribeType.None)
.OrderBy(x => Il2CppSystem.Guid.NewGuid()).First()
};
;
tribe = Enum.GetValues<TribeType>()
.Where(t => t != TribeType.None)
.OrderBy(_ => System.Random.Shared.Next())
.First()
};
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/Multiplayer/Multiplayer.cs` around lines 292 - 295, Remove the stray
semicolon after the object initialization and replace the unreliable shuffling
using OrderBy(x => Il2CppSystem.Guid.NewGuid()) when selecting a random
TribeType: instead use a single proper random source (e.g., System.Random or
RandomNumberGenerator) to pick an index or to shuffle the
Enum.GetValues<TribeType>().Where(t => t != TribeType.None).ToArray(); then
select by random index; update the code that assigns tribe (the selection logic
around the tribe variable and the OrderBy call) to use that deterministic
single-random-source selection and remove the extraneous ';' token.

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

for (int index = 0; index < settings.GetPlayerCount(); ++index)
{
PlayerData player = settings.GetPlayer(index);
if (player.type != PlayerDataType.Bot)
{
var nullableGuid = new Il2CppSystem.Nullable<Il2CppSystem.Guid>(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));
}
}
Loading
Loading