diff --git a/.github/workflows/cicd.yml b/.github/workflows/cicd.yml index db51c1a..8a4b34d 100644 --- a/.github/workflows/cicd.yml +++ b/.github/workflows/cicd.yml @@ -15,8 +15,8 @@ jobs: with: path: ~/.nuget/packages key: nuget-${{ hashFiles('**/*.csproj') }} - - name: Build - run: dotnet build -warnaserror + - name: Build and test + run: dotnet test -warnaserror - name: Build nuget if: github.event_name == 'workflow_dispatch' run: dotnet pack PolyMod.csproj -o nuget diff --git a/PolyMod.sln b/PolyMod.sln index 2e325f4..e01aa6c 100644 --- a/PolyMod.sln +++ b/PolyMod.sln @@ -3,7 +3,9 @@ Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 VisualStudioVersion = 17.4.33205.214 MinimumVisualStudioVersion = 10.0.40219.1 -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "PolyMod", "PolyMod.csproj", "{58B09361-FD7A-48F1-82E1-E2359ADA512F}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "PolyMod", "src\PolyMod.csproj", "{58B09361-FD7A-48F1-82E1-E2359ADA512F}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tests", "tests\Tests.csproj", "{85432E7B-2023-477C-861E-0141E8974DDC}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -12,6 +14,8 @@ Global GlobalSection(ProjectConfigurationPlatforms) = postSolution {58B09361-FD7A-48F1-82E1-E2359ADA512F}.IL2CPP|Any CPU.ActiveCfg = IL2CPP|Any CPU {58B09361-FD7A-48F1-82E1-E2359ADA512F}.IL2CPP|Any CPU.Build.0 = IL2CPP|Any CPU + {85432E7B-2023-477C-861E-0141E8974DDC}.IL2CPP|Any CPU.ActiveCfg = Debug|Any CPU + {85432E7B-2023-477C-861E-0141E8974DDC}.IL2CPP|Any CPU.Build.0 = Debug|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/nuget.config b/nuget.config new file mode 100644 index 0000000..6ebe1ff --- /dev/null +++ b/nuget.config @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/src/Api/Config.cs b/src/Api/Config.cs index 881ccb9..f253bf8 100644 --- a/src/Api/Config.cs +++ b/src/Api/Config.cs @@ -27,25 +27,25 @@ public Config(string modName, ConfigTypes configType) switch (configType) { case ConfigTypes.PerMod: - { - if (!File.Exists(perModConfigPath)) { - return; + if (!File.Exists(perModConfigPath)) + { + return; + } + var jsonText = File.ReadAllText(perModConfigPath); + currentConfig = JsonSerializer.Deserialize(jsonText); + break; } - var jsonText = File.ReadAllText(perModConfigPath); - currentConfig = JsonSerializer.Deserialize(jsonText); - break; - } case ConfigTypes.Exposed: - { - if (!File.Exists(ExposedConfigPath)) { - return; + if (!File.Exists(ExposedConfigPath)) + { + return; + } + var jsonText = File.ReadAllText(ExposedConfigPath); + currentConfig = JsonNode.Parse(jsonText)![modName]?.Deserialize(); + break; } - var jsonText = File.ReadAllText(ExposedConfigPath); - currentConfig = JsonNode.Parse(jsonText)![modName]?.Deserialize(); - break; - } default: throw new ArgumentOutOfRangeException(); } @@ -60,7 +60,7 @@ public void SetDefaultConfig(T defaultValue) Write(defaultConfig); SaveChanges(); } - + /// /// Writes the **entire** config. Usage not recommended, use Edit() instead /// diff --git a/src/Api/GLDConfig.cs b/src/Api/GLDConfig.cs index 6384782..ecda511 100644 --- a/src/Api/GLDConfig.cs +++ b/src/Api/GLDConfig.cs @@ -8,7 +8,7 @@ namespace PolyMod.Api; public class GldConfigTemplate { private static readonly string ConfigPath = Path.Combine(Plugin.BASE_PATH, "mods.json"); - + private readonly string templateText; private JsonObject currentConfig = new(); private string modName; @@ -24,8 +24,8 @@ private void Load() if (File.Exists(ConfigPath)) { var json = File.ReadAllText(ConfigPath); - if (JsonNode.Parse(json) is JsonObject modsConfig - && modsConfig.TryGetPropertyValue(modName, out var modConfigNode) + if (JsonNode.Parse(json) is JsonObject modsConfig + && modsConfig.TryGetPropertyValue(modName, out var modConfigNode) && modConfigNode is JsonObject modConfig) { currentConfig = modConfig; @@ -41,9 +41,9 @@ private void Load() var template = Template.Parse(templateText); var context = new TemplateContext(); var scriptObject = new ScriptObject(); - + bool changedConfig = false; - scriptObject.Import("config", + scriptObject.Import("config", new Func((key, defaultValue) => { if (currentConfig.TryGetPropertyValue(key, out var token) && token != null) @@ -53,7 +53,7 @@ private void Load() changedConfig = true; currentConfig[key] = defaultValue; - + return defaultValue; }) ); diff --git a/src/Api/PolyScript.cs b/src/Api/PolyScript.cs index 8bf8ab2..09fd36e 100644 --- a/src/Api/PolyScript.cs +++ b/src/Api/PolyScript.cs @@ -11,7 +11,7 @@ public abstract class PolyScriptBase /// The mod instance. /// The logger instance. internal abstract void Initialize(Mod mod, ManualLogSource logger); - + /// > /// Called when the mod is loaded. /// @@ -41,7 +41,7 @@ internal override void Initialize(Mod mod, ManualLogSource logger) return Registry.mods[Mod.id].files.FirstOrDefault(f => f.name == fileName)?.bytes; } - public override void Unload() {} + public override void Unload() { } public Mod Mod { get; private set; } = null!; protected Config Config { get; private set; } = null!; diff --git a/src/Json/JsonMerger.cs b/src/Json/JsonMerger.cs index 3c11a7b..44eb212 100644 --- a/src/Json/JsonMerger.cs +++ b/src/Json/JsonMerger.cs @@ -99,13 +99,13 @@ private static JArray MergeArrays(JArray original, JArray patch, bool isSkins) result.Remove(rem); hasCustomValues = true; } - else if(isSkins) + else if (isSkins) { result.Add(str); hasCustomValues = true; } } - if(!hasCustomValues) + if (!hasCustomValues) { result = new JArray(patch); } diff --git a/src/Loader.cs b/src/Loader.cs index 69fe92b..87d5a76 100644 --- a/src/Loader.cs +++ b/src/Loader.cs @@ -24,1096 +24,1096 @@ namespace PolyMod; /// public static class Loader { - internal record TypeMapping(Type type, bool shouldCreateCache = true); - - /// - /// Mappings from JSON data types to their corresponding C# types. - /// - internal static Dictionary typeMappings = new() - { - { "tribeData", new TypeMapping(typeof(TribeType)) }, - { "techData", new TypeMapping(typeof(TechData.Type)) }, - { "unitData", new TypeMapping(typeof(UnitData.Type)) }, - { "improvementData", new TypeMapping(typeof(ImprovementData.Type)) }, - { "terrainData", new TypeMapping(typeof(Polytopia.Data.TerrainData.Type)) }, - { "resourceData", new TypeMapping(typeof(ResourceData.Type)) }, - { "taskData", new TypeMapping(typeof(TaskData.Type)) }, - { "tribeAbility", new TypeMapping(typeof(TribeAbility.Type)) }, - { "unitAbility", new TypeMapping(typeof(UnitAbility.Type)) }, - { "improvementAbility", new TypeMapping(typeof(ImprovementAbility.Type)) }, - { "playerAbility", new TypeMapping(typeof(PlayerAbility.Type)) }, - { "weaponData", new TypeMapping(typeof(UnitData.WeaponEnum)) }, - { "skinData", new TypeMapping(typeof(SkinData), false) } - }; - - /// - /// List of custom game modes to be added. - /// - internal static List gamemodes = new(); - - /// - /// Handlers for processing specific data types during mod loading. - /// - internal static readonly Dictionary>> typeHandlers = new() - { - [typeof(TribeType)] = new List>() { - new((token, duringEnumCacheCreation) => - { - if (duringEnumCacheCreation) - { - Registry.customTribes.Add((TribeType)(int)token["idx"]); - token["style"] = Registry.climateAutoidx; - token["climate"] = Registry.climateAutoidx; - Registry.climateAutoidx++; - } - else - { - if (token["skins"] != null) - { - JArray skins = token["skins"].Cast(); - List skinValues = skins._values.ToArray().ToList(); - foreach (var skin in skinValues) - { - string skinId = skin.ToString(); - if (!Enum.TryParse(skinId, ignoreCase: true, out _)) - { - SkinType skinValue = (SkinType)Registry.autoidx; - EnumCache.AddMapping(skinId.ToLowerInvariant(), skinValue); - EnumCache.AddMapping(skinId.ToLowerInvariant(), skinValue); - Registry.skinInfo.Add(new Visual.SkinInfo(Registry.autoidx, skinId, null)); - Plugin.logger.LogInfo("Created mapping for skinType with id " + skinId + " and index " + Registry.autoidx); - Registry.autoidx++; - if(token["tribeAbilities"] != null) - { - JArray tribeAbilities = token["tribeAbilities"].Cast(); - List tribeAbilitiesValues = tribeAbilities._values.ToArray().ToList(); - - foreach(JToken ability in tribeAbilitiesValues) - { - string abilityValue = ability.ToString(); - - if (Enum.TryParse( - abilityValue, ignoreCase: true, - out TribeAbility.Type tribeAbilityType - ) && tribeAbilityType == TribeAbility.Type.Flooded) - { - string tileEffectId = skinId.ToLowerInvariant() + "_flood"; - EnumCache.AddMapping(tileEffectId, (TileData.EffectType)Registry.autoidx); - EnumCache.AddMapping(tileEffectId, (TileData.EffectType)Registry.autoidx); + internal record TypeMapping(Type type, bool shouldCreateCache = true); + + /// + /// Mappings from JSON data types to their corresponding C# types. + /// + internal static Dictionary typeMappings = new() + { + { "tribeData", new TypeMapping(typeof(TribeType)) }, + { "techData", new TypeMapping(typeof(TechData.Type)) }, + { "unitData", new TypeMapping(typeof(UnitData.Type)) }, + { "improvementData", new TypeMapping(typeof(ImprovementData.Type)) }, + { "terrainData", new TypeMapping(typeof(Polytopia.Data.TerrainData.Type)) }, + { "resourceData", new TypeMapping(typeof(ResourceData.Type)) }, + { "taskData", new TypeMapping(typeof(TaskData.Type)) }, + { "tribeAbility", new TypeMapping(typeof(TribeAbility.Type)) }, + { "unitAbility", new TypeMapping(typeof(UnitAbility.Type)) }, + { "improvementAbility", new TypeMapping(typeof(ImprovementAbility.Type)) }, + { "playerAbility", new TypeMapping(typeof(PlayerAbility.Type)) }, + { "weaponData", new TypeMapping(typeof(UnitData.WeaponEnum)) }, + { "skinData", new TypeMapping(typeof(SkinData), false) } + }; + + /// + /// List of custom game modes to be added. + /// + internal static List gamemodes = new(); + + /// + /// Handlers for processing specific data types during mod loading. + /// + internal static readonly Dictionary>> typeHandlers = new() + { + [typeof(TribeType)] = new List>() { + new((token, duringEnumCacheCreation) => + { + if (duringEnumCacheCreation) + { + Registry.customTribes.Add((TribeType)(int)token["idx"]); + token["style"] = Registry.climateAutoidx; + token["climate"] = Registry.climateAutoidx; + Registry.climateAutoidx++; + } + else + { + if (token["skins"] != null) + { + JArray skins = token["skins"].Cast(); + List skinValues = skins._values.ToArray().ToList(); + foreach (var skin in skinValues) + { + string skinId = skin.ToString(); + if (!Enum.TryParse(skinId, ignoreCase: true, out _)) + { + SkinType skinValue = (SkinType)Registry.autoidx; + EnumCache.AddMapping(skinId.ToLowerInvariant(), skinValue); + EnumCache.AddMapping(skinId.ToLowerInvariant(), skinValue); + Registry.skinInfo.Add(new Visual.SkinInfo(Registry.autoidx, skinId, null)); + Plugin.logger.LogInfo("Created mapping for skinType with id " + skinId + " and index " + Registry.autoidx); + Registry.autoidx++; + if(token["tribeAbilities"] != null) + { + JArray tribeAbilities = token["tribeAbilities"].Cast(); + List tribeAbilitiesValues = tribeAbilities._values.ToArray().ToList(); + + foreach(JToken ability in tribeAbilitiesValues) + { + string abilityValue = ability.ToString(); + + if (Enum.TryParse( + abilityValue, ignoreCase: true, + out TribeAbility.Type tribeAbilityType + ) && tribeAbilityType == TribeAbility.Type.Flooded) + { + string tileEffectId = skinId.ToLowerInvariant() + "_flood"; + EnumCache.AddMapping(tileEffectId, (TileData.EffectType)Registry.autoidx); + EnumCache.AddMapping(tileEffectId, (TileData.EffectType)Registry.autoidx); Visual.customFloodingSkins.Add((TileData.EffectType)Registry.autoidx, skinValue); - Plugin.logger.LogInfo("Created mapping for tileEffect with id " + tileEffectId + " and index " + Registry.autoidx); - Registry.autoidx++; - } - } - } - } - } - Il2CppSystem.Collections.Generic.List modifiedSkins = skins._values; - foreach (var skin in Registry.skinInfo) - { - if (modifiedSkins.Contains(skin.id)) - { - modifiedSkins.Remove(skin.id); - modifiedSkins.Add(skin.idx.ToString()); - } - } - JArray newSkins = new JArray(); - foreach (var item in modifiedSkins) - { - newSkins.Add(item); - } - token["skins"] = newSkins; - } - if (token["preview"] != null) - { - Visual.PreviewTile[] preview = JsonSerializer.Deserialize(token["preview"].ToString())!; - Registry.tribePreviews[Util.GetJTokenName(token)] = preview; - } - } - }) - }, - [typeof(UnitData.Type)] = new List>() { - new((token, duringEnumCacheCreation) => - { - if(duringEnumCacheCreation) - { - if (token["prefab"] == null) - { - Registry.prefabNames.Add((int)token["idx"], EnumCache.GetName(UnitData.Type.Scout).ToLowerInvariant()); - } - } - else - { - if (token["prefab"] != null) - { - Registry.prefabNames.Add((int)token["idx"], token["prefab"].ToString().ToLowerInvariant()); - } - if (token["embarksTo"] != null) - { - string unitId = Util.GetJTokenName(token); - string embarkUnitId = token["embarksTo"].ToString(); - Main.embarkNames[unitId] = embarkUnitId; - } - if (token["weapon"] != null) - { - string weaponString = token["weapon"].ToString(); - if (EnumCache.TryGetType(weaponString, out UnitData.WeaponEnum type)) - { - token["weapon"] = (int)type; - } - } - } - }) - }, - - [typeof(ImprovementData.Type)] = new List>() { - new((token, duringEnumCacheCreation) => - { - if (duringEnumCacheCreation) - { - ImprovementData.Type improvementPrefabType = ImprovementData.Type.CustomsHouse; - if (token["prefab"] != null) - { - string prefabId = CultureInfo.CurrentCulture.TextInfo.ToTitleCase(token["prefab"]!.ToString()); - if (Enum.TryParse(prefabId, out ImprovementData.Type parsedType)) - improvementPrefabType = parsedType; - } - if(token["idx"] != null) - PrefabManager.improvements.TryAdd((ImprovementData.Type)(int)token["idx"], PrefabManager.improvements[improvementPrefabType]); - } - else - { - string improvementId = Util.GetJTokenName(token); - if (token["attractsResource"] != null) - { - string attractsId = token["attractsResource"].ToString(); - Main.attractsResourceNames[improvementId] = attractsId; - } - if (token["attractsToTerrain"] != null) - { - string attractsId = token["attractsToTerrain"].ToString(); - Main.attractsTerrainNames[improvementId] = attractsId; - } - if(token["infoOverride"] != null) - { - Loc.buildingsInfoOverrides[improvementId] = Loc.ReplaceDashesWithDots(token["infoOverride"].ToString()); - } - } - }) - }, - - [typeof(ResourceData.Type)] = new List>() { - new((token, duringEnumCacheCreation) => - { - if (duringEnumCacheCreation) - { - ResourceData.Type resourcePrefabType = ResourceData.Type.Game; - if (token["prefab"] != null) - { - string prefabId = CultureInfo.CurrentCulture.TextInfo.ToTitleCase(token["prefab"]!.ToString()); - if (Enum.TryParse(prefabId, out ResourceData.Type parsedType)) - resourcePrefabType = parsedType; - } - if(token["idx"] != null) - PrefabManager.resources.TryAdd((ResourceData.Type)(int)token["idx"], PrefabManager.resources[resourcePrefabType]); - } - }) - }, - - [typeof(SkinData)] = new List>() { - new((token, duringEnumCacheCreation) => - { - var prop = token.Parent.Cast(); - prop.Replace(new JProperty(prop.Name.ToLower(), prop.Value)); - }) - }, - }; - - /// - /// Represents information for a custom game mode button. - /// - /// The index of the game mode. - /// The action to perform when the button is clicked. - /// The index of the button in the UI. - /// The sprite for the button. - /// The name of the sprite for the button. - public record GameModeButtonsInformation(int gameModeIndex, UIButtonBase.ButtonAction action, int? buttonIndex, Sprite? sprite, string? spriteName); - - /// - /// Adds a new game mode button. - /// - /// The unique identifier for the game mode. - /// The action to perform when the button is clicked. - /// The boolean which decides if GameMode should appear in the menu. - /// The sprite for the button. - /// The name of the sprite for the button. - public static void AddGameMode(string id, UIButtonBase.ButtonAction action, bool shouldShowInMenu = true, Sprite? sprite = null, string? spriteName = null) - { - EnumCache.AddMapping(id, (GameMode)Registry.gameModesAutoidx); - EnumCache.AddMapping(id, (GameMode)Registry.gameModesAutoidx); - if(shouldShowInMenu) - gamemodes.Add(new GameModeButtonsInformation(Registry.gameModesAutoidx, action, null, sprite, spriteName)); - Registry.gameModesAutoidx++; - } - - /// - /// Adds a new data type for patching. - /// - /// The identifier for the data type in JSON. - /// "The C# type corresponding to the identifier. - public static void AddPatchDataType(string typeId, Type type) - { - if (!typeMappings.ContainsKey(typeId)) - typeMappings.Add(typeId, new TypeMapping(type)); - } - - public static void AddPatchDataType(string typeId, Type type, bool shouldCreateCache) - { - if (!typeMappings.ContainsKey(typeId)) - typeMappings.Add(typeId, new TypeMapping(type, shouldCreateCache)); - } - - public static void AddTypeHandler(Type type, Action handler) - { - string typeString = type.ToString(); - if(!typeMappings.ContainsValue(new TypeMapping(type, true)) && !typeMappings.ContainsValue(new TypeMapping(type, false))) - { - Plugin.logger.LogWarning($"Tried adding TypeHandler for type: {typeString} with missing TypeMapping. Please, add TypeMapping first."); - return; - } - if(!typeHandlers.ContainsKey(type)) - typeHandlers[type] = new(); - - typeHandlers[type].Add(handler); - - Plugin.logger.LogInfo($"Added TypeHandler for type: {typeString}."); - } - - /// - /// Loads all mods from the mods directory. - /// - /// A dictionary to populate with the loaded mods. - internal static void RegisterMods(Dictionary mods) - { - Directory.CreateDirectory(Plugin.MODS_PATH); - string[] modContainers = Directory.GetDirectories(Plugin.MODS_PATH) - .Union(Directory.GetFiles(Plugin.MODS_PATH, "*.polymod")) - .Union(Directory.GetFiles(Plugin.MODS_PATH, "*.zip")) - .ToArray(); - foreach (var modContainer in modContainers) - { - Mod.Manifest? manifest = null; - List files = new(); - - // Load mod from directory or zip archive - if (Directory.Exists(modContainer)) - { - foreach (var file in Directory.GetFiles(modContainer, "*", SearchOption.AllDirectories)) - { - ProcessModFile( - Path.GetRelativePath(modContainer, file), - File.ReadAllBytes(file), - files, - ref manifest - ); - } - } - else - { - foreach (var entry in new ZipArchive(File.OpenRead(modContainer)).Entries) - { - ProcessModFile( - entry.FullName, - entry.ReadBytes(), - files, - ref manifest - ); - } - } - #region ValidateManifest() - if (manifest == null) - { - Plugin.logger.LogError($"Mod manifest not found in {modContainer}"); - continue; - } - if (manifest.id == null) - { - Plugin.logger.LogError($"Mod id not found in {modContainer}"); - continue; - } - if (!Regex.IsMatch(manifest.id, @"^(?!polytopia$)[a-z_]+$")) - { - Plugin.logger.LogError($"Mod id {manifest.id} is invalid in {modContainer}"); - continue; - } - if (manifest.version == null) - { - Plugin.logger.LogError($"Mod version not found in {modContainer}"); - continue; - } - if (manifest.authors == null || manifest.authors.Length == 0) - { - Plugin.logger.LogError($"Mod authors not found in {modContainer}"); - continue; - } - if (mods.ContainsKey(manifest.id)) - { - Plugin.logger.LogError($"Mod {manifest.id} already exists"); - continue; - } - #endregion - mods.Add(manifest.id, new( - manifest, - Mod.Status.Success, - files - )); - Plugin.logger.LogInfo($"Registered mod {manifest.id}"); - } - - CheckDependencies(mods); - } - - private static void ProcessModFile(string fullName, byte[] bytes, List files, ref Mod.Manifest? manifest) - { - if (fullName == "manifest.json") - { - manifest = JsonSerializer.Deserialize( - bytes, - new JsonSerializerOptions() - { - Converters = { new VersionJson() }, - } - ); - return; - } - files.Add(new(fullName, bytes)); - } - - internal static void LoadMods(Dictionary mods, out bool dependencyCycle) - { - dependencyCycle = !SortMods(Registry.mods); - if (dependencyCycle) return; - - StringBuilder checksumString = new(); - foreach (var (id, mod) in Registry.mods) - { - if (mod.status != Mod.Status.Success) continue; - foreach (var file in mod.files) - { - checksumString.Append(JsonSerializer.Serialize(file)); - if (Path.GetExtension(file.name) == ".dll") - { - LoadAssemblyFile(mod, file); - } - Match spritesMatch = Regex.Match(Path.GetFileName(file.name), @"^sprites(?:_(.*))?\.json$"); - if (spritesMatch.Success) - { - LoadSpriteInfoFile(mod, file); - } - Match languageMatch = Regex.Match(Path.GetFileName(file.name), @"^language(?:_(.*))?\.json$"); - if (languageMatch.Success) - { - string languageName = languageMatch.Groups[1].Value; - LoadLanguageFile( - mod, - file, - languageName - ); - } - } - if (!mod.client && id != "polytopia") - { - checksumString.Append(id); - checksumString.Append(mod.version.ToString()); - } - } - Compatibility.HashSignatures(checksumString); - - } - private static void CheckDependencies(Dictionary mods) - { - foreach (var (id, mod) in mods) - { - foreach (var dependency in mod.dependencies ?? Array.Empty()) - { - string? message = null; - if (!mods.ContainsKey(dependency.id)) - { - message = $"Dependency {dependency.id} not found"; - } - else - { - Version version = mods[dependency.id].version; - if ( - (dependency.min != null && version < dependency.min) - || - (dependency.max != null && version > dependency.max) - ) - { - message = $"Need dependency {dependency.id} version {dependency.min} - {dependency.max} found {version}"; - } - } - if (message != null) - { - if (dependency.required) - { - Plugin.logger.LogError(message); - mod.status = Mod.Status.DependenciesUnsatisfied; - } - else - { - Plugin.logger.LogWarning(message); - } - } - } - } - } - - /// - /// Sorts mods based on their dependencies using a topological sort. - /// - /// The dictionary of mods to sort. - /// True if the mods could be sorted (no circular dependencies), false otherwise. - private static bool SortMods(Dictionary mods) - { - Stopwatch s = new(); - Dictionary> graph = new(); - Dictionary inDegree = new(); - Dictionary successfulMods = new(); - Dictionary unsuccessfulMods = new(); - foreach (var (id, mod) in mods) - { - if (mod.status == Mod.Status.Success) successfulMods.Add(id, mod); - else unsuccessfulMods.Add(id, mod); - } - foreach (var (id, _) in successfulMods) - { - graph[id] = new(); - inDegree[id] = 0; - } - foreach (var (id, mod) in successfulMods) - { - foreach (var dependency in mod.dependencies ?? Array.Empty()) - { - if (!graph.ContainsKey(dependency.id)) continue; - graph[dependency.id].Add(id); - inDegree[id]++; - } - } - Queue queue = new(); - foreach (var (id, _) in successfulMods) - { - if (inDegree[id] == 0) - { - queue.Enqueue(id); - } - } - Dictionary sorted = new(); - while (queue.Count > 0) - { - var id = queue.Dequeue(); - var mod = successfulMods[id]; - sorted.Add(id, mod); - foreach (var neighbor in graph[id]) - { - inDegree[neighbor]--; - if (inDegree[neighbor] == 0) - { - queue.Enqueue(neighbor); - } - } - } - if (sorted.Count != successfulMods.Count) - { - return false; - } - mods.Clear(); - mods.AddRange(sorted); - mods.AddRange(unsuccessfulMods); - - return true; - } - - /// - /// Loads an assembly file from a mod. - /// - /// The mod the assembly belongs to. - /// The assembly file to load. - public static void LoadAssemblyFile(Mod mod, Mod.File file) - { - try - { - Assembly assembly = Assembly.Load(file.bytes); - if (assembly - .GetTypes() - .FirstOrDefault(t => t.IsSubclassOf(typeof(Api.PolyScriptBase))) - is { } modType) - { - var modInstance = (Api.PolyScriptBase) Activator.CreateInstance(modType)!; - modInstance.Initialize(mod, BepInEx.Logging.Logger.CreateLogSource($"PolyMod] [{mod.id}")); - modInstance.Load(); - return; - } - foreach (Type type in assembly.GetTypes()) - { - MethodInfo? loadWithLogger = type.GetMethod("Load", new Type[] { typeof(ManualLogSource) }); - if (loadWithLogger != null) - { - loadWithLogger.Invoke(null, new object[] - { - BepInEx.Logging.Logger.CreateLogSource($"PolyMod] [{mod.id}") - }); - Plugin.logger.LogInfo($"Invoked Load method with logger from {type.FullName} from {mod.id} mod"); - } - MethodInfo? load = type.GetMethod("Load", Array.Empty()); - if (load != null) - { - load.Invoke(null, null); - Plugin.logger.LogInfo($"Invoked Load method from {type.FullName} from {mod.id} mod"); - } - } - } - catch (TargetInvocationException exception) - { - if (exception.InnerException != null) - { - Plugin.logger.LogError($"Error on loading assembly from {mod.id} mod: {exception.InnerException.Message}"); - mod.status = Mod.Status.Error; - } - } - } - - /// - /// Loads a localization file from a mod. - /// - /// The mod the localization file belongs to. - /// The localization file to load. - public static void LoadLocalizationFile(Mod mod, Mod.File file) - { - try - { - Loc.BuildAndLoadLocalization(JsonSerializer - .Deserialize>>(file.bytes)!); - Plugin.logger.LogInfo($"Registered localization from {mod.id} mod"); - } - catch (Exception e) - { - Plugin.logger.LogError($"Error on loading locatization from {mod.id} mod: {e.StackTrace}"); - } - } - - public static void LoadLanguageFile(Mod mod, Mod.File file, string languageName) - { - try - { - var dict = JsonSerializer.Deserialize>(file.bytes); - if(dict != null) - { - Loc.languagesToAdd.Add(languageName, dict); - Plugin.logger.LogInfo($"Registered language from {mod.id} mod"); - } - } - catch (Exception e) - { - Plugin.logger.LogError($"Error on loading language file from {mod.id} mod: {e.StackTrace}"); - } - } - - /// - /// Loads a sprite file from a mod. - /// - /// The mod the sprite file belongs to. - /// The sprite file to load. - public static void LoadSpriteFile(Mod mod, Mod.File file) - { - string name = Path.GetFileNameWithoutExtension(file.name); - Vector2 pivot = name.Split("_")[0] switch - { - "field" => new(0.5f, 0.0f), - "mountain" => new(0.5f, -0.375f), - _ => new(0.5f, 0.5f), - }; - float pixelsPerUnit = 2112f; - if (Registry.spriteInfos.ContainsKey(name)) - { - Visual.SpriteInfo spriteData = Registry.spriteInfos[name]; - pivot = spriteData.pivot ?? pivot; - pixelsPerUnit = spriteData.pixelsPerUnit ?? pixelsPerUnit; - } - Sprite sprite = Visual.BuildSprite(file.bytes, pivot, pixelsPerUnit); - GameManager.GetSpriteAtlasManager().cachedSprites.TryAdd("Heads", new()); - GameManager.GetSpriteAtlasManager().cachedSprites["Heads"].Add(name, sprite); - Registry.sprites.Add(name, sprite); - } - - /// - /// Updates a sprite with new information. - /// - /// The name of the sprite to update. - public static void UpdateSprite(string name) - { - if (Registry.spriteInfos.ContainsKey(name) && Registry.sprites.ContainsKey(name)) - { - Visual.SpriteInfo spriteData = Registry.spriteInfos[name]; - Sprite sprite = Visual.BuildSpriteWithTexture( - Registry.sprites[name].texture, - spriteData.pivot, - spriteData.pixelsPerUnit - ); - GameManager.GetSpriteAtlasManager().cachedSprites["Heads"][name] = sprite; - Registry.sprites[name] = sprite; - } - } - - /// - /// Loads a sprite info file from a mod. - /// - /// The mod the sprite info file belongs to. - /// The sprite info file to load. - /// A dictionary of sprite information, or null if an error occurred. - public static Dictionary? LoadSpriteInfoFile(Mod mod, Mod.File file) - { - try - { - var deserialized = JsonSerializer.Deserialize>( - file.bytes, - new JsonSerializerOptions() - { - Converters = { new Vector2Json() }, - } - ); - - if (deserialized != null) - { - foreach (var kvp in deserialized) - { - Registry.spriteInfos[kvp.Key] = kvp.Value; - } - } - - Plugin.logger.LogInfo($"Registered sprite data from {mod.id} mod"); - return deserialized; - } - catch (Exception e) - { - Plugin.logger.LogError($"Error on loading sprite data from {mod.id} mod: {e.StackTrace}"); - return null; - } - } - - /// - /// Loads an audio file from a mod. - /// - /// The mod the audio file belongs to. - /// The audio file to load. - public static void LoadAudioFile(Mod mod, Mod.File file) - { - // AudioSource audioSource = new GameObject().AddComponent(); - // GameObject.DontDestroyOnLoad(audioSource); - // audioSource.clip = Managers.Audio.BuildAudioClip(file.bytes); - // Registry.audioClips.Add(Path.GetFileNameWithoutExtension(file.name), audioSource); - // TODO: issue #71 - } - - /// - /// Loads a prefab info file from a mod and creates a new unit prefab. - /// - /// The mod the prefab info file belongs to. - /// The prefab info file to load. - public static void LoadPrefabInfoFile(Mod mod, Mod.File file) - { - try - { - var prefab = JsonSerializer.Deserialize(file.bytes, new JsonSerializerOptions - { - Converters = { new Vector2Json() }, - PropertyNameCaseInsensitive = true, - }); - if (prefab == null || prefab.type != Visual.PrefabType.Unit || prefab.visualParts.Count == 0) - return; - - if (Registry.unitPrefabs.Keys.Any(pref => pref.name == prefab.name)) - { - Plugin.logger.LogInfo($"Prefab {prefab.name} already exists, skipping."); - } - var baseUnit = PrefabManager.GetPrefab(UnitData.Type.Warrior, TribeType.Imperius, SkinType.Default); - if (baseUnit == null) - return; - - var unitInstance = GameObject.Instantiate(baseUnit); - if (unitInstance == null) - return; - - var spriteContainer = unitInstance.transform.GetChild(0); - var material = ClearExistingPartsAndExtractMaterial(spriteContainer); - - var visualParts = ApplyVisualParts(prefab, spriteContainer, material); - - Transform? headPositionMarker = null; - foreach (var vp in visualParts) - { - if (vp.visualPart.gameObject.name == prefab.headPositionMarker) - { - headPositionMarker = vp.visualPart.gameObject.transform; - break; - } - } - - unitInstance.headPositionMarker = headPositionMarker ?? visualParts[0].visualPart.transform; - - var svr = unitInstance.GetComponent(); - svr.visualParts = visualParts.ToArray(); - - GameObject.DontDestroyOnLoad(unitInstance.gameObject); - Registry.unitPrefabs.Add(prefab, unitInstance.GetComponent()); - - Plugin.logger.LogInfo($"Registered prefab {prefab.name} info from {mod.id} mod."); - } - catch (Exception e) - { - Plugin.logger.LogError($"Error on loading prefab {Path.GetFileNameWithoutExtension(file.name)} info from {mod.id} mod: {e.StackTrace}."); - } - } - - /// - /// Clears existing visual parts from a prefab and extracts the material. - /// - /// The transform containing the sprite parts. - /// The material of the original sprite parts. - private static Material? ClearExistingPartsAndExtractMaterial(Transform spriteContainer) - { - Material? material = null; - for (int i = 0; i < spriteContainer.childCount; i++) - { - var child = spriteContainer.GetChild(i); - if (child.gameObject.name == "Head") - { - var renderer = child.GetComponent(); - if (renderer != null) - material = renderer.material; - } - GameObject.Destroy(child.gameObject); - } - return material; - } - - /// - /// Applies new visual parts to a prefab. - /// - /// A list of visual part information. - /// The transform to add the parts to. - /// The material to use for the parts. - /// A list of the created visual parts. - private static List ApplyVisualParts( - Visual.PrefabInfo prefab, - Transform spriteContainer, - Material? material) - { - if (prefab.visualParts.Count == 0) - return new List(); - - List parts = new(); - - foreach (var info in prefab.visualParts) - { - parts.Add(CreateVisualPart(info, spriteContainer, material)); - } - - return parts; - } - - /// - /// Creates a single visual part for a prefab. - /// - /// The information for the visual part. - /// The parent transform for the part. - /// The material to use for the part. - /// The created visual part. - private static SkinVisualsReference.VisualPart CreateVisualPart( - Visual.VisualPartInfo info, - Transform parent, - Material? material) - { - var visualPartObj = new GameObject(info.gameObjectName); - visualPartObj.transform.SetParent(parent); - visualPartObj.transform.position = info.coordinates; - visualPartObj.transform.localScale = info.scale; - visualPartObj.transform.rotation = Quaternion.Euler(0f, 0f, info.rotation); - - var outlineObj = new GameObject("Outline"); - outlineObj.transform.SetParent(visualPartObj.transform); - outlineObj.transform.position = info.coordinates; - outlineObj.transform.localScale = info.scale; - outlineObj.transform.rotation = Quaternion.Euler(0f, 0f, info.rotation); - - var visualPart = new SkinVisualsReference.VisualPart - { - DefaultSpriteName = info.baseName, - visualPart = visualPartObj, - outline = outlineObj, - tintable = info.tintable - }; - - var renderer = visualPartObj.AddComponent(); - renderer.material = material; - renderer.sortingLayerName = "Units"; - renderer.sortingOrder = info.tintable ? 0 : 1; - - visualPart.renderer = new SkinVisualsReference.RendererUnion { spriteRenderer = renderer }; - - var outlineRenderer = outlineObj.AddComponent(); - outlineRenderer.material = material; - outlineRenderer.sortingLayerName = "Units"; - outlineRenderer.sortingOrder = -1; - - visualPart.outlineRenderer = new SkinVisualsReference.RendererUnion { spriteRenderer = outlineRenderer }; - - return visualPart; - } - - /// - /// Loads and applies a game logic data patch from a mod. - /// - /// The mod the patch belongs to. - /// The original game logic data. - /// The patch to apply. - public static void LoadGameLogicDataPatch(Mod mod, JObject gld, JObject patch) - { - try - { - // Merge the patch into the game logic data - gld = JsonMerger.Merge(gld, patch); - Plugin.logger.LogInfo($"Registered patch from {mod.id} mod"); - } - catch (Exception e) - { - Plugin.logger.LogError($"Error on loading patch from {mod.id} mod: {e.StackTrace}"); - mod.status = Mod.Status.Error; - } - } - - /// - /// Loads an asset bundle from a mod. - /// - /// The mod the asset bundle belongs to. - /// The asset bundle file to load. - public static void LoadAssetBundle(Mod mod, Mod.File file) - { - Registry.assetBundles.Add( - Path.GetFileNameWithoutExtension(file.name), - AssetBundle.LoadFromMemory(file.bytes) - ); - } - - /// - /// Processes the merged game logic data after all mods have been loaded and patched. - /// - /// The game logic data object to populate. - /// The root JObject of the merged game logic data. - internal static void ProcessGameLogicData(GameLogicData gameLogicData, JObject rootObject) - { - try - { - CreateMappings(rootObject); - ProcessCustomTribes(); - ProcessPrefabs(); - ProcessEmbarkOverrides(); - ProcessAttractOverrides(); - } - catch (Exception e) - { - Plugin.logger.LogError($"Error on processing modified game logic data : {e.StackTrace}"); - } - } - - internal static void ProcessCustomTribes() - { - foreach (var tribe in Registry.customTribes) - { - if(!GameLogicData.legacyTribeTypesOrder.Contains(tribe)) - { - GameLogicData.legacyTribeTypesOrder.Add(tribe); - } - } - } - - /// - /// Creates EnumCache mappings for custom enum values and invokes type handlers. - /// - /// - internal static void CreateMappings(JObject rootObject) - { - foreach (JToken jtoken in rootObject.SelectTokens("$.*.*").ToArray()) - { - JObject? token = jtoken.TryCast(); - if (token == null) - continue; - - string dataType = Util.GetJTokenName(token, 2); - if (!typeMappings.TryGetValue(dataType, out TypeMapping? typeMapping)) - continue; - - if (token["idx"] == null || !typeMapping.shouldCreateCache) - continue; - - Type targetType = typeMapping.type; - if (!targetType.IsEnum) - { - Plugin.logger.LogWarning($"Type {targetType.FullName} is not an enum, skipping!"); - continue; - } - - string id = Util.GetJTokenName(token); - - if((int)token["idx"] == -1) - { - token["idx"] = Registry.autoidx; - Registry.autoidx++; - } - else if(Plugin.config.allowUnsafeIndexes) - { - Array values = Enum.GetValues(targetType); - - var maxValue = values.Cast().Max(); - - if(maxValue >= (int)token["idx"]) - { - continue; - } - } - else - { - continue; - } - - MethodInfo? methodInfo = typeof(EnumCache<>).MakeGenericType(targetType).GetMethod("AddMapping"); - if (methodInfo == null) - { - Plugin.logger.LogWarning($"Missing AddMapping method for {targetType.FullName}"); - continue; - } - - methodInfo.Invoke(null, new object[] { id, (int)token["idx"] }); - methodInfo.Invoke(null, new object[] { id, (int)token["idx"] }); - - if (typeHandlers.TryGetValue(targetType, out var handlers)) - { - foreach(var handler in handlers) - { - handler(token, true); - } - } - Plugin.logger.LogInfo("Created mapping for " + targetType.ToString() + " with id " + id + " and index " + (int)token["idx"]); - } - foreach (JToken jtoken in rootObject.SelectTokens("$.*.*").ToArray()) - { - JObject? token = jtoken.TryCast(); - if (token != null) - { - string dataType = Util.GetJTokenName(token, 2); - if (typeMappings.TryGetValue(dataType, out TypeMapping? typeMapping)) - { - if (typeHandlers.TryGetValue(typeMapping.type, out var handlers)) - { - foreach(var handler in handlers) - { - handler(token, false); - } - } - } - } - } - } - - /// - /// Processes the prefab registry and populates the PrefabManager with custom prefabs. - /// - internal static void ProcessPrefabs() - { - Plugin.logger.LogInfo($"Processing prefabs, count: {Registry.prefabNames.Count}."); - foreach (KeyValuePair item in Registry.prefabNames) - { - UnitData.Type unitPrefabType = UnitData.Type.Scout; - string prefabId = item.Value; - int hashKey = PrefabManager.GetSkinnedHashKey((UnitData.Type)item.Key, TribeType.None, SkinType.Default); - if (!Enum.TryParse(CultureInfo.CurrentCulture.TextInfo.ToTitleCase(prefabId), out unitPrefabType)) // Existing unit prefabs - { - KeyValuePair prefabInfo = Registry.unitPrefabs.FirstOrDefault( - kv => kv.Key.name == prefabId - ); - if (!EqualityComparer.Default.Equals(prefabInfo.Key, default)) - { - Visual.UnitPrefabInfo unitPrefabInfo = new( - EnumCache.GetName((UnitData.Type)item.Key), - EnumCache.GetName(TribeType.None), - EnumCache.GetName(SkinType.Default) - ); - Visual.customPrefabs.Add(unitPrefabInfo, prefabInfo.Value); - Plugin.logger.LogInfo($"Using custom prefab {prefabId} for unit {item.Key}."); - continue; - } - } - Plugin.logger.LogInfo($"Using existing prefab {EnumCache.GetName(unitPrefabType)} for unit {item.Key}."); - PrefabManager.units.TryAdd( - hashKey, - PrefabManager.units[PrefabManager.GetSkinnedHashKey(unitPrefabType, TribeType.None, SkinType.Default)] - ); - } - } - - /// - /// Processes embark overrides by mapping original embark unit types to configured overrides. - /// - internal static void ProcessEmbarkOverrides() - { - foreach (KeyValuePair entry in Main.embarkNames) - { - try - { - UnitData.Type unit = EnumCache.GetType(entry.Key); - UnitData.Type newUnit = EnumCache.GetType(entry.Value); - Main.embarkOverrides[unit] = newUnit; - Plugin.logger.LogInfo($"Embark unit type for {entry.Key} is now {entry.Value}"); - } - catch - { - Plugin.logger.LogError($"Embark unit type for {entry.Key} is not valid: {entry.Value}"); - } - } - } - - /// - /// Processes attract overrides by mapping improvements to resources and terrain types based on configured overrides. - /// - internal static void ProcessAttractOverrides() - { - foreach (KeyValuePair entry in Main.attractsResourceNames) - { - try - { - ImprovementData.Type improvement = EnumCache.GetType(entry.Key); - ResourceData.Type resource = EnumCache.GetType(entry.Value); - Main.attractsResourceOverrides[improvement] = resource; - Plugin.logger.LogInfo($"Improvement {entry.Key} now attracts {entry.Value}"); - } - catch - { - Plugin.logger.LogError($"Improvement {entry.Key} resource type is not valid: {entry.Value}"); - } - } - foreach (KeyValuePair entry in Main.attractsTerrainNames) - { - try - { - ImprovementData.Type improvement = EnumCache.GetType(entry.Key); - Polytopia.Data.TerrainData.Type terrain = EnumCache.GetType(entry.Value); - Main.attractsTerrainOverrides[improvement] = terrain; - Plugin.logger.LogInfo($"Improvement {entry.Key} now attracts on {entry.Value}"); - } - catch - { - Plugin.logger.LogError($"Improvement {entry.Key} terrain type is not valid: {entry.Value}"); - } - } - } + Plugin.logger.LogInfo("Created mapping for tileEffect with id " + tileEffectId + " and index " + Registry.autoidx); + Registry.autoidx++; + } + } + } + } + } + Il2CppSystem.Collections.Generic.List modifiedSkins = skins._values; + foreach (var skin in Registry.skinInfo) + { + if (modifiedSkins.Contains(skin.id)) + { + modifiedSkins.Remove(skin.id); + modifiedSkins.Add(skin.idx.ToString()); + } + } + JArray newSkins = new JArray(); + foreach (var item in modifiedSkins) + { + newSkins.Add(item); + } + token["skins"] = newSkins; + } + if (token["preview"] != null) + { + Visual.PreviewTile[] preview = JsonSerializer.Deserialize(token["preview"].ToString())!; + Registry.tribePreviews[Util.GetJTokenName(token)] = preview; + } + } + }) + }, + [typeof(UnitData.Type)] = new List>() { + new((token, duringEnumCacheCreation) => + { + if(duringEnumCacheCreation) + { + if (token["prefab"] == null) + { + Registry.prefabNames.Add((int)token["idx"], EnumCache.GetName(UnitData.Type.Scout).ToLowerInvariant()); + } + } + else + { + if (token["prefab"] != null) + { + Registry.prefabNames.Add((int)token["idx"], token["prefab"].ToString().ToLowerInvariant()); + } + if (token["embarksTo"] != null) + { + string unitId = Util.GetJTokenName(token); + string embarkUnitId = token["embarksTo"].ToString(); + Main.embarkNames[unitId] = embarkUnitId; + } + if (token["weapon"] != null) + { + string weaponString = token["weapon"].ToString(); + if (EnumCache.TryGetType(weaponString, out UnitData.WeaponEnum type)) + { + token["weapon"] = (int)type; + } + } + } + }) + }, + + [typeof(ImprovementData.Type)] = new List>() { + new((token, duringEnumCacheCreation) => + { + if (duringEnumCacheCreation) + { + ImprovementData.Type improvementPrefabType = ImprovementData.Type.CustomsHouse; + if (token["prefab"] != null) + { + string prefabId = CultureInfo.CurrentCulture.TextInfo.ToTitleCase(token["prefab"]!.ToString()); + if (Enum.TryParse(prefabId, out ImprovementData.Type parsedType)) + improvementPrefabType = parsedType; + } + if(token["idx"] != null) + PrefabManager.improvements.TryAdd((ImprovementData.Type)(int)token["idx"], PrefabManager.improvements[improvementPrefabType]); + } + else + { + string improvementId = Util.GetJTokenName(token); + if (token["attractsResource"] != null) + { + string attractsId = token["attractsResource"].ToString(); + Main.attractsResourceNames[improvementId] = attractsId; + } + if (token["attractsToTerrain"] != null) + { + string attractsId = token["attractsToTerrain"].ToString(); + Main.attractsTerrainNames[improvementId] = attractsId; + } + if(token["infoOverride"] != null) + { + Loc.buildingsInfoOverrides[improvementId] = Loc.ReplaceDashesWithDots(token["infoOverride"].ToString()); + } + } + }) + }, + + [typeof(ResourceData.Type)] = new List>() { + new((token, duringEnumCacheCreation) => + { + if (duringEnumCacheCreation) + { + ResourceData.Type resourcePrefabType = ResourceData.Type.Game; + if (token["prefab"] != null) + { + string prefabId = CultureInfo.CurrentCulture.TextInfo.ToTitleCase(token["prefab"]!.ToString()); + if (Enum.TryParse(prefabId, out ResourceData.Type parsedType)) + resourcePrefabType = parsedType; + } + if(token["idx"] != null) + PrefabManager.resources.TryAdd((ResourceData.Type)(int)token["idx"], PrefabManager.resources[resourcePrefabType]); + } + }) + }, + + [typeof(SkinData)] = new List>() { + new((token, duringEnumCacheCreation) => + { + var prop = token.Parent.Cast(); + prop.Replace(new JProperty(prop.Name.ToLower(), prop.Value)); + }) + }, + }; + + /// + /// Represents information for a custom game mode button. + /// + /// The index of the game mode. + /// The action to perform when the button is clicked. + /// The index of the button in the UI. + /// The sprite for the button. + /// The name of the sprite for the button. + public record GameModeButtonsInformation(int gameModeIndex, UIButtonBase.ButtonAction action, int? buttonIndex, Sprite? sprite, string? spriteName); + + /// + /// Adds a new game mode button. + /// + /// The unique identifier for the game mode. + /// The action to perform when the button is clicked. + /// The boolean which decides if GameMode should appear in the menu. + /// The sprite for the button. + /// The name of the sprite for the button. + public static void AddGameMode(string id, UIButtonBase.ButtonAction action, bool shouldShowInMenu = true, Sprite? sprite = null, string? spriteName = null) + { + EnumCache.AddMapping(id, (GameMode)Registry.gameModesAutoidx); + EnumCache.AddMapping(id, (GameMode)Registry.gameModesAutoidx); + if (shouldShowInMenu) + gamemodes.Add(new GameModeButtonsInformation(Registry.gameModesAutoidx, action, null, sprite, spriteName)); + Registry.gameModesAutoidx++; + } + + /// + /// Adds a new data type for patching. + /// + /// The identifier for the data type in JSON. + /// "The C# type corresponding to the identifier. + public static void AddPatchDataType(string typeId, Type type) + { + if (!typeMappings.ContainsKey(typeId)) + typeMappings.Add(typeId, new TypeMapping(type)); + } + + public static void AddPatchDataType(string typeId, Type type, bool shouldCreateCache) + { + if (!typeMappings.ContainsKey(typeId)) + typeMappings.Add(typeId, new TypeMapping(type, shouldCreateCache)); + } + + public static void AddTypeHandler(Type type, Action handler) + { + string typeString = type.ToString(); + if (!typeMappings.ContainsValue(new TypeMapping(type, true)) && !typeMappings.ContainsValue(new TypeMapping(type, false))) + { + Plugin.logger.LogWarning($"Tried adding TypeHandler for type: {typeString} with missing TypeMapping. Please, add TypeMapping first."); + return; + } + if (!typeHandlers.ContainsKey(type)) + typeHandlers[type] = new(); + + typeHandlers[type].Add(handler); + + Plugin.logger.LogInfo($"Added TypeHandler for type: {typeString}."); + } + + /// + /// Loads all mods from the mods directory. + /// + /// A dictionary to populate with the loaded mods. + internal static void RegisterMods(Dictionary mods) + { + Directory.CreateDirectory(Plugin.MODS_PATH); + string[] modContainers = Directory.GetDirectories(Plugin.MODS_PATH) + .Union(Directory.GetFiles(Plugin.MODS_PATH, "*.polymod")) + .Union(Directory.GetFiles(Plugin.MODS_PATH, "*.zip")) + .ToArray(); + foreach (var modContainer in modContainers) + { + Mod.Manifest? manifest = null; + List files = new(); + + // Load mod from directory or zip archive + if (Directory.Exists(modContainer)) + { + foreach (var file in Directory.GetFiles(modContainer, "*", SearchOption.AllDirectories)) + { + ProcessModFile( + Path.GetRelativePath(modContainer, file), + File.ReadAllBytes(file), + files, + ref manifest + ); + } + } + else + { + foreach (var entry in new ZipArchive(File.OpenRead(modContainer)).Entries) + { + ProcessModFile( + entry.FullName, + entry.ReadBytes(), + files, + ref manifest + ); + } + } + #region ValidateManifest() + if (manifest == null) + { + Plugin.logger.LogError($"Mod manifest not found in {modContainer}"); + continue; + } + if (manifest.id == null) + { + Plugin.logger.LogError($"Mod id not found in {modContainer}"); + continue; + } + if (!Regex.IsMatch(manifest.id, @"^(?!polytopia$)[a-z_]+$")) + { + Plugin.logger.LogError($"Mod id {manifest.id} is invalid in {modContainer}"); + continue; + } + if (manifest.version == null) + { + Plugin.logger.LogError($"Mod version not found in {modContainer}"); + continue; + } + if (manifest.authors == null || manifest.authors.Length == 0) + { + Plugin.logger.LogError($"Mod authors not found in {modContainer}"); + continue; + } + if (mods.ContainsKey(manifest.id)) + { + Plugin.logger.LogError($"Mod {manifest.id} already exists"); + continue; + } + #endregion + mods.Add(manifest.id, new( + manifest, + Mod.Status.Success, + files + )); + Plugin.logger.LogInfo($"Registered mod {manifest.id}"); + } + + CheckDependencies(mods); + } + + private static void ProcessModFile(string fullName, byte[] bytes, List files, ref Mod.Manifest? manifest) + { + if (fullName == "manifest.json") + { + manifest = JsonSerializer.Deserialize( + bytes, + new JsonSerializerOptions() + { + Converters = { new VersionJson() }, + } + ); + return; + } + files.Add(new(fullName, bytes)); + } + + internal static void LoadMods(Dictionary mods, out bool dependencyCycle) + { + dependencyCycle = !SortMods(Registry.mods); + if (dependencyCycle) return; + + StringBuilder checksumString = new(); + foreach (var (id, mod) in Registry.mods) + { + if (mod.status != Mod.Status.Success) continue; + foreach (var file in mod.files) + { + checksumString.Append(JsonSerializer.Serialize(file)); + if (Path.GetExtension(file.name) == ".dll") + { + LoadAssemblyFile(mod, file); + } + Match spritesMatch = Regex.Match(Path.GetFileName(file.name), @"^sprites(?:_(.*))?\.json$"); + if (spritesMatch.Success) + { + LoadSpriteInfoFile(mod, file); + } + Match languageMatch = Regex.Match(Path.GetFileName(file.name), @"^language(?:_(.*))?\.json$"); + if (languageMatch.Success) + { + string languageName = languageMatch.Groups[1].Value; + LoadLanguageFile( + mod, + file, + languageName + ); + } + } + if (!mod.client && id != "polytopia") + { + checksumString.Append(id); + checksumString.Append(mod.version.ToString()); + } + } + Compatibility.HashSignatures(checksumString); + + } + internal static void CheckDependencies(Dictionary mods) + { + foreach (var (id, mod) in mods) + { + foreach (var dependency in mod.dependencies ?? Array.Empty()) + { + string? message = null; + if (!mods.ContainsKey(dependency.id)) + { + message = $"Dependency {dependency.id} not found"; + } + else + { + Version version = mods[dependency.id].version; + if ( + (dependency.min != null && version < dependency.min) + || + (dependency.max != null && version > dependency.max) + ) + { + message = $"Need dependency {dependency.id} version {dependency.min} - {dependency.max} found {version}"; + } + } + if (message != null) + { + if (dependency.required) + { + Plugin.logger.LogError(message); + mod.status = Mod.Status.DependenciesUnsatisfied; + } + else + { + Plugin.logger.LogWarning(message); + } + } + } + } + } + + /// + /// Sorts mods based on their dependencies using a topological sort. + /// + /// The dictionary of mods to sort. + /// True if the mods could be sorted (no circular dependencies), false otherwise. + internal static bool SortMods(Dictionary mods) + { + Stopwatch s = new(); + Dictionary> graph = new(); + Dictionary inDegree = new(); + Dictionary successfulMods = new(); + Dictionary unsuccessfulMods = new(); + foreach (var (id, mod) in mods) + { + if (mod.status == Mod.Status.Success) successfulMods.Add(id, mod); + else unsuccessfulMods.Add(id, mod); + } + foreach (var (id, _) in successfulMods) + { + graph[id] = new(); + inDegree[id] = 0; + } + foreach (var (id, mod) in successfulMods) + { + foreach (var dependency in mod.dependencies ?? Array.Empty()) + { + if (!graph.ContainsKey(dependency.id)) continue; + graph[dependency.id].Add(id); + inDegree[id]++; + } + } + Queue queue = new(); + foreach (var (id, _) in successfulMods) + { + if (inDegree[id] == 0) + { + queue.Enqueue(id); + } + } + Dictionary sorted = new(); + while (queue.Count > 0) + { + var id = queue.Dequeue(); + var mod = successfulMods[id]; + sorted.Add(id, mod); + foreach (var neighbor in graph[id]) + { + inDegree[neighbor]--; + if (inDegree[neighbor] == 0) + { + queue.Enqueue(neighbor); + } + } + } + if (sorted.Count != successfulMods.Count) + { + return false; + } + mods.Clear(); + mods.AddRange(sorted); + mods.AddRange(unsuccessfulMods); + + return true; + } + + /// + /// Loads an assembly file from a mod. + /// + /// The mod the assembly belongs to. + /// The assembly file to load. + public static void LoadAssemblyFile(Mod mod, Mod.File file) + { + try + { + Assembly assembly = Assembly.Load(file.bytes); + if (assembly + .GetTypes() + .FirstOrDefault(t => t.IsSubclassOf(typeof(Api.PolyScriptBase))) + is { } modType) + { + var modInstance = (Api.PolyScriptBase)Activator.CreateInstance(modType)!; + modInstance.Initialize(mod, BepInEx.Logging.Logger.CreateLogSource($"PolyMod] [{mod.id}")); + modInstance.Load(); + return; + } + foreach (Type type in assembly.GetTypes()) + { + MethodInfo? loadWithLogger = type.GetMethod("Load", new Type[] { typeof(ManualLogSource) }); + if (loadWithLogger != null) + { + loadWithLogger.Invoke(null, new object[] + { + BepInEx.Logging.Logger.CreateLogSource($"PolyMod] [{mod.id}") + }); + Plugin.logger.LogInfo($"Invoked Load method with logger from {type.FullName} from {mod.id} mod"); + } + MethodInfo? load = type.GetMethod("Load", Array.Empty()); + if (load != null) + { + load.Invoke(null, null); + Plugin.logger.LogInfo($"Invoked Load method from {type.FullName} from {mod.id} mod"); + } + } + } + catch (TargetInvocationException exception) + { + if (exception.InnerException != null) + { + Plugin.logger.LogError($"Error on loading assembly from {mod.id} mod: {exception.InnerException.Message}"); + mod.status = Mod.Status.Error; + } + } + } + + /// + /// Loads a localization file from a mod. + /// + /// The mod the localization file belongs to. + /// The localization file to load. + public static void LoadLocalizationFile(Mod mod, Mod.File file) + { + try + { + Loc.BuildAndLoadLocalization(JsonSerializer + .Deserialize>>(file.bytes)!); + Plugin.logger.LogInfo($"Registered localization from {mod.id} mod"); + } + catch (Exception e) + { + Plugin.logger.LogError($"Error on loading locatization from {mod.id} mod: {e.StackTrace}"); + } + } + + public static void LoadLanguageFile(Mod mod, Mod.File file, string languageName) + { + try + { + var dict = JsonSerializer.Deserialize>(file.bytes); + if (dict != null) + { + Loc.languagesToAdd.Add(languageName, dict); + Plugin.logger.LogInfo($"Registered language from {mod.id} mod"); + } + } + catch (Exception e) + { + Plugin.logger.LogError($"Error on loading language file from {mod.id} mod: {e.StackTrace}"); + } + } + + /// + /// Loads a sprite file from a mod. + /// + /// The mod the sprite file belongs to. + /// The sprite file to load. + public static void LoadSpriteFile(Mod mod, Mod.File file) + { + string name = Path.GetFileNameWithoutExtension(file.name); + Vector2 pivot = name.Split("_")[0] switch + { + "field" => new(0.5f, 0.0f), + "mountain" => new(0.5f, -0.375f), + _ => new(0.5f, 0.5f), + }; + float pixelsPerUnit = 2112f; + if (Registry.spriteInfos.ContainsKey(name)) + { + Visual.SpriteInfo spriteData = Registry.spriteInfos[name]; + pivot = spriteData.pivot ?? pivot; + pixelsPerUnit = spriteData.pixelsPerUnit ?? pixelsPerUnit; + } + Sprite sprite = Visual.BuildSprite(file.bytes, pivot, pixelsPerUnit); + GameManager.GetSpriteAtlasManager().cachedSprites.TryAdd("Heads", new()); + GameManager.GetSpriteAtlasManager().cachedSprites["Heads"].Add(name, sprite); + Registry.sprites.Add(name, sprite); + } + + /// + /// Updates a sprite with new information. + /// + /// The name of the sprite to update. + public static void UpdateSprite(string name) + { + if (Registry.spriteInfos.ContainsKey(name) && Registry.sprites.ContainsKey(name)) + { + Visual.SpriteInfo spriteData = Registry.spriteInfos[name]; + Sprite sprite = Visual.BuildSpriteWithTexture( + Registry.sprites[name].texture, + spriteData.pivot, + spriteData.pixelsPerUnit + ); + GameManager.GetSpriteAtlasManager().cachedSprites["Heads"][name] = sprite; + Registry.sprites[name] = sprite; + } + } + + /// + /// Loads a sprite info file from a mod. + /// + /// The mod the sprite info file belongs to. + /// The sprite info file to load. + /// A dictionary of sprite information, or null if an error occurred. + public static Dictionary? LoadSpriteInfoFile(Mod mod, Mod.File file) + { + try + { + var deserialized = JsonSerializer.Deserialize>( + file.bytes, + new JsonSerializerOptions() + { + Converters = { new Vector2Json() }, + } + ); + + if (deserialized != null) + { + foreach (var kvp in deserialized) + { + Registry.spriteInfos[kvp.Key] = kvp.Value; + } + } + + Plugin.logger.LogInfo($"Registered sprite data from {mod.id} mod"); + return deserialized; + } + catch (Exception e) + { + Plugin.logger.LogError($"Error on loading sprite data from {mod.id} mod: {e.StackTrace}"); + return null; + } + } + + /// + /// Loads an audio file from a mod. + /// + /// The mod the audio file belongs to. + /// The audio file to load. + public static void LoadAudioFile(Mod mod, Mod.File file) + { + // AudioSource audioSource = new GameObject().AddComponent(); + // GameObject.DontDestroyOnLoad(audioSource); + // audioSource.clip = Managers.Audio.BuildAudioClip(file.bytes); + // Registry.audioClips.Add(Path.GetFileNameWithoutExtension(file.name), audioSource); + // TODO: issue #71 + } + + /// + /// Loads a prefab info file from a mod and creates a new unit prefab. + /// + /// The mod the prefab info file belongs to. + /// The prefab info file to load. + public static void LoadPrefabInfoFile(Mod mod, Mod.File file) + { + try + { + var prefab = JsonSerializer.Deserialize(file.bytes, new JsonSerializerOptions + { + Converters = { new Vector2Json() }, + PropertyNameCaseInsensitive = true, + }); + if (prefab == null || prefab.type != Visual.PrefabType.Unit || prefab.visualParts.Count == 0) + return; + + if (Registry.unitPrefabs.Keys.Any(pref => pref.name == prefab.name)) + { + Plugin.logger.LogInfo($"Prefab {prefab.name} already exists, skipping."); + } + var baseUnit = PrefabManager.GetPrefab(UnitData.Type.Warrior, TribeType.Imperius, SkinType.Default); + if (baseUnit == null) + return; + + var unitInstance = GameObject.Instantiate(baseUnit); + if (unitInstance == null) + return; + + var spriteContainer = unitInstance.transform.GetChild(0); + var material = ClearExistingPartsAndExtractMaterial(spriteContainer); + + var visualParts = ApplyVisualParts(prefab, spriteContainer, material); + + Transform? headPositionMarker = null; + foreach (var vp in visualParts) + { + if (vp.visualPart.gameObject.name == prefab.headPositionMarker) + { + headPositionMarker = vp.visualPart.gameObject.transform; + break; + } + } + + unitInstance.headPositionMarker = headPositionMarker ?? visualParts[0].visualPart.transform; + + var svr = unitInstance.GetComponent(); + svr.visualParts = visualParts.ToArray(); + + GameObject.DontDestroyOnLoad(unitInstance.gameObject); + Registry.unitPrefabs.Add(prefab, unitInstance.GetComponent()); + + Plugin.logger.LogInfo($"Registered prefab {prefab.name} info from {mod.id} mod."); + } + catch (Exception e) + { + Plugin.logger.LogError($"Error on loading prefab {Path.GetFileNameWithoutExtension(file.name)} info from {mod.id} mod: {e.StackTrace}."); + } + } + + /// + /// Clears existing visual parts from a prefab and extracts the material. + /// + /// The transform containing the sprite parts. + /// The material of the original sprite parts. + private static Material? ClearExistingPartsAndExtractMaterial(Transform spriteContainer) + { + Material? material = null; + for (int i = 0; i < spriteContainer.childCount; i++) + { + var child = spriteContainer.GetChild(i); + if (child.gameObject.name == "Head") + { + var renderer = child.GetComponent(); + if (renderer != null) + material = renderer.material; + } + GameObject.Destroy(child.gameObject); + } + return material; + } + + /// + /// Applies new visual parts to a prefab. + /// + /// A list of visual part information. + /// The transform to add the parts to. + /// The material to use for the parts. + /// A list of the created visual parts. + private static List ApplyVisualParts( + Visual.PrefabInfo prefab, + Transform spriteContainer, + Material? material) + { + if (prefab.visualParts.Count == 0) + return new List(); + + List parts = new(); + + foreach (var info in prefab.visualParts) + { + parts.Add(CreateVisualPart(info, spriteContainer, material)); + } + + return parts; + } + + /// + /// Creates a single visual part for a prefab. + /// + /// The information for the visual part. + /// The parent transform for the part. + /// The material to use for the part. + /// The created visual part. + private static SkinVisualsReference.VisualPart CreateVisualPart( + Visual.VisualPartInfo info, + Transform parent, + Material? material) + { + var visualPartObj = new GameObject(info.gameObjectName); + visualPartObj.transform.SetParent(parent); + visualPartObj.transform.position = info.coordinates; + visualPartObj.transform.localScale = info.scale; + visualPartObj.transform.rotation = Quaternion.Euler(0f, 0f, info.rotation); + + var outlineObj = new GameObject("Outline"); + outlineObj.transform.SetParent(visualPartObj.transform); + outlineObj.transform.position = info.coordinates; + outlineObj.transform.localScale = info.scale; + outlineObj.transform.rotation = Quaternion.Euler(0f, 0f, info.rotation); + + var visualPart = new SkinVisualsReference.VisualPart + { + DefaultSpriteName = info.baseName, + visualPart = visualPartObj, + outline = outlineObj, + tintable = info.tintable + }; + + var renderer = visualPartObj.AddComponent(); + renderer.material = material; + renderer.sortingLayerName = "Units"; + renderer.sortingOrder = info.tintable ? 0 : 1; + + visualPart.renderer = new SkinVisualsReference.RendererUnion { spriteRenderer = renderer }; + + var outlineRenderer = outlineObj.AddComponent(); + outlineRenderer.material = material; + outlineRenderer.sortingLayerName = "Units"; + outlineRenderer.sortingOrder = -1; + + visualPart.outlineRenderer = new SkinVisualsReference.RendererUnion { spriteRenderer = outlineRenderer }; + + return visualPart; + } + + /// + /// Loads and applies a game logic data patch from a mod. + /// + /// The mod the patch belongs to. + /// The original game logic data. + /// The patch to apply. + public static void LoadGameLogicDataPatch(Mod mod, JObject gld, JObject patch) + { + try + { + // Merge the patch into the game logic data + gld = JsonMerger.Merge(gld, patch); + Plugin.logger.LogInfo($"Registered patch from {mod.id} mod"); + } + catch (Exception e) + { + Plugin.logger.LogError($"Error on loading patch from {mod.id} mod: {e.StackTrace}"); + mod.status = Mod.Status.Error; + } + } + + /// + /// Loads an asset bundle from a mod. + /// + /// The mod the asset bundle belongs to. + /// The asset bundle file to load. + public static void LoadAssetBundle(Mod mod, Mod.File file) + { + Registry.assetBundles.Add( + Path.GetFileNameWithoutExtension(file.name), + AssetBundle.LoadFromMemory(file.bytes) + ); + } + + /// + /// Processes the merged game logic data after all mods have been loaded and patched. + /// + /// The game logic data object to populate. + /// The root JObject of the merged game logic data. + internal static void ProcessGameLogicData(GameLogicData gameLogicData, JObject rootObject) + { + try + { + CreateMappings(rootObject); + ProcessCustomTribes(); + ProcessPrefabs(); + ProcessEmbarkOverrides(); + ProcessAttractOverrides(); + } + catch (Exception e) + { + Plugin.logger.LogError($"Error on processing modified game logic data : {e.StackTrace}"); + } + } + + internal static void ProcessCustomTribes() + { + foreach (var tribe in Registry.customTribes) + { + if (!GameLogicData.legacyTribeTypesOrder.Contains(tribe)) + { + GameLogicData.legacyTribeTypesOrder.Add(tribe); + } + } + } + + /// + /// Creates EnumCache mappings for custom enum values and invokes type handlers. + /// + /// + internal static void CreateMappings(JObject rootObject) + { + foreach (JToken jtoken in rootObject.SelectTokens("$.*.*").ToArray()) + { + JObject? token = jtoken.TryCast(); + if (token == null) + continue; + + string dataType = Util.GetJTokenName(token, 2); + if (!typeMappings.TryGetValue(dataType, out TypeMapping? typeMapping)) + continue; + + if (token["idx"] == null || !typeMapping.shouldCreateCache) + continue; + + Type targetType = typeMapping.type; + if (!targetType.IsEnum) + { + Plugin.logger.LogWarning($"Type {targetType.FullName} is not an enum, skipping!"); + continue; + } + + string id = Util.GetJTokenName(token); + + if ((int)token["idx"] == -1) + { + token["idx"] = Registry.autoidx; + Registry.autoidx++; + } + else if (Plugin.config.allowUnsafeIndexes) + { + Array values = Enum.GetValues(targetType); + + var maxValue = values.Cast().Max(); + + if (maxValue >= (int)token["idx"]) + { + continue; + } + } + else + { + continue; + } + + MethodInfo? methodInfo = typeof(EnumCache<>).MakeGenericType(targetType).GetMethod("AddMapping"); + if (methodInfo == null) + { + Plugin.logger.LogWarning($"Missing AddMapping method for {targetType.FullName}"); + continue; + } + + methodInfo.Invoke(null, new object[] { id, (int)token["idx"] }); + methodInfo.Invoke(null, new object[] { id, (int)token["idx"] }); + + if (typeHandlers.TryGetValue(targetType, out var handlers)) + { + foreach (var handler in handlers) + { + handler(token, true); + } + } + Plugin.logger.LogInfo("Created mapping for " + targetType.ToString() + " with id " + id + " and index " + (int)token["idx"]); + } + foreach (JToken jtoken in rootObject.SelectTokens("$.*.*").ToArray()) + { + JObject? token = jtoken.TryCast(); + if (token != null) + { + string dataType = Util.GetJTokenName(token, 2); + if (typeMappings.TryGetValue(dataType, out TypeMapping? typeMapping)) + { + if (typeHandlers.TryGetValue(typeMapping.type, out var handlers)) + { + foreach (var handler in handlers) + { + handler(token, false); + } + } + } + } + } + } + + /// + /// Processes the prefab registry and populates the PrefabManager with custom prefabs. + /// + internal static void ProcessPrefabs() + { + Plugin.logger.LogInfo($"Processing prefabs, count: {Registry.prefabNames.Count}."); + foreach (KeyValuePair item in Registry.prefabNames) + { + UnitData.Type unitPrefabType = UnitData.Type.Scout; + string prefabId = item.Value; + int hashKey = PrefabManager.GetSkinnedHashKey((UnitData.Type)item.Key, TribeType.None, SkinType.Default); + if (!Enum.TryParse(CultureInfo.CurrentCulture.TextInfo.ToTitleCase(prefabId), out unitPrefabType)) // Existing unit prefabs + { + KeyValuePair prefabInfo = Registry.unitPrefabs.FirstOrDefault( + kv => kv.Key.name == prefabId + ); + if (!EqualityComparer.Default.Equals(prefabInfo.Key, default)) + { + Visual.UnitPrefabInfo unitPrefabInfo = new( + EnumCache.GetName((UnitData.Type)item.Key), + EnumCache.GetName(TribeType.None), + EnumCache.GetName(SkinType.Default) + ); + Visual.customPrefabs.Add(unitPrefabInfo, prefabInfo.Value); + Plugin.logger.LogInfo($"Using custom prefab {prefabId} for unit {item.Key}."); + continue; + } + } + Plugin.logger.LogInfo($"Using existing prefab {EnumCache.GetName(unitPrefabType)} for unit {item.Key}."); + PrefabManager.units.TryAdd( + hashKey, + PrefabManager.units[PrefabManager.GetSkinnedHashKey(unitPrefabType, TribeType.None, SkinType.Default)] + ); + } + } + + /// + /// Processes embark overrides by mapping original embark unit types to configured overrides. + /// + internal static void ProcessEmbarkOverrides() + { + foreach (KeyValuePair entry in Main.embarkNames) + { + try + { + UnitData.Type unit = EnumCache.GetType(entry.Key); + UnitData.Type newUnit = EnumCache.GetType(entry.Value); + Main.embarkOverrides[unit] = newUnit; + Plugin.logger.LogInfo($"Embark unit type for {entry.Key} is now {entry.Value}"); + } + catch + { + Plugin.logger.LogError($"Embark unit type for {entry.Key} is not valid: {entry.Value}"); + } + } + } + + /// + /// Processes attract overrides by mapping improvements to resources and terrain types based on configured overrides. + /// + internal static void ProcessAttractOverrides() + { + foreach (KeyValuePair entry in Main.attractsResourceNames) + { + try + { + ImprovementData.Type improvement = EnumCache.GetType(entry.Key); + ResourceData.Type resource = EnumCache.GetType(entry.Value); + Main.attractsResourceOverrides[improvement] = resource; + Plugin.logger.LogInfo($"Improvement {entry.Key} now attracts {entry.Value}"); + } + catch + { + Plugin.logger.LogError($"Improvement {entry.Key} resource type is not valid: {entry.Value}"); + } + } + foreach (KeyValuePair entry in Main.attractsTerrainNames) + { + try + { + ImprovementData.Type improvement = EnumCache.GetType(entry.Key); + Polytopia.Data.TerrainData.Type terrain = EnumCache.GetType(entry.Value); + Main.attractsTerrainOverrides[improvement] = terrain; + Plugin.logger.LogInfo($"Improvement {entry.Key} now attracts on {entry.Value}"); + } + catch + { + Plugin.logger.LogError($"Improvement {entry.Key} terrain type is not valid: {entry.Value}"); + } + } + } } diff --git a/src/Managers/AutoUpdate.cs b/src/Managers/AutoUpdate.cs index 6384616..58ea639 100644 --- a/src/Managers/AutoUpdate.cs +++ b/src/Managers/AutoUpdate.cs @@ -53,7 +53,7 @@ private static void StartScreen_UI2_OnShow() _ => "unknown", }; if (os == "unknown") - { + { Plugin.logger.LogError("Unsupported platform for autoupdate!"); return; } diff --git a/src/Managers/Hub.cs b/src/Managers/Hub.cs index 57da0b6..5afdaf9 100644 --- a/src/Managers/Hub.cs +++ b/src/Managers/Hub.cs @@ -109,7 +109,7 @@ private static void StartScreen_UI2_Init(StartScreen_UI2 __instance, RectTransfo [HarmonyPatch(typeof(StartScreen_UI2), nameof(StartScreen_UI2.RunLayout))] private static void StartScreen_UI2_RunLayout(StartScreen_UI2 __instance, ScreenBase_UI2.ScreenSize screenSize) { - if(polyModButton == null) + if (polyModButton == null) { Plugin.logger.LogWarning("PolyMod Hub button is null when running layout!"); return; @@ -118,10 +118,10 @@ private static void StartScreen_UI2_RunLayout(StartScreen_UI2 __instance, Screen polyModButton.outline.gameObject.SetActive(false); polyModButton.bg.color = Color.white; polyModButton.Text = Localization.Get("polymod.hub"); - float num = 50f; - polyModButton.SetPosition(screenSize.safeRect.Right - (num * 2.5f), screenSize.safeRect.Top - num); + float num = 50f; + polyModButton.SetPosition(screenSize.safeRect.Right - (num * 2.5f), screenSize.safeRect.Top - num); - if(polyModVersion == null) + if (polyModVersion == null) { Plugin.logger.LogWarning("PolyMod Version is null when running layout!"); return; @@ -336,7 +336,7 @@ void OpenDiscord() )); popupButtons.Add(new( "polymod.hub.spriteinfo.update", - callback: DelegateSupport.ConvertDelegate(UpdateSpriteInfos), + callback: DelegateSupport.ConvertDelegate(UpdateSpriteInfos), closesPopup: false )); } diff --git a/src/Managers/Loc.cs b/src/Managers/Loc.cs index 6a8711f..b3b7d17 100644 --- a/src/Managers/Loc.cs +++ b/src/Managers/Loc.cs @@ -13,163 +13,163 @@ namespace PolyMod.Managers; /// public static class Loc { - internal static Dictionary> languagesToAdd = new(); - internal static Dictionary buildingsInfoOverrides = new(); - - /// - /// Patches the localization getter to handle custom enum values. - /// - [HarmonyPrefix] - [HarmonyPatch(typeof(Localization), nameof(Localization.Get), typeof(string), typeof(Il2CppReferenceArray))] - private static bool Localization_Get(ref string key, Il2CppReferenceArray args) - { - List keys = key.Split('.').ToList(); - int? idx = null; - string? name = null; - - // Find any custom enum indices in the localization key - foreach (string item in keys) - { - if (int.TryParse(item, out int parsedIdx)) - { - if (parsedIdx >= Plugin.AUTOIDX_STARTS_FROM) - { - idx = parsedIdx; - } - } - } - - // If a custom enum index is found, try to resolve its name - if (idx != null) - { - foreach (var typeMapping in Loader.typeMappings.Values) - { - if(!typeMapping.shouldCreateCache) - continue; - - MethodInfo? methodInfo = typeof(EnumCache<>).MakeGenericType(typeMapping.type).GetMethod("TryGetName"); - if (methodInfo != null) - { - object?[] parameters = { idx, null }; - object? methodInvokeResult = methodInfo.Invoke(null, parameters); - if (methodInvokeResult != null) - { - if ((bool)methodInvokeResult) - { - name = (string?)parameters[1]; - } - } - } - } - - // If the name was resolved, replace the index with the name in the key - if (name != null && idx != null) - { - int index = keys.IndexOf(idx.ToString()!); - keys[index] = name; - key = string.Join(".", keys); - } - } - return true; - } - - [HarmonyPrefix] - [HarmonyPatch(typeof(Localization), nameof(Localization.Init))] - private static bool Localization_Init() - { - if (Localization.initialized) - return true; - - if (LocalizationManager.Sources.Count == 0) - LocalizationManager.UpdateSources(); - - LanguageSourceData source = LocalizationManager.Sources[0]; - - // Elyrion has no language code. I am almost completely sure it is a bug, by looking at the code. - LanguageData elyrionLanguage = source.GetLanguageData("Elyrion"); - elyrionLanguage.Code = Localization.LANG_CODE_ELYRION; - - foreach(var languageCode in languagesToAdd.Keys) - { - Dictionary terms = languagesToAdd[languageCode]; - - int languageIndex = source.GetLanguageIndexFromCode(languageCode); - string languageName = terms["language"]; - if (languageIndex == -1) - { - source.AddLanguage(languageName, languageCode); - languageIndex = source.GetLanguageIndex(languageName); - } - - foreach (var kvp in terms) - { - TermData term = source.GetTermData(kvp.Key); - if (term == null) - { - source.AddTerm(kvp.Key); - term = source.GetTermData(kvp.Key); - } - - term.Languages[languageIndex] = kvp.Value; - } - - LocalizationManager.UpdateSources(); - - Plugin.logger.LogInfo($"{languageCode} language terms added."); - } - return true; - } - - [HarmonyPrefix] - [HarmonyPatch(typeof(BuildingUtils), nameof(BuildingUtils.GetInfo))] - private static bool BuildingUtils_GetInfo(ref string __result, SkinType skinOfCurrentLocalPlayer, - ImprovementData improvementData, ImprovementState improvementState, PlayerState owner, TileData tileData) - { - TribeType tribe = TribeType.None; - if (owner != null) - tribe = owner.tribe; - - string key = EnumCache.GetName(improvementData.type); - if(buildingsInfoOverrides.ContainsKey(key)) - { - __result = Localization.GetSkinned(skinOfCurrentLocalPlayer, tribe, buildingsInfoOverrides[key]); - return false; - } - - return true; - } - - /// - /// Builds and loads localization data from a dictionary. - /// - /// A dictionary containing the localization data. - internal static void BuildAndLoadLocalization(Dictionary> localization) - { - foreach (var (key, data) in localization) - { - string name = ReplaceDashesWithDots(key); - if (name.StartsWith("tribeskins")) name = "TribeSkins/" + name; - TermData term = LocalizationManager.Sources[0].AddTerm(name); - List strings = new(); - foreach (string language in LocalizationManager.GetAllLanguages(false)) - { - strings.Add(data.GetOrDefault(language, data.GetOrDefault("English", term.Term))!); - } - term.Languages = new Il2CppStringArray(strings.ToArray()); - } - } - - internal static string ReplaceDashesWithDots(string key) - { - string name = key.Replace("_", "."); - return name.Replace("..", "_"); - } - - /// - /// Initializes the Loc manager by patching the necessary methods. - /// - internal static void Init() - { - Harmony.CreateAndPatchAll(typeof(Loc)); - } + internal static Dictionary> languagesToAdd = new(); + internal static Dictionary buildingsInfoOverrides = new(); + + /// + /// Patches the localization getter to handle custom enum values. + /// + [HarmonyPrefix] + [HarmonyPatch(typeof(Localization), nameof(Localization.Get), typeof(string), typeof(Il2CppReferenceArray))] + private static bool Localization_Get(ref string key, Il2CppReferenceArray args) + { + List keys = key.Split('.').ToList(); + int? idx = null; + string? name = null; + + // Find any custom enum indices in the localization key + foreach (string item in keys) + { + if (int.TryParse(item, out int parsedIdx)) + { + if (parsedIdx >= Plugin.AUTOIDX_STARTS_FROM) + { + idx = parsedIdx; + } + } + } + + // If a custom enum index is found, try to resolve its name + if (idx != null) + { + foreach (var typeMapping in Loader.typeMappings.Values) + { + if (!typeMapping.shouldCreateCache) + continue; + + MethodInfo? methodInfo = typeof(EnumCache<>).MakeGenericType(typeMapping.type).GetMethod("TryGetName"); + if (methodInfo != null) + { + object?[] parameters = { idx, null }; + object? methodInvokeResult = methodInfo.Invoke(null, parameters); + if (methodInvokeResult != null) + { + if ((bool)methodInvokeResult) + { + name = (string?)parameters[1]; + } + } + } + } + + // If the name was resolved, replace the index with the name in the key + if (name != null && idx != null) + { + int index = keys.IndexOf(idx.ToString()!); + keys[index] = name; + key = string.Join(".", keys); + } + } + return true; + } + + [HarmonyPrefix] + [HarmonyPatch(typeof(Localization), nameof(Localization.Init))] + private static bool Localization_Init() + { + if (Localization.initialized) + return true; + + if (LocalizationManager.Sources.Count == 0) + LocalizationManager.UpdateSources(); + + LanguageSourceData source = LocalizationManager.Sources[0]; + + // Elyrion has no language code. I am almost completely sure it is a bug, by looking at the code. + LanguageData elyrionLanguage = source.GetLanguageData("Elyrion"); + elyrionLanguage.Code = Localization.LANG_CODE_ELYRION; + + foreach (var languageCode in languagesToAdd.Keys) + { + Dictionary terms = languagesToAdd[languageCode]; + + int languageIndex = source.GetLanguageIndexFromCode(languageCode); + string languageName = terms["language"]; + if (languageIndex == -1) + { + source.AddLanguage(languageName, languageCode); + languageIndex = source.GetLanguageIndex(languageName); + } + + foreach (var kvp in terms) + { + TermData term = source.GetTermData(kvp.Key); + if (term == null) + { + source.AddTerm(kvp.Key); + term = source.GetTermData(kvp.Key); + } + + term.Languages[languageIndex] = kvp.Value; + } + + LocalizationManager.UpdateSources(); + + Plugin.logger.LogInfo($"{languageCode} language terms added."); + } + return true; + } + + [HarmonyPrefix] + [HarmonyPatch(typeof(BuildingUtils), nameof(BuildingUtils.GetInfo))] + private static bool BuildingUtils_GetInfo(ref string __result, SkinType skinOfCurrentLocalPlayer, + ImprovementData improvementData, ImprovementState improvementState, PlayerState owner, TileData tileData) + { + TribeType tribe = TribeType.None; + if (owner != null) + tribe = owner.tribe; + + string key = EnumCache.GetName(improvementData.type); + if (buildingsInfoOverrides.ContainsKey(key)) + { + __result = Localization.GetSkinned(skinOfCurrentLocalPlayer, tribe, buildingsInfoOverrides[key]); + return false; + } + + return true; + } + + /// + /// Builds and loads localization data from a dictionary. + /// + /// A dictionary containing the localization data. + internal static void BuildAndLoadLocalization(Dictionary> localization) + { + foreach (var (key, data) in localization) + { + string name = ReplaceDashesWithDots(key); + if (name.StartsWith("tribeskins")) name = "TribeSkins/" + name; + TermData term = LocalizationManager.Sources[0].AddTerm(name); + List strings = new(); + foreach (string language in LocalizationManager.GetAllLanguages(false)) + { + strings.Add(data.GetOrDefault(language, data.GetOrDefault("English", term.Term))!); + } + term.Languages = new Il2CppStringArray(strings.ToArray()); + } + } + + internal static string ReplaceDashesWithDots(string key) + { + string name = key.Replace("_", "."); + return name.Replace("..", "_"); + } + + /// + /// Initializes the Loc manager by patching the necessary methods. + /// + internal static void Init() + { + Harmony.CreateAndPatchAll(typeof(Loc)); + } } diff --git a/src/Managers/Main.cs b/src/Managers/Main.cs index 6bead52..f271e77 100644 --- a/src/Managers/Main.cs +++ b/src/Managers/Main.cs @@ -17,432 +17,432 @@ namespace PolyMod.Managers; /// public static class Main { - internal static bool dependencyCycle; - - /// - /// The maximum tier for technology, used to extend the tech tree. - /// - internal const int MAX_TECH_TIER = 100; - - /// - /// A stopwatch to measure the time taken to load mods. - /// - internal static readonly Stopwatch stopwatch = new(); - - /// - /// Whether the mod has been fully initialized. - /// - internal static bool fullyInitialized; - - /// - /// A dictionary mapping unit IDs to the IDs of the units they embark into. - /// - internal static Dictionary embarkNames = new(); - - /// - /// A dictionary mapping unit types to the types of the units they embark into. - /// - internal static Dictionary embarkOverrides = new(); - - /// - /// Whether an embark action is currently being executed. - /// - internal static bool currentlyEmbarking = false; - - /// - /// A dictionary mapping improvement IDs to the IDs of the resources they attract. - /// - internal static Dictionary attractsResourceNames = new(); - - /// - /// A dictionary mapping improvement IDs to the IDs of the terrain types they attract resources on. - /// - internal static Dictionary attractsTerrainNames = new(); - - /// - /// A dictionary mapping improvement types to the types of the resources they attract. - /// - internal static Dictionary attractsResourceOverrides = new(); - - /// - /// A dictionary mapping improvement types to the types of the terrain they attract resources on. - /// - internal static Dictionary attractsTerrainOverrides = new(); - - /// - /// Patches the game logic data parsing to load PolyMod content. - /// - [HarmonyPrefix] - [HarmonyPatch(typeof(GameLogicData), nameof(GameLogicData.AddGameLogicPlaceholders))] - private static void GameLogicData_AddGameLogicPlaceholders(GameLogicData __instance, ref JObject rootObject) - { - if (!fullyInitialized) - { - Load(__instance, rootObject); - fullyInitialized = true; - } - } - - /// - /// Patches the purchase manager to unlock custom skins. - /// - [HarmonyPrefix] - [HarmonyPatch(typeof(PurchaseManager), nameof(PurchaseManager.IsSkinUnlocked))] - private static bool PurchaseManager_IsSkinUnlockedInternal(ref bool __result, SkinType skinType) - { - __result = (int)skinType >= Plugin.AUTOIDX_STARTS_FROM && skinType != SkinType.Test; - return !__result; - } - - /// - /// Patches the purchase manager to unlock custom tribes. - /// - [HarmonyPostfix] - [HarmonyPatch(typeof(PurchaseManager), nameof(PurchaseManager.IsTribeUnlocked))] - private static void PurchaseManager_IsTribeUnlocked(ref bool __result, TribeType type) - { - __result = (int)type >= Plugin.AUTOIDX_STARTS_FROM || __result; - } - - /// - /// Patches the steam purchase manager to unlock custom content. - /// - [HarmonyPrefix] - [HarmonyPatch(typeof(SteamPlatformPurchaseManager), nameof(SteamPlatformPurchaseManager.IsProductUnlocked))] - private static bool SteamPlatformPurchaseManager_IsProductUnlocked(ref bool __result, IAPProduct iapProduct) - { - __result = iapProduct == null; - return iapProduct != null; - } - - /// - /// Patches the purchase manager to add custom tribes to the list of unlocked tribes. - /// - [HarmonyPostfix] - [HarmonyPatch(typeof(PurchaseManager), nameof(PurchaseManager.GetUnlockedTribes))] - private static void PurchaseManager_GetUnlockedTribes( - ref Il2CppSystem.Collections.Generic.List __result, - bool forceUpdate = false - ) - { - foreach (var tribe in Registry.customTribes) __result.Add(tribe); - } - - /// - /// Patches the Unity log callback to filter out spammy messages. - /// - [HarmonyPrefix] - [HarmonyPatch(typeof(IL2CPPUnityLogSource), nameof(IL2CPPUnityLogSource.UnityLogCallback))] - private static bool IL2CPPUnityLogSource_UnityLogCallback(string logLine, string exception, LogType type) - { - foreach (string stringToIgnore in Plugin.LOG_MESSAGES_IGNORE) - { - if (logLine.Contains(stringToIgnore)) - return false; - } - return true; - } - - /// - /// Patches the game mode screen to add custom game modes. - /// - [HarmonyPrefix] - [HarmonyPatch(typeof(GameModeScreen), nameof(GameModeScreen.Init))] - private static void GameModeScreen_Init(GameModeScreen __instance) - { - List list = __instance.buttons.ToList(); - for (int i = 0; i < Loader.gamemodes.Count; i++) - { - var item = Loader.gamemodes[i]; - var button = GameObject.Instantiate(__instance.buttons[2]); - list.Add(button); - Sprite? sprite = item.sprite; - if (item.sprite == null && item.spriteName != null) - { - sprite = Registry.GetSprite(item.spriteName); - } - Loader.gamemodes[i] = new Loader.GameModeButtonsInformation(item.gameModeIndex, item.action, __instance.buttons.Length, sprite, item.spriteName); - } - - var newArray = list.ToArray(); - for (int i = 0; i < __instance.buttons.Length; i++) - { - if (newArray[i] != null) newArray[i].OnClicked = __instance.buttons[i].OnClicked; - } - - for (int i = 0; i < Loader.gamemodes.Count; i++) - { - if (Loader.gamemodes[i].buttonIndex != null) - newArray[Loader.gamemodes[i].buttonIndex!.Value].OnClicked = Loader.gamemodes[i].action; - } - - __instance.buttons = newArray; - - for (int i = 0; i < __instance.buttons.Length; i++) - { - GamemodeButton button = __instance.buttons[i]; - var newData = button.gamemodeData.ToList(); - foreach (var info in Loader.gamemodes) - { - string id = EnumCache.GetName((GameMode)info.gameModeIndex).ToLower(); - newData.Add(new GamemodeButton.GamemodeButtonData() - { - gameMode = (GameMode)info.gameModeIndex, - id = System.Globalization.CultureInfo.CurrentCulture.TextInfo.ToTitleCase(id), - descriptionKey = "gamemode." + id + ".description.button", - headerKey = "gamemode." + id + ".caps", - icon = info.sprite - }); - } - button.gamemodeData = newData.ToArray(); - - for (int j = 0; j < Loader.gamemodes.Count; j++) - { - Loader.GameModeButtonsInformation info = Loader.gamemodes[j]; - - if (info.buttonIndex == i) - { - button.SetGamemode(info.buttonIndex.Value); - } - } - } - } - - /// - /// Patches the tech view to correctly display the tech tree with custom technologies. - /// - [HarmonyPrefix] - [HarmonyPatch(typeof(TechView), nameof(TechView.CreateNode))] - public static bool TechView_CreateNode(TechView __instance, TechData data, TechItem parentItem, float angle) - { - // This patch is a reimplementation of the original CreateNode method to fix layout issues with custom techs. - // It recalculates the angles for each node to ensure they are displayed correctly. - GameLogicData gameLogicData = GameManager.GameState.GameLogicData; - TribeData tribeData = gameLogicData.GetTribeData(GameManager.LocalPlayer.tribe); - float baseAngle = 360 / gameLogicData.GetOverride(gameLogicData.GetTechData(TechData.Type.Basic), tribeData).techUnlocks.Count; - float childAngle = 0f; - if (parentItem != null) - childAngle = angle + baseAngle * (data.techUnlocks.Count - 1) / 2f; - foreach (var techData in data.techUnlocks) - { - if (gameLogicData.TryGetData(techData.type, out TechData techData2)) - { - TechData @override = gameLogicData.GetOverride(techData, tribeData); - TechItem techItem = __instance.CreateTechItem(@override, parentItem, childAngle); - __instance.currTechIdx++; - if (@override.techUnlocks != null && @override.techUnlocks.Count > 0) - __instance.CreateNode(@override, techItem, childAngle); - childAngle -= baseAngle; - } - } - Il2CppSystem.Action onItemsRefreshed = __instance.OnItemsRefreshed; - if (onItemsRefreshed == null) - { - return false; - } - onItemsRefreshed.Invoke(__instance); - return false; - } - - /// - /// Patches the embark action to set a flag indicating that an embark is in progress. - /// - [HarmonyPrefix] - [HarmonyPatch(typeof(EmbarkAction), nameof(EmbarkAction.Execute))] - private static bool EmbarkAction_Execute_Prefix(EmbarkAction __instance, GameState gameState) - { - currentlyEmbarking = true; - return true; - } - - /// - /// Patches the unit training method to handle custom embark units. - /// - [HarmonyPrefix] - [HarmonyPatch(typeof(ActionUtils), nameof(ActionUtils.TrainUnit))] - private static bool ActionUtils_TrainUnit(ref UnitState __result, GameState gameState, PlayerState playerState, TileData tile, ref UnitData unitData) - { - if (tile == null || tile.unit == null) - { - return true; - } - - if (currentlyEmbarking) - { - if (embarkOverrides.TryGetValue(tile.unit.type, out UnitData.Type newType)) - { - gameState.GameLogicData.TryGetData(newType, out unitData); - } - currentlyEmbarking = false; - } - return true; - } - - /// - /// Patches the start turn action to handle the 'Attract' ability for custom improvements. - /// - [HarmonyPostfix] - [HarmonyPatch(typeof(StartTurnAction), nameof(StartTurnAction.Execute))] - private static void StartTurnAction_Execute(StartTurnAction __instance, GameState state) - { - // Clear any existing CreateResource actions to prevent duplicates. - for (int i = state.ActionStack.Count - 1; i >= 0; i--) - { - if (state.ActionStack[i].GetActionType() == ActionType.CreateResource) - { - state.ActionStack.RemoveAt(i); - } - } - - // Iterate through all tiles owned by the current player. - for (int i = 0; i < state.Map.Tiles.Length; i++) - { - TileData tileData = state.Map.Tiles[i]; - if (tileData.owner == __instance.PlayerId && tileData.improvement != null && state.CurrentTurn > 0U) - { - ImprovementData improvementData; - state.GameLogicData.TryGetData(tileData.improvement.type, out improvementData); - if (improvementData != null) - { - // If the improvement has the 'Attract' ability and is ready to spawn a resource. - if (improvementData.HasAbility(ImprovementAbility.Type.Attract) && tileData.improvement.GetAge(state) % improvementData.growthRate == 0) - { - ResourceData.Type resourceType = ResourceData.Type.Game; - if (attractsResourceOverrides.TryGetValue(tileData.improvement.type, out ResourceData.Type newType)) - { - resourceType = newType; - } - Polytopia.Data.TerrainData.Type targetTerrain = Polytopia.Data.TerrainData.Type.Forest; - if (attractsTerrainOverrides.TryGetValue(tileData.improvement.type, out Polytopia.Data.TerrainData.Type newTerrain)) - { - targetTerrain = newTerrain; - } - - // Find a valid tile to spawn the resource on. - foreach (TileData tileData2 in state.Map.GetArea(tileData.coordinates, 1, true, false)) - { - if (tileData2.owner == __instance.PlayerId && tileData2.improvement == null && tileData2.resource == null && tileData2.terrain == targetTerrain) - { - state.ActionStack.Add(new CreateResourceAction(__instance.PlayerId, resourceType, tileData2.coordinates, CreateResourceAction.CreateReason.Attract)); - break; - } - } - } - } - } - } - } - - /// - /// Patches the unit creation method to handle custom unit prefabs. - /// - [HarmonyPrefix] - [HarmonyPatch(typeof(Unit), nameof(Unit.CreateUnit))] - private static bool Unit_CreateUnit(Unit __instance, UnitData unitData, TribeType tribe, SkinType unitSkin) - { - Unit unit = PrefabManager.GetPrefab(unitData.type, tribe, unitSkin); - if (unit == null) Console.Write("THIS FUCKING SHIT IS NULL WHAT THE FUCK"); - return true; - } - - /// - /// Initializes the Main manager. - /// - internal static void Init() - { - stopwatch.Start(); - Harmony.CreateAndPatchAll(typeof(Main)); - Mod.Manifest polytopia = new( - "polytopia", - "The Battle of Polytopia", - null, - new(Application.version.ToString()), - new string[] { "Midjiwan AB" }, - Array.Empty() - ); - Registry.mods.Add(polytopia.id, new(polytopia, Mod.Status.Success, new())); - Loader.RegisterMods(Registry.mods); - Loader.LoadMods(Registry.mods, out var cycle); - dependencyCycle = cycle; - stopwatch.Stop(); - } - - /// - /// Loads all mod content. - /// - /// The game logic data to patch. - /// The JSON object representing the game logic data. - internal static void Load(GameLogicData gameLogicData, JObject json) - { - stopwatch.Start(); - Loc.BuildAndLoadLocalization( - JsonSerializer.Deserialize>>( - Plugin.GetResource("localization.json") - )! - ); - if (dependencyCycle) return; - - foreach (var (id, mod) in Registry.mods) - { - if (mod.status != Mod.Status.Success) continue; - foreach (var file in mod.files) - { - if (mod.status != Mod.Status.Success) break; - Match localizationMatch = Regex.Match(Path.GetFileName(file.name), @"^localization(_.*)?\.json$"); - if (localizationMatch.Success) - { - Loader.LoadLocalizationFile(mod, file); - continue; - } - Match patchMatch = Regex.Match(Path.GetFileName(file.name), @"^patch(_.*)?\.json$"); - if (patchMatch.Success) - { - var patchText = new StreamReader(new MemoryStream(file.bytes)).ReadToEnd(); - var template = new Api.GldConfigTemplate(patchText, mod.id); - var text = template.Render(); - if (text is null) - { - mod.status = Mod.Status.Error; - continue; - } - Loader.LoadGameLogicDataPatch( - mod, - json, - JObject.Parse(text) - ); - continue; - } - Match prefabMatch = Regex.Match(Path.GetFileName(file.name), @"^prefab(?:_(.*))?\.json$"); - if (prefabMatch.Success) - { - Loader.LoadPrefabInfoFile( - mod, - file - ); - continue; - } - - switch (Path.GetExtension(file.name)) - { - case ".png": - Loader.LoadSpriteFile(mod, file); - break; - case ".wav": - Loader.LoadAudioFile(mod, file); - break; - case ".bundle": - Loader.LoadAssetBundle(mod, file); - break; - } - } - } - TechItem.techTierFirebaseId.Clear(); - for (int i = 0; i <= MAX_TECH_TIER; i++) - { - TechItem.techTierFirebaseId.Add($"tech_research_{i}"); - } - Loader.ProcessGameLogicData(gameLogicData, json); - stopwatch.Stop(); - Plugin.logger.LogInfo($"Loaded all mods in {stopwatch.ElapsedMilliseconds}ms"); - } + internal static bool dependencyCycle; + + /// + /// The maximum tier for technology, used to extend the tech tree. + /// + internal const int MAX_TECH_TIER = 100; + + /// + /// A stopwatch to measure the time taken to load mods. + /// + internal static readonly Stopwatch stopwatch = new(); + + /// + /// Whether the mod has been fully initialized. + /// + internal static bool fullyInitialized; + + /// + /// A dictionary mapping unit IDs to the IDs of the units they embark into. + /// + internal static Dictionary embarkNames = new(); + + /// + /// A dictionary mapping unit types to the types of the units they embark into. + /// + internal static Dictionary embarkOverrides = new(); + + /// + /// Whether an embark action is currently being executed. + /// + internal static bool currentlyEmbarking = false; + + /// + /// A dictionary mapping improvement IDs to the IDs of the resources they attract. + /// + internal static Dictionary attractsResourceNames = new(); + + /// + /// A dictionary mapping improvement IDs to the IDs of the terrain types they attract resources on. + /// + internal static Dictionary attractsTerrainNames = new(); + + /// + /// A dictionary mapping improvement types to the types of the resources they attract. + /// + internal static Dictionary attractsResourceOverrides = new(); + + /// + /// A dictionary mapping improvement types to the types of the terrain they attract resources on. + /// + internal static Dictionary attractsTerrainOverrides = new(); + + /// + /// Patches the game logic data parsing to load PolyMod content. + /// + [HarmonyPrefix] + [HarmonyPatch(typeof(GameLogicData), nameof(GameLogicData.AddGameLogicPlaceholders))] + private static void GameLogicData_AddGameLogicPlaceholders(GameLogicData __instance, ref JObject rootObject) + { + if (!fullyInitialized) + { + Load(__instance, rootObject); + fullyInitialized = true; + } + } + + /// + /// Patches the purchase manager to unlock custom skins. + /// + [HarmonyPrefix] + [HarmonyPatch(typeof(PurchaseManager), nameof(PurchaseManager.IsSkinUnlocked))] + private static bool PurchaseManager_IsSkinUnlockedInternal(ref bool __result, SkinType skinType) + { + __result = (int)skinType >= Plugin.AUTOIDX_STARTS_FROM && skinType != SkinType.Test; + return !__result; + } + + /// + /// Patches the purchase manager to unlock custom tribes. + /// + [HarmonyPostfix] + [HarmonyPatch(typeof(PurchaseManager), nameof(PurchaseManager.IsTribeUnlocked))] + private static void PurchaseManager_IsTribeUnlocked(ref bool __result, TribeType type) + { + __result = (int)type >= Plugin.AUTOIDX_STARTS_FROM || __result; + } + + /// + /// Patches the steam purchase manager to unlock custom content. + /// + [HarmonyPrefix] + [HarmonyPatch(typeof(SteamPlatformPurchaseManager), nameof(SteamPlatformPurchaseManager.IsProductUnlocked))] + private static bool SteamPlatformPurchaseManager_IsProductUnlocked(ref bool __result, IAPProduct iapProduct) + { + __result = iapProduct == null; + return iapProduct != null; + } + + /// + /// Patches the purchase manager to add custom tribes to the list of unlocked tribes. + /// + [HarmonyPostfix] + [HarmonyPatch(typeof(PurchaseManager), nameof(PurchaseManager.GetUnlockedTribes))] + private static void PurchaseManager_GetUnlockedTribes( + ref Il2CppSystem.Collections.Generic.List __result, + bool forceUpdate = false + ) + { + foreach (var tribe in Registry.customTribes) __result.Add(tribe); + } + + /// + /// Patches the Unity log callback to filter out spammy messages. + /// + [HarmonyPrefix] + [HarmonyPatch(typeof(IL2CPPUnityLogSource), nameof(IL2CPPUnityLogSource.UnityLogCallback))] + private static bool IL2CPPUnityLogSource_UnityLogCallback(string logLine, string exception, LogType type) + { + foreach (string stringToIgnore in Plugin.LOG_MESSAGES_IGNORE) + { + if (logLine.Contains(stringToIgnore)) + return false; + } + return true; + } + + /// + /// Patches the game mode screen to add custom game modes. + /// + [HarmonyPrefix] + [HarmonyPatch(typeof(GameModeScreen), nameof(GameModeScreen.Init))] + private static void GameModeScreen_Init(GameModeScreen __instance) + { + List list = __instance.buttons.ToList(); + for (int i = 0; i < Loader.gamemodes.Count; i++) + { + var item = Loader.gamemodes[i]; + var button = GameObject.Instantiate(__instance.buttons[2]); + list.Add(button); + Sprite? sprite = item.sprite; + if (item.sprite == null && item.spriteName != null) + { + sprite = Registry.GetSprite(item.spriteName); + } + Loader.gamemodes[i] = new Loader.GameModeButtonsInformation(item.gameModeIndex, item.action, __instance.buttons.Length, sprite, item.spriteName); + } + + var newArray = list.ToArray(); + for (int i = 0; i < __instance.buttons.Length; i++) + { + if (newArray[i] != null) newArray[i].OnClicked = __instance.buttons[i].OnClicked; + } + + for (int i = 0; i < Loader.gamemodes.Count; i++) + { + if (Loader.gamemodes[i].buttonIndex != null) + newArray[Loader.gamemodes[i].buttonIndex!.Value].OnClicked = Loader.gamemodes[i].action; + } + + __instance.buttons = newArray; + + for (int i = 0; i < __instance.buttons.Length; i++) + { + GamemodeButton button = __instance.buttons[i]; + var newData = button.gamemodeData.ToList(); + foreach (var info in Loader.gamemodes) + { + string id = EnumCache.GetName((GameMode)info.gameModeIndex).ToLower(); + newData.Add(new GamemodeButton.GamemodeButtonData() + { + gameMode = (GameMode)info.gameModeIndex, + id = System.Globalization.CultureInfo.CurrentCulture.TextInfo.ToTitleCase(id), + descriptionKey = "gamemode." + id + ".description.button", + headerKey = "gamemode." + id + ".caps", + icon = info.sprite + }); + } + button.gamemodeData = newData.ToArray(); + + for (int j = 0; j < Loader.gamemodes.Count; j++) + { + Loader.GameModeButtonsInformation info = Loader.gamemodes[j]; + + if (info.buttonIndex == i) + { + button.SetGamemode(info.buttonIndex.Value); + } + } + } + } + + /// + /// Patches the tech view to correctly display the tech tree with custom technologies. + /// + [HarmonyPrefix] + [HarmonyPatch(typeof(TechView), nameof(TechView.CreateNode))] + public static bool TechView_CreateNode(TechView __instance, TechData data, TechItem parentItem, float angle) + { + // This patch is a reimplementation of the original CreateNode method to fix layout issues with custom techs. + // It recalculates the angles for each node to ensure they are displayed correctly. + GameLogicData gameLogicData = GameManager.GameState.GameLogicData; + TribeData tribeData = gameLogicData.GetTribeData(GameManager.LocalPlayer.tribe); + float baseAngle = 360 / gameLogicData.GetOverride(gameLogicData.GetTechData(TechData.Type.Basic), tribeData).techUnlocks.Count; + float childAngle = 0f; + if (parentItem != null) + childAngle = angle + baseAngle * (data.techUnlocks.Count - 1) / 2f; + foreach (var techData in data.techUnlocks) + { + if (gameLogicData.TryGetData(techData.type, out TechData techData2)) + { + TechData @override = gameLogicData.GetOverride(techData, tribeData); + TechItem techItem = __instance.CreateTechItem(@override, parentItem, childAngle); + __instance.currTechIdx++; + if (@override.techUnlocks != null && @override.techUnlocks.Count > 0) + __instance.CreateNode(@override, techItem, childAngle); + childAngle -= baseAngle; + } + } + Il2CppSystem.Action onItemsRefreshed = __instance.OnItemsRefreshed; + if (onItemsRefreshed == null) + { + return false; + } + onItemsRefreshed.Invoke(__instance); + return false; + } + + /// + /// Patches the embark action to set a flag indicating that an embark is in progress. + /// + [HarmonyPrefix] + [HarmonyPatch(typeof(EmbarkAction), nameof(EmbarkAction.Execute))] + private static bool EmbarkAction_Execute_Prefix(EmbarkAction __instance, GameState gameState) + { + currentlyEmbarking = true; + return true; + } + + /// + /// Patches the unit training method to handle custom embark units. + /// + [HarmonyPrefix] + [HarmonyPatch(typeof(ActionUtils), nameof(ActionUtils.TrainUnit))] + private static bool ActionUtils_TrainUnit(ref UnitState __result, GameState gameState, PlayerState playerState, TileData tile, ref UnitData unitData) + { + if (tile == null || tile.unit == null) + { + return true; + } + + if (currentlyEmbarking) + { + if (embarkOverrides.TryGetValue(tile.unit.type, out UnitData.Type newType)) + { + gameState.GameLogicData.TryGetData(newType, out unitData); + } + currentlyEmbarking = false; + } + return true; + } + + /// + /// Patches the start turn action to handle the 'Attract' ability for custom improvements. + /// + [HarmonyPostfix] + [HarmonyPatch(typeof(StartTurnAction), nameof(StartTurnAction.Execute))] + private static void StartTurnAction_Execute(StartTurnAction __instance, GameState state) + { + // Clear any existing CreateResource actions to prevent duplicates. + for (int i = state.ActionStack.Count - 1; i >= 0; i--) + { + if (state.ActionStack[i].GetActionType() == ActionType.CreateResource) + { + state.ActionStack.RemoveAt(i); + } + } + + // Iterate through all tiles owned by the current player. + for (int i = 0; i < state.Map.Tiles.Length; i++) + { + TileData tileData = state.Map.Tiles[i]; + if (tileData.owner == __instance.PlayerId && tileData.improvement != null && state.CurrentTurn > 0U) + { + ImprovementData improvementData; + state.GameLogicData.TryGetData(tileData.improvement.type, out improvementData); + if (improvementData != null) + { + // If the improvement has the 'Attract' ability and is ready to spawn a resource. + if (improvementData.HasAbility(ImprovementAbility.Type.Attract) && tileData.improvement.GetAge(state) % improvementData.growthRate == 0) + { + ResourceData.Type resourceType = ResourceData.Type.Game; + if (attractsResourceOverrides.TryGetValue(tileData.improvement.type, out ResourceData.Type newType)) + { + resourceType = newType; + } + Polytopia.Data.TerrainData.Type targetTerrain = Polytopia.Data.TerrainData.Type.Forest; + if (attractsTerrainOverrides.TryGetValue(tileData.improvement.type, out Polytopia.Data.TerrainData.Type newTerrain)) + { + targetTerrain = newTerrain; + } + + // Find a valid tile to spawn the resource on. + foreach (TileData tileData2 in state.Map.GetArea(tileData.coordinates, 1, true, false)) + { + if (tileData2.owner == __instance.PlayerId && tileData2.improvement == null && tileData2.resource == null && tileData2.terrain == targetTerrain) + { + state.ActionStack.Add(new CreateResourceAction(__instance.PlayerId, resourceType, tileData2.coordinates, CreateResourceAction.CreateReason.Attract)); + break; + } + } + } + } + } + } + } + + /// + /// Patches the unit creation method to handle custom unit prefabs. + /// + [HarmonyPrefix] + [HarmonyPatch(typeof(Unit), nameof(Unit.CreateUnit))] + private static bool Unit_CreateUnit(Unit __instance, UnitData unitData, TribeType tribe, SkinType unitSkin) + { + Unit unit = PrefabManager.GetPrefab(unitData.type, tribe, unitSkin); + if (unit == null) Console.Write("THIS FUCKING SHIT IS NULL WHAT THE FUCK"); + return true; + } + + /// + /// Initializes the Main manager. + /// + internal static void Init() + { + stopwatch.Start(); + Harmony.CreateAndPatchAll(typeof(Main)); + Mod.Manifest polytopia = new( + "polytopia", + "The Battle of Polytopia", + null, + new(Application.version.ToString()), + new string[] { "Midjiwan AB" }, + Array.Empty() + ); + Registry.mods.Add(polytopia.id, new(polytopia, Mod.Status.Success, new())); + Loader.RegisterMods(Registry.mods); + Loader.LoadMods(Registry.mods, out var cycle); + dependencyCycle = cycle; + stopwatch.Stop(); + } + + /// + /// Loads all mod content. + /// + /// The game logic data to patch. + /// The JSON object representing the game logic data. + internal static void Load(GameLogicData gameLogicData, JObject json) + { + stopwatch.Start(); + Loc.BuildAndLoadLocalization( + JsonSerializer.Deserialize>>( + Plugin.GetResource("localization.json") + )! + ); + if (dependencyCycle) return; + + foreach (var (id, mod) in Registry.mods) + { + if (mod.status != Mod.Status.Success) continue; + foreach (var file in mod.files) + { + if (mod.status != Mod.Status.Success) break; + Match localizationMatch = Regex.Match(Path.GetFileName(file.name), @"^localization(_.*)?\.json$"); + if (localizationMatch.Success) + { + Loader.LoadLocalizationFile(mod, file); + continue; + } + Match patchMatch = Regex.Match(Path.GetFileName(file.name), @"^patch(_.*)?\.json$"); + if (patchMatch.Success) + { + var patchText = new StreamReader(new MemoryStream(file.bytes)).ReadToEnd(); + var template = new Api.GldConfigTemplate(patchText, mod.id); + var text = template.Render(); + if (text is null) + { + mod.status = Mod.Status.Error; + continue; + } + Loader.LoadGameLogicDataPatch( + mod, + json, + JObject.Parse(text) + ); + continue; + } + Match prefabMatch = Regex.Match(Path.GetFileName(file.name), @"^prefab(?:_(.*))?\.json$"); + if (prefabMatch.Success) + { + Loader.LoadPrefabInfoFile( + mod, + file + ); + continue; + } + + switch (Path.GetExtension(file.name)) + { + case ".png": + Loader.LoadSpriteFile(mod, file); + break; + case ".wav": + Loader.LoadAudioFile(mod, file); + break; + case ".bundle": + Loader.LoadAssetBundle(mod, file); + break; + } + } + } + TechItem.techTierFirebaseId.Clear(); + for (int i = 0; i <= MAX_TECH_TIER; i++) + { + TechItem.techTierFirebaseId.Add($"tech_research_{i}"); + } + Loader.ProcessGameLogicData(gameLogicData, json); + stopwatch.Stop(); + Plugin.logger.LogInfo($"Loaded all mods in {stopwatch.ElapsedMilliseconds}ms"); + } } diff --git a/src/Managers/Visual.cs b/src/Managers/Visual.cs index bccc5fc..628b7b2 100644 --- a/src/Managers/Visual.cs +++ b/src/Managers/Visual.cs @@ -18,942 +18,942 @@ namespace PolyMod.Managers; /// public static class Visual { - /// - /// Represents a tile in a tribe preview. - /// - public class PreviewTile - { - /// The x-coordinate of the tile. - [JsonInclude] - public int? x = null; - /// The y-coordinate of the tile. - [JsonInclude] - public int? y = null; - /// The terrain type of the tile. - [JsonInclude] - [JsonConverter(typeof(EnumCacheJson))] - public Polytopia.Data.TerrainData.Type terrainType = Polytopia.Data.TerrainData.Type.None; - /// The resource type on the tile. - [JsonInclude] - [JsonConverter(typeof(EnumCacheJson))] - public ResourceData.Type? resourceType = null; - /// The unit type on the tile. - [JsonInclude] - [JsonConverter(typeof(EnumCacheJson))] - public UnitData.Type? unitType = null; - /// The improvement type on the tile. - [JsonInclude] - [JsonConverter(typeof(EnumCacheJson))] - public ImprovementData.Type? improvementType = null; - } - - /// Represents information about a sprite, such as its pivot and pixels per unit. - public record SpriteInfo(float? pixelsPerUnit, Vector2? pivot); - - /// Represents information about a custom skin. - public record SkinInfo(int idx, string id, SkinData? skinData); - - /// A dictionary of custom widths for basic popups. - public static Dictionary basicPopupWidths = new(); - /// Represents information about a unit prefab. - public struct UnitPrefabInfo - { - public UnitPrefabInfo(string type, string tribe, string skin) - { - this.type = type; - this.tribe = tribe; - this.skin = skin; - } - public string type; - public string tribe; - public string skin; - } - private const int BASIC_POPUP_LEGACY_ID = 29; - public static Dictionary customPrefabs = new(); - private static UnitData.Type currentUnitTypeUI = UnitData.Type.None; - private static TribeType attackerTribe = TribeType.None; - /// - /// A dictionary of skin types of tribes, which can flood tiles, keyed by custom flood tile effect. - /// - internal static Dictionary customFloodingSkins = new(); - private static bool isTakingSnapshot = false; - private static float? baseOrthographicCameraSize = null; - - /// The type of a custom prefab. - public enum PrefabType - { - Unit, - Improvement, - Resource - } - - /// Represents information about a custom prefab. - public record PrefabInfo - { - public PrefabType type { get; init; } - public string name { get; init; } - public List visualParts { get; init; } - public string headPositionMarker { get; init; } - - [JsonConstructor] - public PrefabInfo( - PrefabType type, - List visualParts, - string name = "", - string headPositionMarker = "") - { - this.type = type; - this.name = name.ToLowerInvariant(); - this.visualParts = visualParts; - this.headPositionMarker = headPositionMarker; - } - } - - /// Represents information about a visual part of a prefab. - public record VisualPartInfo( - string gameObjectName, - string baseName, - float rotation = 0f, - Vector2 coordinates = new Vector2(), - Vector2 scale = new Vector2(), - bool tintable = false - ); - private static bool enableOutlines = false; - private static bool seenWarningWCPopup = false; - public record PreviewInfo(int arrayIdx, SaveStateData saveStateData, PreviewTile[] customPreview); - - #region General - - /// A placeholder patch for the TechItem.SetupComplete method. - [HarmonyPostfix] - [HarmonyPatch(typeof(TechItem), nameof(TechItem.SetupComplete))] - private static void TechItem_SetupComplete() - { - } - - /// Patches the sprite atlas manager to load custom sprites. - [HarmonyPrefix] - [HarmonyPatch(typeof(SpriteAtlasManager), nameof(SpriteAtlasManager.LoadSprite), typeof(string), typeof(string), typeof(SpriteCallback))] - private static bool SpriteAtlasManager_LoadSprite(SpriteAtlasManager __instance, string atlas, string sprite, SpriteCallback completion) - { - bool found = false; - __instance.LoadSpriteAtlas(atlas, (Il2CppSystem.Action)GetAtlas); - - return !found; - - void GetAtlas(SpriteAtlas spriteAtlas) - { - if (spriteAtlas != null) - { - List names = sprite.Split('_').ToList(); - List filteredNames = new List(names); - string style = ""; - foreach (string item in names) - { - string upperitem = char.ToUpper(item[0]) + item[1..]; - if (EnumCache.TryGetType(item, out TribeType tribe) || EnumCache.TryGetType(item, out SkinType skin) - || EnumCache.TryGetType(upperitem, out TribeType tribeUpper) || EnumCache.TryGetType(upperitem, out SkinType skinUpper)) - { - filteredNames.Remove(item); - style = item; - continue; - } - } - string name = string.Join("_", filteredNames); - Sprite? newSprite = Registry.GetSprite(name, style); - if (newSprite != null) - { - completion?.Invoke(atlas, sprite, newSprite); - found = true; - } - } - } - } - - /// Patches the sprite atlas manager to look up custom sprites. - [HarmonyPostfix] - [HarmonyPatch(typeof(SpriteAtlasManager), nameof(SpriteAtlasManager.DoSpriteLookup))] - private static void SpriteAtlasManager_DoSpriteLookup(ref SpriteAtlasManager.SpriteLookupResult __result, SpriteAtlasManager __instance, string baseName, TribeType tribe, SkinType skin, int level) - { - baseName = Util.FormatSpriteName(baseName); - - Sprite? sprite = Registry.GetSprite(baseName, Util.GetStyle(tribe, skin), level); - if (sprite != null) - __result.sprite = sprite; - } - - #endregion - #region Units - - /// Enables outlines when the interaction bar is shown. - [HarmonyPrefix] - [HarmonyPatch(typeof(InteractionBar), nameof(InteractionBar.Show))] - private static bool InteractionBar_Show(InteractionBar __instance, bool instant, bool force) - { - enableOutlines = true; - return true; - } - - /// Prevents outlines from being created when they are disabled. - [HarmonyPrefix] - [HarmonyPatch(typeof(UISpriteDuplicator), nameof(UISpriteDuplicator.CreateImage), typeof(SpriteRenderer), typeof(Transform), typeof(Transform), typeof(float), typeof(Vector2), typeof(bool))] - private static bool UISpriteDuplicator_CreateImage(SpriteRenderer spriteRenderer, Transform source, Transform destination, float scale, Vector2 offset, bool forceFullAlpha) - { - return !(spriteRenderer.sortingOrder == -1 && !enableOutlines); - } - - /// Disables outlines after the interaction bar is shown. - [HarmonyPostfix] - [HarmonyPatch(typeof(InteractionBar), nameof(InteractionBar.Show))] - private static void InteractionBar_Show_Postfix(InteractionBar __instance, bool instant, bool force) - { - enableOutlines = false; - } - - /// Sets the current unit type being rendered in the UI. - [HarmonyPrefix] - [HarmonyPatch(typeof(UIUnitRenderer), nameof(UIUnitRenderer.CreateUnit))] - private static bool UIUnitRenderer_CreateUnit_Prefix(UIUnitRenderer __instance) - { - currentUnitTypeUI = __instance.unitType; - return true; - } - - /// Resets the current unit type being rendered in the UI. - [HarmonyPostfix] - [HarmonyPatch(typeof(UIUnitRenderer), nameof(UIUnitRenderer.CreateUnit))] - private static void UIUnitRenderer_CreateUnit_Postfix(UIUnitRenderer __instance) - { - currentUnitTypeUI = UnitData.Type.None; - } - - /// Skins the visual parts of a unit with custom sprites. - [HarmonyPostfix] - [HarmonyPatch(typeof(SkinVisualsRenderer), nameof(SkinVisualsRenderer.SkinWorldObject))] - private static void SkinVisualsRenderer_SkinWorldObject( - SkinVisualsRenderer.SkinWorldType type, - SkinVisualsReference skinVisuals, - SkinVisualsTransientData transientSkinData, - bool checkOutlines, - int level) - { - if (type != SkinVisualsRenderer.SkinWorldType.Unit || skinVisuals == null || - skinVisuals.visualParts == null || transientSkinData == null) - return; - - Unit unit = skinVisuals.gameObject.GetComponent(); - - string unitTypeName = EnumCache.GetName(UnitData.Type.Warrior); - - if(unit != null && unit.UnitData != null) - unitTypeName = EnumCache.GetName(unit.unitData.type); - if (currentUnitTypeUI != UnitData.Type.None) - unitTypeName = EnumCache.GetName(currentUnitTypeUI); - - string style = Util.GetStyle(transientSkinData.unitSettings.tribe, transientSkinData.unitSettings.skin); - - foreach (var visualPart in skinVisuals.visualParts) - { - if(visualPart.visualPart != null) - UpdateVisualPart(visualPart, $"{visualPart.visualPart.name}_{unitTypeName}", style); - } - } - - [HarmonyPostfix] - [HarmonyPatch(typeof(PrefabManager), nameof(PrefabManager.GetPrefab), - typeof(UnitData.Type), typeof(TribeType), typeof(SkinType) - )] - private static void PrefabManager_GetPrefab(ref Unit __result, UnitData.Type type, TribeType tribe, SkinType skin) - { - UnitPrefabInfo unitPrefabInfo = new( - EnumCache.GetName(type), - EnumCache.GetName(TribeType.None), - EnumCache.GetName(SkinType.Default) - ); - if(customPrefabs.ContainsKey( - unitPrefabInfo - )) - { - __result = customPrefabs[unitPrefabInfo]; - } - } - - #endregion - #region Level - - /// Updates the visual parts of a resource with custom sprites. - [HarmonyPostfix] - [HarmonyPatch(typeof(Resource), nameof(Resource.UpdateObject), typeof(MapRenderContext), typeof(SkinVisualsTransientData))] - private static void Resource_UpdateObject(Resource __instance, MapRenderContext ctx, SkinVisualsTransientData transientSkinData) - { - if (__instance.data != null) - { - string style = Util.GetStyle(__instance.tile.data.climate, __instance.tile.data.Skin); - string name = EnumCache.GetName(__instance.tile.data.resource.type); - - foreach (SkinVisualsReference.VisualPart visualPart in __instance.GetSkinVisualsReference().visualParts) - { - UpdateVisualPart(visualPart, name, style); - } - } - } - - /// Updates a building with a custom sprite. - [HarmonyPostfix] - [HarmonyPatch(typeof(Building), nameof(Building.UpdateObject), typeof(MapRenderContext), typeof(SkinVisualsTransientData))] - private static void Building_UpdateObject(Building __instance, MapRenderContext ctx, SkinVisualsTransientData transientSkinData) - { - string style = Util.GetStyle(transientSkinData.foundingTribeSettings.tribe, transientSkinData.foundingTribeSettings.skin); - string name = EnumCache.GetName(__instance.tile.data.improvement.type); - Sprite? sprite = Registry.GetSprite(name, style, __instance.Level); - if (sprite != null) - { - __instance.Sprite = sprite; - } - } - - /// Updates the terrain graphics with custom sprites. - [HarmonyPostfix] - [HarmonyPatch(typeof(TerrainRenderer), nameof(TerrainRenderer.UpdateGraphics))] - private static void TerrainRenderer_UpdateGraphics(TerrainRenderer __instance, Tile tile) - { - string terrain = EnumCache.GetName(tile.data.terrain) ?? string.Empty; - - TribeType tribe = tile.data.climate; - SkinType skinType = tile.data.Skin; - - string flood = ""; - if ( - tile.data.effects.Contains(TileData.EffectType.Flooded) || - (tribe == TribeType.Aquarion && tile.data.terrain == Polytopia.Data.TerrainData.Type.Mountain) - ) // TODO: Check all tribes with flooded abil instead of Aquarion only. - { - foreach (var effect in tile.data.effects) - { - if(customFloodingSkins.ContainsKey(effect)) - { - skinType = customFloodingSkins[effect]; - break; - } - } - flood = "_flooded"; - } - if (tile.data.terrain is Polytopia.Data.TerrainData.Type.Forest or Polytopia.Data.TerrainData.Type.Mountain) - { - string propertyName = terrain.ToLower(); - terrain = "field"; - - PropertyInfo? rendererProperty = tile.GetType().GetProperty(propertyName + "Renderer", - BindingFlags.Public | BindingFlags.Instance); - - if (rendererProperty != null) - { - PolytopiaSpriteRenderer? renderer = (PolytopiaSpriteRenderer?)rendererProperty.GetValue(tile); - if (renderer != null) - { - Sprite? additionalSprite = Registry.GetSprite(propertyName + flood, Util.GetStyle(tribe, skinType)); - if (additionalSprite != null) - { - renderer.Sprite = additionalSprite; - rendererProperty.SetValue(tile, renderer); - } - } - } - } - - Sprite? sprite = Registry.GetSprite(terrain + flood, Util.GetStyle(tribe, skinType)); - if (sprite != null) - { - __instance.spriteRenderer.Sprite = sprite; - } - } - - /// Adds custom flood tile effect when tile is flooded for custom aqua skins. - [HarmonyPostfix] - [HarmonyPatch(typeof(TileData), nameof(TileData.Flood))] - private static void TileData_Flood(TileData __instance, PlayerState playerState) - { - if(customFloodingSkins.ContainsValue(playerState.skinType)) - { - TileData.EffectType effectType = customFloodingSkins.FirstOrDefault(x => x.Value == playerState.skinType).Key; - __instance.AddEffect(effectType); - } - } - - /// Removes custom flood effect loc from tile tip header. - [HarmonyPostfix] - [HarmonyPatch(typeof(TileTipGenerator), nameof(TileTipGenerator.GetTileTipHeader))] - public static void GetTileTipHeader(ref string __result, TileData tileData, SkinType skin, - PlayerState playerState, GameState gameState, GameLogicData gameLogicData) - { - foreach(var effect in tileData.effects) - { - if(customFloodingSkins.ContainsKey(effect)) - { - string localizedEffect = Localization.GetSkinned(skin, playerState.tribe, $"tile.effect.{effect.GetName()}"); - __result = __result.Replace($", {localizedEffect}", string.Empty); - } - } - } - - /// Forces an update of the mesh for a PolytopiaSpriteRenderer with a custom sprite. - [HarmonyPostfix] - [HarmonyPatch(typeof(PolytopiaSpriteRenderer), nameof(PolytopiaSpriteRenderer.ForceUpdateMesh))] - private static void PolytopiaSpriteRenderer_ForceUpdateMesh(PolytopiaSpriteRenderer __instance) - { - if (__instance.sprite != null && string.IsNullOrEmpty(__instance.atlasName)) - { - MaterialPropertyBlock materialPropertyBlock = new(); - materialPropertyBlock.SetVector("_Flip", new Vector4(1f, 1f, 0f, 0f)); - materialPropertyBlock.SetTexture("_MainTex", __instance.sprite.texture); - __instance.meshRenderer.SetPropertyBlock(materialPropertyBlock); - } - } + /// + /// Represents a tile in a tribe preview. + /// + public class PreviewTile + { + /// The x-coordinate of the tile. + [JsonInclude] + public int? x = null; + /// The y-coordinate of the tile. + [JsonInclude] + public int? y = null; + /// The terrain type of the tile. + [JsonInclude] + [JsonConverter(typeof(EnumCacheJson))] + public Polytopia.Data.TerrainData.Type terrainType = Polytopia.Data.TerrainData.Type.None; + /// The resource type on the tile. + [JsonInclude] + [JsonConverter(typeof(EnumCacheJson))] + public ResourceData.Type? resourceType = null; + /// The unit type on the tile. + [JsonInclude] + [JsonConverter(typeof(EnumCacheJson))] + public UnitData.Type? unitType = null; + /// The improvement type on the tile. + [JsonInclude] + [JsonConverter(typeof(EnumCacheJson))] + public ImprovementData.Type? improvementType = null; + } + + /// Represents information about a sprite, such as its pivot and pixels per unit. + public record SpriteInfo(float? pixelsPerUnit, Vector2? pivot); + + /// Represents information about a custom skin. + public record SkinInfo(int idx, string id, SkinData? skinData); + + /// A dictionary of custom widths for basic popups. + public static Dictionary basicPopupWidths = new(); + /// Represents information about a unit prefab. + public struct UnitPrefabInfo + { + public UnitPrefabInfo(string type, string tribe, string skin) + { + this.type = type; + this.tribe = tribe; + this.skin = skin; + } + public string type; + public string tribe; + public string skin; + } + private const int BASIC_POPUP_LEGACY_ID = 29; + public static Dictionary customPrefabs = new(); + private static UnitData.Type currentUnitTypeUI = UnitData.Type.None; + private static TribeType attackerTribe = TribeType.None; + /// + /// A dictionary of skin types of tribes, which can flood tiles, keyed by custom flood tile effect. + /// + internal static Dictionary customFloodingSkins = new(); + private static bool isTakingSnapshot = false; + private static float? baseOrthographicCameraSize = null; + + /// The type of a custom prefab. + public enum PrefabType + { + Unit, + Improvement, + Resource + } + + /// Represents information about a custom prefab. + public record PrefabInfo + { + public PrefabType type { get; init; } + public string name { get; init; } + public List visualParts { get; init; } + public string headPositionMarker { get; init; } + + [JsonConstructor] + public PrefabInfo( + PrefabType type, + List visualParts, + string name = "", + string headPositionMarker = "") + { + this.type = type; + this.name = name.ToLowerInvariant(); + this.visualParts = visualParts; + this.headPositionMarker = headPositionMarker; + } + } + + /// Represents information about a visual part of a prefab. + public record VisualPartInfo( + string gameObjectName, + string baseName, + float rotation = 0f, + Vector2 coordinates = new Vector2(), + Vector2 scale = new Vector2(), + bool tintable = false + ); + private static bool enableOutlines = false; + private static bool seenWarningWCPopup = false; + public record PreviewInfo(int arrayIdx, SaveStateData saveStateData, PreviewTile[] customPreview); + + #region General + + /// A placeholder patch for the TechItem.SetupComplete method. + [HarmonyPostfix] + [HarmonyPatch(typeof(TechItem), nameof(TechItem.SetupComplete))] + private static void TechItem_SetupComplete() + { + } + + /// Patches the sprite atlas manager to load custom sprites. + [HarmonyPrefix] + [HarmonyPatch(typeof(SpriteAtlasManager), nameof(SpriteAtlasManager.LoadSprite), typeof(string), typeof(string), typeof(SpriteCallback))] + private static bool SpriteAtlasManager_LoadSprite(SpriteAtlasManager __instance, string atlas, string sprite, SpriteCallback completion) + { + bool found = false; + __instance.LoadSpriteAtlas(atlas, (Il2CppSystem.Action)GetAtlas); + + return !found; + + void GetAtlas(SpriteAtlas spriteAtlas) + { + if (spriteAtlas != null) + { + List names = sprite.Split('_').ToList(); + List filteredNames = new List(names); + string style = ""; + foreach (string item in names) + { + string upperitem = char.ToUpper(item[0]) + item[1..]; + if (EnumCache.TryGetType(item, out TribeType tribe) || EnumCache.TryGetType(item, out SkinType skin) + || EnumCache.TryGetType(upperitem, out TribeType tribeUpper) || EnumCache.TryGetType(upperitem, out SkinType skinUpper)) + { + filteredNames.Remove(item); + style = item; + continue; + } + } + string name = string.Join("_", filteredNames); + Sprite? newSprite = Registry.GetSprite(name, style); + if (newSprite != null) + { + completion?.Invoke(atlas, sprite, newSprite); + found = true; + } + } + } + } + + /// Patches the sprite atlas manager to look up custom sprites. + [HarmonyPostfix] + [HarmonyPatch(typeof(SpriteAtlasManager), nameof(SpriteAtlasManager.DoSpriteLookup))] + private static void SpriteAtlasManager_DoSpriteLookup(ref SpriteAtlasManager.SpriteLookupResult __result, SpriteAtlasManager __instance, string baseName, TribeType tribe, SkinType skin, int level) + { + baseName = Util.FormatSpriteName(baseName); + + Sprite? sprite = Registry.GetSprite(baseName, Util.GetStyle(tribe, skin), level); + if (sprite != null) + __result.sprite = sprite; + } + + #endregion + #region Units + + /// Enables outlines when the interaction bar is shown. + [HarmonyPrefix] + [HarmonyPatch(typeof(InteractionBar), nameof(InteractionBar.Show))] + private static bool InteractionBar_Show(InteractionBar __instance, bool instant, bool force) + { + enableOutlines = true; + return true; + } + + /// Prevents outlines from being created when they are disabled. + [HarmonyPrefix] + [HarmonyPatch(typeof(UISpriteDuplicator), nameof(UISpriteDuplicator.CreateImage), typeof(SpriteRenderer), typeof(Transform), typeof(Transform), typeof(float), typeof(Vector2), typeof(bool))] + private static bool UISpriteDuplicator_CreateImage(SpriteRenderer spriteRenderer, Transform source, Transform destination, float scale, Vector2 offset, bool forceFullAlpha) + { + return !(spriteRenderer.sortingOrder == -1 && !enableOutlines); + } + + /// Disables outlines after the interaction bar is shown. + [HarmonyPostfix] + [HarmonyPatch(typeof(InteractionBar), nameof(InteractionBar.Show))] + private static void InteractionBar_Show_Postfix(InteractionBar __instance, bool instant, bool force) + { + enableOutlines = false; + } + + /// Sets the current unit type being rendered in the UI. + [HarmonyPrefix] + [HarmonyPatch(typeof(UIUnitRenderer), nameof(UIUnitRenderer.CreateUnit))] + private static bool UIUnitRenderer_CreateUnit_Prefix(UIUnitRenderer __instance) + { + currentUnitTypeUI = __instance.unitType; + return true; + } + + /// Resets the current unit type being rendered in the UI. + [HarmonyPostfix] + [HarmonyPatch(typeof(UIUnitRenderer), nameof(UIUnitRenderer.CreateUnit))] + private static void UIUnitRenderer_CreateUnit_Postfix(UIUnitRenderer __instance) + { + currentUnitTypeUI = UnitData.Type.None; + } + + /// Skins the visual parts of a unit with custom sprites. + [HarmonyPostfix] + [HarmonyPatch(typeof(SkinVisualsRenderer), nameof(SkinVisualsRenderer.SkinWorldObject))] + private static void SkinVisualsRenderer_SkinWorldObject( + SkinVisualsRenderer.SkinWorldType type, + SkinVisualsReference skinVisuals, + SkinVisualsTransientData transientSkinData, + bool checkOutlines, + int level) + { + if (type != SkinVisualsRenderer.SkinWorldType.Unit || skinVisuals == null || + skinVisuals.visualParts == null || transientSkinData == null) + return; + + Unit unit = skinVisuals.gameObject.GetComponent(); + + string unitTypeName = EnumCache.GetName(UnitData.Type.Warrior); + + if (unit != null && unit.UnitData != null) + unitTypeName = EnumCache.GetName(unit.unitData.type); + if (currentUnitTypeUI != UnitData.Type.None) + unitTypeName = EnumCache.GetName(currentUnitTypeUI); + + string style = Util.GetStyle(transientSkinData.unitSettings.tribe, transientSkinData.unitSettings.skin); + + foreach (var visualPart in skinVisuals.visualParts) + { + if (visualPart.visualPart != null) + UpdateVisualPart(visualPart, $"{visualPart.visualPart.name}_{unitTypeName}", style); + } + } + + [HarmonyPostfix] + [HarmonyPatch(typeof(PrefabManager), nameof(PrefabManager.GetPrefab), + typeof(UnitData.Type), typeof(TribeType), typeof(SkinType) + )] + private static void PrefabManager_GetPrefab(ref Unit __result, UnitData.Type type, TribeType tribe, SkinType skin) + { + UnitPrefabInfo unitPrefabInfo = new( + EnumCache.GetName(type), + EnumCache.GetName(TribeType.None), + EnumCache.GetName(SkinType.Default) + ); + if (customPrefabs.ContainsKey( + unitPrefabInfo + )) + { + __result = customPrefabs[unitPrefabInfo]; + } + } + + #endregion + #region Level + + /// Updates the visual parts of a resource with custom sprites. + [HarmonyPostfix] + [HarmonyPatch(typeof(Resource), nameof(Resource.UpdateObject), typeof(MapRenderContext), typeof(SkinVisualsTransientData))] + private static void Resource_UpdateObject(Resource __instance, MapRenderContext ctx, SkinVisualsTransientData transientSkinData) + { + if (__instance.data != null) + { + string style = Util.GetStyle(__instance.tile.data.climate, __instance.tile.data.Skin); + string name = EnumCache.GetName(__instance.tile.data.resource.type); + + foreach (SkinVisualsReference.VisualPart visualPart in __instance.GetSkinVisualsReference().visualParts) + { + UpdateVisualPart(visualPart, name, style); + } + } + } + + /// Updates a building with a custom sprite. + [HarmonyPostfix] + [HarmonyPatch(typeof(Building), nameof(Building.UpdateObject), typeof(MapRenderContext), typeof(SkinVisualsTransientData))] + private static void Building_UpdateObject(Building __instance, MapRenderContext ctx, SkinVisualsTransientData transientSkinData) + { + string style = Util.GetStyle(transientSkinData.foundingTribeSettings.tribe, transientSkinData.foundingTribeSettings.skin); + string name = EnumCache.GetName(__instance.tile.data.improvement.type); + Sprite? sprite = Registry.GetSprite(name, style, __instance.Level); + if (sprite != null) + { + __instance.Sprite = sprite; + } + } + + /// Updates the terrain graphics with custom sprites. + [HarmonyPostfix] + [HarmonyPatch(typeof(TerrainRenderer), nameof(TerrainRenderer.UpdateGraphics))] + private static void TerrainRenderer_UpdateGraphics(TerrainRenderer __instance, Tile tile) + { + string terrain = EnumCache.GetName(tile.data.terrain) ?? string.Empty; + + TribeType tribe = tile.data.climate; + SkinType skinType = tile.data.Skin; + + string flood = ""; + if ( + tile.data.effects.Contains(TileData.EffectType.Flooded) || + (tribe == TribeType.Aquarion && tile.data.terrain == Polytopia.Data.TerrainData.Type.Mountain) + ) // TODO: Check all tribes with flooded abil instead of Aquarion only. + { + foreach (var effect in tile.data.effects) + { + if (customFloodingSkins.ContainsKey(effect)) + { + skinType = customFloodingSkins[effect]; + break; + } + } + flood = "_flooded"; + } + if (tile.data.terrain is Polytopia.Data.TerrainData.Type.Forest or Polytopia.Data.TerrainData.Type.Mountain) + { + string propertyName = terrain.ToLower(); + terrain = "field"; + + PropertyInfo? rendererProperty = tile.GetType().GetProperty(propertyName + "Renderer", + BindingFlags.Public | BindingFlags.Instance); + + if (rendererProperty != null) + { + PolytopiaSpriteRenderer? renderer = (PolytopiaSpriteRenderer?)rendererProperty.GetValue(tile); + if (renderer != null) + { + Sprite? additionalSprite = Registry.GetSprite(propertyName + flood, Util.GetStyle(tribe, skinType)); + if (additionalSprite != null) + { + renderer.Sprite = additionalSprite; + rendererProperty.SetValue(tile, renderer); + } + } + } + } + + Sprite? sprite = Registry.GetSprite(terrain + flood, Util.GetStyle(tribe, skinType)); + if (sprite != null) + { + __instance.spriteRenderer.Sprite = sprite; + } + } + + /// Adds custom flood tile effect when tile is flooded for custom aqua skins. + [HarmonyPostfix] + [HarmonyPatch(typeof(TileData), nameof(TileData.Flood))] + private static void TileData_Flood(TileData __instance, PlayerState playerState) + { + if (customFloodingSkins.ContainsValue(playerState.skinType)) + { + TileData.EffectType effectType = customFloodingSkins.FirstOrDefault(x => x.Value == playerState.skinType).Key; + __instance.AddEffect(effectType); + } + } + + /// Removes custom flood effect loc from tile tip header. + [HarmonyPostfix] + [HarmonyPatch(typeof(TileTipGenerator), nameof(TileTipGenerator.GetTileTipHeader))] + public static void GetTileTipHeader(ref string __result, TileData tileData, SkinType skin, + PlayerState playerState, GameState gameState, GameLogicData gameLogicData) + { + foreach (var effect in tileData.effects) + { + if (customFloodingSkins.ContainsKey(effect)) + { + string localizedEffect = Localization.GetSkinned(skin, playerState.tribe, $"tile.effect.{effect.GetName()}"); + __result = __result.Replace($", {localizedEffect}", string.Empty); + } + } + } + + /// Forces an update of the mesh for a PolytopiaSpriteRenderer with a custom sprite. + [HarmonyPostfix] + [HarmonyPatch(typeof(PolytopiaSpriteRenderer), nameof(PolytopiaSpriteRenderer.ForceUpdateMesh))] + private static void PolytopiaSpriteRenderer_ForceUpdateMesh(PolytopiaSpriteRenderer __instance) + { + if (__instance.sprite != null && string.IsNullOrEmpty(__instance.atlasName)) + { + MaterialPropertyBlock materialPropertyBlock = new(); + materialPropertyBlock.SetVector("_Flip", new Vector4(1f, 1f, 0f, 0f)); + materialPropertyBlock.SetTexture("_MainTex", __instance.sprite.texture); + __instance.meshRenderer.SetPropertyBlock(materialPropertyBlock); + } + } [HarmonyPostfix] [HarmonyPatch(typeof(Tile), nameof(Tile.Render), typeof(MapRenderContext), typeof(SkinVisualsTransientData))] private static void Tile_Render(Tile __instance, MapRenderContext ctx, SkinVisualsTransientData transientSkinningData) { Sprite? sprite = Registry.GetSprite("hidden"); - if(sprite != null) - { - __instance.fogOfWarRenderer.atlasName = ""; - __instance.fogOfWarRenderer.sprite = sprite; - __instance.fogOfWarRenderer.spriteRenderer.sprite = sprite; - } - } - - #endregion - #region Tribe Preview - - [HarmonyPostfix] - [HarmonyPatch(typeof(TribePreviewRegistry), nameof(TribePreviewRegistry.PreloadTribePreviews))] - public static void PreloadTribePreviews() - { - Dictionary previewSaveStateDatas = new(); - - SaveStateData fallbackSaveStateData = TribePreviewRegistry.saveStateDatas[TribePreviewRegistry.fallbackStateIndex]; - foreach(string tribe in Registry.tribePreviews.Keys) - { - string previewId = GetTribePreviewName(tribe); - PreviewTile[] customPreview = Registry.tribePreviews[tribe]; - previewSaveStateDatas[previewId] = new(TribePreviewRegistry.fallbackStateIndex, fallbackSaveStateData, customPreview); - } - - for (int i = 0; i < TribePreviewRegistry.saveStateDatas.Length; i++) - { - SaveStateData saveStateData = TribePreviewRegistry.saveStateDatas[i]; - if(previewSaveStateDatas.ContainsKey(saveStateData.name)) - { - previewSaveStateDatas[saveStateData.name] = new(i, saveStateData, previewSaveStateDatas[saveStateData.name].customPreview); - } - } - - foreach (string previewId in previewSaveStateDatas.Keys) - { - var data = previewSaveStateDatas[previewId]; - SaveStateData? customSaveStateData = ApplyCustomPreview(previewId, data.saveStateData, data.customPreview); - if(customSaveStateData == null) - continue; - - if(data.arrayIdx == TribePreviewRegistry.fallbackStateIndex) - { - List saveStateDatas = TribePreviewRegistry.saveStateDatas.ToList(); - saveStateDatas.Add(customSaveStateData); - TribePreviewRegistry.saveStateDatas = saveStateDatas.ToArray(); - } - else - { - TribePreviewRegistry.saveStateDatas[data.arrayIdx] = customSaveStateData; - } - } - } - - private static string GetTribePreviewName(string tribeType) - { - return "worldpreview_" + tribeType; - } - - private static SaveStateData? ApplyCustomPreview(string previewId, SaveStateData originalPreview, PreviewTile[] preview) - { - SaveStateData? saveStateData = null; - if (DiskSerializationHelpers.FromLZ4CompressedByteArray( - originalPreview.byteArray, out ClientSerializationWrapper clientSerializationWrapper, out int version)) - { - GameState currentGameState = clientSerializationWrapper.GetCurrentGameState(); - foreach (var previewTile in preview) - { - if(previewTile.x == null || previewTile.y == null) - continue; - - WorldCoordinates coordinates = new((int)previewTile.x, (int)previewTile.y); - TileData? tileData = currentGameState.Map.GetTile(coordinates); - if(tileData == null) - continue; - - if(previewTile.terrainType != Polytopia.Data.TerrainData.Type.None) - tileData.terrain = previewTile.terrainType; - - if(previewTile.resourceType != null) - { - ResourceState? resourceState = null; - ResourceData.Type resourceType = (ResourceData.Type)previewTile.resourceType; - if(resourceType != ResourceData.Type.None) - { - resourceState = new ResourceState { type = resourceType }; - } - tileData.resource = resourceState; - } - - if(previewTile.unitType != null) - { - tileData.unit = null; - if(previewTile.unitType != Polytopia.Data.UnitData.Type.None && - currentGameState.TryGetPlayer(currentGameState.CurrentPlayer, out PlayerState playerState) && - currentGameState.GameLogicData.TryGetData((UnitData.Type)previewTile.unitType, out UnitData unitData)) - { - UnitState unitState = ActionUtils.TrainUnit(currentGameState, playerState, tileData, unitData); - unitState.moved = false; - unitState.attacked = false; - } - } - - - if(previewTile.improvementType != null) - { - tileData.improvement = null; - ImprovementData.Type improvementType = (ImprovementData.Type)previewTile.improvementType; - if( - previewTile.improvementType != ImprovementData.Type.None && - currentGameState.GameLogicData.TryGetData( - improvementType, - out ImprovementData improvementData) - ) - { - tileData.improvement = new ImprovementState - { - type = improvementData.type, - borderSize = (ushort)improvementData.borderSize, - level = 0, - xp = 0, - production = 1, - founded = (ushort)currentGameState.CurrentTurn, - baseScore = (ushort)improvementData.GetScoreReward(), - founder = currentGameState.CurrentPlayer - }; - } - } - } - - saveStateData = ScriptableObject.CreateInstance(); - saveStateData.name = previewId; - saveStateData.byteArray = DiskSerializationHelpers.ToLZ4CompressedByteArray(clientSerializationWrapper, version); - } - - return saveStateData; - } - - [HarmonyPrefix] - [HarmonyPatch(typeof(SpriteCamera), nameof(SpriteCamera.TakeSnapshotOfMapState))] - private static bool SpriteCamera_TakeSnapshotOfMapState(ref Texture2D __result, SpriteCamera.SnapshotMapData mapSnapshot, - float w, float h, TribeType tribe, SkinType skin, TribeType climate, int color, bool clearMapAfterwards) - { - if(!Plugin.config.debug) - return true; - - var instance = SpriteCamera.instance; - if(baseOrthographicCameraSize == null) - baseOrthographicCameraSize = instance.spriteCamera.orthographicSize; - - instance.spriteCamera.orthographicSize = (float)baseOrthographicCameraSize * 2; - isTakingSnapshot = true; - - return true; - } - - [HarmonyPostfix] - [HarmonyPatch(typeof(SpriteCamera), nameof(SpriteCamera.TakeSnapshotOfMapState))] - private static void SpriteCamera_TakeSnapshotOfMapState_Postfix(ref Texture2D __result, SpriteCamera.SnapshotMapData mapSnapshot, - float w, float h, TribeType tribe, SkinType skin, TribeType climate, int color, bool clearMapAfterwards) - { - if(isTakingSnapshot) - isTakingSnapshot = false; - } - - [HarmonyPostfix] - [HarmonyPatch(typeof(Tile), nameof(Tile.RenderDebug))] - public static void Tile_RenderDebug(Tile __instance) - { - if(!isTakingSnapshot) - return; - - GameObject textObj = new GameObject("CoordinateText"); - textObj.transform.SetParent(__instance.transform); - textObj.transform.localPosition = new Vector3(0, 0.0f, 0); - - TextMeshPro textMesh = textObj.AddComponent(); - textMesh.text = __instance.Data.coordinates.ToString(); - textMesh.fontSize = 3; - textMesh.alignment = TextAlignmentOptions.Center; - textMesh.color = Color.white; - - MeshRenderer renderer = textObj.GetComponent(); - renderer.sortingLayerID = MeshCache.TERRAIN_LAYER_ID; - renderer.sortingOrder = __instance.Depth + 10; - } - - #endregion - #region UI - - /// Provides custom sprites for improvements in the UI. - [HarmonyPostfix] - [HarmonyPatch(typeof(UIUtils), nameof(UIUtils.GetImprovementSprite), typeof(ImprovementData.Type), typeof(TribeType), typeof(SkinType), typeof(SpriteAtlasManager))] - private static void UIUtils_GetImprovementSprite(ref Sprite __result, ImprovementData.Type improvement, TribeType tribe, SkinType skin, SpriteAtlasManager atlasManager) - { - Sprite? sprite = Registry.GetSprite(EnumCache.GetName(improvement), Util.GetStyle(tribe, skin)); - if (sprite != null) - { - __result = sprite; - } - } - - /// Provides custom sprites for improvements in the UI. - [HarmonyPostfix] - [HarmonyPatch(typeof(UIUtils), nameof(UIUtils.GetImprovementSprite), typeof(SkinVisualsTransientData), typeof(ImprovementData.Type), typeof(SpriteAtlasManager))] - private static void UIUtils_GetImprovementSprite_2(ref Sprite __result, SkinVisualsTransientData data, ImprovementData.Type improvement, SpriteAtlasManager atlasManager) - { - UIUtils_GetImprovementSprite(ref __result, improvement, data.foundingTribeSettings.tribe, data.foundingTribeSettings.skin, atlasManager); - } - - /// Provides custom sprites for resources in the UI. - [HarmonyPostfix] - [HarmonyPatch(typeof(UIUtils), nameof(UIUtils.GetResourceSprite))] - private static void UIUtils_GetResourceSprite(ref Sprite __result, SkinVisualsTransientData data, ResourceData.Type resource, SpriteAtlasManager atlasManager) - { - Sprite? sprite = Registry.GetSprite(EnumCache.GetName(resource), Util.GetStyle(data.tileClimateSettings.tribe, data.tileClimateSettings.skin)); - if (sprite != null) - { - __result = sprite; - } - } - - #endregion - #region Houses - - /// Provides custom sprites for houses in the city view. - [HarmonyPostfix] - [HarmonyPatch(typeof(CityRenderer), nameof(CityRenderer.GetHouse))] - private static void CityRenderer_GetHouse(ref PolytopiaSpriteRenderer __result, CityRenderer __instance, TribeType tribe, int type, SkinType skinType) - { - PolytopiaSpriteRenderer polytopiaSpriteRenderer = __result; - - if (type != __instance.HOUSE_WORKSHOP && type != __instance.HOUSE_PARK) - { - Sprite? sprite = Registry.GetSprite("house", Util.GetStyle(tribe, skinType), type); - if (sprite != null) - { - polytopiaSpriteRenderer.Sprite = sprite; - TerrainMaterialHelper.SetSpriteSaturated(polytopiaSpriteRenderer, __instance.IsEnemyCity); - __result = polytopiaSpriteRenderer; - } - } - } - - /// Provides custom sprites for houses in the UI city renderer. - [HarmonyPostfix] - [HarmonyPatch(typeof(UICityRenderer), nameof(UICityRenderer.GetResource))] - private static void UICityRenderer_GetResource(ref GameObject __result, string baseName, TribeType tribe, SkinType skin) - { - Image imageComponent = __result.GetComponent(); - string[] tokens = baseName.Split('_'); - if (tokens.Length > 0) - { - if (tokens[0] == "House") - { - int level = 0; - if (tokens.Length > 1) - { - _ = int.TryParse(tokens[1], out level); - } - - Sprite? sprite = Registry.GetSprite("house", Util.GetStyle(tribe, skin), level); - if (sprite == null) - { - return; - } - imageComponent.sprite = sprite; - imageComponent.SetNativeSize(); - } - } - } - - #endregion - #region Icons - - /// Provides custom sprites for UI icons. - [HarmonyPostfix] - [HarmonyPatch(typeof(UIIconData), nameof(UIIconData.GetImage))] - private static void UIIconData_GetImage(ref Image __result, string id) - { - Sprite? sprite; - if (GameManager.LocalPlayer != null) - { - sprite = Registry.GetSprite(id, Util.GetStyle(GameManager.LocalPlayer.tribe, GameManager.LocalPlayer.skinType)); - } - else - { - sprite = Registry.GetSprite(id); - } - if (sprite != null) - { - __result.sprite = sprite; - __result.useSpriteMesh = true; - __result.SetNativeSize(); - } - } - - /// Provides custom sprites for face icons in the game info row. - [HarmonyPostfix] - [HarmonyPatch(typeof(GameInfoRow), nameof(GameInfoRow.LoadFaceIcon), typeof(TribeType), typeof(SkinType))] - private static void GameInfoRow_LoadFaceIcon(GameInfoRow __instance, TribeType type, SkinType skinType) - { - string style = EnumCache.GetName(type); - - if (style == "None") - { - for (int i = 0; i < 20; i++) - { - type += byte.MaxValue + 1; - style = EnumCache.GetName(type); - - if (style != "None") - { - break; - } - } - } - - Sprite? sprite = Registry.GetSprite("head", Util.GetStyle(type, skinType)); - - if (sprite != null) - { - __instance.SetFaceIcon(sprite); - } - - if (__instance.icon.sprite == null) - { - __instance.LoadFaceIcon(SpriteData.SpecialFaceIcon.neutral); - } - } - - /// Provides custom sprites for player info icons. - [HarmonyPostfix] - [HarmonyPatch(typeof(PlayerInfoIcon), nameof(PlayerInfoIcon.SetData), typeof(TribeType), typeof(SkinType), typeof(SpriteData.SpecialFaceIcon), typeof(Color), typeof(DiplomacyRelationState), typeof(PlayerInfoIcon.Mood))] - private static void PlayerInfoIcon_SetData(PlayerInfoIcon __instance, TribeType tribe, SkinType skin, SpriteData.SpecialFaceIcon face, Color color, DiplomacyRelationState diplomacyState, PlayerInfoIcon.Mood mood) - { - if (face == SpriteData.SpecialFaceIcon.tribe) - { - Sprite? sprite = Registry.GetSprite("head", Util.GetStyle(tribe, skin)); - if (sprite != null) - { - __instance.HeadImage.sprite = sprite; - Vector2 size = sprite.rect.size; - __instance.HeadImage.rectTransform.sizeDelta = size * __instance.rectTransform.GetHeight() / 512f; - } - } - } - - #endregion - #region Popups - - /// Updates the width of a basic popup if a custom width is set. - [HarmonyPostfix] - [HarmonyPatch(typeof(PopupBase), nameof(PopupBase.RefreshHeightWhenHeightIsKnownAndDontUseCoroutines))] - private static void PopupBase_RefreshHeightWhenHeightIsKnownAndDontUseCoroutines(PopupBase __instance) - { - UpdateWidth(__instance); - } - - private static void UpdateWidth(PopupBase __instance) - { - int id = __instance.GetInstanceID(); - if (basicPopupWidths.ContainsKey(id)) - __instance.rectTransform.SetWidth(basicPopupWidths[id]); - } - - /// Sets the attacker's tribe before a unit attacks. - [HarmonyPrefix] - [HarmonyPatch(typeof(Unit), nameof(Unit.Attack))] - private static bool Unit_Attack(Unit __instance, WorldCoordinates target, bool moveToTarget, Il2CppSystem.Action onComplete) - { - if (__instance.Owner != null) - { - attackerTribe = __instance.Owner.tribe; - } - return true; - } - - /// Sets the skin of a weapon's graphics, using custom sprites if available. - [HarmonyPostfix] - [HarmonyPatch(typeof(WeaponGFX), nameof(WeaponGFX.SetSkin))] - private static void WeaponGFX_SetSkin(WeaponGFX __instance, SkinType skinType) - { - if (attackerTribe != TribeType.None) - { - Sprite? sprite = Registry.GetSprite(__instance.defaultSprite.name, Util.GetStyle(attackerTribe, skinType)); - if (sprite != null) - { - __instance.spriteRenderer.sprite = sprite; - } - attackerTribe = TribeType.None; - } - } - - /// Removes a popup's custom width when it is hidden. - [HarmonyPostfix] - [HarmonyPatch(typeof(PopupBase), nameof(PopupBase.Hide))] - private static void PopupBase_Hide(PopupBase __instance) - { - basicPopupWidths.Remove(__instance.GetInstanceID()); - } - - [HarmonyPrefix] - [HarmonyPatch(typeof(StartScreen_UI2), nameof(StartScreen_UI2.OnWeeklyChallengeClicked))] - private static bool StartScreen_OnWeeklyChallengeClicked(StartScreen_UI2 __instance) - { - if(seenWarningWCPopup) - return true; - - BasicPopup popup = PopupManager.GetBasicPopup(); - popup.Header = Localization.Get("polymod.hub"); - popup.Description = Localization.Get("polymod.wc.warning", new Il2CppSystem.Object[] { Localization.Get("weeklychallenge", new Il2CppSystem.Object[] { }) }); - - void WCProceed() - { - seenWarningWCPopup = true; - __instance.OnWeeklyChallengeClicked(); - } - List popupButtons = new() - { - new("buttons.back"), - new( - "polymod.wc.proceed", - PopupBase.PopupButtonData.States.None, - callback: DelegateSupport.ConvertDelegate(WCProceed), - customColorStates: ColorConstants.redButtonColorStates - ) - }; - popup.buttonData = popupButtons.ToArray(); - popup.Show(); - return false; - } - - /// Shows a basic popup with a custom width. - public static void ShowSetWidth(this BasicPopupLegacy self, int width) - { - basicPopupWidths.Add(self.GetInstanceID(), width); - self.Show(); - } - - // [HarmonyPrefix] - // [HarmonyPatch(typeof(BasicPopup), nameof(BasicPopup.SetButtonData))] - // public static bool SetButtonData(BasicPopup __instance, PopupBase.PopupButtonData[] buttonDatas, bool updateOmniCursor) - // { - // if (buttonDatas == null) - // { - // __instance.SetDefaultOkbutton(); - // return false; - // } - // foreach (var buttonData in buttonDatas) - // { - // var MainButton = __instance.createButton(buttonData.text, buttonData.callback, UIButtonBase_UI2.ButtonStyle.Suggested); - // MainButton.OnClickedSignal.Add(DelegateSupport.ConvertDelegate(__instance.Hide)); - // } - // __instance.RunLayout(); - // return false; - // } - - #endregion - - internal static BasicPopupLegacy GetBasicPopupLegacy() - { - WhatsNewPopup whatsNewPopup = PopupManager.GetWhatsNewPopup(); - BasicPopupLegacy original = PopupManager.instance.popupPrefabs[BASIC_POPUP_LEGACY_ID].Cast(); - BasicPopupLegacy basicPopupLegacy = UnityEngine.Object.Instantiate(original, PopupManager.instance.transform); - basicPopupLegacy.buttonContainer = GameObject.Instantiate(whatsNewPopup.buttonContainer, basicPopupLegacy.transform); - basicPopupLegacy.popupId = "basicPopupLegacy"; - basicPopupLegacy.popupManager = PopupManager.instance; - basicPopupLegacy.Init(); - basicPopupLegacy.identifier = null; - basicPopupLegacy.rectTransform.SetAsLastSibling(); - return basicPopupLegacy; - } - - /// Updates a visual part with a custom sprite. - private static void UpdateVisualPart(SkinVisualsReference.VisualPart? visualPart, string name, string style) - { - if(visualPart == null || visualPart.visualPart == null) - return; - - Sprite? sprite = Registry.GetSprite(name, style) ?? Registry.GetSprite(visualPart.visualPart.name, style); - - if (sprite != null && visualPart.renderer != null) - { - if (visualPart.renderer.spriteRenderer != null) - visualPart.renderer.spriteRenderer.sprite = sprite; - else if (visualPart.renderer.polytopiaSpriteRenderer != null) - visualPart.renderer.polytopiaSpriteRenderer.sprite = sprite; - } - - Sprite? outlineSprite = Registry.GetSprite($"{name}_outline", style) ?? Registry.GetSprite($"{visualPart.visualPart.name}_outline", style); - - if (outlineSprite != null && visualPart.outlineRenderer != null) - { - if (visualPart.outlineRenderer.spriteRenderer != null) - visualPart.outlineRenderer.spriteRenderer.sprite = outlineSprite; - else if (visualPart.outlineRenderer.polytopiaSpriteRenderer != null) - visualPart.outlineRenderer.polytopiaSpriteRenderer.sprite = outlineSprite; - } - } - - /// Builds a sprite from raw byte data. - /// The raw byte data of the image. - /// The pivot point of the sprite. - /// The number of pixels per unit for the sprite. - /// The created sprite. - public static Sprite BuildSprite(byte[] data, Vector2? pivot = null, float pixelsPerUnit = 2112f) - { - Texture2D texture = new(1, 1, TextureFormat.RGBA32, true); - texture.LoadImage(data); - Color[] pixels = texture.GetPixels(); - for (int i = 0; i < pixels.Length; i++) - { - pixels[i] = new Color(pixels[i].r, pixels[i].g, pixels[i].b, pixels[i].a); - } - texture.SetPixels(pixels); - texture.filterMode = FilterMode.Trilinear; - texture.Apply(); - return BuildSpriteWithTexture(texture, pivot, pixelsPerUnit); - } - - /// Builds a sprite from a texture. - /// The texture to create the sprite from. - /// The pivot point of the sprite. - /// The number of pixels per unit for the sprite. - /// The created sprite. - public static Sprite BuildSpriteWithTexture(Texture2D texture, Vector2? pivot = null, float? pixelsPerUnit = 2112f) - { - return Sprite.Create( - texture, - new(0, 0, texture.width, texture.height), - pivot ?? new(0.5f, 0.5f), - pixelsPerUnit ?? 2112f - ); - } - - /// Initializes the Visual manager by patching the necessary methods. - internal static void Init() - { - Harmony.CreateAndPatchAll(typeof(Visual)); - } + if (sprite != null) + { + __instance.fogOfWarRenderer.atlasName = ""; + __instance.fogOfWarRenderer.sprite = sprite; + __instance.fogOfWarRenderer.spriteRenderer.sprite = sprite; + } + } + + #endregion + #region Tribe Preview + + [HarmonyPostfix] + [HarmonyPatch(typeof(TribePreviewRegistry), nameof(TribePreviewRegistry.PreloadTribePreviews))] + public static void PreloadTribePreviews() + { + Dictionary previewSaveStateDatas = new(); + + SaveStateData fallbackSaveStateData = TribePreviewRegistry.saveStateDatas[TribePreviewRegistry.fallbackStateIndex]; + foreach (string tribe in Registry.tribePreviews.Keys) + { + string previewId = GetTribePreviewName(tribe); + PreviewTile[] customPreview = Registry.tribePreviews[tribe]; + previewSaveStateDatas[previewId] = new(TribePreviewRegistry.fallbackStateIndex, fallbackSaveStateData, customPreview); + } + + for (int i = 0; i < TribePreviewRegistry.saveStateDatas.Length; i++) + { + SaveStateData saveStateData = TribePreviewRegistry.saveStateDatas[i]; + if (previewSaveStateDatas.ContainsKey(saveStateData.name)) + { + previewSaveStateDatas[saveStateData.name] = new(i, saveStateData, previewSaveStateDatas[saveStateData.name].customPreview); + } + } + + foreach (string previewId in previewSaveStateDatas.Keys) + { + var data = previewSaveStateDatas[previewId]; + SaveStateData? customSaveStateData = ApplyCustomPreview(previewId, data.saveStateData, data.customPreview); + if (customSaveStateData == null) + continue; + + if (data.arrayIdx == TribePreviewRegistry.fallbackStateIndex) + { + List saveStateDatas = TribePreviewRegistry.saveStateDatas.ToList(); + saveStateDatas.Add(customSaveStateData); + TribePreviewRegistry.saveStateDatas = saveStateDatas.ToArray(); + } + else + { + TribePreviewRegistry.saveStateDatas[data.arrayIdx] = customSaveStateData; + } + } + } + + private static string GetTribePreviewName(string tribeType) + { + return "worldpreview_" + tribeType; + } + + private static SaveStateData? ApplyCustomPreview(string previewId, SaveStateData originalPreview, PreviewTile[] preview) + { + SaveStateData? saveStateData = null; + if (DiskSerializationHelpers.FromLZ4CompressedByteArray( + originalPreview.byteArray, out ClientSerializationWrapper clientSerializationWrapper, out int version)) + { + GameState currentGameState = clientSerializationWrapper.GetCurrentGameState(); + foreach (var previewTile in preview) + { + if (previewTile.x == null || previewTile.y == null) + continue; + + WorldCoordinates coordinates = new((int)previewTile.x, (int)previewTile.y); + TileData? tileData = currentGameState.Map.GetTile(coordinates); + if (tileData == null) + continue; + + if (previewTile.terrainType != Polytopia.Data.TerrainData.Type.None) + tileData.terrain = previewTile.terrainType; + + if (previewTile.resourceType != null) + { + ResourceState? resourceState = null; + ResourceData.Type resourceType = (ResourceData.Type)previewTile.resourceType; + if (resourceType != ResourceData.Type.None) + { + resourceState = new ResourceState { type = resourceType }; + } + tileData.resource = resourceState; + } + + if (previewTile.unitType != null) + { + tileData.unit = null; + if (previewTile.unitType != Polytopia.Data.UnitData.Type.None && + currentGameState.TryGetPlayer(currentGameState.CurrentPlayer, out PlayerState playerState) && + currentGameState.GameLogicData.TryGetData((UnitData.Type)previewTile.unitType, out UnitData unitData)) + { + UnitState unitState = ActionUtils.TrainUnit(currentGameState, playerState, tileData, unitData); + unitState.moved = false; + unitState.attacked = false; + } + } + + + if (previewTile.improvementType != null) + { + tileData.improvement = null; + ImprovementData.Type improvementType = (ImprovementData.Type)previewTile.improvementType; + if ( + previewTile.improvementType != ImprovementData.Type.None && + currentGameState.GameLogicData.TryGetData( + improvementType, + out ImprovementData improvementData) + ) + { + tileData.improvement = new ImprovementState + { + type = improvementData.type, + borderSize = (ushort)improvementData.borderSize, + level = 0, + xp = 0, + production = 1, + founded = (ushort)currentGameState.CurrentTurn, + baseScore = (ushort)improvementData.GetScoreReward(), + founder = currentGameState.CurrentPlayer + }; + } + } + } + + saveStateData = ScriptableObject.CreateInstance(); + saveStateData.name = previewId; + saveStateData.byteArray = DiskSerializationHelpers.ToLZ4CompressedByteArray(clientSerializationWrapper, version); + } + + return saveStateData; + } + + [HarmonyPrefix] + [HarmonyPatch(typeof(SpriteCamera), nameof(SpriteCamera.TakeSnapshotOfMapState))] + private static bool SpriteCamera_TakeSnapshotOfMapState(ref Texture2D __result, SpriteCamera.SnapshotMapData mapSnapshot, + float w, float h, TribeType tribe, SkinType skin, TribeType climate, int color, bool clearMapAfterwards) + { + if (!Plugin.config.debug) + return true; + + var instance = SpriteCamera.instance; + if (baseOrthographicCameraSize == null) + baseOrthographicCameraSize = instance.spriteCamera.orthographicSize; + + instance.spriteCamera.orthographicSize = (float)baseOrthographicCameraSize * 2; + isTakingSnapshot = true; + + return true; + } + + [HarmonyPostfix] + [HarmonyPatch(typeof(SpriteCamera), nameof(SpriteCamera.TakeSnapshotOfMapState))] + private static void SpriteCamera_TakeSnapshotOfMapState_Postfix(ref Texture2D __result, SpriteCamera.SnapshotMapData mapSnapshot, + float w, float h, TribeType tribe, SkinType skin, TribeType climate, int color, bool clearMapAfterwards) + { + if (isTakingSnapshot) + isTakingSnapshot = false; + } + + [HarmonyPostfix] + [HarmonyPatch(typeof(Tile), nameof(Tile.RenderDebug))] + public static void Tile_RenderDebug(Tile __instance) + { + if (!isTakingSnapshot) + return; + + GameObject textObj = new GameObject("CoordinateText"); + textObj.transform.SetParent(__instance.transform); + textObj.transform.localPosition = new Vector3(0, 0.0f, 0); + + TextMeshPro textMesh = textObj.AddComponent(); + textMesh.text = __instance.Data.coordinates.ToString(); + textMesh.fontSize = 3; + textMesh.alignment = TextAlignmentOptions.Center; + textMesh.color = Color.white; + + MeshRenderer renderer = textObj.GetComponent(); + renderer.sortingLayerID = MeshCache.TERRAIN_LAYER_ID; + renderer.sortingOrder = __instance.Depth + 10; + } + + #endregion + #region UI + + /// Provides custom sprites for improvements in the UI. + [HarmonyPostfix] + [HarmonyPatch(typeof(UIUtils), nameof(UIUtils.GetImprovementSprite), typeof(ImprovementData.Type), typeof(TribeType), typeof(SkinType), typeof(SpriteAtlasManager))] + private static void UIUtils_GetImprovementSprite(ref Sprite __result, ImprovementData.Type improvement, TribeType tribe, SkinType skin, SpriteAtlasManager atlasManager) + { + Sprite? sprite = Registry.GetSprite(EnumCache.GetName(improvement), Util.GetStyle(tribe, skin)); + if (sprite != null) + { + __result = sprite; + } + } + + /// Provides custom sprites for improvements in the UI. + [HarmonyPostfix] + [HarmonyPatch(typeof(UIUtils), nameof(UIUtils.GetImprovementSprite), typeof(SkinVisualsTransientData), typeof(ImprovementData.Type), typeof(SpriteAtlasManager))] + private static void UIUtils_GetImprovementSprite_2(ref Sprite __result, SkinVisualsTransientData data, ImprovementData.Type improvement, SpriteAtlasManager atlasManager) + { + UIUtils_GetImprovementSprite(ref __result, improvement, data.foundingTribeSettings.tribe, data.foundingTribeSettings.skin, atlasManager); + } + + /// Provides custom sprites for resources in the UI. + [HarmonyPostfix] + [HarmonyPatch(typeof(UIUtils), nameof(UIUtils.GetResourceSprite))] + private static void UIUtils_GetResourceSprite(ref Sprite __result, SkinVisualsTransientData data, ResourceData.Type resource, SpriteAtlasManager atlasManager) + { + Sprite? sprite = Registry.GetSprite(EnumCache.GetName(resource), Util.GetStyle(data.tileClimateSettings.tribe, data.tileClimateSettings.skin)); + if (sprite != null) + { + __result = sprite; + } + } + + #endregion + #region Houses + + /// Provides custom sprites for houses in the city view. + [HarmonyPostfix] + [HarmonyPatch(typeof(CityRenderer), nameof(CityRenderer.GetHouse))] + private static void CityRenderer_GetHouse(ref PolytopiaSpriteRenderer __result, CityRenderer __instance, TribeType tribe, int type, SkinType skinType) + { + PolytopiaSpriteRenderer polytopiaSpriteRenderer = __result; + + if (type != __instance.HOUSE_WORKSHOP && type != __instance.HOUSE_PARK) + { + Sprite? sprite = Registry.GetSprite("house", Util.GetStyle(tribe, skinType), type); + if (sprite != null) + { + polytopiaSpriteRenderer.Sprite = sprite; + TerrainMaterialHelper.SetSpriteSaturated(polytopiaSpriteRenderer, __instance.IsEnemyCity); + __result = polytopiaSpriteRenderer; + } + } + } + + /// Provides custom sprites for houses in the UI city renderer. + [HarmonyPostfix] + [HarmonyPatch(typeof(UICityRenderer), nameof(UICityRenderer.GetResource))] + private static void UICityRenderer_GetResource(ref GameObject __result, string baseName, TribeType tribe, SkinType skin) + { + Image imageComponent = __result.GetComponent(); + string[] tokens = baseName.Split('_'); + if (tokens.Length > 0) + { + if (tokens[0] == "House") + { + int level = 0; + if (tokens.Length > 1) + { + _ = int.TryParse(tokens[1], out level); + } + + Sprite? sprite = Registry.GetSprite("house", Util.GetStyle(tribe, skin), level); + if (sprite == null) + { + return; + } + imageComponent.sprite = sprite; + imageComponent.SetNativeSize(); + } + } + } + + #endregion + #region Icons + + /// Provides custom sprites for UI icons. + [HarmonyPostfix] + [HarmonyPatch(typeof(UIIconData), nameof(UIIconData.GetImage))] + private static void UIIconData_GetImage(ref Image __result, string id) + { + Sprite? sprite; + if (GameManager.LocalPlayer != null) + { + sprite = Registry.GetSprite(id, Util.GetStyle(GameManager.LocalPlayer.tribe, GameManager.LocalPlayer.skinType)); + } + else + { + sprite = Registry.GetSprite(id); + } + if (sprite != null) + { + __result.sprite = sprite; + __result.useSpriteMesh = true; + __result.SetNativeSize(); + } + } + + /// Provides custom sprites for face icons in the game info row. + [HarmonyPostfix] + [HarmonyPatch(typeof(GameInfoRow), nameof(GameInfoRow.LoadFaceIcon), typeof(TribeType), typeof(SkinType))] + private static void GameInfoRow_LoadFaceIcon(GameInfoRow __instance, TribeType type, SkinType skinType) + { + string style = EnumCache.GetName(type); + + if (style == "None") + { + for (int i = 0; i < 20; i++) + { + type += byte.MaxValue + 1; + style = EnumCache.GetName(type); + + if (style != "None") + { + break; + } + } + } + + Sprite? sprite = Registry.GetSprite("head", Util.GetStyle(type, skinType)); + + if (sprite != null) + { + __instance.SetFaceIcon(sprite); + } + + if (__instance.icon.sprite == null) + { + __instance.LoadFaceIcon(SpriteData.SpecialFaceIcon.neutral); + } + } + + /// Provides custom sprites for player info icons. + [HarmonyPostfix] + [HarmonyPatch(typeof(PlayerInfoIcon), nameof(PlayerInfoIcon.SetData), typeof(TribeType), typeof(SkinType), typeof(SpriteData.SpecialFaceIcon), typeof(Color), typeof(DiplomacyRelationState), typeof(PlayerInfoIcon.Mood))] + private static void PlayerInfoIcon_SetData(PlayerInfoIcon __instance, TribeType tribe, SkinType skin, SpriteData.SpecialFaceIcon face, Color color, DiplomacyRelationState diplomacyState, PlayerInfoIcon.Mood mood) + { + if (face == SpriteData.SpecialFaceIcon.tribe) + { + Sprite? sprite = Registry.GetSprite("head", Util.GetStyle(tribe, skin)); + if (sprite != null) + { + __instance.HeadImage.sprite = sprite; + Vector2 size = sprite.rect.size; + __instance.HeadImage.rectTransform.sizeDelta = size * __instance.rectTransform.GetHeight() / 512f; + } + } + } + + #endregion + #region Popups + + /// Updates the width of a basic popup if a custom width is set. + [HarmonyPostfix] + [HarmonyPatch(typeof(PopupBase), nameof(PopupBase.RefreshHeightWhenHeightIsKnownAndDontUseCoroutines))] + private static void PopupBase_RefreshHeightWhenHeightIsKnownAndDontUseCoroutines(PopupBase __instance) + { + UpdateWidth(__instance); + } + + private static void UpdateWidth(PopupBase __instance) + { + int id = __instance.GetInstanceID(); + if (basicPopupWidths.ContainsKey(id)) + __instance.rectTransform.SetWidth(basicPopupWidths[id]); + } + + /// Sets the attacker's tribe before a unit attacks. + [HarmonyPrefix] + [HarmonyPatch(typeof(Unit), nameof(Unit.Attack))] + private static bool Unit_Attack(Unit __instance, WorldCoordinates target, bool moveToTarget, Il2CppSystem.Action onComplete) + { + if (__instance.Owner != null) + { + attackerTribe = __instance.Owner.tribe; + } + return true; + } + + /// Sets the skin of a weapon's graphics, using custom sprites if available. + [HarmonyPostfix] + [HarmonyPatch(typeof(WeaponGFX), nameof(WeaponGFX.SetSkin))] + private static void WeaponGFX_SetSkin(WeaponGFX __instance, SkinType skinType) + { + if (attackerTribe != TribeType.None) + { + Sprite? sprite = Registry.GetSprite(__instance.defaultSprite.name, Util.GetStyle(attackerTribe, skinType)); + if (sprite != null) + { + __instance.spriteRenderer.sprite = sprite; + } + attackerTribe = TribeType.None; + } + } + + /// Removes a popup's custom width when it is hidden. + [HarmonyPostfix] + [HarmonyPatch(typeof(PopupBase), nameof(PopupBase.Hide))] + private static void PopupBase_Hide(PopupBase __instance) + { + basicPopupWidths.Remove(__instance.GetInstanceID()); + } + + [HarmonyPrefix] + [HarmonyPatch(typeof(StartScreen_UI2), nameof(StartScreen_UI2.OnWeeklyChallengeClicked))] + private static bool StartScreen_OnWeeklyChallengeClicked(StartScreen_UI2 __instance) + { + if (seenWarningWCPopup) + return true; + + BasicPopup popup = PopupManager.GetBasicPopup(); + popup.Header = Localization.Get("polymod.hub"); + popup.Description = Localization.Get("polymod.wc.warning", new Il2CppSystem.Object[] { Localization.Get("weeklychallenge", new Il2CppSystem.Object[] { }) }); + + void WCProceed() + { + seenWarningWCPopup = true; + __instance.OnWeeklyChallengeClicked(); + } + List popupButtons = new() + { + new("buttons.back"), + new( + "polymod.wc.proceed", + PopupBase.PopupButtonData.States.None, + callback: DelegateSupport.ConvertDelegate(WCProceed), + customColorStates: ColorConstants.redButtonColorStates + ) + }; + popup.buttonData = popupButtons.ToArray(); + popup.Show(); + return false; + } + + /// Shows a basic popup with a custom width. + public static void ShowSetWidth(this BasicPopupLegacy self, int width) + { + basicPopupWidths.Add(self.GetInstanceID(), width); + self.Show(); + } + + // [HarmonyPrefix] + // [HarmonyPatch(typeof(BasicPopup), nameof(BasicPopup.SetButtonData))] + // public static bool SetButtonData(BasicPopup __instance, PopupBase.PopupButtonData[] buttonDatas, bool updateOmniCursor) + // { + // if (buttonDatas == null) + // { + // __instance.SetDefaultOkbutton(); + // return false; + // } + // foreach (var buttonData in buttonDatas) + // { + // var MainButton = __instance.createButton(buttonData.text, buttonData.callback, UIButtonBase_UI2.ButtonStyle.Suggested); + // MainButton.OnClickedSignal.Add(DelegateSupport.ConvertDelegate(__instance.Hide)); + // } + // __instance.RunLayout(); + // return false; + // } + + #endregion + + internal static BasicPopupLegacy GetBasicPopupLegacy() + { + WhatsNewPopup whatsNewPopup = PopupManager.GetWhatsNewPopup(); + BasicPopupLegacy original = PopupManager.instance.popupPrefabs[BASIC_POPUP_LEGACY_ID].Cast(); + BasicPopupLegacy basicPopupLegacy = UnityEngine.Object.Instantiate(original, PopupManager.instance.transform); + basicPopupLegacy.buttonContainer = GameObject.Instantiate(whatsNewPopup.buttonContainer, basicPopupLegacy.transform); + basicPopupLegacy.popupId = "basicPopupLegacy"; + basicPopupLegacy.popupManager = PopupManager.instance; + basicPopupLegacy.Init(); + basicPopupLegacy.identifier = null; + basicPopupLegacy.rectTransform.SetAsLastSibling(); + return basicPopupLegacy; + } + + /// Updates a visual part with a custom sprite. + private static void UpdateVisualPart(SkinVisualsReference.VisualPart? visualPart, string name, string style) + { + if (visualPart == null || visualPart.visualPart == null) + return; + + Sprite? sprite = Registry.GetSprite(name, style) ?? Registry.GetSprite(visualPart.visualPart.name, style); + + if (sprite != null && visualPart.renderer != null) + { + if (visualPart.renderer.spriteRenderer != null) + visualPart.renderer.spriteRenderer.sprite = sprite; + else if (visualPart.renderer.polytopiaSpriteRenderer != null) + visualPart.renderer.polytopiaSpriteRenderer.sprite = sprite; + } + + Sprite? outlineSprite = Registry.GetSprite($"{name}_outline", style) ?? Registry.GetSprite($"{visualPart.visualPart.name}_outline", style); + + if (outlineSprite != null && visualPart.outlineRenderer != null) + { + if (visualPart.outlineRenderer.spriteRenderer != null) + visualPart.outlineRenderer.spriteRenderer.sprite = outlineSprite; + else if (visualPart.outlineRenderer.polytopiaSpriteRenderer != null) + visualPart.outlineRenderer.polytopiaSpriteRenderer.sprite = outlineSprite; + } + } + + /// Builds a sprite from raw byte data. + /// The raw byte data of the image. + /// The pivot point of the sprite. + /// The number of pixels per unit for the sprite. + /// The created sprite. + public static Sprite BuildSprite(byte[] data, Vector2? pivot = null, float pixelsPerUnit = 2112f) + { + Texture2D texture = new(1, 1, TextureFormat.RGBA32, true); + texture.LoadImage(data); + Color[] pixels = texture.GetPixels(); + for (int i = 0; i < pixels.Length; i++) + { + pixels[i] = new Color(pixels[i].r, pixels[i].g, pixels[i].b, pixels[i].a); + } + texture.SetPixels(pixels); + texture.filterMode = FilterMode.Trilinear; + texture.Apply(); + return BuildSpriteWithTexture(texture, pivot, pixelsPerUnit); + } + + /// Builds a sprite from a texture. + /// The texture to create the sprite from. + /// The pivot point of the sprite. + /// The number of pixels per unit for the sprite. + /// The created sprite. + public static Sprite BuildSpriteWithTexture(Texture2D texture, Vector2? pivot = null, float? pixelsPerUnit = 2112f) + { + return Sprite.Create( + texture, + new(0, 0, texture.width, texture.height), + pivot ?? new(0.5f, 0.5f), + pixelsPerUnit ?? 2112f + ); + } + + /// Initializes the Visual manager by patching the necessary methods. + internal static void Init() + { + Harmony.CreateAndPatchAll(typeof(Visual)); + } } diff --git a/src/Mod.cs b/src/Mod.cs index 61fbe13..5c5de5b 100644 --- a/src/Mod.cs +++ b/src/Mod.cs @@ -9,10 +9,10 @@ public class Mod /// Represents a dependency of a mod. /// /// The unique identifier of the dependency. - /// The minimum compatible version of the dependency. - /// The maximum compatible version of the dependency. + /// The minimum compatible version of the dependency, or null for no minimum. + /// The maximum compatible version of the dependency, or null for no maximum. /// Whether the dependency is required for the mod to function. - public record Dependency(string id, Version min, Version max, bool required = true); + public record Dependency(string id, Version? min, Version? max, bool required = true); /// /// Represents the manifest of a mod, containing metadata. diff --git a/src/Plugin.cs b/src/Plugin.cs index 96ae942..c273eb4 100644 --- a/src/Plugin.cs +++ b/src/Plugin.cs @@ -14,158 +14,158 @@ namespace PolyMod; [BepInPlugin("com.polymod", "PolyMod", VERSION)] public partial class Plugin : BepInEx.Unity.IL2CPP.BasePlugin { - /// - /// Represents the configuration for PolyMod. - /// - /// Whether to enable debug mode. - /// Whether to automatically update PolyMod. - /// Whether to include pre-release versions when updating. - internal record PolyConfig( - bool debug = false, - bool autoUpdate = true, - bool updatePrerelease = false, - bool allowUnsafeIndexes = false - ); - - /// - /// The starting index for automatically assigned IDs. - /// - internal const int AUTOIDX_STARTS_FROM = 1000; - - /// - /// The key used to store the last version for which the incompatibility warning was shown. - /// - internal const string INCOMPATIBILITY_WARNING_LAST_VERSION_KEY - = "INCOMPATIBILITY_WARNING_LAST_VERSION"; - - /// - /// The base path for PolyMod files. - /// - public static readonly string BASE_PATH = Path.Combine(BepInEx.Paths.BepInExRootPath, ".."); - - /// - /// The path to the mods directory. - /// - public static readonly string MODS_PATH = Path.Combine(BASE_PATH, "Mods"); - - /// - /// The path to the directory where game data is dumped. - /// - public static readonly string DUMPED_DATA_PATH = Path.Combine(BASE_PATH, "DumpedData"); - - /// - /// The path to the directory where localization is dumped. - /// - public static readonly string LOGIC_DUMP_PATH = Path.Combine(DUMPED_DATA_PATH, "Logic"); - - /// - /// The path to the directory where gameLogicData and avatarData are dumped. - /// - public static readonly string LOCALIZATION_DUMP_PATH = Path.Combine(DUMPED_DATA_PATH, "Localization"); - - /// - /// The path to the PolyMod configuration file. - /// - internal static readonly string CONFIG_PATH = Path.Combine(BASE_PATH, "PolyMod.json"); - - /// - /// The path to the checksum file. - /// - internal static readonly string CHECKSUM_PATH - = Path.Combine(BASE_PATH, "CHECKSUM"); - - /// - /// The link to the PolyMod Discord server. - /// - internal static readonly string DISCORD_LINK = "https://discord.gg/eWPdhWtfVy"; - - /// - /// A list of log messages to ignore. - /// - internal static readonly List LOG_MESSAGES_IGNORE = new() - { - "Failed to find atlas", - "Could not find sprite", - "Couldn't find prefab for type", - "MARKET: id:", - "Missing name for value", - }; + /// + /// Represents the configuration for PolyMod. + /// + /// Whether to enable debug mode. + /// Whether to automatically update PolyMod. + /// Whether to include pre-release versions when updating. + internal record PolyConfig( + bool debug = false, + bool autoUpdate = true, + bool updatePrerelease = false, + bool allowUnsafeIndexes = false + ); + + /// + /// The starting index for automatically assigned IDs. + /// + internal const int AUTOIDX_STARTS_FROM = 1000; + + /// + /// The key used to store the last version for which the incompatibility warning was shown. + /// + internal const string INCOMPATIBILITY_WARNING_LAST_VERSION_KEY + = "INCOMPATIBILITY_WARNING_LAST_VERSION"; + + /// + /// The base path for PolyMod files. + /// + public static readonly string BASE_PATH = Path.Combine(BepInEx.Paths.BepInExRootPath, ".."); + + /// + /// The path to the mods directory. + /// + public static readonly string MODS_PATH = Path.Combine(BASE_PATH, "Mods"); + + /// + /// The path to the directory where game data is dumped. + /// + public static readonly string DUMPED_DATA_PATH = Path.Combine(BASE_PATH, "DumpedData"); + + /// + /// The path to the directory where localization is dumped. + /// + public static readonly string LOGIC_DUMP_PATH = Path.Combine(DUMPED_DATA_PATH, "Logic"); + + /// + /// The path to the directory where gameLogicData and avatarData are dumped. + /// + public static readonly string LOCALIZATION_DUMP_PATH = Path.Combine(DUMPED_DATA_PATH, "Localization"); + + /// + /// The path to the PolyMod configuration file. + /// + internal static readonly string CONFIG_PATH = Path.Combine(BASE_PATH, "PolyMod.json"); + + /// + /// The path to the checksum file. + /// + internal static readonly string CHECKSUM_PATH + = Path.Combine(BASE_PATH, "CHECKSUM"); + + /// + /// The link to the PolyMod Discord server. + /// + internal static readonly string DISCORD_LINK = "https://discord.gg/eWPdhWtfVy"; + + /// + /// A list of log messages to ignore. + /// + internal static readonly List LOG_MESSAGES_IGNORE = new() + { + "Failed to find atlas", + "Could not find sprite", + "Couldn't find prefab for type", + "MARKET: id:", + "Missing name for value", + }; #pragma warning disable CS8618 - /// - /// The PolyMod configuration. - /// - internal static PolyConfig config; - - /// - /// The logger instance for PolyMod. - /// - internal static ManualLogSource logger; + /// + /// The PolyMod configuration. + /// + internal static PolyConfig config; + + /// + /// The logger instance for PolyMod. + /// + internal static ManualLogSource logger; #pragma warning restore CS8618 - /// - /// The entry point for the plugin. - /// - public override void Load() - { - try - { - config = JsonSerializer.Deserialize(File.ReadAllText(CONFIG_PATH))!; - } - catch - { - config = new(); - } - WriteConfig(); - UpdateConsole(); - logger = Log; - ConfigFile.CoreConfig[new("Logging.Disk", "WriteUnityLog")].BoxedValue = true; - - AutoUpdate.Init(); - - Compatibility.Init(); - - Audio.Init(); - Loc.Init(); - Visual.Init(); - Hub.Init(); - - Main.Init(); - } - - /// - /// Gets a resource stream from the assembly. - /// - /// The ID of the resource. - /// The resource stream. - internal static Stream GetResource(string id) - { - return Assembly.GetExecutingAssembly().GetManifestResourceStream( - $"{typeof(Plugin).Namespace}.resources.{id}" - )!; - } - - /// - /// Writes the configuration to disk. - /// - internal static void WriteConfig() - { - File.WriteAllText(CONFIG_PATH, JsonSerializer.Serialize(config)); - } - - /// - /// Updates the console based on the debug configuration. - /// - internal static void UpdateConsole() - { - if (config.debug) - { - ConsoleManager.CreateConsole(); - } - else - { - ConsoleManager.DetachConsole(); - } - } + /// + /// The entry point for the plugin. + /// + public override void Load() + { + try + { + config = JsonSerializer.Deserialize(File.ReadAllText(CONFIG_PATH))!; + } + catch + { + config = new(); + } + WriteConfig(); + UpdateConsole(); + logger = Log; + ConfigFile.CoreConfig[new("Logging.Disk", "WriteUnityLog")].BoxedValue = true; + + AutoUpdate.Init(); + + Compatibility.Init(); + + Audio.Init(); + Loc.Init(); + Visual.Init(); + Hub.Init(); + + Main.Init(); + } + + /// + /// Gets a resource stream from the assembly. + /// + /// The ID of the resource. + /// The resource stream. + internal static Stream GetResource(string id) + { + return Assembly.GetExecutingAssembly().GetManifestResourceStream( + $"{typeof(Plugin).Namespace}.resources.{id}" + )!; + } + + /// + /// Writes the configuration to disk. + /// + internal static void WriteConfig() + { + File.WriteAllText(CONFIG_PATH, JsonSerializer.Serialize(config)); + } + + /// + /// Updates the console based on the debug configuration. + /// + internal static void UpdateConsole() + { + if (config.debug) + { + ConsoleManager.CreateConsole(); + } + else + { + ConsoleManager.DetachConsole(); + } + } } diff --git a/PolyMod.csproj b/src/PolyMod.csproj similarity index 87% rename from PolyMod.csproj rename to src/PolyMod.csproj index d17309e..e00a8bc 100644 --- a/PolyMod.csproj +++ b/src/PolyMod.csproj @@ -3,12 +3,6 @@ net6.0 enable enable - - https://api.nuget.org/v3/index.json; - https://nuget.bepinex.dev/v3/index.json; - https://nuget.samboy.dev/v3/index.json; - https://polymod.dev/nuget/v3/index.json; - IL2CPP PolyMod 1.2.18 @@ -30,6 +24,7 @@ + diff --git a/src/Registry.cs b/src/Registry.cs index 3a3d36a..4468130 100644 --- a/src/Registry.cs +++ b/src/Registry.cs @@ -11,115 +11,115 @@ namespace PolyMod; /// public static class Registry { - /// - /// The next available index for automatically assigned IDs. - /// - public static int autoidx = Plugin.AUTOIDX_STARTS_FROM; - - /// - /// A dictionary of all loaded sprites, keyed by their name. - /// - public static Dictionary sprites = new(); - - /// - /// A dictionary of all loaded audio clips, keyed by their name. - /// - public static Dictionary audioClips = new(); - - /// - /// A dictionary of all loaded mods, keyed by their ID. - /// - public static Dictionary mods = new(); - - /// - /// A dictionary of tribe previews, keyed by tribe name. - /// - public static Dictionary tribePreviews = new(); - - /// - /// A dictionary of sprite information, keyed by sprite name. - /// - public static Dictionary spriteInfos = new(); - - /// - /// A dictionary of prefab names, keyed by their enum value. - /// - public static Dictionary prefabNames = new(); - - /// - /// A dictionary of custom unit prefabs, keyed by their prefab info. - /// - public static Dictionary unitPrefabs = new(); - - /// - /// A dictionary of custom resource prefabs, keyed by their prefab info. - /// - public static Dictionary resourcePrefabs = new(); - - /// - /// A dictionary of custom improvement prefabs, keyed by their prefab info. - /// - public static Dictionary improvementsPrefabs = new(); - - /// - /// A dictionary of loaded asset bundles, keyed by their name. - /// - public static Dictionary assetBundles = new(); - - /// - /// A list of custom tribes. - /// - public static List customTribes = new(); - - /// - /// A list of custom skin information. - /// - public static List skinInfo = new(); - - /// - /// The next available index for climate styles. - /// - public static int climateAutoidx = (int)Enum.GetValues(typeof(TribeType)).Cast().Last(); - - /// - /// The next available index for game modes. - /// - public static int gameModesAutoidx = Enum.GetValues(typeof(GameMode)).Length; - - /// - /// Gets a sprite from the registry, trying to find the best match. - /// - /// The name of the sprite. - /// The style of the sprite (e.g., tribe or skin). - /// The level of the sprite (e.g., for cities). - /// The requested sprite, or null if not found. The lookup priority is: name_style_level, name__level, name_style_, name__. - public static Sprite? GetSprite(string name, string style = "", int level = 0) - { - Sprite? sprite = null; - name = name.ToLower(); - style = style.ToLower(); - // This looks backwards, but GetOrDefault returns the provided default value if the key isn't found. - // This means `sprite` is updated sequentially, and the last, most-specific match is the one that is kept. - sprite = sprites.GetOrDefault($"{name}__", sprite); - sprite = sprites.GetOrDefault($"{name}_{style}_", sprite); - sprite = sprites.GetOrDefault($"{name}__{level}", sprite); - sprite = sprites.GetOrDefault($"{name}_{style}_{level}", sprite); - return sprite; - } - - /// - /// Gets an audio clip from the registry. - /// - /// The name of the audio clip. - /// The style of the audio clip. - /// The requested audio clip, or null if not found. - public static AudioClip? GetAudioClip(string name, string style) - { - AudioSource? audioSource = null; - name = name.ToLower(); - style = style.ToLower(); - audioSource = audioClips.GetOrDefault($"{name}_{style}", audioSource); - if (audioSource == null) return null; - return audioSource.clip; - } + /// + /// The next available index for automatically assigned IDs. + /// + public static int autoidx = Plugin.AUTOIDX_STARTS_FROM; + + /// + /// A dictionary of all loaded sprites, keyed by their name. + /// + public static Dictionary sprites = new(); + + /// + /// A dictionary of all loaded audio clips, keyed by their name. + /// + public static Dictionary audioClips = new(); + + /// + /// A dictionary of all loaded mods, keyed by their ID. + /// + public static Dictionary mods = new(); + + /// + /// A dictionary of tribe previews, keyed by tribe name. + /// + public static Dictionary tribePreviews = new(); + + /// + /// A dictionary of sprite information, keyed by sprite name. + /// + public static Dictionary spriteInfos = new(); + + /// + /// A dictionary of prefab names, keyed by their enum value. + /// + public static Dictionary prefabNames = new(); + + /// + /// A dictionary of custom unit prefabs, keyed by their prefab info. + /// + public static Dictionary unitPrefabs = new(); + + /// + /// A dictionary of custom resource prefabs, keyed by their prefab info. + /// + public static Dictionary resourcePrefabs = new(); + + /// + /// A dictionary of custom improvement prefabs, keyed by their prefab info. + /// + public static Dictionary improvementsPrefabs = new(); + + /// + /// A dictionary of loaded asset bundles, keyed by their name. + /// + public static Dictionary assetBundles = new(); + + /// + /// A list of custom tribes. + /// + public static List customTribes = new(); + + /// + /// A list of custom skin information. + /// + public static List skinInfo = new(); + + /// + /// The next available index for climate styles. + /// + public static int climateAutoidx = (int)Enum.GetValues(typeof(TribeType)).Cast().Last(); + + /// + /// The next available index for game modes. + /// + public static int gameModesAutoidx = Enum.GetValues(typeof(GameMode)).Length; + + /// + /// Gets a sprite from the registry, trying to find the best match. + /// + /// The name of the sprite. + /// The style of the sprite (e.g., tribe or skin). + /// The level of the sprite (e.g., for cities). + /// The requested sprite, or null if not found. The lookup priority is: name_style_level, name__level, name_style_, name__. + public static Sprite? GetSprite(string name, string style = "", int level = 0) + { + Sprite? sprite = null; + name = name.ToLower(); + style = style.ToLower(); + // This looks backwards, but GetOrDefault returns the provided default value if the key isn't found. + // This means `sprite` is updated sequentially, and the last, most-specific match is the one that is kept. + sprite = sprites.GetOrDefault($"{name}__", sprite); + sprite = sprites.GetOrDefault($"{name}_{style}_", sprite); + sprite = sprites.GetOrDefault($"{name}__{level}", sprite); + sprite = sprites.GetOrDefault($"{name}_{style}_{level}", sprite); + return sprite; + } + + /// + /// Gets an audio clip from the registry. + /// + /// The name of the audio clip. + /// The style of the audio clip. + /// The requested audio clip, or null if not found. + public static AudioClip? GetAudioClip(string name, string style) + { + AudioSource? audioSource = null; + name = name.ToLower(); + style = style.ToLower(); + audioSource = audioClips.GetOrDefault($"{name}_{style}", audioSource); + if (audioSource == null) return null; + return audioSource.clip; + } } diff --git a/src/Util.cs b/src/Util.cs index 3396965..aef2ebb 100644 --- a/src/Util.cs +++ b/src/Util.cs @@ -79,26 +79,66 @@ internal static Version CutRevision(this Version self) /// /// Compares two version strings to see if the first is older or equal to the second. - /// Handles pre-release identifiers. + /// Pre-release tags follow semantic-version rules: the part after the first dash is + /// split on dots, purely numeric identifiers compare numerically and sort before + /// alphanumeric ones, and a longer identifier list is newer. A version with a + /// pre-release tag is always older than the same version without one. /// /// The first version string. /// The second version string. /// True if version1 is older or equal to version2, false otherwise. internal static bool IsVersionOlderOrEqual(this string version1, string version2) { - Version version1_ = new(version1.Split('-')[0]); - Version version2_ = new(version2.Split('-')[0]); + Version core1 = ParseVersion(version1, out string? pre1); + Version core2 = ParseVersion(version2, out string? pre2); - if (version1_ < version2_) return true; - if (version1_ > version2_) return false; + if (core1 != core2) + return core1 < core2; - string pre1 = version1.Contains('-') ? version1.Split('-')[1] : ""; - string pre2 = version2.Contains('-') ? version2.Split('-')[1] : ""; + return ComparePreRelease(pre1, pre2) <= 0; + } - if (string.IsNullOrEmpty(pre1) && !string.IsNullOrEmpty(pre2)) return false; - if (!string.IsNullOrEmpty(pre1) && string.IsNullOrEmpty(pre2)) return true; + private static Version ParseVersion(string version, out string? preRelease) + { + int dash = version.IndexOf('-'); + if (dash < 0) + { + preRelease = null; + return new Version(version); + } + preRelease = version[(dash + 1)..]; + if (preRelease.Length == 0) + preRelease = null; + return new Version(version[..dash]); + } - return string.Compare(pre1, pre2, StringComparison.Ordinal) <= 0; + private static int ComparePreRelease(string? left, string? right) + { + if (left == null && right == null) return 0; + if (left == null) return 1; + if (right == null) return -1; + + string[] leftIds = left.Split('.'); + string[] rightIds = right.Split('.'); + int shared = Math.Min(leftIds.Length, rightIds.Length); + for (int i = 0; i < shared; i++) + { + int comparison = CompareIdentifier(leftIds[i], rightIds[i]); + if (comparison != 0) + return comparison; + } + return leftIds.Length.CompareTo(rightIds.Length); + } + + private static int CompareIdentifier(string left, string right) + { + bool leftNumeric = left.Length > 0 && left.All(char.IsDigit); + bool rightNumeric = right.Length > 0 && right.All(char.IsDigit); + if (leftNumeric && rightNumeric) + return long.Parse(left).CompareTo(long.Parse(right)); + if (leftNumeric) return -1; + if (rightNumeric) return 1; + return string.Compare(left, right, StringComparison.Ordinal); } /// diff --git a/tests/CheckDependenciesTests.cs b/tests/CheckDependenciesTests.cs new file mode 100644 index 0000000..b297606 --- /dev/null +++ b/tests/CheckDependenciesTests.cs @@ -0,0 +1,59 @@ +using PolyMod; + +namespace Tests; + +public class CheckDependenciesTests +{ + [Fact] + public void MissingRequiredDependencyMarksModUnsatisfied() + { + var mod = ModFactory.MakeMod("b", dependencies: new Mod.Dependency("ghost", null, null)); + Loader.CheckDependencies(ModFactory.Mods(ModFactory.MakeMod("a"), mod)); + Assert.Equal(Mod.Status.DependenciesUnsatisfied, mod.status); + } + + [Fact] + public void VersionBelowMinimumMarksModUnsatisfied() + { + var dep = new Mod.Dependency("a", min: new Version(2, 0, 0), max: null); + var mod = ModFactory.MakeMod("b", dependencies: dep); + Loader.CheckDependencies(ModFactory.Mods(ModFactory.MakeMod("a"), mod)); + Assert.Equal(Mod.Status.DependenciesUnsatisfied, mod.status); + } + + [Fact] + public void VersionAboveMaximumMarksModUnsatisfied() + { + var dep = new Mod.Dependency("a", min: null, max: new Version(1, 0, 0)); + var mod = ModFactory.MakeMod("b", dependencies: dep); + Loader.CheckDependencies(ModFactory.Mods(ModFactory.MakeMod("a", version: new Version(2, 0, 0)), mod)); + Assert.Equal(Mod.Status.DependenciesUnsatisfied, mod.status); + } + + [Fact] + public void VersionEqualToBoundsIsAllowed() + { + var dep = new Mod.Dependency("a", min: new Version(2, 0, 0), max: new Version(2, 0, 0)); + var mod = ModFactory.MakeMod("b", dependencies: dep); + Loader.CheckDependencies(ModFactory.Mods(ModFactory.MakeMod("a", version: new Version(2, 0, 0)), mod)); + Assert.Equal(Mod.Status.Success, mod.status); + } + + [Fact] + public void SatisfiedDependencyKeepsModSuccessful() + { + var dep = new Mod.Dependency("a", min: new Version(1, 0, 0), max: new Version(2, 0, 0)); + var mod = ModFactory.MakeMod("b", dependencies: dep); + Loader.CheckDependencies(ModFactory.Mods(ModFactory.MakeMod("a"), mod)); + Assert.Equal(Mod.Status.Success, mod.status); + } + + [Fact] + public void MissingOptionalDependencyOnlyWarns() + { + var dep = new Mod.Dependency("ghost", null, null, required: false); + var mod = ModFactory.MakeMod("b", dependencies: dep); + Loader.CheckDependencies(ModFactory.Mods(mod)); + Assert.Equal(Mod.Status.Success, mod.status); + } +} diff --git a/tests/LoaderTests.cs b/tests/LoaderTests.cs new file mode 100644 index 0000000..f7aae24 --- /dev/null +++ b/tests/LoaderTests.cs @@ -0,0 +1,60 @@ +using PolyMod; + +namespace Tests; + +public class LoaderTests +{ + private static string[] Ids(Dictionary mods) => + mods.Select(kv => kv.Key).ToArray(); + + [Fact] + public void DependencyLoadsBeforeDependent() + { + var mods = ModFactory.Mods( + ModFactory.MakeMod("b", dependencies: new Mod.Dependency("a", null, null)), + ModFactory.MakeMod("a")); + Assert.True(Loader.SortMods(mods)); + Assert.Equal(new[] { "a", "b" }, Ids(mods)); + } + + [Fact] + public void TransitiveChainSortsDeepestFirst() + { + var mods = ModFactory.Mods( + ModFactory.MakeMod("c", dependencies: new Mod.Dependency("b", null, null)), + ModFactory.MakeMod("b", dependencies: new Mod.Dependency("a", null, null)), + ModFactory.MakeMod("a")); + Assert.True(Loader.SortMods(mods)); + Assert.Equal(new[] { "a", "b", "c" }, Ids(mods)); + } + + [Fact] + public void DependencyCycleReturnsFalseAndLeavesDictionaryUntouched() + { + var mods = ModFactory.Mods( + ModFactory.MakeMod("a", dependencies: new Mod.Dependency("b", null, null)), + ModFactory.MakeMod("b", dependencies: new Mod.Dependency("a", null, null))); + Assert.False(Loader.SortMods(mods)); + Assert.Equal(new[] { "a", "b" }, Ids(mods)); + } + + [Fact] + public void DependencyOnMissingModDoesNotBlockSorting() + { + var mods = ModFactory.Mods( + ModFactory.MakeMod("b", dependencies: new Mod.Dependency("ghost", null, null)), + ModFactory.MakeMod("a")); + Assert.True(Loader.SortMods(mods)); + Assert.Equal(2, mods.Count); + } + + [Fact] + public void FailedModsSinkToEnd() + { + var mods = ModFactory.Mods( + ModFactory.MakeMod("bad", Mod.Status.Error), + ModFactory.MakeMod("good")); + Assert.True(Loader.SortMods(mods)); + Assert.Equal(new[] { "good", "bad" }, Ids(mods)); + } +} diff --git a/tests/ModFactory.cs b/tests/ModFactory.cs new file mode 100644 index 0000000..1a78280 --- /dev/null +++ b/tests/ModFactory.cs @@ -0,0 +1,17 @@ +using PolyMod; + +namespace Tests; + +internal static class ModFactory +{ + internal static Mod MakeMod( + string id, + Mod.Status status = Mod.Status.Success, + Version? version = null, + params Mod.Dependency[] dependencies) => + new(new Mod.Manifest(id, null, null, version ?? new Version(1, 0, 0), new[] { "author" }, dependencies), + status, new()); + + internal static Dictionary Mods(params Mod[] mods) => + mods.ToDictionary(mod => mod.id); +} diff --git a/tests/TestSetup.cs b/tests/TestSetup.cs new file mode 100644 index 0000000..87f54f1 --- /dev/null +++ b/tests/TestSetup.cs @@ -0,0 +1,30 @@ +using System.Reflection; +using System.Runtime.CompilerServices; +using BepInEx.Logging; +using PolyMod; + +namespace Tests; + +internal static class TestSetup +{ + [ModuleInitializer] + internal static void Init() + { + var root = AppContext.BaseDirectory; + var prop = typeof(BepInEx.Paths).GetProperty( + nameof(BepInEx.Paths.BepInExRootPath), + BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static); + try + { + prop?.SetValue(null, root); + } + catch (ArgumentException) + { + var field = typeof(BepInEx.Paths) + .GetFields(BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.Static) + .FirstOrDefault(f => f.Name.Contains("BepInExRootPath", StringComparison.Ordinal)); + field?.SetValue(null, root); + } + Plugin.logger ??= new ManualLogSource("tests"); + } +} diff --git a/tests/Tests.csproj b/tests/Tests.csproj new file mode 100644 index 0000000..1709e23 --- /dev/null +++ b/tests/Tests.csproj @@ -0,0 +1,21 @@ + + + + net8.0 + enable + enable + false + Tests + LatestMajor + + + + + + + + + + + + diff --git a/tests/UtilTests.cs b/tests/UtilTests.cs new file mode 100644 index 0000000..2bb4799 --- /dev/null +++ b/tests/UtilTests.cs @@ -0,0 +1,34 @@ +using PolyMod; + +namespace Tests; + +public class UtilTests +{ + [Theory] + [InlineData("1.0.0", "2.0.0", true)] // older core + [InlineData("2.0.0", "1.0.0", false)] // newer core + [InlineData("2.0.0", "2.0.0", true)] // equal + [InlineData("2.0", "2.0.0", true)] // shorter version sorts first + [InlineData("2.0.0.1", "2.0.0", false)] // revision makes it newer + [InlineData("2.0.0", "2.0.0-beta", false)] // release is newer than its pre-release + [InlineData("2.0.0-beta", "2.0.0", true)] + [InlineData("2.0.0-alpha", "2.0.0-beta", true)] // alphanumeric identifiers compare lexically + [InlineData("2.0.0-rc.9", "2.0.0-rc.10", true)] // numeric identifiers compare numerically + [InlineData("2.0.0-rc.10", "2.0.0-rc.9", false)] + [InlineData("2.0.0-9", "2.0.0-10", true)] + [InlineData("2.0.0-1", "2.0.0-alpha", true)] // numeric sorts before alphanumeric + [InlineData("2.0.0-pre", "2.0.0-pre.1", true)] // more identifiers mean a newer pre-release + [InlineData("2.0.0-pre.1", "2.0.0-pre", false)] + [InlineData("2.0.0-alpha-1", "2.0.0-alpha-2", true)] // everything after the first dash is the tag + [InlineData("2.0.0-alpha-2", "2.0.0-alpha-1", false)] + public void VersionComparisons(string version, string other, bool expected) => + Assert.Equal(expected, version.IsVersionOlderOrEqual(other)); + + [Fact] + public void HashProducesUppercaseSha256Hex() + { + Assert.Equal( + "9F86D081884C7D659A2FEAA0C55AD015A3BF4F1B2B0B822CD15D6C15B0F00A08", + Util.Hash("test")); + } +} diff --git a/tests/VersionJsonTests.cs b/tests/VersionJsonTests.cs new file mode 100644 index 0000000..f85fe15 --- /dev/null +++ b/tests/VersionJsonTests.cs @@ -0,0 +1,17 @@ +using PolyMod.Json; +using System.Text.Json; + +namespace Tests; + +public class VersionJsonTests +{ + private static readonly JsonSerializerOptions options = new() { Converters = { new VersionJson() } }; + + [Fact] + public void VersionRoundTripsThroughString() + { + var json = JsonSerializer.Serialize(new Version(1, 2, 3), options); + Assert.Equal("\"1.2.3\"", json); + Assert.Equal(new Version(1, 2, 3), JsonSerializer.Deserialize(json, options)); + } +}