Skip to content
Draft
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 @@ -406,4 +406,6 @@ dist/

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

run.bat
run.bat

.idea/
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.17</Version>
<Version>1.3.0-pre-android-1</Version>
<PolytopiaVersion>2.17.2.16299</PolytopiaVersion>
<Authors>PolyModdingTeam</Authors>
<Description>The Battle of Polytopia's mod loader.</Description>
Expand Down
89 changes: 89 additions & 0 deletions src/Android/AndroidHandler.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
using HarmonyLib;
using PolytopiaBackendBase;
using PolytopiaBackendBase.Auth;
using UnityEngine;

namespace PolyMod.Android;

public static class AndroidHandler
{
internal static void Init()
{
if (Application.platform != RuntimePlatform.Android) return;

Harmony.CreateAndPatchAll(typeof(AndroidHandler));
}

/// <summary>
/// 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.
/// </summary>
[HarmonyPrefix]
[HarmonyPatch(typeof(GameManager), nameof(GameManager.IsMultiplayerEnabled), MethodType.Getter)]
public static bool GameManager_IsMultiplayerEnabled(ref bool __result)
{
__result = true;
return false;
}

/// <summary>
/// Replace the Android login flow to skip Google Play Games SDK entirely.
/// Uses deviceUniqueIdentifier as the auth code for the Polydystopia backend.
/// </summary>
[HarmonyPrefix]
[HarmonyPatch(typeof(PolytopiaBackendAdapter), "LoginPlatformAndroid")]
public static bool LoginPlatformAndroid_Prefix(
ref Il2CppSystem.Threading.Tasks.Task<ServerResponse<PolytopiaToken>> __result,
PolytopiaBackendAdapter __instance)
{
// Mark social login as cached so the post-login flow doesn't bail out
__instance.HasSocialLoginCached = true;

var model = new LoginGooglePlayBindingModel();
model.AuthCode = SystemInfo.deviceUniqueIdentifier;
model.DeviceId = SystemInfo.deviceUniqueIdentifier;

Plugin.logger.LogInfo($"Multiplayer> Android login with DeviceId: {model.DeviceId}");
__result = __instance.LoginGooglePlay(model);
return false;
}

/// <summary>
/// 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.
/// </summary>
[HarmonyPrefix]
[HarmonyPatch(typeof(AnalyticsManager), nameof(AnalyticsManager.IsAnalyticsEnabled))]
private static bool AnalyticsManager_IsAnalyticsEnabled(ref bool __result)
{
__result = false;
return false;
}

/// <summary>
/// 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).
/// </summary>
[HarmonyPrefix]
[HarmonyPatch(typeof(FirebaseMessagingManager), nameof(FirebaseMessagingManager.Init))]
private static bool FirebaseMessagingManager_Init()
{
return false;
}

/// <summary>
/// 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.
/// </summary>
[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;
}
}
9 changes: 9 additions & 0 deletions src/Managers/Compatibility.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,15 @@ internal static class Compatibility
internal static bool shouldResetSettings = false;
private static bool sawSignatureWarning;

/// <summary>
/// Whether all loaded mods are client only. If at least one non client only mod exists this returns false.
/// </summary>
/// <returns></returns>
public static bool IsClientOnly()
{
return Registry.mods.Select(modPair => modPair.Value).All(mod => mod.client);
}

/// <summary>
/// Hashes the signatures of all loaded mods to create a checksum.
/// </summary>
Expand Down
228 changes: 228 additions & 0 deletions src/Multiplayer/ModMultiplayer.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,228 @@
using HarmonyLib;
using Il2CppMicrosoft.AspNetCore.SignalR.Client;
using Newtonsoft.Json;
using PolyMod.Managers;
using PolyMod.Multiplayer.ViewModels;
using Polytopia.Data;
using PolytopiaBackendBase;
using PolytopiaBackendBase.Common;
using PolytopiaBackendBase.Game;
using PolytopiaBackendBase.Game.BindingModels;
using UnityEngine;

namespace PolyMod.Multiplayer;

public class ModMultiplayer
{
internal static void Init()
{
if (Compatibility.IsClientOnly())
{
Plugin.logger?.LogInfo($"All loaded mods are client only. Skipping modded multiplayer initialization.");

return;
}

Plugin.logger?.LogInfo($"Starting modded multiplayer initialization.");

Harmony.CreateAndPatchAll(typeof(ModMultiplayer));


Plugin.logger?.LogInfo($"Finished modded multiplayer initialization.");
}

[HarmonyPrefix]
[HarmonyPatch(typeof(BackendAdapter), nameof(BackendAdapter.StartLobbyGame))]
private static bool BackendAdapter_StartLobbyGame_Modded(
ref Il2CppSystem.Threading.Tasks.Task<ServerResponse<LobbyGameViewModel>> __result,
BackendAdapter __instance,
StartLobbyBindingModel model)
{
// On Android, let the game's original StartLobbyGame handle it
if (Application.platform == RuntimePlatform.Android) return true;

Plugin.logger.LogInfo("Multiplayer> BackendAdapter_StartLobbyGame_Modded");
var taskCompletionSource = new Il2CppSystem.Threading.Tasks.TaskCompletionSource<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 Settings created");

var setupGameDataViewModel = new SetupGameDataViewModel
{
lobbyId = lobbyGameViewModel.Id.ToString(),
serializedGameState = serializedGameState,
gameSettingsJson = gameSettingsJson
};

var setupData = System.Text.Json.JsonSerializer.Serialize(setupGameDataViewModel);

var serverResponse = await instance.HubConnection.InvokeAsync<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()
};
;
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